@kernhq/module-inventory 0.1.2 → 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 (213) hide show
  1. package/README.md +134 -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 +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  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 +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  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 +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  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 +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  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 +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  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/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -1,8 +1,54 @@
1
1
  import { dirname, join } from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
- import { inventoryContract, inventoryEvents, inventoryPermissions, MODULE_ID } from '../contract.js'
4
- import { defineModule, defineServerModule, implement_, packageVersion } from './_impl.js'
5
- import { schema } from './schema.js'
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'
8
+ import {
9
+ Asset,
10
+ InventorySettings,
11
+ inventoryCapabilities,
12
+ inventoryContract,
13
+ inventoryEvents,
14
+ inventoryNotificationTypes,
15
+ inventoryPermissions,
16
+ MODULE_ID,
17
+ } from '../contract/index.js'
18
+ import { inventoryJobs } from './jobs.js'
19
+ import { defineModule, defineServerModule, inventoryRouter, packageVersion } from './router.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'])
6
52
 
7
53
  export const inventoryModule = defineServerModule({
8
54
  definition: defineModule({
@@ -10,24 +56,288 @@ export const inventoryModule = defineServerModule({
10
56
  name: 'Inventory',
11
57
  version: packageVersion(import.meta.url),
12
58
  description:
13
- 'The asset register: what the company owns, who holds each item, and everything that happened to it',
59
+ 'The asset register: what the company owns, item by item — tags, serial numbers, purchase and warranty details',
14
60
  icon: 'briefcase',
15
61
  permissions: inventoryPermissions,
62
+ capabilities: inventoryCapabilities,
16
63
  events: inventoryEvents,
64
+ notificationTypes: inventoryNotificationTypes,
65
+ settings: InventorySettings,
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 }],
17
79
  }),
18
80
  /** Attached so the developer panel can check the router against what was promised. */
19
81
  contract: inventoryContract,
20
82
  schema,
21
83
  migrationsFolder: join(dirname(fileURLToPath(import.meta.url)), '../../migrations'),
22
- router: implement_,
84
+ router: inventoryRouter,
85
+
86
+ jobs: inventoryJobs(),
87
+
23
88
  /**
24
- * What this module reacts to. The pattern may be an exact name, `module.*`, or `*`; handlers are
25
- * durable consumers in production, so one that throws is retried rather than lost.
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.
26
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
+
27
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
+ */
28
222
  'core.workspace.created': async (event, kernel) => {
29
- kernel.log.info({ module: MODULE_ID, event: event.name }, 'a workspace was created')
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')
30
293
  },
31
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
+ },
32
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
+
33
342
  export default inventoryModule
343
+ export type InventoryModule = typeof inventoryModule