@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
@@ -6,13 +6,19 @@ import {
6
6
  packageVersion,
7
7
  type RequestContext,
8
8
  requires,
9
+ requiresCapability,
9
10
  type Tx,
10
11
  workspaceScoped,
11
12
  } from '@kernhq/kernel'
12
13
  import { implement } from '@orpc/server'
13
14
  import { inventoryContract, inventoryEvents, MODULE_ID } from '../contract/index.js'
14
15
  import { toAsset, type Written } from './services/assets.js'
16
+ import { toAttachment } from './services/attachments.js'
17
+ import { toCategory } from './services/categories.js'
18
+ import { type CustodyWritten, toCustodyPeriod } from './services/custody.js'
15
19
  import { inventoryServices } from './services/index.js'
20
+ import { requireWorkspaceMember } from './services/members.js'
21
+ import { type RepairWritten, toRepair } from './services/repairs.js'
16
22
 
17
23
  /**
18
24
  * oRPC router for `/api/inventory`.
@@ -32,15 +38,29 @@ const os = implement(inventoryContract).$context<RequestContext>()
32
38
  export function inventoryRouter(kernel: Kernel) {
33
39
  const svc = inventoryServices(kernel)
34
40
  const scoped = os.use(workspaceScoped(MODULE_ID))
41
+ /**
42
+ * The capability gate, between the workspace gate and the permission check.
43
+ *
44
+ * That order is the whole point: a workspace with Inventory switched off is refused before
45
+ * anything reveals which capabilities it would have had, and a workspace with `repairs` off
46
+ * answers **404** rather than 403 — the surface is not part of its API, so it answers like
47
+ * anything else that is not there. `module.test.ts` checks the identity and the position of all
48
+ * three, because a missing one is invisible to `tsc`.
49
+ */
50
+ const cap = (id: string) => requiresCapability(MODULE_ID, id)
35
51
 
36
52
  /**
37
53
  * Everything a write announces, once the transaction that wrote it has actually committed.
38
54
  *
39
- * Three things, and all three are after the commit on purpose: the core activity feed's copy of
40
- * the history row, the event for whatever reacts later, and the realtime change that redraws a
41
- * screen somebody is looking at now. A mutation that skips them leaves the product believing the
42
- * old answer; a mutation that fires them *inside* the transaction tells the rest of the instance
43
- * about a row a rollback then took away, which is worse — it cannot be retracted.
55
+ * Four things, and all four are after the commit on purpose: the core activity feed's copy of the
56
+ * history row, the event for whatever reacts later, the realtime change that redraws a screen
57
+ * somebody is looking at now, and the search index. A mutation that skips them leaves the product
58
+ * believing the old answer; a mutation that fires them *inside* the transaction tells the rest of
59
+ * the instance about a row a rollback then took away, which is worse — it cannot be retracted.
60
+ *
61
+ * `reindex` needs no direction argument: it re-reads the row, and an archived one comes back as
62
+ * `null`, which is what takes it out of the index. So archive and restore are the same call here,
63
+ * exactly as they are the same procedure.
44
64
  */
