@kernhq/module-inventory 0.2.0 → 0.4.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 (198) hide show
  1. package/README.md +106 -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 +345 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +245 -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 +1108 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +253 -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 +1392 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +385 -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 +169 -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 +157 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +309 -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/0008_category_order_unique.sql +71 -0
  117. package/migrations/meta/0002_snapshot.json +1054 -0
  118. package/migrations/meta/0003_snapshot.json +1070 -0
  119. package/migrations/meta/0004_snapshot.json +1130 -0
  120. package/migrations/meta/0005_snapshot.json +1135 -0
  121. package/migrations/meta/_journal.json +49 -0
  122. package/package.json +2 -1
  123. package/src/client/api-instance.ts +27 -2
  124. package/src/client/bidi.test.ts +148 -0
  125. package/src/client/bidi.ts +85 -0
  126. package/src/client/components/AssetDetailPanel.svelte +614 -0
  127. package/src/client/components/AssetFormDialog.svelte +71 -22
  128. package/src/client/components/AssetPhoto.svelte +178 -0
  129. package/src/client/components/AttachmentsSection.svelte +327 -0
  130. package/src/client/components/CustodyDialog.svelte +201 -0
  131. package/src/client/components/RepairDialog.svelte +271 -0
  132. package/src/client/components/RepairsSection.svelte +318 -0
  133. package/src/client/components/Timeline.svelte +347 -0
  134. package/src/client/components/TimelineText.svelte +124 -0
  135. package/src/client/core-api.ts +71 -0
  136. package/src/client/custody.test.ts +31 -0
  137. package/src/client/custody.ts +34 -0
  138. package/src/client/errors.test.ts +395 -0
  139. package/src/client/errors.ts +229 -0
  140. package/src/client/links.test.ts +74 -0
  141. package/src/client/links.ts +44 -0
  142. package/src/client/members.test.ts +132 -0
  143. package/src/client/members.ts +116 -0
  144. package/src/client/messages.test.ts +127 -0
  145. package/src/client/messages.ts +1106 -18
  146. package/src/client/mock.test.ts +465 -1
  147. package/src/client/mock.ts +1105 -79
  148. package/src/client/module.ts +73 -1
  149. package/src/client/pages/AssetsPage.svelte +317 -45
  150. package/src/client/permissions.ts +7 -0
  151. package/src/client/price.test.ts +151 -3
  152. package/src/client/price.ts +155 -11
  153. package/src/client/query.ts +36 -0
  154. package/src/client/reorder.test.ts +100 -0
  155. package/src/client/reorder.ts +79 -0
  156. package/src/client/repairs.test.ts +38 -0
  157. package/src/client/repairs.ts +38 -0
  158. package/src/client/sequence.test.ts +248 -0
  159. package/src/client/sequence.ts +185 -0
  160. package/src/client/settings/CategoriesSettings.svelte +746 -0
  161. package/src/client/settings/GeneralSettings.svelte +0 -0
  162. package/src/client/status.ts +29 -0
  163. package/src/client/timeline.test.ts +175 -0
  164. package/src/client/timeline.ts +206 -0
  165. package/src/client/widgets/OverviewWidget.svelte +127 -26
  166. package/src/client/widgets/RepairsWidget.svelte +124 -0
  167. package/src/contract/capabilities.ts +53 -9
  168. package/src/contract/events.ts +49 -0
  169. package/src/contract/index.ts +1 -0
  170. package/src/contract/models.ts +270 -0
  171. package/src/contract/notifications.ts +73 -0
  172. package/src/contract/permissions.ts +55 -2
  173. package/src/contract/router.ts +284 -1
  174. package/src/contract/settings.ts +24 -4
  175. package/src/module.test.ts +217 -4
  176. package/src/server/index.ts +311 -4
  177. package/src/server/inventory.int.test.ts +4109 -19
  178. package/src/server/jobs.ts +444 -0
  179. package/src/server/migrations.test.ts +253 -2
  180. package/src/server/router.ts +486 -6
  181. package/src/server/schema.ts +171 -3
  182. package/src/server/services/assets.ts +214 -54
  183. package/src/server/services/attachments.ts +215 -0
  184. package/src/server/services/audience.ts +77 -0
  185. package/src/server/services/categories.ts +337 -0
  186. package/src/server/services/cursor.ts +104 -0
  187. package/src/server/services/custody.ts +471 -0
  188. package/src/server/services/db-errors.ts +42 -0
  189. package/src/server/services/index.ts +34 -1
  190. package/src/server/services/members.ts +54 -0
  191. package/src/server/services/notify.ts +52 -7
  192. package/src/server/services/offboarding.ts +150 -0
  193. package/src/server/services/repairs.ts +567 -0
  194. package/src/server/services/search.ts +166 -0
  195. package/src/server/services/stats.ts +88 -0
  196. package/src/server/services/status.test.ts +34 -0
  197. package/src/server/services/status.ts +143 -0
  198. package/src/client/settings/core-api.ts +0 -32
@@ -1,4 +1,13 @@
1
- import type { Asset, AssetStatus } from '../contract/index.js'
1
+ import type {
2
+ Asset,
3
+ AssetHistoryEntry,
4
+ AssetStatus,
5
+ Attachment,
6
+ Category,
7
+ CustodyPeriod,
8
+ InventoryStats,
9
+ Repair,
10
+ } from '../contract/index.js'
2
11
 
3
12
  /**
4
13
  * The in-memory implementation of this module's API.
@@ -18,13 +27,59 @@ import type { Asset, AssetStatus } from '../contract/index.js'
18
27
  * it ignored `sort`, `categoryId` and `custodianUserId` entirely. So the ordering, the paging and
19
28
  * the cursor format below are deliberately the same as `src/server/services/assets.ts` — read them
20
29
  * as one pair, and change them as one pair.
30
+ *
31
+ * Custody keeps the same invariant the server keeps, for the same reason: a handover closes the open
32
+ * period, opens a new one, moves `custodianUserId`/`custodySince`/`status` and appends a history
33
+ * row, and it refuses the three things the server refuses — assigning something already held,
34
+ * handing on something nobody holds, and touching an archived item. A demo that lets you assign the
35
+ * same laptop twice teaches somebody the product does.
36
+ *
37
+ * Repairs keep the same invariant too, including the one that is easiest to get wrong: **an item at
38
+ * a repairer is still whoever's it was**, so a repair moves `status` and never `custodianUserId`,
39
+ * and completing one returns the item to `assigned` rather than to `in_stock` when somebody still
40
+ * holds it. `statusFor` below is this file's copy of `deriveStatus` on the server; read them as one
41
+ * pair and change them as one pair.
21
42
  */
22
43
  type AssetSort = 'recent' | 'name' | 'code'
23
44
 
