@kernhq/module-inventory 0.2.0 → 0.3.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 (193) hide show
  1. package/README.md +104 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +322 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +213 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1052 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +225 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1337 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +363 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +158 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/meta/0002_snapshot.json +1054 -0
  117. package/migrations/meta/0003_snapshot.json +1070 -0
  118. package/migrations/meta/0004_snapshot.json +1130 -0
  119. package/migrations/meta/0005_snapshot.json +1135 -0
  120. package/migrations/meta/_journal.json +42 -0
  121. package/package.json +1 -1
  122. package/src/client/api-instance.ts +27 -2
  123. package/src/client/bidi.test.ts +148 -0
  124. package/src/client/bidi.ts +85 -0
  125. package/src/client/components/AssetDetailPanel.svelte +614 -0
  126. package/src/client/components/AssetFormDialog.svelte +71 -22
  127. package/src/client/components/AssetPhoto.svelte +178 -0
  128. package/src/client/components/AttachmentsSection.svelte +327 -0
  129. package/src/client/components/CustodyDialog.svelte +201 -0
  130. package/src/client/components/RepairDialog.svelte +271 -0
  131. package/src/client/components/RepairsSection.svelte +318 -0
  132. package/src/client/components/Timeline.svelte +347 -0
  133. package/src/client/components/TimelineText.svelte +124 -0
  134. package/src/client/core-api.ts +71 -0
  135. package/src/client/custody.test.ts +31 -0
  136. package/src/client/custody.ts +34 -0
  137. package/src/client/errors.test.ts +365 -0
  138. package/src/client/errors.ts +201 -0
  139. package/src/client/links.test.ts +74 -0
  140. package/src/client/links.ts +44 -0
  141. package/src/client/members.test.ts +132 -0
  142. package/src/client/members.ts +116 -0
  143. package/src/client/messages.test.ts +127 -0
  144. package/src/client/messages.ts +1043 -18
  145. package/src/client/mock.test.ts +395 -1
  146. package/src/client/mock.ts +1066 -79
  147. package/src/client/module.ts +54 -0
  148. package/src/client/pages/AssetsPage.svelte +317 -45
  149. package/src/client/permissions.ts +7 -0
  150. package/src/client/price.test.ts +151 -3
  151. package/src/client/price.ts +155 -11
  152. package/src/client/query.ts +36 -0
  153. package/src/client/repairs.test.ts +38 -0
  154. package/src/client/repairs.ts +38 -0
  155. package/src/client/settings/CategoriesSettings.svelte +421 -0
  156. package/src/client/settings/GeneralSettings.svelte +0 -0
  157. package/src/client/status.ts +29 -0
  158. package/src/client/timeline.test.ts +175 -0
  159. package/src/client/timeline.ts +206 -0
  160. package/src/client/widgets/OverviewWidget.svelte +127 -26
  161. package/src/client/widgets/RepairsWidget.svelte +124 -0
  162. package/src/contract/capabilities.ts +53 -9
  163. package/src/contract/events.ts +49 -0
  164. package/src/contract/index.ts +1 -0
  165. package/src/contract/models.ts +237 -0
  166. package/src/contract/notifications.ts +73 -0
  167. package/src/contract/permissions.ts +55 -2
  168. package/src/contract/router.ts +255 -1
  169. package/src/contract/settings.ts +24 -4
  170. package/src/module.test.ts +194 -4
  171. package/src/server/index.ts +311 -4
  172. package/src/server/inventory.int.test.ts +3573 -18
  173. package/src/server/jobs.ts +444 -0
  174. package/src/server/migrations.test.ts +113 -0
  175. package/src/server/router.ts +462 -6
  176. package/src/server/schema.ts +160 -3
  177. package/src/server/services/assets.ts +214 -54
  178. package/src/server/services/attachments.ts +215 -0
  179. package/src/server/services/audience.ts +77 -0
  180. package/src/server/services/categories.ts +136 -0
  181. package/src/server/services/cursor.ts +104 -0
  182. package/src/server/services/custody.ts +471 -0
  183. package/src/server/services/db-errors.ts +42 -0
  184. package/src/server/services/index.ts +34 -1
  185. package/src/server/services/members.ts +54 -0
  186. package/src/server/services/notify.ts +52 -7
  187. package/src/server/services/offboarding.ts +150 -0
  188. package/src/server/services/repairs.ts +567 -0
  189. package/src/server/services/search.ts +166 -0
  190. package/src/server/services/stats.ts +88 -0
  191. package/src/server/services/status.test.ts +34 -0
  192. package/src/server/services/status.ts +143 -0
  193. package/src/client/settings/core-api.ts +0 -32
