@kernhq/module-inventory 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +55 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +52 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +33 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +22 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +15 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +15 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +146 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +107 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/permissions.d.ts +22 -0
  19. package/dist/contract/permissions.d.ts.map +1 -0
  20. package/dist/contract/permissions.js +26 -0
  21. package/dist/contract/permissions.js.map +1 -0
  22. package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
  23. package/dist/contract/router.d.ts.map +1 -0
  24. package/dist/contract/router.js +42 -0
  25. package/dist/contract/router.js.map +1 -0
  26. package/dist/contract/settings.d.ts +18 -0
  27. package/dist/contract/settings.d.ts.map +1 -0
  28. package/dist/contract/settings.js +29 -0
  29. package/dist/contract/settings.js.map +1 -0
  30. package/dist/server/index.d.ts +4 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +9 -13
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
  35. package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
  36. package/dist/server/router.js +83 -0
  37. package/dist/server/router.js.map +1 -0
  38. package/dist/server/schema.d.ts +25 -11
  39. package/dist/server/schema.d.ts.map +1 -1
  40. package/dist/server/schema.js +28 -10
  41. package/dist/server/schema.js.map +1 -1
  42. package/dist/server/services/assets.d.ts +73 -0
  43. package/dist/server/services/assets.d.ts.map +1 -0
  44. package/dist/server/services/assets.js +261 -0
  45. package/dist/server/services/assets.js.map +1 -0
  46. package/dist/server/services/index.d.ts +10 -0
  47. package/dist/server/services/index.d.ts.map +1 -0
  48. package/dist/server/services/index.js +15 -0
  49. package/dist/server/services/index.js.map +1 -0
  50. package/dist/server/services/notify.d.ts +63 -0
  51. package/dist/server/services/notify.d.ts.map +1 -0
  52. package/dist/server/services/notify.js +105 -0
  53. package/dist/server/services/notify.js.map +1 -0
  54. package/migrations/0000_init.sql +12 -3
  55. package/migrations/0001_rls.sql +24 -0
  56. package/migrations/meta/0000_snapshot.json +40 -13
  57. package/migrations/meta/_journal.json +2 -2
  58. package/package.json +12 -9
  59. package/src/client/api.ts +1 -1
  60. package/src/client/components/AssetFormDialog.svelte +130 -47
  61. package/src/client/i18n.ts +11 -166
  62. package/src/client/index.ts +8 -1
  63. package/src/client/messages.test.ts +169 -0
  64. package/src/client/messages.ts +399 -0
  65. package/src/client/mock.test.ts +161 -0
  66. package/src/client/mock.ts +267 -45
  67. package/src/client/module.ts +22 -2
  68. package/src/client/pages/AssetsPage.svelte +358 -137
  69. package/src/client/permissions.ts +1 -1
  70. package/src/client/price.test.ts +106 -0
  71. package/src/client/price.ts +135 -0
  72. package/src/client/query.test.ts +58 -0
  73. package/src/client/query.ts +15 -2
  74. package/src/client/settings/GeneralSettings.svelte +0 -0
  75. package/src/client/settings/core-api.ts +32 -0
  76. package/src/client/widgets/OverviewWidget.svelte +16 -3
  77. package/src/contract/capabilities.ts +55 -0
  78. package/src/contract/events.ts +34 -0
  79. package/src/contract/index.ts +15 -0
  80. package/src/contract/models.ts +123 -0
  81. package/src/contract/permissions.ts +26 -0
  82. package/src/contract/router.ts +46 -0
  83. package/src/contract/settings.ts +30 -0
  84. package/src/module.test.ts +140 -7
  85. package/src/server/index.ts +16 -13
  86. package/src/server/inventory.int.test.ts +819 -0
  87. package/src/server/migrations.test.ts +138 -0
  88. package/src/server/router.ts +118 -0
  89. package/src/server/schema.ts +27 -10
  90. package/src/server/services/assets.ts +368 -0
  91. package/src/server/services/index.ts +23 -0
  92. package/src/server/services/notify.ts +151 -0
  93. package/tsconfig.base.json +22 -0
  94. package/tsconfig.client.json +1 -1
  95. package/tsconfig.json +1 -1
  96. package/vitest.config.ts +18 -3
  97. package/dist/contract.d.ts.map +0 -1
  98. package/dist/contract.js +0 -119
  99. package/dist/contract.js.map +0 -1
  100. package/dist/server/_impl.js +0 -204
  101. package/dist/server/_impl.js.map +0 -1
  102. package/src/contract.ts +0 -143
  103. package/src/server/_impl.ts +0 -275