45
+ /**
46
+ * The people the **shell's** own mock puts in this workspace.
47
+ *
48
+ * Copied ids rather than a fetch, because this file has no client and no way to ask. The coupling is
49
+ * deliberate and narrow: without it every name in the custody tab and the timeline would resolve to
50
+ * "A former member", which is a demo of the fallback rather than a demo of the feature. If the
51
+ * shell's mock people ever change, this is where the demo stops naming them.
52
+ */
53
+ const PEOPLE = {
54
+ maya: '01920000-0000-7000-8000-000000000001',
55
+ dan: '01920000-0000-7000-8000-000000000002',
56
+ ines: '01920000-0000-7000-8000-000000000003',
57
+ } as const
58
+
59
+ interface CategorySeed {
60
+ key: string
61
+ name: string
62
+ order: number
63
+ archived?: boolean
64
+ }
65
+
66
+ const CATEGORY_SEEDS: CategorySeed[] = [
67
+ { key: 'laptops', name: 'Laptops', order: 0 },
68
+ { key: 'displays', name: 'Displays', order: 1 },
69
+ { key: 'furniture', name: 'Furniture', order: 2 },
70
+ { key: 'cameras', name: 'Cameras', order: 3 },
71
+ // One archived, so the demo shows what archiving actually does: it leaves the picker and the
72
+ // filter, and the asset filed under it goes on saying what it is.
73
+ { key: 'phones', name: 'Phones', order: 4, archived: true },
74
+ ]
75
+
24
76
  interface Seed {
25
77
  code: string
26
78
  name: string
27
79
  status: AssetStatus
80
+ categoryKey?: string
81
+ /** Who is holding it. Kept in step with `status` here exactly as the server keeps it. */
82
+ custodian?: string
28
83
  serialNumber?: string | null
29
84
  location?: string | null
30
85
  warrantyUntil?: string | null
@@ -36,38 +91,73 @@ const SEEDS: Seed[] = [
36
91
  code: 'INV-0001',
37
92
  name: 'MacBook Pro 14"',
38
93
  status: 'assigned',
94
+ categoryKey: 'laptops',
95
+ custodian: PEOPLE.dan,
39
96
  serialNumber: 'C02X1234JGH7',
40
97
  location: 'Istanbul · 3rd floor',
41
98
  warrantyUntil: '2027-03-14',
42
99
  },
43
- { code: 'INV-0002', name: 'Dell UltraSharp 27"', status: 'in_stock', location: 'Istanbul · store room' },
100
+ {
101
+ code: 'INV-0002',
102
+ name: 'Dell UltraSharp 27"',
103
+ status: 'in_stock',
104
+ categoryKey: 'displays',
105
+ location: 'Istanbul · store room',
106
+ },
44
107
  {
45
108
  code: 'INV-0003',
46
109
  name: 'iPhone 15',
47
110
  status: 'under_repair',
111
+ // Filed under the archived category on purpose: the row still names "Phones".
112
+ categoryKey: 'phones',
48
113
  serialNumber: 'F17GX9QKLM',
49
114
  location: 'With the repairer',
50
115
  },
51
- { code: 'INV-0004', name: 'Herman Miller Aeron', status: 'assigned', location: 'Istanbul · 2nd floor' },
52
- { code: 'INV-0005', name: 'Canon EOS R6', status: 'reserved', location: 'Istanbul · store room' },
116
+ {
117
+ code: 'INV-0004',
118
+ name: 'Herman Miller Aeron',
119
+ status: 'assigned',
120
+ categoryKey: 'furniture',
121
+ custodian: PEOPLE.ines,
122
+ location: 'Istanbul · 2nd floor',
123
+ },
124
+ {
125
+ code: 'INV-0005',
126
+ name: 'Canon EOS R6',
127
+ status: 'reserved',
128
+ categoryKey: 'cameras',
129
+ location: 'Istanbul · store room',
130
+ },
53
131
  { code: 'INV-0006', name: 'ThinkPad X1 Carbon', status: 'retired', archived: true },
54
132
  ]
55
133
 
56
134
  /**
57
135
  * An error shaped like one the real client surfaces.
58
136
  *
59
- * oRPC hands a failure to the screen as an `Error` carrying the contract's `code`, and the screens
60
- * show `error.message`. A mock that throws a bare `Error` teaches a demo that every failure looks
61
- * the same, and hides the one branch — `BAD_REQUEST` on a stale page marker this file exists to
62
- * reproduce.
137
+ * oRPC hands a failure to the screen as an `Error` carrying the contract's `code`, and a mock that
138
+ * throws a bare `Error` teaches a demo that every failure looks the same — hiding the two branches
139
+ * this file exists to reproduce, `BAD_REQUEST` on a stale page marker and `CONFLICT` on a handover
140
+ * somebody else got to first.
141
+ *
142
+ * **`data.reason` is part of that shape, and leaving it out made the demo lie in a new way.** The
143
+ * screens no longer render `error.message`: `errors.ts` reads the stable reason token each refusal
144
+ * carries and shows a translated sentence, keeping the server's English only for a failure it does
145
+ * not recognise. A mock that threw the message and no token would send every one of these down the
146
+ * fallback path — so the demo would show English prose where the product shows Persian, which is
147
+ * exactly the defect that was just fixed, reproduced by the thing meant to reproduce the product.
148
+ *
149
+ * The shape matches `kernErrorToORPC`: `{ reason, …details }` under `data`, not beside it.
63
150
  */
64
151
  class MockApiError extends Error {
152
+ readonly data: { reason: string } | undefined
65
153
  constructor(
66
- readonly code: 'BAD_REQUEST' | 'NOT_FOUND',
154
+ readonly code: 'BAD_REQUEST' | 'NOT_FOUND' | 'CONFLICT',
67
155
  message: string,
156
+ reason?: string,
68
157
  ) {
69
158
  super(message)
70
159
  this.name = 'MockApiError'
160
+ this.data = reason ? { reason } : undefined
71
161
  }
72
162
  }
73
163
 
@@ -130,32 +220,103 @@ function compare(a: Asset, b: Asset, sort: AssetSort): number {
130
220
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
131
221
  }
132
222
 