@@ -22,9 +22,29 @@ export const InventorySettings = z.object({
22
22
  */
23
23
  assetCodePrefix: z.string().max(8).default('INV-'),
24
24
  assetCodePad: z.number().int().min(1).max(10).default(4),
25
- // `warrantyNoticeDays` used to sit here, describing a warranty sweep that has never existed —
26
- // no job, no subscription, nothing reading the number. A setting nothing enforces is the same
27
- // lie as a capability nothing checks: it teaches an administrator that the settings page does
28
- // not mean anything. It comes back with the sweep.
25
+ /**
26
+ * How far ahead of a warranty running out somebody is told.
27
+ *
28
+ * This spent a release describing a sweep that did not exist no job, no subscription, nothing
29
+ * reading the number — and was taken out for it. It is back because `warranty-sweep` reads it
30
+ * every morning: a live asset whose `warranty_until` falls inside this many days earns exactly
31
+ * one notice, marked on the row so the next morning does not send it again.
32
+ *
33
+ * A month by default, which is about how long it takes to decide whether to extend a warranty or
34
+ * budget for a replacement. Capped at a year: further out than that is not a notice, it is a
35
+ * report.
36
+ */
37
+ warrantyNoticeDays: z.number().int().min(1).max(365).default(30),
38
+ /**
39
+ * How long an item may be at a repairer before somebody is asked to chase it.
40
+ *
41
+ * Read by `repair-overdue`, which tells the person who logged the repair — and whoever is still
42
+ * holding the item — once, and then leaves them alone. Two weeks by default: long enough that an
43
+ * ordinary screen replacement never trips it, short enough that a laptop nobody chased does.
44
+ *
45
+ * A workspace with the `repairs` capability off never meets this: the sweep asks first, and a
46
+ * setting whose feature is switched off changes nothing rather than firing quietly.
47
+ */
48
+ repairOverdueDays: z.number().int().min(1).max(365).default(14),
29
49
  })
30
50
  export type InventorySettings = z.infer<typeof InventorySettings>
@@ -11,6 +11,9 @@
11
11
  *
12
12
  * Add your module's real tests next to it; this one keeps working as the contract grows.
13
13
  */