@@ -1,4 +1,4 @@
1
- import type { Asset } from '../contract.js'
1
+ import type { Asset, AssetStatus } from '../contract/index.js'
2
2
 
3
3
  /**
4
4
  * The in-memory implementation of this module's API.
@@ -8,64 +8,269 @@ import type { Asset } from '../contract.js'
8
8
  * never checks an env var itself.
9
9
  *
10
10
  * Keep it in step with the contract. A module whose mock is missing a procedure has a working page
11
- * and a broken demo, in exactly the environment used to show the product.
11
+ * and a broken demo, in exactly the environment used to show the product — and one whose mock
12
+ * ignores a filter has a demo that contradicts its own controls.
13
+ *
14
+ * **It contradicted the product in five measured ways, and this file is the answer to all five.**
15
+ * It returned seed order while the server defaults to newest first; it issued `INV-0013` for the
16
+ * seventh asset because one counter was doing two jobs; it served page one for ever for any cursor
17
+ * it did not recognise, which is an endless "Load more" where the server answers `BAD_REQUEST`; and
18
+ * it ignored `sort`, `categoryId` and `custodianUserId` entirely. So the ordering, the paging and
19
+ * the cursor format below are deliberately the same as `src/server/services/assets.ts` — read them
20
+ * as one pair, and change them as one pair.
21
+ */
22
+ type AssetSort = 'recent' | 'name' | 'code'
23
+
24
+ interface Seed {
25
+ code: string
26
+ name: string
27
+ status: AssetStatus
28
+ serialNumber?: string | null
29
+ location?: string | null
30
+ warrantyUntil?: string | null
31
+ archived?: boolean
32
+ }
33
+
34
+ const SEEDS: Seed[] = [
35
+ {
36
+ code: 'INV-0001',
37
+ name: 'MacBook Pro 14"',
38
+ status: 'assigned',
39
+ serialNumber: 'C02X1234JGH7',
40
+ location: 'Istanbul · 3rd floor',
41
+ warrantyUntil: '2027-03-14',
42
+ },
43
+ { code: 'INV-0002', name: 'Dell UltraSharp 27"', status: 'in_stock', location: 'Istanbul · store room' },
44
+ {
45
+ code: 'INV-0003',
46
+ name: 'iPhone 15',
47
+ status: 'under_repair',
48
+ serialNumber: 'F17GX9QKLM',
49
+ location: 'With the repairer',
50
+ },
51
+ { code: 'INV-0004', name: 'Herman Miller Aeron', status: 'assigned', location: 'Istanbul · 2nd floor' },
52
+ { code: 'INV-0005', name: 'Canon EOS R6', status: 'reserved', location: 'Istanbul · store room' },
53
+ { code: 'INV-0006', name: 'ThinkPad X1 Carbon', status: 'retired', archived: true },
54
+ ]
55
+
56
+ /**
57
+ * An error shaped like one the real client surfaces.
58
+ *
59
+ * oRPC hands a failure to the screen as an `Error` carrying the contract's `code`, and the screens
60
+ * show `error.message`. A mock that throws a bare `Error` teaches a demo that every failure looks
61
+ * the same, and hides the one branch — `BAD_REQUEST` on a stale page marker — this file exists to
62
+ * reproduce.
12
63
  */