133
- export function createMockInventoryApi() {
223
+ /** Days ago, as an ISO instant — so a seeded timeline reads as history rather than as "just now". */
224
+ const daysAgo = (days: number) => new Date(Date.now() - days * 864e5).toISOString()
225
+
226
+ /** The same instant as a plain date, which is what a `date` column stores. */
227
+ const dateDaysAgo = (days: number) => daysAgo(days).slice(0, 10)
228
+
229
+ /**
230
+ * What core knows about a file, as far as this module is concerned.
231
+ *
232
+ * The **server** asks core for it before recording an attachment. This file has no core to ask —
233
+ * it is data with no client and no imports — so whoever builds the mock passes a resolver in.
234
+ * `api-instance.ts` supplies one backed by the shell's own mock file store, which is where the
235
+ * bytes an upload produced actually went; the default below is for the unit tests, which attach
236
+ * ids that were never uploaded anywhere.
237
+ */
238
+ export interface MockFileFacts {
239
+ name: string
240
+ mimeType: string | null
241
+ size: number | null
242
+ }
243
+
244
+ export interface MockInventoryOptions {
245
+ describeFile?: (fileId: string) => Promise<MockFileFacts>
246
+ }
247
+
248
+ export function createMockInventoryApi(options: MockInventoryOptions = {}) {
249
+ const describeFile =
250
+ options.describeFile ?? (async () => ({ name: 'Uploaded file', mimeType: null, size: null }))
134
251
  /**
135
- * Two counters, because they were one and it showed.
252
+ * Four id sequences, and separate tag numbering, because they were one and it showed.
136
253
  *
137
254
  * `blank()` incremented the id counter and `create` then formatted a code out of it, so six seeds
138
255
  * consumed six numbers and the seventh asset somebody added in a demo was filed as `INV-0013`
139
256
  * beside `INV-0006`. An id and a human-readable tag are separate sequences on the server too.
257
+ *
258
+ * The four entity namespaces differ in the *third* group rather than the last, so no asset id can
259
+ * ever equal a category id, a period id — or one of the shell's mock user ids, which live in the
260
+ * same `…-8000-…` space and would otherwise collide with the first asset.
140
261
  */
141
- let nextIdNumber = 1
142
- let nextCodeNumber = SEEDS.length + 1
262
+ const counters = { asset: 1, category: 1, period: 1, history: 1, repair: 1, attachment: 1 }
263
+ const newId = (kind: keyof typeof counters, group: string) =>
264
+ `01920000-0000-7000-${group}-${String(counters[kind]++).padStart(12, '0')}`
265
+ const assetId = () => newId('asset', '8001')
266
+ const categoryId = () => newId('category', '8002')
267
+ const periodId = () => newId('period', '8003')
268
+ const historyId = () => newId('history', '8004')
269
+ const repairId = () => newId('repair', '8005')
270
+ const attachmentId = () => newId('attachment', '8006')
143
271
 
144
- const newId = () => `01920000-0000-7000-8000-${String(nextIdNumber++).padStart(12, '0')}`
272
+ let nextCodeNumber = SEEDS.length + 1
145
273
  const pad = (n: number) => `INV-${String(n).padStart(4, '0')}`
146
274
 
275
+ /**
276
+ * The workspace every seeded row belongs to, learned from the first call that names one.
277
+ *
278
+ * The seeds are built before any workspace exists, and only `list` used to stamp its argument
279
+ * onto what it returned — so the same asset came back with a real `workspaceId` from the list and
280
+ * an empty one from `get`, `update` and `archive`. A demo where one row disagrees with itself
281
+ * about which workspace it is in is the kind of thing nobody notices until a screen keys a cache
282
+ * off it.
283
+ */
284
+ let workspace = '' as Asset['workspaceId']
285
+ const remember = (id?: string) => {
286
+ if (id) workspace = id as Asset['workspaceId']
287
+ }
288
+ const stamp = <T extends { workspaceId: Asset['workspaceId'] }>(row: T): T => ({
289
+ ...row,
290
+ workspaceId: workspace,
291
+ })
292
+
293
+ // ---------------------------------------------------------------------------- categories
294
+
295
+ const categories: Category[] = CATEGORY_SEEDS.map((seed) => ({
296
+ id: categoryId(),
297
+ workspaceId: '' as Asset['workspaceId'],
298
+ name: seed.name,
299
+ order: seed.order,
300
+ createdAt: daysAgo(60),
301
+ updatedAt: daysAgo(60),
302
+ archivedAt: seed.archived ? daysAgo(10) : null,
303
+ }))
304
+ const categoryByKey = new Map(CATEGORY_SEEDS.map((seed, i) => [seed.key, categories[i]!.id]))
305
+
306
+ // -------------------------------------------------------------------------------- assets
307
+
147
308
  function blank(code: string, seed: Partial<Seed> & { name?: string } = {}): Asset {
148
309
  const now = new Date().toISOString()
149
310
  return {
150
- id: newId(),
311
+ id: assetId(),
151
312
  workspaceId: '' as Asset['workspaceId'],
152
313
  code: seed.code ?? code,
153
314
  name: seed.name ?? '',
154
315
  description: '',
155
- categoryId: null,
316
+ categoryId: seed.categoryKey ? (categoryByKey.get(seed.categoryKey) ?? null) : null,
156
317
  status: seed.status ?? 'in_stock',
157
- custodianUserId: null,
158
- custodySince: null,
318
+ custodianUserId: seed.custodian ?? null,
319
+ custodySince: seed.custodian ? daysAgo(21) : null,
159
320
  serialNumber: seed.serialNumber ?? null,
160
321
  location: seed.location ?? null,
161
322
  purchasedOn: null,
@@ -173,43 +334,316 @@ export function createMockInventoryApi() {
173
334
 
174
335
  const assets: Asset[] = SEEDS.map((seed, i) => blank(pad(i + 1), seed))
175
336
 
337
+ // ------------------------------------------------------------------- custody and history
338
+
339
+ const periods: CustodyPeriod[] = []
340
+ const history: AssetHistoryEntry[] = []
341
+
342
+ const appendHistory = (
343
+ asset: Asset,
344
+ action: string,
345
+ opts: {
346
+ actorId?: string | null
347
+ changes?: AssetHistoryEntry['changes']
348
+ data?: Record<string, unknown>
349
+ occurredAt?: string
350
+ } = {},
351
+ ): AssetHistoryEntry => {
352
+ const entry: AssetHistoryEntry = {
353
+ id: historyId(),
354
+ assetId: asset.id,
355
+ actorId: opts.actorId === undefined ? PEOPLE.maya : opts.actorId,
356
+ action,
357
+ changes: opts.changes ?? [],
358
+ data: opts.data ?? {},
359
+ occurredAt: opts.occurredAt ?? new Date().toISOString(),
360
+ }
361
+ history.push(entry)
362
+ return entry
363
+ }
364
+
176
365
  /**
177
- * The workspace every seeded row belongs to, learned from the first call that names one.
366
+ * A seeded past, so the timeline and "previous holders" have something to draw.
178
367
  *
179
- * The seeds are built before any workspace exists, and only `list` used to stamp its argument
180
- * onto what it returned so the same asset came back with a real `workspaceId` from the list and
181
- * an empty one from `get`, `update` and `archive`. A demo where one row disagrees with itself
182
- * about which workspace it is in is the kind of thing nobody notices until a screen keys a cache
183
- * off it.
368
+ * INV-0001 has been round the houses on purpose — created, handed to Maya, handed on to Dan, and
369
+ * edited because that is the one asset a demo opens, and an empty Custody tab beside an empty
370
+ * History tab shows neither feature.
184
371
  */
185
- let workspace = '' as Asset['workspaceId']
186
- const remember = (id?: string) => {
187
- if (id) workspace = id as Asset['workspaceId']
372
+ for (const asset of assets) {
373
+ appendHistory(asset, 'created', { occurredAt: daysAgo(45) })
374
+ }
375
+ {
376
+ const first = assets[0]!
377
+ const closed: CustodyPeriod = {
378
+ id: periodId(),
379
+ workspaceId: '' as Asset['workspaceId'],
380
+ assetId: first.id,
381
+ userId: PEOPLE.maya,
382
+ note: 'For the Berlin trip',
383
+ effectiveFrom: daysAgo(40),
384
+ effectiveTo: daysAgo(21),
385
+ createdBy: PEOPLE.maya,
386
+ createdAt: daysAgo(40),
387
+ }
388
+ periods.push(closed)
389
+ appendHistory(first, 'assigned', {
390
+ occurredAt: daysAgo(40),
391
+ data: { userId: PEOPLE.maya, note: closed.note },
392
+ })
393
+ appendHistory(first, 'updated', {
394
+ occurredAt: daysAgo(30),
395
+ changes: [{ field: 'warrantyUntil', from: '2026-03-14', to: '2027-03-14' }],
396
+ })
397
+ appendHistory(first, 'transferred', {
398
+ occurredAt: daysAgo(21),
399
+ data: { userId: PEOPLE.dan, previousUserId: PEOPLE.maya },
400
+ })
188
401
  }
189
- const stamp = (asset: Asset): Asset => ({ ...asset, workspaceId: workspace })
402
+ for (const asset of assets) {
403
+ if (!asset.custodianUserId) continue
404
+ periods.push({
405
+ id: periodId(),
406
+ workspaceId: '' as Asset['workspaceId'],
407
+ assetId: asset.id,
408
+ userId: asset.custodianUserId,
409
+ note: null,
410
+ effectiveFrom: asset.custodySince ?? daysAgo(21),
411
+ effectiveTo: null,
412
+ createdBy: PEOPLE.maya,
413
+ createdAt: asset.custodySince ?? daysAgo(21),
414
+ })
415
+ if (asset !== assets[0])
416
+ appendHistory(asset, 'assigned', {
417
+ occurredAt: asset.custodySince ?? daysAgo(21),
418
+ data: { userId: asset.custodianUserId },
419
+ })
420
+ }
421
+
422
+ // --------------------------------------------------------------- repairs and attachments
423
+
424
+ const repairs: Repair[] = []
425
+ const attachments: Attachment[] = []
426
+
427
+ /**
428
+ * This file's copy of the server's `deriveStatus`, and it has to stay a copy of it.
429
+ *
430
+ * Repair wins the status column, custody keeps the custodian column, and neither cancels the
431
+ * other — a laptop at the repairer is still whoever's it was. A mock that wrote `assigned`
432
+ * unconditionally after a handover would show a demo audience an item as back in the office while
433
+ * its repair card said it was in a workshop.
434
+ */
435
+ const statusFor = (asset: Asset): AssetStatus => {
436
+ if (repairs.some((repair) => repair.assetId === asset.id && repair.returnedOn === null))
437
+ return 'under_repair'
438
+ return asset.custodianUserId ? 'assigned' : 'in_stock'
439
+ }
440
+
441
+ const restamp = (asset: Asset) => {
442
+ asset.status = statusFor(asset)
443
+ asset.updatedAt = new Date().toISOString()
444
+ }
445
+
446
+ {
447
+ /**
448
+ * A seeded repair for the one asset the seeds call `under_repair`, and a finished one for the
449
+ * asset a demo opens.
450
+ *
451
+ * Without the open row, INV-0003 would carry a status nothing in the demo could explain — a
452
+ * status column and a Repairs tab disagreeing about the same item is exactly the thing this
453
+ * file exists to prevent.
454
+ */
455
+ const away = assets[2]!
456
+ repairs.push({
457
+ id: repairId(),
458
+ workspaceId: '' as Asset['workspaceId'],
459
+ assetId: away.id,
460
+ summary: 'Cracked screen',
461
+ detail: null,
462
+ vendor: 'Kadıköy Teknik',
463
+ costMinor: null,
464
+ currency: null,
465
+ sentOn: dateDaysAgo(6),
466
+ returnedOn: null,
467
+ createdBy: PEOPLE.maya,
468
+ createdAt: daysAgo(6),
469
+ updatedAt: daysAgo(6),
470
+ })
471
+ appendHistory(away, 'repair_logged', {
472
+ occurredAt: daysAgo(6),
473
+ data: { repairId: repairs[0]!.id, summary: 'Cracked screen', vendor: 'Kadıköy Teknik' },
474
+ })
475
+
476
+ const first = assets[0]!
477
+ const past: Repair = {
478
+ id: repairId(),
479
+ workspaceId: '' as Asset['workspaceId'],
480
+ assetId: first.id,
481
+ summary: 'Battery replacement',
482
+ detail: 'Swelling under the trackpad.',
483
+ vendor: 'Apple Authorised Service',
484
+ costMinor: 18900,
485
+ currency: 'EUR',
486
+ sentOn: dateDaysAgo(34),
487
+ returnedOn: dateDaysAgo(27),
488
+ createdBy: PEOPLE.maya,
489
+ createdAt: daysAgo(34),
490
+ updatedAt: daysAgo(27),
491
+ }
492
+ repairs.push(past)
493
+ appendHistory(first, 'repair_logged', {
494
+ occurredAt: daysAgo(34),
495
+ data: { repairId: past.id, summary: past.summary, vendor: past.vendor },
496
+ })
497
+ appendHistory(first, 'repair_completed', {
498
+ occurredAt: daysAgo(27),
499
+ data: { repairId: past.id, summary: past.summary, costMinor: 18900, currency: 'EUR' },
500
+ })
501
+
502
+ // Two files: the asset's own receipt, and the invoice for the repair above. The pair is what
503
+ // shows that a repair keeps its own paperwork rather than dropping it in with everything else.
504
+ attachments.push({
505
+ id: attachmentId(),
506
+ workspaceId: '' as Asset['workspaceId'],
507
+ assetId: first.id,
508
+ repairId: null,
509
+ fileId: '01920000-0000-7000-8007-000000000001',
510
+ name: 'Purchase receipt.pdf',
511
+ mimeType: 'application/pdf',
512
+ size: 184_320,
513
+ uploadedBy: PEOPLE.maya,
514
+ createdAt: daysAgo(45),
515
+ })
516
+ attachments.push({
517
+ id: attachmentId(),
518
+ workspaceId: '' as Asset['workspaceId'],
519
+ assetId: first.id,
520
+ repairId: past.id,
521
+ fileId: '01920000-0000-7000-8007-000000000002',
522
+ name: 'Repair invoice.pdf',
523
+ mimeType: 'application/pdf',
524
+ size: 96_140,
525
+ uploadedBy: PEOPLE.maya,
526
+ createdAt: daysAgo(27),
527
+ })
528
+ }
529
+
530
+ // -------------------------------------------------------------------------------- helpers
190
531
 
191
532
  const matches = (a: Asset, q?: string) =>
192
533
  !q ||
193
534
  [a.name, a.code, a.serialNumber ?? ''].some((field) => field.toLowerCase().includes(q.toLowerCase()))
194
535
 
195
- const find = (assetId: string) => {
196
- const asset = assets.find((a) => a.id === assetId)
536
+ const find = (id: string) => {
537
+ const asset = assets.find((a) => a.id === id)
197
538
  if (!asset) throw new MockApiError('NOT_FOUND', 'Asset not found')
198
539
  return asset
199
540
  }
200
541
 
542
+ const findCategory = (id: string) => {
543
+ const category = categories.find((c) => c.id === id)
544
+ if (!category) throw new MockApiError('NOT_FOUND', 'Category not found')
545
+ return category
546
+ }
547
+
548
+ /**
549
+ * One past the highest position in use, archived rows counted — the server's `appended()`.
550
+ *
551
+ * Archived rows count because one of them can be restored, and a restored category landing on a
552
+ * live one's number is the tie that made a "Position" field a bad idea in the first place.
553
+ */
554
+ const appendedOrder = () => categories.reduce((max, c) => Math.max(max, c.order), -1) + 1
555
+
556
+ const openPeriodFor = (id: string) =>
557
+ periods.find((period) => period.assetId === id && period.effectiveTo === null)
558
+
559
+ /** At most one, exactly as `inventory_repairs_one_open_uq` guarantees on the server. */
560
+ const openRepairFor = (id: string) =>
561
+ repairs.find((repair) => repair.assetId === id && repair.returnedOn === null)
562
+
563
+ const findRepair = (id: string) => {
564
+ const repair = repairs.find((row) => row.id === id)
565
+ if (!repair) throw new MockApiError('NOT_FOUND', 'Repair not found')
566
+ return repair
567
+ }
568
+
569
+ /** Today as a plain date, which is what the server fills in when a caller sends none. */
570
+ const today = () => new Date().toISOString().slice(0, 10)
571
+
572
+ /** The one refusal a live asset can produce before any of the three verbs runs. */
573
+ const liveAsset = (id: string) => {
574
+ const asset = find(id)
575
+ if (asset.archivedAt)
576
+ throw new MockApiError(
577
+ 'CONFLICT',
578
+ 'This item is archived. Restore it before handing it over.',
579
+ 'inventory.custody.archived',
580
+ )
581
+ return asset
582
+ }
583
+
584
+ const listAssets = ({
585
+ q,
586
+ status,
587
+ categoryId: category,
588
+ custodianUserId,
589
+ archived = false,
590
+ sort = 'recent',
591
+ limit = 50,
592
+ cursor,
593
+ }: {
594
+ q?: string
595
+ status?: AssetStatus
596
+ categoryId?: string
597
+ custodianUserId?: string
598
+ archived?: boolean
599
+ sort?: AssetSort
600
+ limit?: number
601
+ cursor?: string
602
+ }) => {
603
+ // Every control the screen offers filters here, or the demo argues with its own toolbar.
604
+ const all = assets
605
+ .filter((a) => matches(a, q))
606
+ .filter((a) => (status ? a.status === status : true))
607
+ .filter((a) => (category ? a.categoryId === category : true))
608
+ .filter((a) => (custodianUserId ? a.custodianUserId === custodianUserId : true))
609
+ .filter((a) => (archived ? true : !a.archivedAt))
610
+ .map(stamp)
611
+
612
+ // `recent` is the only descending order, and it is the contract's default — the list opens
613
+ // on what was added last, which is what the server does and what the old mock did not.
614
+ const descending = sort === 'recent'
615
+ all.sort((a, b) => (descending ? -compare(a, b, sort) : compare(a, b, sort)))
616
+
617
+ let rows = all
618
+ if (cursor) {
619
+ const mark = decodeCursor(cursor, sort)
620
+ const bookmarked = assets.find((a) => a.id === mark.i)
621
+ /**
622
+ * A row archived or removed between two pages leaves nothing to compare against and the
623
+ * page simply ends — the server's deliberate choice, because somebody else editing while
624
+ * you read is an ordinary race rather than a malformed request.
625
+ */
626
+ if (!bookmarked) rows = []
627
+ else
628
+ rows = all.filter((a) => {
629
+ const order = compare(a, bookmarked, sort)
630
+ return descending ? order < 0 : order > 0
631
+ })
632
+ }
633
+
634
+ // limit + 1 to learn whether there is a next page, exactly as the server does.
635
+ const window = rows.slice(0, limit + 1)
636
+ const items = window.slice(0, limit)
637
+ const last = items.at(-1)
638
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: sort }) : null
639
+ return { items, nextCursor }
640
+ }
641
+
201
642
  return {
202
643
  assets: {
203
644
  list: async ({
204
645
  workspaceId,
205
- q,
206
- status,
207
- categoryId,
208
- custodianUserId,
209
- archived = false,
210
- sort = 'recent',
211
- limit = 50,
212
- cursor,
646
+ ...rest
213
647
  }: {
214
648
  workspaceId: string
215
649
  q?: string
@@ -222,56 +656,48 @@ export function createMockInventoryApi() {
222
656
  cursor?: string
223
657
  }) => {
224
658
  remember(workspaceId)
225
- // Every control the screen offers filters here, or the demo argues with its own toolbar.
226
- const all = assets
227
- .filter((a) => matches(a, q))
228
- .filter((a) => (status ? a.status === status : true))
229
- .filter((a) => (categoryId ? a.categoryId === categoryId : true))
230
- .filter((a) => (custodianUserId ? a.custodianUserId === custodianUserId : true))
231
- .filter((a) => (archived ? true : !a.archivedAt))
232
- .map(stamp)
659
+ return listAssets(rest)
660
+ },
233
661
 
234
- // `recent` is the only descending order, and it is the contract's default the list opens
235
- // on what was added last, which is what the server does and what the old mock did not.
236
- const descending = sort === 'recent'
237
- all.sort((a, b) => (descending ? -compare(a, b, sort) : compare(a, b, sort)))
238
-
239
- let rows = all
240
- if (cursor) {
241
- const mark = decodeCursor(cursor, sort)
242
- const bookmarked = assets.find((a) => a.id === mark.i)
243
- /**
244
- * A row archived or removed between two pages leaves nothing to compare against and the
245
- * page simply ends — the server's deliberate choice, because somebody else editing while
246
- * you read is an ordinary race rather than a malformed request.
247
- */
248
- if (!bookmarked) rows = []
249
- else
250
- rows = all.filter((a) => {
251
- const order = compare(a, bookmarked, sort)
252
- return descending ? order < 0 : order > 0
253
- })
254
- }
662
+ get: async ({ workspaceId, assetId: id }: { workspaceId?: string; assetId: string }) => {
663
+ remember(workspaceId)
664
+ return stamp(find(id))
665
+ },
255
666
 
256
- // limit + 1 to learn whether there is a next page, exactly as the server does.
667
+ /**
668
+ * The timeline, newest first, paged by **id** — the server's ordering, for the server's
669
+ * reason: an id already carries the clock and is unique where a timestamp two rows written in
670
+ * one transaction share is not.
671
+ */
672
+ history: async ({
673
+ workspaceId,
674
+ assetId: id,
675
+ limit = 50,
676
+ cursor,
677
+ }: {
678
+ workspaceId?: string
679
+ assetId: string
680
+ limit?: number
681
+ cursor?: string
682
+ }) => {
683
+ remember(workspaceId)
684
+ find(id)
685
+ const all = history.filter((entry) => entry.assetId === id).sort((a, b) => (a.id < b.id ? 1 : -1))
686
+ const rows = cursor ? all.filter((entry) => entry.id < decodeCursor(cursor, 'recent').i) : all
257
687
  const window = rows.slice(0, limit + 1)
258
688
  const items = window.slice(0, limit)
259
689
  const last = items.at(-1)
260
- const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: sort }) : null
690
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
261
691
  return { items, nextCursor }
262
692
  },
263
693
 
264
- get: async ({ workspaceId, assetId }: { workspaceId?: string; assetId: string }) => {
265
- remember(workspaceId)
266
- return stamp(find(assetId))
267
- },
268
-
269
694
  create: async ({ workspaceId, ...rest }: { workspaceId: string } & Record<string, unknown>) => {
270
695
  remember(workspaceId)
271
696
  const asset: Asset = {
272
697
  ...blank(pad(nextCodeNumber++)),
273
698
  name: String(rest.name ?? ''),
274
699
  description: String(rest.description ?? ''),
700
+ categoryId: (rest.categoryId as string | undefined) ?? null,
275
701
  serialNumber: (rest.serialNumber as string | undefined) ?? null,
276
702
  location: (rest.location as string | undefined) ?? null,
277
703
  purchasedFrom: (rest.purchasedFrom as string | undefined) ?? null,
@@ -281,25 +707,32 @@ export function createMockInventoryApi() {
281
707
  currency: (rest.currency as string | undefined) ?? null,
282
708
  }
283
709
  assets.push(asset)
710
+ appendHistory(asset, 'created')
284
711
  return stamp(asset)
285
712
  },
286
713
 
287
714
  update: async ({
288
- assetId,
715
+ assetId: id,
289
716
  workspaceId,
290
717
  ...rest
291
718
  }: { assetId: string; workspaceId?: string } & Record<string, unknown>) => {
292
719
  remember(workspaceId)
293
- const asset = find(assetId)
720
+ const asset = find(id)
721
+ // The diff before the assignment, so the timeline records what actually moved — the server
722
+ // does the same and for the same reason: an update that changed nothing writes nothing.
723
+ const changes = Object.entries(rest)
724
+ .filter(([field, value]) => (asset as Record<string, unknown>)[field] !== value)
725
+ .map(([field, value]) => ({ field, from: (asset as Record<string, unknown>)[field], to: value }))
294
726
  // `workspaceId` is not among the fields assigned: it is routing, not a field of the asset,
295
727
  // and letting a patch carry it means a demo can move a row to a workspace that is not real.
296
728
  Object.assign(asset, rest, { updatedAt: new Date().toISOString() })
729
+ if (changes.length) appendHistory(asset, 'updated', { changes })
297
730
  return stamp(asset)
298
731
  },
299
732
 
300
733
  archive: async ({
301
734
  workspaceId,
302
- assetId,
735
+ assetId: id,
303
736
  archived,
304
737
  }: {
305
738
  workspaceId?: string
@@ -307,11 +740,604 @@ export function createMockInventoryApi() {
307
740
  archived?: boolean
308
741
  }) => {
309
742
  remember(workspaceId)
310
- const asset = find(assetId)
311
- asset.archivedAt = archived === false ? null : new Date().toISOString()
743
+ const asset = find(id)
744
+ const archiving = archived !== false
745
+ // Somebody is still answerable for a held item; taking it out of the register does not
746
+ // change that, it only stops anybody being able to find out. The server refuses this too.
747
+ if (archiving && asset.custodianUserId)
748
+ throw new MockApiError(
749
+ 'CONFLICT',
750
+ 'Somebody is still holding this item. Take it back before archiving it.',
751
+ 'inventory.asset.still_held',
752
+ )
753
+ // And an item at a repairer cannot leave the register either: money is committed and the
754
+ // thing is out of the building. The server refuses this for the same reason.
755
+ if (archiving && openRepairFor(id))
756
+ throw new MockApiError(
757
+ 'CONFLICT',
758
+ 'This item is away for repair. Log the repair as returned before archiving it.',
759
+ 'inventory.asset.under_repair',
760
+ )
761
+ asset.archivedAt = archiving ? new Date().toISOString() : null
312
762
  asset.updatedAt = new Date().toISOString()
763
+ appendHistory(asset, archiving ? 'retired' : 'restored')
313
764
  return stamp(asset)
314
765
  },
315
766
  },
767
+
768
+ categories: {
769
+ list: async ({ workspaceId, archived = false }: { workspaceId: string; archived?: boolean }) => {
770
+ remember(workspaceId)
771
+ return categories
772
+ .filter((category) => (archived ? true : !category.archivedAt))
773
+ .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
774
+ .map(stamp)
775
+ },
776
+
777
+ create: async ({ workspaceId, name }: { workspaceId: string; name: string }) => {
778
+ remember(workspaceId)
779
+ // The server's unique index is what actually decides, and it answers a duplicate with a
780
+ // sentence rather than a 500. The demo answers the same way.
781
+ if (categories.some((category) => category.name === name))
782
+ throw new MockApiError(
783
+ 'CONFLICT',
784
+ `This workspace already has a category called “${name}”.`,
785
+ 'inventory.category.name_taken',
786
+ )
787
+ const now = new Date().toISOString()
788
+ const category: Category = {
789
+ id: categoryId(),
790
+ workspaceId: '' as Asset['workspaceId'],
791
+ name,
792
+ // Appended, like the server: a new category joins the end of the sequence rather than
793
+ // landing at the front tied with whatever is already there.
794
+ order: appendedOrder(),
795
+ createdAt: now,
796
+ updatedAt: now,
797
+ archivedAt: null,
798
+ }
799
+ categories.push(category)
800
+ return stamp(category)
801
+ },
802
+
803
+ update: async ({
804
+ workspaceId,
805
+ categoryId: id,
806
+ ...rest
807
+ }: {
808
+ workspaceId?: string
809
+ categoryId: string
810
+ name?: string
811
+ }) => {
812
+ remember(workspaceId)
813
+ const category = findCategory(id)
814
+ if (rest.name && categories.some((c) => c.id !== id && c.name === rest.name))
815
+ throw new MockApiError(
816
+ 'CONFLICT',
817
+ `This workspace already has a category called “${rest.name}”.`,
818
+ 'inventory.category.name_taken',
819
+ )
820
+ Object.assign(category, rest, { updatedAt: new Date().toISOString() })
821
+ return stamp(category)
822
+ },
823
+
824
+ archive: async ({
825
+ workspaceId,
826
+ categoryId: id,
827
+ archived,
828
+ }: {
829
+ workspaceId?: string
830
+ categoryId: string
831
+ archived?: boolean
832
+ }) => {
833
+ remember(workspaceId)
834
+ const category = findCategory(id)
835
+ // Nothing deletes: an asset filed under this category keeps naming it.
836
+ const restoring = archived === false
837
+ category.archivedAt = restoring ? null : new Date().toISOString()
838
+ // A restore appends, like the server: the position it left with belongs to somebody else by
839
+ // now, and the end of the list is the one place a person can find it again.
840
+ if (restoring) category.order = appendedOrder()
841
+ category.updatedAt = new Date().toISOString()
842
+ return stamp(category)
843
+ },
844
+
845
+ /**
846
+ * The sequence, rewritten from the ids — and the two refusals the server makes, because a
847
+ * demo that cannot reproduce them is a demo of the happy path.
848
+ *
849
+ * An id this workspace does not have is `NOT_FOUND`; a list that does not name every live
850
+ * category exactly once is the stale conflict, reason and all, so the settings page's rollback
851
+ * can be exercised without a server.
852
+ */
853
+ reorder: async ({ workspaceId, categoryIds }: { workspaceId?: string; categoryIds: string[] }) => {
854
+ remember(workspaceId)
855
+ const named = new Set(categoryIds)
856
+ if (named.size !== categoryIds.length)
857
+ throw new MockApiError('BAD_REQUEST', 'That list of categories names the same one more than once.')
858
+ for (const id of categoryIds) findCategory(id)
859
+ const live = categories.filter((category) => !category.archivedAt)
860
+ if (
861
+ live.some((category) => !named.has(category.id)) ||
862
+ categoryIds.some((id) => findCategory(id).archivedAt)
863
+ )
864
+ throw new MockApiError(
865
+ 'CONFLICT',
866
+ 'The categories changed while this list was open, so this order was not saved. Reload the list and arrange it again.',
867
+ 'inventory.category.order_stale',
868
+ )
869
+ const now = new Date().toISOString()
870
+ for (const [index, id] of categoryIds.entries()) {
871
+ const category = findCategory(id)
872
+ if (category.order === index) continue
873
+ category.order = index
874
+ category.updatedAt = now
875
+ }
876
+ return categoryIds.map((id) => stamp(findCategory(id)))
877
+ },
878
+ },
879
+
880
+ /**
881
+ * The same invariant the server keeps, in one place rather than three.
882
+ *
883
+ * Each verb closes what has to close, opens what has to open, brings the three denormalised
884
+ * columns on the asset into step and appends a history row. `transfer` does it in one step
885
+ * rather than as a return followed by an assign, because two steps would leave the asset in
886
+ * stock in between — visible in the list, and permanently visible in the timeline as a return
887
+ * nobody performed.
888
+ */
889
+ custody: {
890
+ history: async ({
891
+ workspaceId,
892
+ assetId: id,
893
+ limit = 100,
894
+ }: {
895
+ workspaceId?: string
896
+ assetId: string
897
+ limit?: number
898
+ }) => {
899
+ remember(workspaceId)
900
+ find(id)
901
+ return (
902
+ periods
903
+ .filter((period) => period.assetId === id)
904
+ /**
905
+ * `effective_from` and then the id, which is the server's ordering and needs both.
906
+ *
907
+ * A hand-on closes one period and opens another *at the same instant* — that is what
908
+ * makes them abut — so the timestamps are equal and there is no order between the two
909
+ * rows without the id. Sorting on the timestamp alone showed the handover before the
910
+ * return it replaced, at random, and the panel's "who had this before me" list opened
911
+ * with the row that is still open.
912
+ */
913
+ .sort((a, b) => {
914
+ if (a.effectiveFrom !== b.effectiveFrom) return a.effectiveFrom < b.effectiveFrom ? 1 : -1
915
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0
916
+ })
917
+ .slice(0, limit)
918
+ .map(stamp)
919
+ )
920
+ },
921
+
922
+ byUser: async ({
923
+ workspaceId,
924
+ userId,
925
+ limit = 50,
926
+ cursor,
927
+ }: {
928
+ workspaceId: string
929
+ userId: string
930
+ limit?: number
931
+ cursor?: string
932
+ }) => {
933
+ remember(workspaceId)
934
+ return listAssets({ custodianUserId: userId, archived: false, sort: 'recent', limit, cursor })
935
+ },
936
+
937
+ assign: async ({
938
+ workspaceId,
939
+ assetId: id,
940
+ userId,
941
+ note,
942
+ }: {
943
+ workspaceId: string
944
+ assetId: string
945
+ userId: string
946
+ note?: string | null
947
+ }) => {
948
+ remember(workspaceId)
949
+ const asset = liveAsset(id)
950
+ const open = openPeriodFor(id)
951
+ if (open)
952
+ throw new MockApiError(
953
+ 'CONFLICT',
954
+ open.userId === userId
955
+ ? 'They are already holding this item.'
956
+ : 'Somebody else is holding this item. Hand it on, or take it back first.',
957
+ 'inventory.custody.already_held',
958
+ )
959
+ const at = new Date().toISOString()
960
+ const period: CustodyPeriod = {
961
+ id: periodId(),
962
+ workspaceId: '' as Asset['workspaceId'],
963
+ assetId: id,
964
+ userId,
965
+ note: note ?? null,
966
+ effectiveFrom: at,
967
+ effectiveTo: null,
968
+ createdBy: PEOPLE.maya,
969
+ createdAt: at,
970
+ }
971
+ periods.push(period)
972
+ // The status is derived, never assumed: an item at a repairer stays `under_repair` when it
973
+ // changes hands, because a repair does not release whoever is answerable for it.
974
+ Object.assign(asset, { custodianUserId: userId, custodySince: at })
975
+ restamp(asset)
976
+ appendHistory(asset, 'assigned', { data: { userId, ...(note ? { note } : {}) } })
977
+ return { asset: stamp(asset), period: stamp(period) }
978
+ },
979
+
980
+ transfer: async ({
981
+ workspaceId,
982
+ assetId: id,
983
+ userId,
984
+ note,
985
+ }: {
986
+ workspaceId: string
987
+ assetId: string
988
+ userId: string
989
+ note?: string | null
990
+ }) => {
991
+ remember(workspaceId)
992
+ const asset = liveAsset(id)
993
+ const open = openPeriodFor(id)
994
+ if (!open)
995
+ throw new MockApiError(
996
+ 'CONFLICT',
997
+ 'Nobody is holding this item, so there is nothing to hand on. Assign it instead.',
998
+ 'inventory.custody.not_held',
999
+ )
1000
+ if (open.userId === userId)
1001
+ throw new MockApiError(
1002
+ 'CONFLICT',
1003
+ 'They are already holding this item.',
1004
+ 'inventory.custody.already_held',
1005
+ )
1006
+ // One instant for both halves, so the closing period and the opening one abut exactly and
1007
+ // the asset is never held by nobody for a microsecond.
1008
+ const at = new Date().toISOString()
1009
+ open.effectiveTo = at
1010
+ const period: CustodyPeriod = {
1011
+ id: periodId(),
1012
+ workspaceId: '' as Asset['workspaceId'],
1013
+ assetId: id,
1014
+ userId,
1015
+ note: note ?? null,
1016
+ effectiveFrom: at,
1017
+ effectiveTo: null,
1018
+ createdBy: PEOPLE.maya,
1019
+ createdAt: at,
1020
+ }
1021
+ periods.push(period)
1022
+ Object.assign(asset, { custodianUserId: userId, custodySince: at })
1023
+ restamp(asset)
1024
+ appendHistory(asset, 'transferred', {
1025
+ data: { userId, previousUserId: open.userId, ...(note ? { note } : {}) },
1026
+ })
1027
+ return { asset: stamp(asset), period: stamp(period) }
1028
+ },
1029
+
1030
+ return: async ({
1031
+ workspaceId,
1032
+ assetId: id,
1033
+ note,
1034
+ }: {
1035
+ workspaceId: string
1036
+ assetId: string
1037
+ note?: string | null
1038
+ }) => {
1039
+ remember(workspaceId)
1040
+ const asset = liveAsset(id)
1041
+ const open = openPeriodFor(id)
1042
+ if (!open)
1043
+ throw new MockApiError(
1044
+ 'CONFLICT',
1045
+ 'Nobody is holding this item, so there is nothing to take back.',
1046
+ 'inventory.custody.not_held',
1047
+ )
1048
+ const at = new Date().toISOString()
1049
+ open.effectiveTo = at
1050
+ Object.assign(asset, { custodianUserId: null, custodySince: null })
1051
+ restamp(asset)
1052
+ appendHistory(asset, 'returned', {
1053
+ data: { previousUserId: open.userId, ...(note ? { note } : {}) },
1054
+ })
1055
+ // Null, like the server: something closed and nothing opened.
1056
+ return { asset: stamp(asset), period: null }
1057
+ },
1058
+ },
1059
+
1060
+ /**
1061
+ * Repairs, with the refusals the server makes and the status rule it keeps.
1062
+ *
1063
+ * A demo that lets you send the same laptop away twice, or that shows an item as back in the
1064
+ * office the moment its repair is logged as finished while somebody still has it, teaches an
1065
+ * audience that the product does those things.
1066
+ */
1067
+ repairs: {
1068
+ list: async ({
1069
+ workspaceId,
1070
+ assetId: forAsset,
1071
+ open,
1072
+ limit = 50,
1073
+ cursor,
1074
+ }: {
1075
+ workspaceId: string
1076
+ assetId?: string
1077
+ open?: boolean
1078
+ limit?: number
1079
+ cursor?: string
1080
+ }) => {
1081
+ remember(workspaceId)
1082
+ if (forAsset) find(forAsset)
1083
+ const all = repairs
1084
+ .filter((repair) => (forAsset ? repair.assetId === forAsset : true))
1085
+ .filter((repair) =>
1086
+ open === undefined ? true : open ? repair.returnedOn === null : repair.returnedOn !== null,
1087
+ )
1088
+ // Newest logged first, by id — the server's ordering, for the server's reason: an id is
1089
+ // uuidv7 and already carries the clock, where `sent_on` is a date two repairs share.
1090
+ .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
1091
+ const rows = cursor ? all.filter((repair) => repair.id < decodeCursor(cursor, 'recent').i) : all
1092
+ const window = rows.slice(0, limit + 1)
1093
+ const items = window.slice(0, limit).map((repair) => {
1094
+ const asset = assets.find((row) => row.id === repair.assetId)
1095
+ // Joined, never stored: renaming an asset renames it here at once.
1096
+ return {
1097
+ ...stamp(repair),
1098
+ assetCode: asset?.code ?? '',
1099
+ assetName: asset?.name ?? '',
1100
+ }
1101
+ })
1102
+ const last = window.slice(0, limit).at(-1)
1103
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
1104
+ return { items, nextCursor }
1105
+ },
1106
+
1107
+ create: async ({
1108
+ workspaceId,
1109
+ assetId: id,
1110
+ summary,
1111
+ detail,
1112
+ vendor,
1113
+ costMinor,
1114
+ currency,
1115
+ sentOn,
1116
+ }: {
1117
+ workspaceId: string
1118
+ assetId: string
1119
+ summary: string
1120
+ detail?: string | null
1121
+ vendor?: string | null
1122
+ costMinor?: number | null
1123
+ currency?: string | null
1124
+ sentOn?: string
1125
+ }) => {
1126
+ remember(workspaceId)
1127
+ const asset = find(id)
1128
+ if (asset.archivedAt)
1129
+ throw new MockApiError(
1130
+ 'CONFLICT',
1131
+ 'This item is archived. Restore it before sending it for repair.',
1132
+ 'inventory.repair.archived',
1133
+ )
1134
+ if (openRepairFor(id))
1135
+ throw new MockApiError(
1136
+ 'CONFLICT',
1137
+ 'This item is already away for repair. Log that one as returned first.',
1138
+ 'inventory.repair.already_open',
1139
+ )
1140
+ const now = new Date().toISOString()
1141
+ const repair: Repair = {
1142
+ id: repairId(),
1143
+ workspaceId: '' as Asset['workspaceId'],
1144
+ assetId: id,
1145
+ summary,
1146
+ detail: detail ?? null,
1147
+ vendor: vendor ?? null,
1148
+ costMinor: costMinor ?? null,
1149
+ // A cost with no currency inherits the asset's, exactly as the server does — a number
1150
+ // with no unit is not a cost.
1151
+ currency: currency ?? (costMinor !== null && costMinor !== undefined ? asset.currency : null),
1152
+ sentOn: sentOn ?? today(),
1153
+ returnedOn: null,
1154
+ createdBy: PEOPLE.maya,
1155
+ createdAt: now,
1156
+ updatedAt: now,
1157
+ }
1158
+ repairs.push(repair)
1159
+ restamp(asset)
1160
+ appendHistory(asset, 'repair_logged', {
1161
+ data: { repairId: repair.id, summary, ...(repair.vendor ? { vendor: repair.vendor } : {}) },
1162
+ })
1163
+ return { repair: stamp(repair), asset: stamp(asset) }
1164
+ },
1165
+
1166
+ update: async ({
1167
+ workspaceId,
1168
+ repairId: id,
1169
+ ...rest
1170
+ }: {
1171
+ workspaceId: string
1172
+ repairId: string
1173
+ } & Record<string, unknown>) => {
1174
+ remember(workspaceId)
1175
+ const repair = findRepair(id)
1176
+ // `returnedOn` is not patchable on the server: one column decides the derived status, so
1177
+ // exactly one procedure moves it.
1178
+ const { returnedOn: _ignored, ...patch } = rest
1179
+ Object.assign(repair, patch, { updatedAt: new Date().toISOString() })
1180
+ const asset = find(repair.assetId)
1181
+ // Same inheritance rule as `complete` and the server: only when an amount is arriving and
1182
+ // the repair has no currency at all.
1183
+ if (
1184
+ patch.currency === undefined &&
1185
+ repair.currency === null &&
1186
+ patch.costMinor !== undefined &&
1187
+ patch.costMinor !== null
1188
+ )
1189
+ repair.currency = asset.currency
1190
+ return { repair: stamp(repair), asset: stamp(asset) }
1191
+ },
1192
+
1193
+ complete: async ({
1194
+ workspaceId,
1195
+ repairId: id,
1196
+ returnedOn,
1197
+ costMinor,
1198
+ currency,
1199
+ }: {
1200
+ workspaceId: string
1201
+ repairId: string
1202
+ returnedOn?: string
1203
+ costMinor?: number | null
1204
+ currency?: string | null
1205
+ }) => {
1206
+ remember(workspaceId)
1207
+ const repair = findRepair(id)
1208
+ if (repair.returnedOn)
1209
+ throw new MockApiError(
1210
+ 'CONFLICT',
1211
+ 'This repair is already logged as finished.',
1212
+ 'inventory.repair.already_complete',
1213
+ )
1214
+ const back = returnedOn ?? today()
1215
+ if (back < repair.sentOn)
1216
+ throw new MockApiError(
1217
+ 'CONFLICT',
1218
+ 'A repair cannot come back before it was sent.',
1219
+ 'inventory.repair.returned_before_sent',
1220
+ )
1221
+ const asset = find(repair.assetId)
1222
+ repair.returnedOn = back
1223
+ if (costMinor !== undefined) repair.costMinor = costMinor
1224
+ // The server's one inheritance rule: an amount is *arriving* and the repair has no currency
1225
+ // at all. Not "a cost exists", which would give a deliberately cleared currency back.
1226
+ if (currency !== undefined) repair.currency = currency ?? null
1227
+ else if (repair.currency === null && costMinor !== undefined && costMinor !== null)
1228
+ repair.currency = asset.currency
1229
+ repair.updatedAt = new Date().toISOString()
1230
+ // Back to whoever still holds it, or into stock — never blindly into stock.
1231
+ restamp(asset)
1232
+ appendHistory(asset, 'repair_completed', {
1233
+ data: {
1234
+ repairId: repair.id,
1235
+ summary: repair.summary,
1236
+ ...(repair.costMinor !== null ? { costMinor: repair.costMinor, currency: repair.currency } : {}),
1237
+ },
1238
+ })
1239
+ return { repair: stamp(repair), asset: stamp(asset) }
1240
+ },
1241
+ },
1242
+
1243
+ /**
1244
+ * Files, recorded against an asset or one of its repairs.
1245
+ *
1246
+ * The bytes went to the shell's own mock file store through `uploadFile`; this only records
1247
+ * that the asset has them. The name comes from `describeFile`, because the real server asks
1248
+ * core for it and this file has no core to ask.
1249
+ */
1250
+ attachments: {
1251
+ list: async ({ workspaceId, assetId: id }: { workspaceId: string; assetId: string }) => {
1252
+ remember(workspaceId)
1253
+ find(id)
1254
+ return attachments
1255
+ .filter((row) => row.assetId === id)
1256
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
1257
+ .map(stamp)
1258
+ },
1259
+
1260
+ add: async ({
1261
+ workspaceId,
1262
+ assetId: id,
1263
+ fileIds,
1264
+ repairId: forRepair,
1265
+ }: {
1266
+ workspaceId: string
1267
+ assetId: string
1268
+ fileIds: string[]
1269
+ repairId?: string | null
1270
+ }) => {
1271
+ remember(workspaceId)
1272
+ find(id)
1273
+ if (forRepair) findRepair(forRepair)
1274
+ const added: Attachment[] = []
1275
+ for (const fileId of [...new Set(fileIds)]) {
1276
+ // The same file twice on one asset adds nothing rather than failing, exactly as the
1277
+ // server's `on conflict do nothing` does.
1278
+ if (attachments.some((row) => row.assetId === id && row.fileId === fileId)) continue
1279
+ const facts = await describeFile(fileId)
1280
+ const row: Attachment = {
1281
+ id: attachmentId(),
1282
+ workspaceId: '' as Asset['workspaceId'],
1283
+ assetId: id,
1284
+ repairId: forRepair ?? null,
1285
+ fileId,
1286
+ name: facts.name,
1287
+ mimeType: facts.mimeType,
1288
+ size: facts.size,
1289
+ uploadedBy: PEOPLE.maya,
1290
+ createdAt: new Date().toISOString(),
1291
+ }
1292
+ attachments.push(row)
1293
+ added.push(row)
1294
+ appendHistory(find(id), 'attachment_added', {
1295
+ data: { attachmentId: row.id, name: row.name, ...(forRepair ? { repairId: forRepair } : {}) },
1296
+ })
1297
+ }
1298
+ return added.map(stamp)
1299
+ },
1300
+
1301
+ remove: async ({ workspaceId, attachmentId: id }: { workspaceId: string; attachmentId: string }) => {
1302
+ remember(workspaceId)
1303
+ const index = attachments.findIndex((row) => row.id === id)
1304
+ if (index < 0) throw new MockApiError('NOT_FOUND', 'Attachment not found')
1305
+ const [row] = attachments.splice(index, 1)
1306
+ appendHistory(find(row!.assetId), 'attachment_removed', {
1307
+ data: { attachmentId: row!.id, name: row!.name },
1308
+ })
1309
+ return { id: row!.id }
1310
+ },
1311
+ },
1312
+
1313
+ /**
1314
+ * The register in numbers.
1315
+ *
1316
+ * `outForRepair` is a number here rather than null because the shell's mock has every
1317
+ * capability on; the *shape* is what matters — a screen that reads it has to handle both.
1318
+ */
1319
+ stats: {
1320
+ summary: async ({ workspaceId }: { workspaceId: string }) => {
1321
+ remember(workspaceId)
1322
+ const live = assets.filter((asset) => !asset.archivedAt)
1323
+ const byStatus = {
1324
+ in_stock: 0,
1325
+ assigned: 0,
1326
+ reserved: 0,
1327
+ under_repair: 0,
1328
+ lost: 0,
1329
+ retired: 0,
1330
+ } satisfies Record<AssetStatus, number>
1331
+ for (const asset of live) byStatus[asset.status] += 1
1332
+ const summary: InventoryStats = {
1333
+ total: live.length,
1334
+ archived: assets.length - live.length,
1335
+ byStatus,
1336
+ outForRepair: repairs.filter((repair) => repair.returnedOn === null).length,
1337
+ unassigned: live.filter((asset) => !asset.custodianUserId).length,
1338
+ }
1339
+ return summary
1340
+ },
1341
+ },
316
1342
  }
317
1343
  }