45
65
  const announce = async (
46
66
  workspaceId: WorkspaceId,
@@ -53,12 +73,129 @@ export function inventoryRouter(kernel: Kernel) {
53
73
  if (written.activity) await svc.notify.activity(written.activity)
54
74
  await kernel.emit(event, { assetId, workspaceId }, { workspaceId, actorId: actorId ?? undefined })
55
75
  await svc.notify.change(workspaceId, 'asset', assetId, op)
76
+ await svc.search.reindex(workspaceId, assetId)
77
+ }
78
+
79
+ /**
80
+ * The same three, plus the one interruption this module makes.
81
+ *
82
+ * Custody is announced as a change to the **asset** rather than to a custody entity, and that is
83
+ * deliberate: `src/client/query.ts` keys the timeline and the period list under the asset's own
84
+ * prefix, so one `entity: 'asset'` invalidates the row, the list, the panel and both of its tabs.
85
+ * A second entity name would need a second subscription for no gain.
86
+ */
87
+ const announceCustody = async (
88
+ workspaceId: WorkspaceId,
89
+ written: CustodyWritten,
90
+ actorId: string | null,
91
+ ) => {
92
+ const assetId = written.asset.id
93
+ await svc.notify.activity(written.activity)
94
+ await kernel.emit(
95
+ inventoryEvents.custodyChanged,
96
+ {
97
+ assetId,
98
+ workspaceId,
99
+ userId: written.userId,
100
+ previousUserId: written.previousUserId,
101
+ },
102
+ { workspaceId, actorId: actorId ?? undefined },
103
+ )
104
+ await svc.notify.change(workspaceId, 'asset', assetId, 'updated')
105
+ // The document carries `custodianUserId` and `status`, and both just moved.
106
+ await svc.search.reindex(workspaceId, assetId)
107
+ if (written.notifyUserId)
108
+ await svc.notify.notify({
109
+ workspaceId,
110
+ userIds: [written.notifyUserId],
111
+ type: 'inventory.custody.assigned',
112
+ // The tag first, because that is what is printed on the sticker the person is looking for.
113
+ title: `${written.asset.code} was handed to you`,
114
+ body: written.asset.name,
115
+ object: { module: MODULE_ID, type: 'asset', id: assetId },
116
+ url: `/inventory?asset=${assetId}`,
117
+ groupKey: assetId,
118
+ actorId,
119
+ exclude: [actorId],
120
+ })
121
+ }
122
+
123
+ /**
124
+ * A repair's three, and the second entity it has to invalidate.
125
+ *
126
+ * `entity: 'asset'` refreshes the panel, the row and the list — the query keys hang the asset's
127
+ * repairs one segment under the asset's own key for exactly that reason. The workspace-wide
128
+ * repair list (the *what is away* card) cannot hang there, because it belongs to no single asset,
129
+ * so it is keyed under `['inventory', 'repair', …]` and needs its own change. Two changes for one
130
+ * write is the honest cost of one entity being asked about at two scopes.
131
+ */
132
+ const announceRepair = async (
133
+ workspaceId: WorkspaceId,
134
+ written: RepairWritten,
135
+ event: typeof inventoryEvents.repairOpened | typeof inventoryEvents.repairCompleted | null,
136
+ op: 'created' | 'updated',
137
+ actorId: string | null,
138
+ ) => {
139
+ if (written.activity) await svc.notify.activity(written.activity)
140
+ if (event)
141
+ await kernel.emit(
142
+ event,
143
+ { assetId: written.asset.id, repairId: written.repair.id, workspaceId },
144
+ { workspaceId, actorId: actorId ?? undefined },
145
+ )
146
+ await svc.notify.change(workspaceId, 'asset', written.asset.id, 'updated')
147
+ await svc.notify.change(workspaceId, 'repair', written.repair.id, op)
148
+ // Sending an item away and logging it back both move `assets.status`, which the document
149
+ // carries. An edit does not, and reindexing anyway is one cheap call rather than a fourth
150
+ // branch that has to stay right.
151
+ await svc.search.reindex(workspaceId, written.asset.id)
56
152
  }
57
153
 
58
154
  /** Every handler is this: a workspace-bound transaction, tagged with who is asking, and a service. */
59
155
  const run = <T>(context: RequestContext, workspaceId: string, fn: (tx: Tx) => Promise<T>): Promise<T> =>
60
156
  kernel.database.withWorkspace(workspaceId, fn, { userId: context.principal.userId })
61
157
 
158
+ /**
159
+ * A file id in a request is a claim about somebody else's row, and it is checked before it is
160
+ * stored.
161
+ *
162
+ * `attachments.add` has always asked core whose file an id belongs to; `assets.create` and
163
+ * `assets.update` did not, so `photoFileId` was the one file id this module accepted on trust —
164
+ * and a member of one workspace could point an asset at another workspace's file and read its
165
+ * name and size back out through the panel. The module boundary is no help: core answers this
166
+ * module as a service, so the id is the only thing standing between the two workspaces.
167
+ *
168
+ * The same check, deliberately, rather than a second one that might drift: `describe` is what
169
+ * refuses a foreign file and an upload that never finished, and it is called **before** the
170
+ * transaction opens for the reason its own docblock gives — a broker round trip while holding a
171
+ * pooled connection starves the pool and fails the write whenever core is briefly away.
172
+ *
173
+ * `undefined` means the request never mentioned a photo and `null` means "take it off", and
174
+ * neither is a file to check.
175
+ */
176
+ const checkPhoto = async (workspaceId: WorkspaceId, photoFileId?: string | null): Promise<void> => {
177
+ if (typeof photoFileId === 'string') await svc.attachments.describe(workspaceId, [photoFileId])
178
+ }
179
+
180
+ /**
181
+ * Does this workspace record repairs? Asked by the procedures that are **not** behind the
182
+ * capability but whose answer depends on it.
183
+ *
184
+ * `under_repair` is a status the `repairs` capability owns, and custody and archiving both decide
185
+ * it — custody because `deriveStatus` reads the open repair, archiving because it refuses an item
186
+ * that is away. With the capability off both of those were reading a fact the workspace can no
187
+ * longer act on: `repairs.complete` answers 404, so an asset went `under_repair` and stayed there,
188
+ * behind an archive that refused it and told the person to complete a repair they cannot reach.
189
+ *
190
+ * Read **before** the transaction opens, like every other settings lookup in this module and for
191
+ * the reason `AssetService.codeFormat` gives — `kernel.capabilities` resolves module settings over
192
+ * the broker, and awaiting one while holding a pooled connection starves the pool and fails the
193
+ * write whenever core is briefly away. The result is cached by the kernel for fifteen seconds, so
194
+ * this is not a round trip per request.
195
+ */
196
+ const recordsRepairs = async (workspaceId: WorkspaceId): Promise<boolean> =>
197
+ (await kernel.capabilities(workspaceId, MODULE_ID)).has('repairs')
198
+
62
199
  return os.router({
63
200
  assets: {
64
201
  list: scoped.assets.list
@@ -73,6 +210,14 @@ export function inventoryRouter(kernel: Kernel) {
73
210
  ),
74
211
  ),
75
212
 
213
+ history: scoped.assets.history
214
+ .use(requires('inventory.asset.view'))
215
+ .handler(({ input, context }) =>
216
+ run(context, input.workspaceId, (tx) =>
217
+ svc.assets.history(tx, input.workspaceId, input.assetId, input),
218
+ ),
219
+ ),
220
+
76
221
  create: scoped.assets.create
77
222
  .use(requires('inventory.asset.manage'))
78
223
  .handler(async ({ input, context }) => {
@@ -80,6 +225,7 @@ export function inventoryRouter(kernel: Kernel) {
80
225
  // Module settings are read *before* the transaction opens. They come from core over the
81
226
  // broker, and awaiting that inside the transaction holds a pooled connection and the
82
227
  // counter row lock across a network call to another service.
228
+ await checkPhoto(input.workspaceId, input.photoFileId)
83
229
  const format = await svc.assets.codeFormat(input.workspaceId)
84
230
  const written = await run(context, input.workspaceId, (tx) =>
85
231
  svc.assets.create(tx, input.workspaceId, actorId, input, format),
@@ -92,6 +238,7 @@ export function inventoryRouter(kernel: Kernel) {
92
238
  .use(requires('inventory.asset.manage'))
93
239
  .handler(async ({ input, context }) => {
94
240
  const actorId = context.principal.userId
241
+ await checkPhoto(input.workspaceId, input.photoFileId)
95
242
  const written = await run(context, input.workspaceId, (tx) =>
96
243
  svc.assets.update(tx, input.workspaceId, actorId, input.assetId, input),
97
244
  )
@@ -103,8 +250,9 @@ export function inventoryRouter(kernel: Kernel) {
103
250
  .use(requires('inventory.asset.manage'))
104
251
  .handler(async ({ input, context }) => {
105
252
  const actorId = context.principal.userId
253
+ const repairsOn = await recordsRepairs(input.workspaceId)
106
254
  const written = await run(context, input.workspaceId, (tx) =>
107
- svc.assets.archive(tx, input.workspaceId, actorId, input.assetId, input.archived),
255
+ svc.assets.archive(tx, input.workspaceId, actorId, input.assetId, input.archived, repairsOn),
108
256
  )
109
257
  // This procedure is the restore path too, and it used to announce `archived` either way —
110
258
  // telling every subscriber that a restored item had just been retired, while the module's
@@ -114,5 +262,313 @@ export function inventoryRouter(kernel: Kernel) {
114
262
  return toAsset(written.row)
115
263
  }),
116
264
  },
265
+
266
+ custody: {
267
+ /**
268
+ * Reads ride `inventory.asset.view`, writes take `inventory.custody.manage`.
269
+ *
270
+ * The split is argued where the keys are declared: `custodianUserId` is already a field of
271
+ * `Asset` under `asset.view`, so gating the timeline behind a second key would refuse the
272
+ * history while the row above it named the same person.
273
+ */
274
+ history: scoped.custody.history.use(requires('inventory.asset.view')).handler(({ input, context }) =>
275
+ run(context, input.workspaceId, async (tx) => {
276
+ // 404 first: a timeline for an asset in another workspace must answer "not yours"
277
+ // rather than an empty list, which reads as "nothing has ever happened to it".
278
+ await svc.assets.get(tx, input.workspaceId, input.assetId)
279
+ const rows = await svc.custody.history(tx, input.workspaceId, input.assetId, input.limit)
280
+ return rows.map(toCustodyPeriod)
281
+ }),
282
+ ),
283
+
284
+ /**
285
+ * What one person holds, answered by the asset list with the custodian filter fixed.
286
+ *
287
+ * One query path rather than two: `assets.list` already has the filter, the index and the
288
+ * keyset cursor, and a second query answering the same question is a second query to keep in
289
+ * step. Archived rows are out, because an item somebody still holds cannot be archived at
290
+ * all — `assets.archive` refuses it — so an archived row here would be one this module has
291
+ * already made impossible.
292
+ */
293
+ byUser: scoped.custody.byUser.use(requires('inventory.asset.view')).handler(({ input, context }) =>
294
+ run(context, input.workspaceId, (tx) =>
295
+ svc.assets.list(tx, {
296
+ workspaceId: input.workspaceId,
297
+ limit: input.limit,
298
+ ...(input.cursor ? { cursor: input.cursor } : {}),
299
+ custodianUserId: input.userId,
300
+ archived: false,
301
+ sort: 'recent',
302
+ }),
303
+ ),
304
+ ),
305
+
306
+ assign: scoped.custody.assign
307
+ .use(requires('inventory.custody.manage'))
308
+ .handler(async ({ input, context }) => {
309
+ const actorId = context.principal.userId
310
+ // Core is asked whether this person is really a member **before** the transaction opens,
311
+ // for the reason `AssetService.codeFormat` documents — and because writing the row first
312
+ // and checking afterwards would mean rolling back a handover somebody has already been
313
+ // notified about. `requireWorkspaceMember` argues what it refuses and why it refuses
314
+ // rather than swallowing a failure.
315
+ await requireWorkspaceMember(kernel, input.workspaceId, input.userId)
316
+ const repairsOn = await recordsRepairs(input.workspaceId)
317
+ const written = await run(context, input.workspaceId, (tx) =>
318
+ svc.custody.assign(
319
+ tx,
320
+ input.workspaceId,
321
+ actorId,
322
+ input.assetId,
323
+ input.userId,
324
+ input.note ?? null,
325
+ repairsOn,
326
+ ),
327
+ )
328
+ await announceCustody(input.workspaceId, written, actorId)
329
+ return { asset: toAsset(written.asset), period: written.period && toCustodyPeriod(written.period) }
330
+ }),
331
+
332
+ transfer: scoped.custody.transfer
333
+ .use(requires('inventory.custody.manage'))
334
+ .handler(async ({ input, context }) => {
335
+ const actorId = context.principal.userId
336
+ // The same check as `assign`, for the same reason: this is the other verb that writes a
337
+ // person into `custody_periods.user_id` and then sends them a notification.
338
+ await requireWorkspaceMember(kernel, input.workspaceId, input.userId)
339
+ const repairsOn = await recordsRepairs(input.workspaceId)
340
+ const written = await run(context, input.workspaceId, (tx) =>
341
+ svc.custody.transfer(
342
+ tx,
343
+ input.workspaceId,
344
+ actorId,
345
+ input.assetId,
346
+ input.userId,
347
+ input.note ?? null,
348
+ repairsOn,
349
+ ),
350
+ )
351
+ await announceCustody(input.workspaceId, written, actorId)
352
+ return { asset: toAsset(written.asset), period: written.period && toCustodyPeriod(written.period) }
353
+ }),
354
+
355
+ return: scoped.custody.return
356
+ .use(requires('inventory.custody.manage'))
357
+ .handler(async ({ input, context }) => {
358
+ const actorId = context.principal.userId
359
+ const repairsOn = await recordsRepairs(input.workspaceId)
360
+ const written = await run(context, input.workspaceId, (tx) =>
361
+ svc.custody.return(tx, input.workspaceId, actorId, input.assetId, input.note ?? null, repairsOn),
362
+ )
363
+ await announceCustody(input.workspaceId, written, actorId)
364
+ return { asset: toAsset(written.asset), period: null }
365
+ }),
366
+ },
367
+
368
+ /**
369
+ * A workspace's own filing, and the one entity name every change here carries.
370
+ *
371
+ * **`update` and `archive` used to announce a second change as `entity: 'asset'` carrying the
372
+ * category's id.** No asset has that id, so the change described a row that does not exist: a
373
+ * subscriber that acts on `{entity, id}` — patching a cached row, logging what moved, deciding
374
+ * what to refetch — is acting on a lie, and the client's own
375
+ * `invalidateQueries(['inventory', 'asset', id])` matched nothing, because the segment after
376
+ * the entity in this module's keys is the *workspace*. What it did was work by accident: the
377
+ * blunter `['inventory', 'asset']` invalidation fires on any asset change whatever its id.
378
+ *
379
+ * It is not needed either, and that is why it is gone rather than corrected. A category's name
380
+ * is resolved on the client from the categories query itself — `AssetsPage` and
381
+ * `AssetDetailPanel` both build their `id → name` map from `inventoryKeys.categories(ws, true)`
382
+ * — so refreshing that one query is what makes every asset row on screen say the new name. The
383
+ * asset rows themselves did not change: archiving a category leaves `assets.category_id`
384
+ * exactly where it was, which is the whole reason categories archive rather than delete.
385
+ */
386
+ categories: {
387
+ /**
388
+ * Reading rides `inventory.asset.view` because the picker on the asset form needs it — a
389
+ * `category.manage` on the read would leave everybody who may edit an asset unable to see
390
+ * what to file it under.
391
+ */
392
+ list: scoped.categories.list
393
+ .use(requires('inventory.asset.view'))
394
+ .handler(({ input, context }) =>
395
+ run(context, input.workspaceId, (tx) => svc.categories.list(tx, input.workspaceId, input.archived)),
396
+ ),
397
+
398
+ create: scoped.categories.create
399
+ .use(requires('inventory.category.manage'))
400
+ .handler(async ({ input, context }) => {
401
+ const row = await run(context, input.workspaceId, (tx) =>
402
+ svc.categories.create(tx, input.workspaceId, input.name, input.order ?? 0),
403
+ )
404
+ // No event: nothing outside this module has an opinion about a workspace's own filing.
405
+ // The realtime change is what the settings page, the filter and the form picker need.
406
+ await svc.notify.change(input.workspaceId, 'category', row.id, 'created')
407
+ return toCategory(row)
408
+ }),
409
+
410
+ update: scoped.categories.update
411
+ .use(requires('inventory.category.manage'))
412
+ .handler(async ({ input, context }) => {
413
+ const row = await run(context, input.workspaceId, (tx) =>
414
+ svc.categories.update(tx, input.workspaceId, input.categoryId, input),
415
+ )
416
+ // One change, naming the row that actually moved. Every screen that prints a category's
417
+ // name resolves it from the categories query, so this is what re-renders the asset list,
418
+ // the filter and the picker as well as the settings page.
419
+ await svc.notify.change(input.workspaceId, 'category', row.id, 'updated')
420
+ return toCategory(row)
421
+ }),
422
+
423
+ archive: scoped.categories.archive
424
+ .use(requires('inventory.category.manage'))
425
+ .handler(async ({ input, context }) => {
426
+ const row = await run(context, input.workspaceId, (tx) =>
427
+ svc.categories.archive(tx, input.workspaceId, input.categoryId, input.archived),
428
+ )
429
+ // Archiving takes the category out of every picker and every filter and leaves each asset
430
+ // still filed under it — so the row that changed is the category, and only the category.
431
+ await svc.notify.change(input.workspaceId, 'category', row.id, 'updated')
432
+ return toCategory(row)
433
+ }),
434
+ },
435
+
436
+ /**
437
+ * Repairs, behind the `repairs` capability.
438
+ *
439
+ * Every one of these carries three middlewares in this order — `workspaceScoped`, `cap`,
440
+ * `requires` — so a workspace that does not record repairs gets 404 from all four, which is
441
+ * what makes the hidden tab and the API agree.
442
+ */
443
+ repairs: {
444
+ list: scoped.repairs.list
445
+ .use(cap('repairs'))
446
+ .use(requires('inventory.asset.view'))
447
+ .handler(({ input, context }) =>
448
+ run(context, input.workspaceId, async (tx) => {
449
+ // 404 first when the question is about one asset: a repair list for an asset in another
450
+ // workspace must answer "not yours" rather than an empty list, which reads as "it has
451
+ // never been repaired". The same reasoning `custody.history` gives.
452
+ if (input.assetId) await svc.assets.get(tx, input.workspaceId, input.assetId)
453
+ return svc.repairs.list(tx, input.workspaceId, input)
454
+ }),
455
+ ),
456
+
457
+ create: scoped.repairs.create
458
+ .use(cap('repairs'))
459
+ .use(requires('inventory.repair.manage'))
460
+ .handler(async ({ input, context }) => {
461
+ const actorId = context.principal.userId
462
+ const written = await run(context, input.workspaceId, (tx) =>
463
+ svc.repairs.create(tx, input.workspaceId, actorId, input.assetId, input),
464
+ )
465
+ await announceRepair(input.workspaceId, written, inventoryEvents.repairOpened, 'created', actorId)
466
+ return { repair: toRepair(written.repair), asset: toAsset(written.asset) }
467
+ }),
468
+
469
+ update: scoped.repairs.update
470
+ .use(cap('repairs'))
471
+ .use(requires('inventory.repair.manage'))
472
+ .handler(async ({ input, context }) => {
473
+ const actorId = context.principal.userId
474
+ const written = await run(context, input.workspaceId, (tx) =>
475
+ svc.repairs.update(tx, input.workspaceId, input.repairId, input),
476
+ )
477
+ // No event: correcting a vendor or a cost is not a fact anything outside this module
478
+ // reacts to. The realtime change is what the panel and the card need.
479
+ await announceRepair(input.workspaceId, written, null, 'updated', actorId)
480
+ return { repair: toRepair(written.repair), asset: toAsset(written.asset) }
481
+ }),
482
+
483
+ complete: scoped.repairs.complete
484
+ .use(cap('repairs'))
485
+ .use(requires('inventory.repair.manage'))
486
+ .handler(async ({ input, context }) => {
487
+ const actorId = context.principal.userId
488
+ const written = await run(context, input.workspaceId, (tx) =>
489
+ svc.repairs.complete(tx, input.workspaceId, actorId, input.repairId, input),
490
+ )
491
+ await announceRepair(
492
+ input.workspaceId,
493
+ written,
494
+ inventoryEvents.repairCompleted,
495
+ 'updated',
496
+ actorId,
497
+ )
498
+ return { repair: toRepair(written.repair), asset: toAsset(written.asset) }
499
+ }),
500
+ },
501
+
502
+ /**
503
+ * Files, behind the `attachments` capability.
504
+ *
505
+ * Writing takes `inventory.asset.manage` rather than a key of its own: filing a receipt against
506
+ * an asset is editing the asset's record, and a workspace that lets somebody correct a serial
507
+ * number lets them attach the warranty card it came with.
508
+ */
509
+ attachments: {
510
+ list: scoped.attachments.list
511
+ .use(cap('attachments'))
512
+ .use(requires('inventory.asset.view'))
513
+ .handler(({ input, context }) =>
514
+ run(context, input.workspaceId, async (tx) => {
515
+ await svc.assets.get(tx, input.workspaceId, input.assetId)
516
+ return svc.attachments.list(tx, input.workspaceId, input.assetId)
517
+ }),
518
+ ),
519
+
520
+ add: scoped.attachments.add
521
+ .use(cap('attachments'))
522
+ .use(requires('inventory.asset.manage'))
523
+ .handler(async ({ input, context }) => {
524
+ const actorId = context.principal.userId
525
+ // Core is asked what these files are **before** the transaction opens. It is a call over
526
+ // the broker, and awaiting one while holding a pooled connection is the failure
527
+ // `AssetService.codeFormat` documents — the pool starves under concurrent writes, and an
528
+ // attach fails outright whenever core is briefly away.
529
+ const files = await svc.attachments.describe(input.workspaceId, input.fileIds)
530
+ const written = await run(context, input.workspaceId, (tx) =>
531
+ svc.attachments.add(tx, input.workspaceId, actorId, input.assetId, input.repairId ?? null, files),
532
+ )
533
+ for (const activity of written.activities) await svc.notify.activity(activity)
534
+ // No event and no notification: a filed receipt is not something outside this module
535
+ // reacts to, and it is certainly not worth interrupting anybody about.
536
+ if (written.rows.length)
537
+ await svc.notify.change(input.workspaceId, 'asset', input.assetId, 'updated')
538
+ return written.rows.map(toAttachment)
539
+ }),
540
+
541
+ remove: scoped.attachments.remove
542
+ .use(cap('attachments'))
543
+ .use(requires('inventory.asset.manage'))
544
+ .handler(async ({ input, context }) => {
545
+ const actorId = context.principal.userId
546
+ const removed = await run(context, input.workspaceId, (tx) =>
547
+ svc.attachments.remove(tx, input.workspaceId, actorId, input.attachmentId),
548
+ )
549
+ await svc.notify.activity(removed.activity)
550
+ await svc.notify.change(input.workspaceId, 'asset', removed.row.assetId, 'updated')
551
+ return { id: removed.row.id }
552
+ }),
553
+ },
554
+
555
+ stats: {
556
+ /**
557
+ * Not behind a capability: it counts assets, which is `core`.
558
+ *
559
+ * The capability it does read is `repairs`, and it reads it here rather than as middleware —
560
+ * `outForRepair` comes back **null** for a workspace that does not track repairs, where 404
561
+ * would take the whole count line and the dashboard card away because a *different* feature
562
+ * is off. Read before the transaction opens, like every other settings lookup in this module.
563
+ */
564
+ summary: scoped.stats.summary
565
+ .use(requires('inventory.asset.view'))
566
+ .handler(async ({ input, context }) => {
567
+ const on = await kernel.capabilities(input.workspaceId, MODULE_ID)
568
+ return run(context, input.workspaceId, (tx) =>
569
+ svc.stats.summary(tx, input.workspaceId, on.has('repairs')),
570
+ )
571
+ }),
572
+ },
117
573
  })
118
574
  }