13
- interface MockAsset extends Asset {}
64
+ class MockApiError extends Error {
65
+ constructor(
66
+ readonly code: 'BAD_REQUEST' | 'NOT_FOUND',
67
+ message: string,
68
+ ) {
69
+ super(message)
70
+ this.name = 'MockApiError'
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The server's page boundary, byte for byte: base64url of `{i: <row id>, s: <sort>}`.
76
+ *
77
+ * `btoa` rather than `Buffer` because this runs in a browser, and it is safe here for the reason
78
+ * the server's version is small: the payload is a uuid and a sort name, so it is pure ASCII.
79
+ */
80
+ const base64url = (text: string) => btoa(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
81
+
82
+ const fromBase64url = (text: string) =>
83
+ atob(
84
+ text
85
+ .replace(/-/g, '+')
86
+ .replace(/_/g, '/')
87
+ .padEnd(Math.ceil(text.length / 4) * 4, '='),
88
+ )
89
+
90
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
91
+
92
+ const encodeCursor = (bookmark: { i: string; s: AssetSort }) => base64url(JSON.stringify(bookmark))
93
+
94
+ /**
95
+ * Refuse a marker this list did not issue, exactly where the server refuses it.
96
+ *
97
+ * The old mock read an unknown cursor as "start at 0", so `nextCursor` was non-null for ever and
98
+ * "Load more" served page one until somebody closed the tab. A cursor bound to the wrong sort is
99
+ * the same class of bug and is refused for the same reason.
100
+ */
101
+ function decodeCursor(cursor: string, sort: AssetSort): { i: string; s: AssetSort } {
102
+ const refuse = () => new MockApiError('BAD_REQUEST', 'That page marker is not one this list issued')
103
+ let parsed: { i?: unknown; s?: unknown } | null
104
+ try {
105
+ parsed = JSON.parse(fromBase64url(cursor)) as { i?: unknown; s?: unknown } | null
106
+ } catch {
107
+ throw refuse()
108
+ }
109
+ if (typeof parsed?.i !== 'string' || !UUID.test(parsed.i)) throw refuse()
110
+ if (parsed.s !== sort) throw refuse()
111
+ return { i: parsed.i, s: sort }
112
+ }
113
+
114
+ /** Which field an ordering sorts on. `recent` is the id, because a uuidv7 is already in time order. */
115
+ const sortKeyOf = (asset: Asset, sort: AssetSort) =>
116
+ sort === 'name' ? asset.name : sort === 'code' ? asset.code : asset.id
117
+
118
+ /**
119
+ * `(sortKey, id)` against `(sortKey, id)` — the tuple the server's row comparison uses.
120
+ *
121
+ * The id tiebreak is what makes the boundary total: two assets with the same name would otherwise
122
+ * have no defined order between them, and a page boundary that lands between them would repeat or
123
+ * skip a row. JS compares by code point where Postgres compares by collation, which is close enough
124
+ * for six seeded rows and is the one place this file is an approximation rather than a mirror.
125
+ */
126
+ function compare(a: Asset, b: Asset, sort: AssetSort): number {
127
+ const ka = sortKeyOf(a, sort)
128
+ const kb = sortKeyOf(b, sort)
129
+ if (ka !== kb) return ka < kb ? -1 : 1
130
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
131
+ }
14
132
 
15
133
  export function createMockInventoryApi() {
16
- let counter = 2
17
- const assets: MockAsset[] = [
18
- seed('01920000-0000-7000-8000-000000000001', 'INV-0001', 'MacBook Pro 14"', 'assigned'),
19
- ]
134
+ /**
135
+ * Two counters, because they were one and it showed.
136
+ *
137
+ * `blank()` incremented the id counter and `create` then formatted a code out of it, so six seeds
138
+ * consumed six numbers and the seventh asset somebody added in a demo was filed as `INV-0013`
139
+ * beside `INV-0006`. An id and a human-readable tag are separate sequences on the server too.
140
+ */
141
+ let nextIdNumber = 1
142
+ let nextCodeNumber = SEEDS.length + 1
20
143
 
21
- function seed(id: string, code: string, name: string, status: Asset['status']): MockAsset {
144
+ const newId = () => `01920000-0000-7000-8000-${String(nextIdNumber++).padStart(12, '0')}`
145
+ const pad = (n: number) => `INV-${String(n).padStart(4, '0')}`
146
+
147
+ function blank(code: string, seed: Partial<Seed> & { name?: string } = {}): Asset {
22
148
  const now = new Date().toISOString()
23
149
  return {
24
- id,
150
+ id: newId(),
25
151
  workspaceId: '' as Asset['workspaceId'],
26
- code,
27
- name,
152
+ code: seed.code ?? code,
153
+ name: seed.name ?? '',
28
154
  description: '',
29
155
  categoryId: null,
30
- status,
156
+ status: seed.status ?? 'in_stock',
31
157
  custodianUserId: null,
32
158
  custodySince: null,
33
- serialNumber: null,
34
- location: null,
159
+ serialNumber: seed.serialNumber ?? null,
160
+ location: seed.location ?? null,
35
161
  purchasedOn: null,
36
162
  purchasedFrom: null,
37
163
  priceMinor: null,
38
164
  currency: null,
39
- warrantyUntil: null,
165
+ warrantyUntil: seed.warrantyUntil ?? null,
40
166
  photoFileId: null,
167
+ custom: {},
41
168
  createdAt: now,
42
169
  updatedAt: now,
43
- archivedAt: null,
170
+ archivedAt: seed.archived ? now : null,
44
171
  }
45
172
  }
46
173
 
174
+ const assets: Asset[] = SEEDS.map((seed, i) => blank(pad(i + 1), seed))
175
+
176
+ /**
177
+ * The workspace every seeded row belongs to, learned from the first call that names one.
178
+ *
179
+ * The seeds are built before any workspace exists, and only `list` used to stamp its argument
180
+ * onto what it returned — so the same asset came back with a real `workspaceId` from the list and
181
+ * an empty one from `get`, `update` and `archive`. A demo where one row disagrees with itself
182
+ * about which workspace it is in is the kind of thing nobody notices until a screen keys a cache
183
+ * off it.
184
+ */
185
+ let workspace = '' as Asset['workspaceId']
186
+ const remember = (id?: string) => {
187
+ if (id) workspace = id as Asset['workspaceId']
188
+ }
189
+ const stamp = (asset: Asset): Asset => ({ ...asset, workspaceId: workspace })
190
+
191
+ const matches = (a: Asset, q?: string) =>
192
+ !q ||
193
+ [a.name, a.code, a.serialNumber ?? ''].some((field) => field.toLowerCase().includes(q.toLowerCase()))
194
+
195
+ const find = (assetId: string) => {
196
+ const asset = assets.find((a) => a.id === assetId)
197
+ if (!asset) throw new MockApiError('NOT_FOUND', 'Asset not found')
198
+ return asset
199
+ }
200
+
47
201
  return {
48
202
  assets: {
49
- list: async ({ workspaceId, q }: { workspaceId: string; q?: string }) => ({
50
- items: assets
51
- .filter((a) => !q || a.name.includes(q) || a.code.includes(q) || (a.serialNumber ?? '').includes(q))
52
- .map((a) => ({ ...a, workspaceId })),
53
- nextCursor: null,
54
- }),
55
- get: async ({ assetId }: { assetId: string }) => {
56
- const asset = assets.find((a) => a.id === assetId)
57
- if (!asset) throw new Error('Asset not found')
58
- return { ...asset }
203
+ list: async ({
204
+ workspaceId,
205
+ q,
206
+ status,
207
+ categoryId,
208
+ custodianUserId,
209
+ archived = false,
210
+ sort = 'recent',
211
+ limit = 50,
212
+ cursor,
213
+ }: {
214
+ workspaceId: string
215
+ q?: string
216
+ status?: AssetStatus
217
+ categoryId?: string
218
+ custodianUserId?: string
219
+ archived?: boolean
220
+ sort?: AssetSort
221
+ limit?: number
222
+ cursor?: string
223
+ }) => {
224
+ remember(workspaceId)
225
+ // Every control the screen offers filters here, or the demo argues with its own toolbar.
226
+ const all = assets
227
+ .filter((a) => matches(a, q))
228
+ .filter((a) => (status ? a.status === status : true))
229
+ .filter((a) => (categoryId ? a.categoryId === categoryId : true))
230
+ .filter((a) => (custodianUserId ? a.custodianUserId === custodianUserId : true))
231
+ .filter((a) => (archived ? true : !a.archivedAt))
232
+ .map(stamp)
233
+
234
+ // `recent` is the only descending order, and it is the contract's default — the list opens
235
+ // on what was added last, which is what the server does and what the old mock did not.
236
+ const descending = sort === 'recent'
237
+ all.sort((a, b) => (descending ? -compare(a, b, sort) : compare(a, b, sort)))
238
+
239
+ let rows = all
240
+ if (cursor) {
241
+ const mark = decodeCursor(cursor, sort)
242
+ const bookmarked = assets.find((a) => a.id === mark.i)
243
+ /**
244
+ * A row archived or removed between two pages leaves nothing to compare against and the
245
+ * page simply ends — the server's deliberate choice, because somebody else editing while
246
+ * you read is an ordinary race rather than a malformed request.
247
+ */
248
+ if (!bookmarked) rows = []
249
+ else
250
+ rows = all.filter((a) => {
251
+ const order = compare(a, bookmarked, sort)
252
+ return descending ? order < 0 : order > 0
253
+ })
254
+ }
255
+
256
+ // limit + 1 to learn whether there is a next page, exactly as the server does.
257
+ const window = rows.slice(0, limit + 1)
258
+ const items = window.slice(0, limit)
259
+ const last = items.at(-1)
260
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: sort }) : null
261
+ return { items, nextCursor }
59
262
  },
263
+
264
+ get: async ({ workspaceId, assetId }: { workspaceId?: string; assetId: string }) => {
265
+ remember(workspaceId)
266
+ return stamp(find(assetId))
267
+ },
268
+
60
269
  create: async ({ workspaceId, ...rest }: { workspaceId: string } & Record<string, unknown>) => {
61
- const asset: MockAsset = {
62
- ...seed(
63
- crypto.randomUUID(),
64
- `INV-${String(counter++).padStart(4, '0')}`,
65
- String(rest.name),
66
- 'in_stock',
67
- ),
68
- workspaceId: workspaceId as Asset['workspaceId'],
270
+ remember(workspaceId)
271
+ const asset: Asset = {
272
+ ...blank(pad(nextCodeNumber++)),
273
+ name: String(rest.name ?? ''),
69
274
  description: String(rest.description ?? ''),
70
275
  serialNumber: (rest.serialNumber as string | undefined) ?? null,
71
276
  location: (rest.location as string | undefined) ?? null,
@@ -75,20 +280,37 @@ export function createMockInventoryApi() {
75
280
  priceMinor: (rest.priceMinor as number | undefined) ?? null,
76
281
  currency: (rest.currency as string | undefined) ?? null,
77
282
  }
78
- assets.unshift(asset)
79
- return { ...asset }
283
+ assets.push(asset)
284
+ return stamp(asset)
80
285
  },
81
- update: async ({ assetId, ...rest }: { assetId: string } & Record<string, unknown>) => {
82
- const asset = assets.find((a) => a.id === assetId)
83
- if (!asset) throw new Error('Asset not found')
84
- Object.assign(asset, rest)
85
- return { ...asset }
286
+
287
+ update: async ({
288
+ assetId,
289
+ workspaceId,
290
+ ...rest
291
+ }: { assetId: string; workspaceId?: string } & Record<string, unknown>) => {
292
+ remember(workspaceId)
293
+ const asset = find(assetId)
294
+ // `workspaceId` is not among the fields assigned: it is routing, not a field of the asset,
295
+ // and letting a patch carry it means a demo can move a row to a workspace that is not real.
296
+ Object.assign(asset, rest, { updatedAt: new Date().toISOString() })
297
+ return stamp(asset)
86
298
  },
87
- archive: async ({ assetId, archived }: { assetId: string; archived?: boolean }) => {
88
- const asset = assets.find((a) => a.id === assetId)
89
- if (!asset) throw new Error('Asset not found')
299
+
300
+ archive: async ({
301
+ workspaceId,
302
+ assetId,
303
+ archived,
304
+ }: {
305
+ workspaceId?: string
306
+ assetId: string
307
+ archived?: boolean
308
+ }) => {
309
+ remember(workspaceId)
310
+ const asset = find(assetId)
90
311
  asset.archivedAt = archived === false ? null : new Date().toISOString()
91
- return { ...asset }
312
+ asset.updatedAt = new Date().toISOString()
313
+ return stamp(asset)
92
314
  },
93
315
  },
94
316
  }
@@ -89,8 +89,28 @@ export const inventoryClientModule = defineClientModule({
89
89
  },
90
90
  ],
91
91
 
92
- /** A settings page's `id` is its URL: the shell mounts it at `/<ws>/settings/inventory/<id>`. */
93
- settingsPages: [],
92
+ /**
93
+ * A settings page's `id` is its URL: the shell mounts it at `/<ws>/settings/inventory/<id>`.
94
+ *
95
+ * This was `[]` while the server enforced `assetCodePrefix` and `assetCodePad` on every asset it
96
+ * created — two settings nobody, at any permission level, could change. `core.modules.manage`
97
+ * rather than an inventory permission, because that is what core gates
98
+ * `workspaces.modules.updateSettings` on: offering the page on a wider permission shows somebody
99
+ * a form the server then refuses on save.
100
+ */
101
+ settingsPages: [
102
+ {
103
+ id: 'general',
104
+ get label() {
105
+ return t('settings_general')
106
+ },
107
+ icon: 'sliders-vertical',
108
+ scope: 'workspace',
109
+ permission: 'core.modules.manage',
110
+ order: 1,
111
+ component: () => import('./settings/GeneralSettings.svelte'),
112
+ },
113
+ ],
94
114
  })
95
115
 
96
116
  export default inventoryClientModule