14
+ import { readdirSync, readFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
14
17
  import type { Kernel } from '@kernhq/kernel'
15
18
  import { describe, expect, it } from 'vitest'
16
19
  import {
@@ -122,6 +125,40 @@ async function reachedFor(middleware: unknown): Promise<Reached> {
122
125
 
123
126
  const chainOf = (name: string): unknown[] => implemented[name]?.['~orpc'].middlewares ?? []
124
127
 
128
+ /** What every implemented procedure's middleware chain reached for, resolved once. */
129
+ async function resolveChains(): Promise<Record<string, Reached[]>> {
130
+ const entries = await Promise.all(
131
+ Object.keys(implemented).map(
132
+ async (name) => [name, await Promise.all(chainOf(name).map(reachedFor))] as const,
133
+ ),
134
+ )
135
+ return Object.fromEntries(entries)
136
+ }
137
+
138
+ /**
139
+ * Which of `names` the router does **not** put behind `capability`, in the right place.
140
+ *
141
+ * A pure function of the chains rather than an assertion inside a loop, so the check can be aimed at
142
+ * a chain this file builds by hand — which is the only way to show that it rejects what it claims
143
+ * to. An assertion that has never been seen to fail is a comment with a green tick beside it.
144
+ *
145
+ * Two ways to fail, and both matter: no capability gate at all, and a gate that sits *after* the
146
+ * permission check. The order is the whole point of `workspaceScoped` → `requiresCapability` →
147
+ * `requires`: a workspace with the module off must be refused before anything reveals which
148
+ * capabilities it would have had, and a workspace with the capability off must get 404 rather than
149
+ * the 403 a permission check would produce first.
150
+ */
151
+ function ungated(capability: string, names: readonly string[], chains: Record<string, Reached[]>): string[] {
152
+ return names.filter((name) => {
153
+ const chain = chains[name] ?? []
154
+ const gate = chain.findIndex((r) => r.capability === `${MODULE_ID}.${capability}`)
155
+ const permission = chain.findIndex((r) => r.permission !== undefined)
156
+ // Index 0 is `workspaceScoped`'s place, so a gate there is a gate that displaced it.
157
+ if (gate < 1) return true
158
+ return permission !== -1 && gate > permission
159
+ })
160
+ }
161
+
125
162
  describe('every procedure is authorised', () => {
126
163
  const declaredKeys = new Set(inventoryPermissions.map((p) => p.key))
127
164
 
@@ -168,15 +205,89 @@ describe('the module declares what it uses', () => {
168
205
  * Capabilities, which are the one thing here that cannot be seen by reading a handler.
169
206
  *
170
207
  * A missing `requiresCapability` is invisible: the procedure compiles, every other test passes, and
171
- * the only symptom is a workspace successfully calling a feature it switched off. So the map is
172
- * declared as data in the contract and checked against the router here.
208
+ * the only symptom is a workspace successfully calling a feature it switched off.
173
209
  *
174
- * The map is empty while `core` is the only capability `core` is `required`, so nothing sits
175
- * behind a switch anyone can flip. These tests are what keep that true as the map fills up.
210
+ * **The expectation is derived from the contract, not opted into.** This used to check only the
211
+ * procedures named in `inventoryCapabilityProcedures` so the map was both the claim and the
212
+ * evidence for it, and the regression it exists to catch walked straight past: add
213
+ * `repairs.cancel` to the contract, forget the middleware, forget the map, and every test here is
214
+ * green while a workspace with repairs switched off can call it. A list that has to be updated by
215
+ * the same person who forgot the thing it is guarding is not a guard.
216
+ *
217
+ * So the rule is read off the module's own declarations instead: **a switchable capability owns the
218
+ * router group named after it**, and every procedure in that group is gated on it. `repairs.*`
219
+ * belongs to `repairs`; `attachments.*` belongs to `attachments`. Adding a procedure to either group
220
+ * fails this file until it carries the middleware, whatever the map says. The map is still checked —
221
+ * it is what the client reads — but now against the group rather than against itself.
176
222
  */
177
223
  describe('capabilities are enforced where they are declared', () => {
178
224
  const gated = new Set(Object.values(inventoryCapabilityProcedures).flat())
179
225
 
226
+ /** The capabilities a workspace can actually switch: `core` is `required` and owns nothing. */
227
+ const switchable = inventoryCapabilities.filter((c) => !c.required).map((c) => c.id)
228
+
229
+ /** The contract's procedures under a capability's own name — the derived expectation. */
230
+ const groupOf = (capability: string) =>
231
+ Object.keys(declared)
232
+ .filter((name) => name.startsWith(`${capability}.`))
233
+ .sort()
234
+
235
+ it('gates every procedure in a switchable capability’s own group, named in the map or not', async () => {
236
+ const chains = await resolveChains()
237
+ for (const capability of switchable) {
238
+ expect(
239
+ groupOf(capability).length,
240
+ `${capability}: declared as a switch with no procedures behind it — a switch that changes nothing teaches an administrator that the switchboard does not mean anything`,
241
+ ).toBeGreaterThan(0)
242
+ expect(
243
+ ungated(capability, groupOf(capability), chains),
244
+ `these need requiresCapability('${MODULE_ID}', '${capability}') between the workspace gate and the permission check`,
245
+ ).toEqual([])
246
+ }
247
+ })
248
+
249
+ it('names that whole group in the map, so the client and the router cannot drift', () => {
250
+ // The client reads this map to decide what to hide; the router decides what to answer. A
251
+ // procedure gated in one and not the other is a tab that is there and 404s, or hidden and works.
252
+ for (const capability of switchable)
253
+ expect([...(inventoryCapabilityProcedures[capability] ?? [])].sort()).toEqual(groupOf(capability))
254
+ })
255
+
256
+ it('rejects an ungated procedure, and one gated in the wrong place', () => {
257
+ /**
258
+ * The assertion aimed at chains built here, because a check nobody has watched fail is a check
259
+ * nobody knows the shape of. Four procedures, one correct and three not, and `ungated` has to
260
+ * name exactly the three.
261
+ */
262
+ const chains: Record<string, Reached[]> = {
263
+ 'repairs.list': [
264
+ { module: MODULE_ID },
265
+ { capability: `${MODULE_ID}.repairs` },
266
+ { permission: 'inventory.asset.view' },
267
+ ],
268
+ // The regression this file exists for: somebody added a procedure and no middleware.
269
+ 'repairs.create': [{ module: MODULE_ID }, { permission: 'inventory.repair.manage' }],
270
+ // Gated, but after the permission — so a workspace with the capability off gets 403 from the
271
+ // permission check before the 404 that is the honest answer.
272
+ 'repairs.update': [
273
+ { module: MODULE_ID },
274
+ { permission: 'inventory.repair.manage' },
275
+ { capability: `${MODULE_ID}.repairs` },
276
+ ],
277
+ // Gated on a different capability, which the identity check is what catches.
278
+ 'repairs.complete': [
279
+ { module: MODULE_ID },
280
+ { capability: `${MODULE_ID}.attachments` },
281
+ { permission: 'inventory.repair.manage' },
282
+ ],
283
+ }
284
+ expect(ungated('repairs', Object.keys(chains), chains)).toEqual([
285
+ 'repairs.create',
286
+ 'repairs.update',
287
+ 'repairs.complete',
288
+ ])
289
+ })
290
+
180
291
  it('names only capabilities the module actually declares', () => {
181
292
  const declaredIds = new Set(inventoryCapabilities.map((c) => c.id))
182
293
  for (const id of Object.keys(inventoryCapabilityProcedures))
@@ -210,3 +321,82 @@ describe('capabilities are enforced where they are declared', () => {
210
321
  expect(inventoryCapabilities.find((c) => c.id === 'core')?.required).toBe(true)
211
322
  })
212
323
  })
324
+
325
+ /**
326
+ * The manifest's other declarations — the ones that are handlers rather than middleware.
327
+ *
328
+ * Every entry here is a promise to the rest of the product, and each has exactly one shape of lie
329
+ * available to it: an object type with no resolver renders a link to nothing, a search indexer for a
330
+ * type nobody declared indexes documents nothing can open, a notification type nothing sends is a
331
+ * row in everybody's preferences that changes nothing, and a job with no cron never runs. All four
332
+ * compile. `objectTypes` was removed from this module in 0.2.0 for precisely the first of them.
333
+ */
334
+ describe('the platform surfaces the module declares', () => {
335
+ const objectTypes = inventoryModule.definition.objectTypes ?? []
336
+
337
+ it('backs every object type with a resolver and an indexer', () => {
338
+ const resolved = new Set((inventoryModule.resolvers ?? []).map((r) => r.type))
339
+ const indexed = new Set((inventoryModule.search ?? []).flatMap((s) => s.types))
340
+ for (const { type } of objectTypes)
341
+ expect({ type, resolved: resolved.has(type), indexed: indexed.has(type) }).toEqual({
342
+ type,
343
+ resolved: true,
344
+ indexed: true,
345
+ })
346
+ })
347
+
348
+ it('declares every type it resolves or indexes, so nothing points at an undeclared noun', () => {
349
+ const declared = new Set(objectTypes.map((o) => o.type))
350
+ for (const resolver of inventoryModule.resolvers ?? [])
351
+ expect({ type: resolver.type, declared: declared.has(resolver.type) }).toEqual({
352
+ type: resolver.type,
353
+ declared: true,
354
+ })
355
+ for (const type of (inventoryModule.search ?? []).flatMap((s) => s.types))
356
+ expect({ type, declared: declared.has(type) }).toEqual({ type, declared: true })
357
+ })
358
+
359
+ it('offers a full reindex as well as a single load', () => {
360
+ // Without `scan`, `core.search.reindex` walks this module and finds nothing to do — silently,
361
+ // which is the worst way for a repair mechanism to be missing.
362
+ for (const indexer of inventoryModule.search ?? [])
363
+ expect({ types: indexer.types, scan: typeof indexer.scan }).toEqual({
364
+ types: indexer.types,
365
+ scan: 'function',
366
+ })
367
+ })
368
+
369
+ it('gives every scheduled job a name of its own and a schedule', () => {
370
+ const jobs = inventoryModule.jobs ?? []
371
+ expect(jobs.length, 'a module with sweeps declares them').toBeGreaterThan(0)
372
+ expect(new Set(jobs.map((j) => j.name)).size, 'two jobs sharing a name share a queue').toBe(jobs.length)
373
+ // Every job this module has is a sweep; one without a cron would be a queue nothing enqueues.
374
+ for (const job of jobs)
375
+ expect({ job: job.name, cron: typeof job.cron }).toEqual({ job: job.name, cron: 'string' })
376
+ })
377
+
378
+ it('names every notification type under its own module id', () => {
379
+ for (const type of inventoryModule.definition.notificationTypes ?? [])
380
+ expect(type.type.startsWith(`${MODULE_ID}.`), type.type).toBe(true)
381
+ })
382
+
383
+ /**
384
+ * A notification type nothing sends is the same lie as a permission nothing checks — and unlike a
385
+ * permission there is no middleware to inspect, so this reads the server for the string.
386
+ *
387
+ * Crude on purpose, and honest about it: it proves the *literal* appears in a file that sends
388
+ * notifications, not that the branch is reachable. A type whose key was built by concatenation
389
+ * would slip past it. Every one of this module's four is a literal at its call site, and keeping
390
+ * them that way is the point — a notification type assembled at runtime is one nobody can grep
391
+ * for either.
392
+ */
393
+ it('has a sender in the server for every notification type it declares', () => {
394
+ const here = dirname(fileURLToPath(import.meta.url))
395
+ const sources = readdirSync(join(here, 'server'), { recursive: true, encoding: 'utf8' })
396
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
397
+ .map((f) => readFileSync(join(here, 'server', f), 'utf8'))
398
+ .join('\n')
399
+ for (const { type } of inventoryModule.definition.notificationTypes ?? [])
400
+ expect({ type, sent: sources.includes(`'${type}'`) }).toEqual({ type, sent: true })
401
+ })
402
+ })
@@ -1,15 +1,54 @@
1
1
  import { dirname, join } from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
+ import type { core, Principal } from '@kernhq/contracts'
4
+ import { WorkspaceId } from '@kernhq/contracts'
5
+ import { KernError, type Kernel } from '@kernhq/kernel'
6
+ import { and, eq, inArray } from 'drizzle-orm'
7
+ import { z } from 'zod'
3
8
  import {
9
+ Asset,
4
10
  InventorySettings,
5
11
  inventoryCapabilities,
6
12
  inventoryContract,
7
13
  inventoryEvents,
14
+ inventoryNotificationTypes,
8
15
  inventoryPermissions,
9
16
  MODULE_ID,
10
17
  } from '../contract/index.js'
18
+ import { inventoryJobs } from './jobs.js'
11
19
  import { defineModule, defineServerModule, inventoryRouter, packageVersion } from './router.js'
12
- import { schema } from './schema.js'
20
+ import { assets, categories, schema, workspaces } from './schema.js'
21
+ import { toAsset } from './services/assets.js'
22
+ import { inventoryServices } from './services/index.js'
23
+ import { ASSET_ICON, assetUrl } from './services/search.js'
24
+
25
+ /**
26
+ * The categories a workspace starts with.
27
+ *
28
+ * Five, because an empty picker on the asset form is a question a new workspace cannot answer yet —
29
+ * "what do you file a laptop under" has an obvious answer and asking it costs somebody a trip to a
30
+ * settings page before they can add their first asset. Ordered rather than alphabetical: this is the
31
+ * order somebody scanning a list expects, and every one of them is renamable, archivable and
32
+ * ignorable.
33
+ */
34
+ const DEFAULT_CATEGORIES = ['Laptops', 'Phones', 'Monitors', 'Furniture', 'Vehicles'] as const
35
+
36
+ /**
37
+ * The `procedures` below are this module's service-to-service surface, reachable only over
38
+ * `kernel.call`. They deliberately run with elevated access — no workspace membership is checked,
39
+ * because the caller is another service and has none — so they must never be callable by an end
40
+ * user: anything a person does goes through the oRPC router and its permission middleware.
41
+ *
42
+ * Copied deliberately from `module-tracker`, down to the shape of the check, so that the one line
43
+ * standing between "an internal read" and "a permission-free read of any workspace's register"
44
+ * looks the same in every module somebody audits.
45
+ */
46
+ function requireService(principal: Principal): void {
47
+ if (principal.kind !== 'service' && !principal.instanceAdmin) throw KernError.forbidden()
48
+ }
49
+
50
+ /** HR's statuses that mean somebody is on their way out. Their own enum; read, never guessed. */
51
+ const LEAVING_STATUSES = new Set(['offboarding', 'terminated'])
13
52
 
14
53
  export const inventoryModule = defineServerModule({
15
54
  definition: defineModule({
@@ -22,15 +61,283 @@ export const inventoryModule = defineServerModule({
22
61
  permissions: inventoryPermissions,
23
62
  capabilities: inventoryCapabilities,
24
63
  events: inventoryEvents,
64
+ notificationTypes: inventoryNotificationTypes,
25
65
  settings: InventorySettings,
26
- // `objectTypes` returns with the resolver that turns a mention or a link into an asset and the
27
- // indexer that puts one in search declaring the type with neither made both resolve to
28
- // nothing, which reads to a user as a broken link rather than as a feature not built yet.
66
+ /**
67
+ * `inventory:asset:<id>` is a thing the rest of the product may point at.
68
+ *
69
+ * This came *off* the manifest in 0.2.0 and is back now, and the difference is the two handlers
70
+ * under it: `resolvers` turns the reference into a title, a URL and an icon, and `search` puts
71
+ * the row where somebody can find it in the first place. A declared type with neither renders a
72
+ * link to nothing, which reads to a person as a broken product rather than as a feature that
73
+ * has not shipped — the same lie as a permission nothing checks.
74
+ *
75
+ * `channelable: false`, unlike a tracker issue: a channel per laptop is not a thing anybody
76
+ * wants, and the conversation about an asset belongs wherever the work does.
77
+ */
78
+ objectTypes: [{ type: 'asset', label: 'Asset', icon: ASSET_ICON, channelable: false }],
29
79
  }),
30
80
  /** Attached so the developer panel can check the router against what was promised. */
31
81
  contract: inventoryContract,
32
82
  schema,
33
83
  migrationsFolder: join(dirname(fileURLToPath(import.meta.url)), '../../migrations'),
34
84
  router: inventoryRouter,
85
+
86
+ jobs: inventoryJobs(),
87
+
88
+ /**
89
+ * `inventory:asset:<id>` rendered wherever it is mentioned — a chat message, a tracker issue, a
90
+ * notification.
91
+ *
92
+ * The permission is checked **once for the batch**, not per id: `inventory.asset.view` is a
93
+ * workspace permission and every asset in a workspace has the same audience, so there is nothing
94
+ * per-row to decide. Somebody without it gets nulls, which the caller renders as plain text
95
+ * rather than as a link to a title they were not entitled to read — a resolver is a read like any
96
+ * other, and the fact that it is called by another module does not make it exempt.
97
+ */
98
+ resolvers: [
99
+ {
100
+ type: 'asset',
101
+ resolve: async (workspaceId, ids, principal, kernel) => {
102
+ const may = await kernel.authz
103
+ .can(principal, 'inventory.asset.view', { kind: 'workspace', workspaceId })
104
+ .catch(() => false)
105
+ if (!may) return ids.map(() => null)
106
+ return kernel.database.withWorkspace(workspaceId, async (tx) => {
107
+ const rows = await tx
108
+ .select({
109
+ id: assets.id,
110
+ code: assets.code,
111
+ name: assets.name,
112
+ status: assets.status,
113
+ archivedAt: assets.archivedAt,
114
+ })
115
+ .from(assets)
116
+ .where(and(eq(assets.workspaceId, workspaceId), inArray(assets.id, ids)))
117
+ const byId = new Map(rows.map((row) => [row.id, row]))
118
+ return ids.map((id) => {
119
+ const row = byId.get(id)
120
+ if (!row) return null
121
+ return {
122
+ id,
123
+ // The tag first, because that is what is printed on the sticker somebody is holding.
124
+ title: `${row.code} ${row.name}`,
125
+ url: assetUrl(id),
126
+ icon: ASSET_ICON,
127
+ /**
128
+ * An archived asset still resolves — a link written last year must not turn into
129
+ * nothing — and says so, where the search index drops it. A reference is a fact about
130
+ * the past; a search hit is an offer to go somewhere now.
131
+ *
132
+ * The value is the module's own status vocabulary, not a translated label, and
133
+ * `archived` is lowercase for the same reason `in_stock` is: a resolver runs in a
134
+ * service with no locale to render into, so it hands over the machine value and the
135
+ * caller decides. Every module's resolver does this today — `module-tracker` returns
136
+ * a raw `statusId`. Translating it needs the platform to give a resolver the reader's
137
+ * locale, which it does not, and inventing an English sentence here would look
138
+ * finished while being wrong in four of the five languages this module ships.
139
+ */
140
+ subtitle: row.archivedAt ? 'archived' : row.status,
141
+ }
142
+ })
143
+ })
144
+ },
145
+ },
146
+ ],
147
+
148
+ /**
149
+ * The register in the workspace-wide search index, so an asset tag read off a sticker finds the
150
+ * item from the command palette.
151
+ *
152
+ * Both halves are `SearchService`'s, so the document a mutation writes and the document a full
153
+ * reindex writes cannot drift: `load` answers `null` for an archived or missing row, which is what
154
+ * takes it back out, and `scan` pages by keyset for `core.search.reindex`.
155
+ */
156
+ search: [
157
+ {
158
+ types: ['asset'],
159
+ load: (workspaceId, id, kernel): Promise<core.SearchDocument | null> =>
160
+ inventoryServices(kernel).search.load(workspaceId, id),
161
+ scan: (workspaceId, kernel) => inventoryServices(kernel).search.scan(workspaceId),
162
+ },
163
+ ],
164
+
165
+ /** Answers other modules and services may ask, without reaching into `mod_inventory`. */
166
+ procedures: {
167
+ /**
168
+ * One asset, for whatever holds an id and needs to say what it is — an automation, a report, a
169
+ * module that recorded an `ObjectRef` and now has to act on it.
170
+ */
171
+ 'asset.byId': {
172
+ input: z.object({ workspaceId: WorkspaceId, assetId: z.uuid() }),
173
+ output: Asset,
174
+ handler: async (input, { kernel, principal }) => {
175
+ requireService(principal)
176
+ return kernel.database.withWorkspace(input.workspaceId, async (tx) =>
177
+ toAsset(await inventoryServices(kernel).assets.get(tx, input.workspaceId, input.assetId)),
178
+ )
179
+ },
180
+ },
181
+ /**
182
+ * What one person is holding — the offboarding question, asked from outside.
183
+ *
184
+ * This module answers it for itself in the subscription below; the procedure exists because it
185
+ * is the question *other* modules ask about a person, and the alternative is each of them
186
+ * learning the shape of `mod_inventory.assets`. Answered through `AssetService.list` with the
187
+ * custodian filter fixed, which is the same one query path `custody.byUser` uses rather than a
188
+ * second one to keep in step.
189
+ */
190
+ 'assets.byCustodian': {
191
+ input: z.object({
192
+ workspaceId: WorkspaceId,
193
+ userId: z.uuid(),
194
+ limit: z.number().int().min(1).max(200).default(100),
195
+ cursor: z.string().max(500).optional(),
196
+ }),
197
+ output: z.object({ items: z.array(Asset), nextCursor: z.string().nullable() }),
198
+ handler: async (input, { kernel, principal }) => {
199
+ requireService(principal)
200
+ return kernel.database.withWorkspace(input.workspaceId, (tx) =>
201
+ inventoryServices(kernel).assets.list(tx, {
202
+ workspaceId: input.workspaceId,
203
+ limit: input.limit,
204
+ ...(input.cursor ? { cursor: input.cursor } : {}),
205
+ custodianUserId: input.userId,
206
+ archived: false,
207
+ sort: 'recent',
208
+ }),
209
+ )
210
+ },
211
+ },
212
+ },
213
+
214
+ subscriptions: {
215
+ /**
216
+ * Register the workspace so the sweeps can find it.
217
+ *
218
+ * Both halves are needed and neither is redundant: `onWorkspaceEnabled` covers somebody
219
+ * switching the module on for an existing workspace, and this covers a workspace created while
220
+ * the module is already on by default. A workspace registered twice is one row either way.
221
+ */
222
+ 'core.workspace.created': async (event, kernel) => {
223
+ const { workspaceId } = event.payload as { workspaceId: string }
224
+ await registerWorkspace(kernel, workspaceId)
225
+ },
226
+
227
+ /**
228
+ * A member removed from the workspace does **not** silently vanish from the register.
229
+ *
230
+ * The obvious implementation — clear `custodian_user_id` for everything they held — is the one
231
+ * this module refuses to write, and the reason is the same one `OffboardingService` gives:
232
+ * custody is an effective-dated record of who was answerable for what, and an item does not
233
+ * come back because an account was closed. Clearing the column would say the laptop is in stock
234
+ * while it is in somebody's bag, and it would do it *without a custody period closing*, so the
235
+ * timeline would show a handover that never ended and a return that never happened. `assets
236
+ * .archive` already refuses to archive a held item for exactly this reason.
237
+ *
238
+ * So: nothing moves, and the people who can take the item back are told there is something to
239
+ * collect. The custodian field keeps naming somebody who is no longer a member, and every
240
+ * screen in this module already renders an unresolvable id as "a former member" rather than as
241
+ * a uuid — which is the honest thing for it to say.
242
+ */
243
+ 'core.member.removed': async (event, kernel) => {
244
+ const { workspaceId, userId } = event.payload as { workspaceId: string; userId: string }
245
+ await inventoryServices(kernel).offboarding.raise(workspaceId, userId, 'removed')
246
+ },
247
+
248
+ /**
249
+ * HR says somebody is on their way out.
250
+ *
251
+ * **Inert without HR, in three independent ways**, because this module installs, boots and works
252
+ * in a workspace that has never had HR and there is no `dependsOn: ['hr']` on the manifest:
253
+ *
254
+ * 1. the event only exists if HR is running — a subscription to a pattern nothing publishes is
255
+ * a subscription that never fires;
256
+ * 2. `OffboardingService.raise` asks whether *Inventory* is switched on for that workspace
257
+ * before it does anything, because an event bus is instance-wide and one workspace having HR
258
+ * says nothing about another;
259
+ * 3. the one thing it needs from HR — the account behind a `personId` — is a `kernel.call`, and
260
+ * a call to a procedure nothing hosts throws, which is caught here and logged as nothing to
261
+ * do. A module never imports another module.
262
+ *
263
+ * `offboarding` matters more than `terminated`: somebody in their notice period is still at
264
+ * their desk, which is when a list of things to collect is worth having. `terminated` is the
265
+ * backstop for a workspace that never uses the intermediate status.
266
+ */
267
+ 'hr.person.status_changed': async (event, kernel) => {
268
+ const payload = event.payload as { workspaceId: string; personId: string; to: string }
269
+ if (!LEAVING_STATUSES.has(payload.to)) return
270
+ const person = await kernel
271
+ .call<{ userId: string | null } | null>('hr.person.get', {
272
+ workspaceId: payload.workspaceId,
273
+ personId: payload.personId,
274
+ })
275
+ .catch((err: unknown) => {
276
+ /**
277
+ * `UNAVAILABLE` is what the broker answers for a procedure nothing hosts, which is the
278
+ * ordinary state of an instance without HR and not worth a line in a log. Anything else
279
+ * is HR being *present and failing*, and swallowing the two the same way would mean the
280
+ * one case somebody should fix looks exactly like the case they should ignore.
281
+ */
282
+ if ((err as { code?: string }).code !== 'UNAVAILABLE')
283
+ kernel.log.warn(
284
+ { err: err instanceof Error ? err.message : String(err), module: MODULE_ID },
285
+ 'inventory: could not ask People who is behind a person leaving',
286
+ )
287
+ return null
288
+ })
289
+ // No account: `user_id` is legitimately null for somebody who never had a Kern account, and
290
+ // for somebody core has already removed — that path is the subscription above.
291
+ if (!person?.userId) return
292
+ await inventoryServices(kernel).offboarding.raise(payload.workspaceId, person.userId, 'leaving')
293
+ },
294
+ },
295
+
296
+ /**
297
+ * A workspace that switches Inventory on gets a filing system and a place in the scheduler.
298
+ *
299
+ * Idempotent, because it runs again every time somebody switches the module off and back on.
300
+ * Neither half may double up:
301
+ *
302
+ * - the registry row is an `on conflict do nothing` on its own primary key;
303
+ * - the categories are seeded **only into a workspace that has none at all**, archived ones
304
+ * included. Keying on the names instead would re-create "Laptops" for a workspace that had
305
+ * renamed it to "Notebooks" — the second toggle would quietly hand somebody a duplicate of
306
+ * their own category under the name they had rejected.
307
+ */
308
+ onWorkspaceEnabled: async (workspaceId, kernel) => {
309
+ await registerWorkspace(kernel, workspaceId)
310
+ await kernel.database.withWorkspace(workspaceId, async (tx) => {
311
+ const [existing] = await tx
312
+ .select({ id: categories.id })
313
+ .from(categories)
314
+ .where(eq(categories.workspaceId, workspaceId))
315
+ .limit(1)
316
+ if (existing) return
317
+ await tx.insert(categories).values(
318
+ DEFAULT_CATEGORIES.map((name, index) => ({
319
+ workspaceId,
320
+ name,
321
+ order: index + 1,
322
+ })),
323
+ )
324
+ })
325
+ },
35
326
  })
327
+
328
+ /**
329
+ * Remember that this workspace exists, so a job woken by a clock can find it.
330
+ *
331
+ * Written inside `withWorkspace` rather than through the unbound handle: the table carries a
332
+ * row-level security policy like every other tenant table, and a bound session satisfies it without
333
+ * needing the connection to be the schema's owner. The enumeration in `jobs.ts` is the one place
334
+ * that cannot do the same, and says so.
335
+ */
336
+ async function registerWorkspace(kernel: Kernel, workspaceId: string): Promise<void> {
337
+ await kernel.database.withWorkspace(workspaceId, (tx) =>
338
+ tx.insert(workspaces).values({ workspaceId }).onConflictDoNothing({ target: workspaces.workspaceId }),
339
+ )
340
+ }
341
+
36
342
  export default inventoryModule
343
+ export type InventoryModule = typeof inventoryModule