@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
@@ -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,308 @@ 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
+ })
401
+ }
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
+ })
188
528
  }
189
- const stamp = (asset: Asset): Asset => ({ ...asset, workspaceId: workspace })
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
+ const openPeriodFor = (id: string) =>
549
+ periods.find((period) => period.assetId === id && period.effectiveTo === null)
550
+
551
+ /** At most one, exactly as `inventory_repairs_one_open_uq` guarantees on the server. */
552
+ const openRepairFor = (id: string) =>
553
+ repairs.find((repair) => repair.assetId === id && repair.returnedOn === null)
554
+
555
+ const findRepair = (id: string) => {
556
+ const repair = repairs.find((row) => row.id === id)
557
+ if (!repair) throw new MockApiError('NOT_FOUND', 'Repair not found')
558
+ return repair
559
+ }
560
+
561
+ /** Today as a plain date, which is what the server fills in when a caller sends none. */
562
+ const today = () => new Date().toISOString().slice(0, 10)
563
+
564
+ /** The one refusal a live asset can produce before any of the three verbs runs. */
565
+ const liveAsset = (id: string) => {
566
+ const asset = find(id)
567
+ if (asset.archivedAt)
568
+ throw new MockApiError(
569
+ 'CONFLICT',
570
+ 'This item is archived. Restore it before handing it over.',
571
+ 'inventory.custody.archived',
572
+ )
573
+ return asset
574
+ }
575
+
576
+ const listAssets = ({
577
+ q,
578
+ status,
579
+ categoryId: category,
580
+ custodianUserId,
581
+ archived = false,
582
+ sort = 'recent',
583
+ limit = 50,
584
+ cursor,
585
+ }: {
586
+ q?: string
587
+ status?: AssetStatus
588
+ categoryId?: string
589
+ custodianUserId?: string
590
+ archived?: boolean
591
+ sort?: AssetSort
592
+ limit?: number
593
+ cursor?: string
594
+ }) => {
595
+ // Every control the screen offers filters here, or the demo argues with its own toolbar.
596
+ const all = assets
597
+ .filter((a) => matches(a, q))
598
+ .filter((a) => (status ? a.status === status : true))
599
+ .filter((a) => (category ? a.categoryId === category : true))
600
+ .filter((a) => (custodianUserId ? a.custodianUserId === custodianUserId : true))
601
+ .filter((a) => (archived ? true : !a.archivedAt))
602
+ .map(stamp)
603
+
604
+ // `recent` is the only descending order, and it is the contract's default — the list opens
605
+ // on what was added last, which is what the server does and what the old mock did not.
606
+ const descending = sort === 'recent'
607
+ all.sort((a, b) => (descending ? -compare(a, b, sort) : compare(a, b, sort)))
608
+
609
+ let rows = all
610
+ if (cursor) {
611
+ const mark = decodeCursor(cursor, sort)
612
+ const bookmarked = assets.find((a) => a.id === mark.i)
613
+ /**
614
+ * A row archived or removed between two pages leaves nothing to compare against and the
615
+ * page simply ends — the server's deliberate choice, because somebody else editing while
616
+ * you read is an ordinary race rather than a malformed request.
617
+ */
618
+ if (!bookmarked) rows = []
619
+ else
620
+ rows = all.filter((a) => {
621
+ const order = compare(a, bookmarked, sort)
622
+ return descending ? order < 0 : order > 0
623
+ })
624
+ }
625
+
626
+ // limit + 1 to learn whether there is a next page, exactly as the server does.
627
+ const window = rows.slice(0, limit + 1)
628
+ const items = window.slice(0, limit)
629
+ const last = items.at(-1)
630
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: sort }) : null
631
+ return { items, nextCursor }
632
+ }
633
+
201
634
  return {
202
635
  assets: {
203
636
  list: async ({
204
637
  workspaceId,
205
- q,
206
- status,
207
- categoryId,
208
- custodianUserId,
209
- archived = false,
210
- sort = 'recent',
211
- limit = 50,
212
- cursor,
638
+ ...rest
213
639
  }: {
214
640
  workspaceId: string
215
641
  q?: string
@@ -222,56 +648,48 @@ export function createMockInventoryApi() {
222
648
  cursor?: string
223
649
  }) => {
224
650
  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)
651
+ return listAssets(rest)
652
+ },
233
653
 
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
- }
654
+ get: async ({ workspaceId, assetId: id }: { workspaceId?: string; assetId: string }) => {
655
+ remember(workspaceId)
656
+ return stamp(find(id))
657
+ },
255
658
 
256
- // limit + 1 to learn whether there is a next page, exactly as the server does.
659
+ /**
660
+ * The timeline, newest first, paged by **id** — the server's ordering, for the server's
661
+ * reason: an id already carries the clock and is unique where a timestamp two rows written in
662
+ * one transaction share is not.
663
+ */
664
+ history: async ({
665
+ workspaceId,
666
+ assetId: id,
667
+ limit = 50,
668
+ cursor,
669
+ }: {
670
+ workspaceId?: string
671
+ assetId: string
672
+ limit?: number
673
+ cursor?: string
674
+ }) => {
675
+ remember(workspaceId)
676
+ find(id)
677
+ const all = history.filter((entry) => entry.assetId === id).sort((a, b) => (a.id < b.id ? 1 : -1))
678
+ const rows = cursor ? all.filter((entry) => entry.id < decodeCursor(cursor, 'recent').i) : all
257
679
  const window = rows.slice(0, limit + 1)
258
680
  const items = window.slice(0, limit)
259
681
  const last = items.at(-1)
260
- const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: sort }) : null
682
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
261
683
  return { items, nextCursor }
262
684
  },
263
685
 
264
- get: async ({ workspaceId, assetId }: { workspaceId?: string; assetId: string }) => {
265
- remember(workspaceId)
266
- return stamp(find(assetId))
267
- },
268
-
269
686
  create: async ({ workspaceId, ...rest }: { workspaceId: string } & Record<string, unknown>) => {
270
687
  remember(workspaceId)
271
688
  const asset: Asset = {
272
689
  ...blank(pad(nextCodeNumber++)),
273
690
  name: String(rest.name ?? ''),
274
691
  description: String(rest.description ?? ''),
692
+ categoryId: (rest.categoryId as string | undefined) ?? null,
275
693
  serialNumber: (rest.serialNumber as string | undefined) ?? null,
276
694
  location: (rest.location as string | undefined) ?? null,
277
695
  purchasedFrom: (rest.purchasedFrom as string | undefined) ?? null,
@@ -281,25 +699,32 @@ export function createMockInventoryApi() {
281
699
  currency: (rest.currency as string | undefined) ?? null,
282
700
  }
283
701
  assets.push(asset)
702
+ appendHistory(asset, 'created')
284
703
  return stamp(asset)
285
704
  },
286
705
 
287
706
  update: async ({
288
- assetId,
707
+ assetId: id,
289
708
  workspaceId,
290
709
  ...rest
291
710
  }: { assetId: string; workspaceId?: string } & Record<string, unknown>) => {
292
711
  remember(workspaceId)
293
- const asset = find(assetId)
712
+ const asset = find(id)
713
+ // The diff before the assignment, so the timeline records what actually moved — the server
714
+ // does the same and for the same reason: an update that changed nothing writes nothing.
715
+ const changes = Object.entries(rest)
716
+ .filter(([field, value]) => (asset as Record<string, unknown>)[field] !== value)
717
+ .map(([field, value]) => ({ field, from: (asset as Record<string, unknown>)[field], to: value }))
294
718
  // `workspaceId` is not among the fields assigned: it is routing, not a field of the asset,
295
719
  // and letting a patch carry it means a demo can move a row to a workspace that is not real.
296
720
  Object.assign(asset, rest, { updatedAt: new Date().toISOString() })
721
+ if (changes.length) appendHistory(asset, 'updated', { changes })
297
722
  return stamp(asset)
298
723
  },
299
724
 
300
725
  archive: async ({
301
726
  workspaceId,
302
- assetId,
727
+ assetId: id,
303
728
  archived,
304
729
  }: {
305
730
  workspaceId?: string
@@ -307,11 +732,573 @@ export function createMockInventoryApi() {
307
732
  archived?: boolean
308
733
  }) => {
309
734
  remember(workspaceId)
310
- const asset = find(assetId)
311
- asset.archivedAt = archived === false ? null : new Date().toISOString()
735
+ const asset = find(id)
736
+ const archiving = archived !== false
737
+ // Somebody is still answerable for a held item; taking it out of the register does not
738
+ // change that, it only stops anybody being able to find out. The server refuses this too.
739
+ if (archiving && asset.custodianUserId)
740
+ throw new MockApiError(
741
+ 'CONFLICT',
742
+ 'Somebody is still holding this item. Take it back before archiving it.',
743
+ 'inventory.asset.still_held',
744
+ )
745
+ // And an item at a repairer cannot leave the register either: money is committed and the
746
+ // thing is out of the building. The server refuses this for the same reason.
747
+ if (archiving && openRepairFor(id))
748
+ throw new MockApiError(
749
+ 'CONFLICT',
750
+ 'This item is away for repair. Log the repair as returned before archiving it.',
751
+ 'inventory.asset.under_repair',
752
+ )
753
+ asset.archivedAt = archiving ? new Date().toISOString() : null
312
754
  asset.updatedAt = new Date().toISOString()
755
+ appendHistory(asset, archiving ? 'retired' : 'restored')
313
756
  return stamp(asset)
314
757
  },
315
758
  },
759
+
760
+ categories: {
761
+ list: async ({ workspaceId, archived = false }: { workspaceId: string; archived?: boolean }) => {
762
+ remember(workspaceId)
763
+ return categories
764
+ .filter((category) => (archived ? true : !category.archivedAt))
765
+ .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
766
+ .map(stamp)
767
+ },
768
+
769
+ create: async ({
770
+ workspaceId,
771
+ name,
772
+ order = 0,
773
+ }: {
774
+ workspaceId: string
775
+ name: string
776
+ order?: number
777
+ }) => {
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
+ order,
793
+ createdAt: now,
794
+ updatedAt: now,
795
+ archivedAt: null,
796
+ }
797
+ categories.push(category)
798
+ return stamp(category)
799
+ },
800
+
801
+ update: async ({
802
+ workspaceId,
803
+ categoryId: id,
804
+ ...rest
805
+ }: {
806
+ workspaceId?: string
807
+ categoryId: string
808
+ name?: string
809
+ order?: number
810
+ }) => {
811
+ remember(workspaceId)
812
+ const category = findCategory(id)
813
+ if (rest.name && categories.some((c) => c.id !== id && c.name === rest.name))
814
+ throw new MockApiError(
815
+ 'CONFLICT',
816
+ `This workspace already has a category called “${rest.name}”.`,
817
+ 'inventory.category.name_taken',
818
+ )
819
+ Object.assign(category, rest, { updatedAt: new Date().toISOString() })
820
+ return stamp(category)
821
+ },
822
+
823
+ archive: async ({
824
+ workspaceId,
825
+ categoryId: id,
826
+ archived,
827
+ }: {
828
+ workspaceId?: string
829
+ categoryId: string
830
+ archived?: boolean
831
+ }) => {
832
+ remember(workspaceId)
833
+ const category = findCategory(id)
834
+ // Nothing deletes: an asset filed under this category keeps naming it.
835
+ category.archivedAt = archived === false ? null : new Date().toISOString()
836
+ category.updatedAt = new Date().toISOString()
837
+ return stamp(category)
838
+ },
839
+ },
840
+
841
+ /**
842
+ * The same invariant the server keeps, in one place rather than three.
843
+ *
844
+ * Each verb closes what has to close, opens what has to open, brings the three denormalised
845
+ * columns on the asset into step and appends a history row. `transfer` does it in one step
846
+ * rather than as a return followed by an assign, because two steps would leave the asset in
847
+ * stock in between — visible in the list, and permanently visible in the timeline as a return
848
+ * nobody performed.
849
+ */
850
+ custody: {
851
+ history: async ({
852
+ workspaceId,
853
+ assetId: id,
854
+ limit = 100,
855
+ }: {
856
+ workspaceId?: string
857
+ assetId: string
858
+ limit?: number
859
+ }) => {
860
+ remember(workspaceId)
861
+ find(id)
862
+ return (
863
+ periods
864
+ .filter((period) => period.assetId === id)
865
+ /**
866
+ * `effective_from` and then the id, which is the server's ordering and needs both.
867
+ *
868
+ * A hand-on closes one period and opens another *at the same instant* — that is what
869
+ * makes them abut — so the timestamps are equal and there is no order between the two
870
+ * rows without the id. Sorting on the timestamp alone showed the handover before the
871
+ * return it replaced, at random, and the panel's "who had this before me" list opened
872
+ * with the row that is still open.
873
+ */
874
+ .sort((a, b) => {
875
+ if (a.effectiveFrom !== b.effectiveFrom) return a.effectiveFrom < b.effectiveFrom ? 1 : -1
876
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0
877
+ })
878
+ .slice(0, limit)
879
+ .map(stamp)
880
+ )
881
+ },
882
+
883
+ byUser: async ({
884
+ workspaceId,
885
+ userId,
886
+ limit = 50,
887
+ cursor,
888
+ }: {
889
+ workspaceId: string
890
+ userId: string
891
+ limit?: number
892
+ cursor?: string
893
+ }) => {
894
+ remember(workspaceId)
895
+ return listAssets({ custodianUserId: userId, archived: false, sort: 'recent', limit, cursor })
896
+ },
897
+
898
+ assign: async ({
899
+ workspaceId,
900
+ assetId: id,
901
+ userId,
902
+ note,
903
+ }: {
904
+ workspaceId: string
905
+ assetId: string
906
+ userId: string
907
+ note?: string | null
908
+ }) => {
909
+ remember(workspaceId)
910
+ const asset = liveAsset(id)
911
+ const open = openPeriodFor(id)
912
+ if (open)
913
+ throw new MockApiError(
914
+ 'CONFLICT',
915
+ open.userId === userId
916
+ ? 'They are already holding this item.'
917
+ : 'Somebody else is holding this item. Hand it on, or take it back first.',
918
+ 'inventory.custody.already_held',
919
+ )
920
+ const at = new Date().toISOString()
921
+ const period: CustodyPeriod = {
922
+ id: periodId(),
923
+ workspaceId: '' as Asset['workspaceId'],
924
+ assetId: id,
925
+ userId,
926
+ note: note ?? null,
927
+ effectiveFrom: at,
928
+ effectiveTo: null,
929
+ createdBy: PEOPLE.maya,
930
+ createdAt: at,
931
+ }
932
+ periods.push(period)
933
+ // The status is derived, never assumed: an item at a repairer stays `under_repair` when it
934
+ // changes hands, because a repair does not release whoever is answerable for it.
935
+ Object.assign(asset, { custodianUserId: userId, custodySince: at })
936
+ restamp(asset)
937
+ appendHistory(asset, 'assigned', { data: { userId, ...(note ? { note } : {}) } })
938
+ return { asset: stamp(asset), period: stamp(period) }
939
+ },
940
+
941
+ transfer: async ({
942
+ workspaceId,
943
+ assetId: id,
944
+ userId,
945
+ note,
946
+ }: {
947
+ workspaceId: string
948
+ assetId: string
949
+ userId: string
950
+ note?: string | null
951
+ }) => {
952
+ remember(workspaceId)
953
+ const asset = liveAsset(id)
954
+ const open = openPeriodFor(id)
955
+ if (!open)
956
+ throw new MockApiError(
957
+ 'CONFLICT',
958
+ 'Nobody is holding this item, so there is nothing to hand on. Assign it instead.',
959
+ 'inventory.custody.not_held',
960
+ )
961
+ if (open.userId === userId)
962
+ throw new MockApiError(
963
+ 'CONFLICT',
964
+ 'They are already holding this item.',
965
+ 'inventory.custody.already_held',
966
+ )
967
+ // One instant for both halves, so the closing period and the opening one abut exactly and
968
+ // the asset is never held by nobody for a microsecond.
969
+ const at = new Date().toISOString()
970
+ open.effectiveTo = at
971
+ const period: CustodyPeriod = {
972
+ id: periodId(),
973
+ workspaceId: '' as Asset['workspaceId'],
974
+ assetId: id,
975
+ userId,
976
+ note: note ?? null,
977
+ effectiveFrom: at,
978
+ effectiveTo: null,
979
+ createdBy: PEOPLE.maya,
980
+ createdAt: at,
981
+ }
982
+ periods.push(period)
983
+ Object.assign(asset, { custodianUserId: userId, custodySince: at })
984
+ restamp(asset)
985
+ appendHistory(asset, 'transferred', {
986
+ data: { userId, previousUserId: open.userId, ...(note ? { note } : {}) },
987
+ })
988
+ return { asset: stamp(asset), period: stamp(period) }
989
+ },
990
+
991
+ return: async ({
992
+ workspaceId,
993
+ assetId: id,
994
+ note,
995
+ }: {
996
+ workspaceId: string
997
+ assetId: string
998
+ note?: string | null
999
+ }) => {
1000
+ remember(workspaceId)
1001
+ const asset = liveAsset(id)
1002
+ const open = openPeriodFor(id)
1003
+ if (!open)
1004
+ throw new MockApiError(
1005
+ 'CONFLICT',
1006
+ 'Nobody is holding this item, so there is nothing to take back.',
1007
+ 'inventory.custody.not_held',
1008
+ )
1009
+ const at = new Date().toISOString()
1010
+ open.effectiveTo = at
1011
+ Object.assign(asset, { custodianUserId: null, custodySince: null })
1012
+ restamp(asset)
1013
+ appendHistory(asset, 'returned', {
1014
+ data: { previousUserId: open.userId, ...(note ? { note } : {}) },
1015
+ })
1016
+ // Null, like the server: something closed and nothing opened.
1017
+ return { asset: stamp(asset), period: null }
1018
+ },
1019
+ },
1020
+
1021
+ /**
1022
+ * Repairs, with the refusals the server makes and the status rule it keeps.
1023
+ *
1024
+ * A demo that lets you send the same laptop away twice, or that shows an item as back in the
1025
+ * office the moment its repair is logged as finished while somebody still has it, teaches an
1026
+ * audience that the product does those things.
1027
+ */
1028
+ repairs: {
1029
+ list: async ({
1030
+ workspaceId,
1031
+ assetId: forAsset,
1032
+ open,
1033
+ limit = 50,
1034
+ cursor,
1035
+ }: {
1036
+ workspaceId: string
1037
+ assetId?: string
1038
+ open?: boolean
1039
+ limit?: number
1040
+ cursor?: string
1041
+ }) => {
1042
+ remember(workspaceId)
1043
+ if (forAsset) find(forAsset)
1044
+ const all = repairs
1045
+ .filter((repair) => (forAsset ? repair.assetId === forAsset : true))
1046
+ .filter((repair) =>
1047
+ open === undefined ? true : open ? repair.returnedOn === null : repair.returnedOn !== null,
1048
+ )
1049
+ // Newest logged first, by id — the server's ordering, for the server's reason: an id is
1050
+ // uuidv7 and already carries the clock, where `sent_on` is a date two repairs share.
1051
+ .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
1052
+ const rows = cursor ? all.filter((repair) => repair.id < decodeCursor(cursor, 'recent').i) : all
1053
+ const window = rows.slice(0, limit + 1)
1054
+ const items = window.slice(0, limit).map((repair) => {
1055
+ const asset = assets.find((row) => row.id === repair.assetId)
1056
+ // Joined, never stored: renaming an asset renames it here at once.
1057
+ return {
1058
+ ...stamp(repair),
1059
+ assetCode: asset?.code ?? '',
1060
+ assetName: asset?.name ?? '',
1061
+ }
1062
+ })
1063
+ const last = window.slice(0, limit).at(-1)
1064
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
1065
+ return { items, nextCursor }
1066
+ },
1067
+
1068
+ create: async ({
1069
+ workspaceId,
1070
+ assetId: id,
1071
+ summary,
1072
+ detail,
1073
+ vendor,
1074
+ costMinor,
1075
+ currency,
1076
+ sentOn,
1077
+ }: {
1078
+ workspaceId: string
1079
+ assetId: string
1080
+ summary: string
1081
+ detail?: string | null
1082
+ vendor?: string | null
1083
+ costMinor?: number | null
1084
+ currency?: string | null
1085
+ sentOn?: string
1086
+ }) => {
1087
+ remember(workspaceId)
1088
+ const asset = find(id)
1089
+ if (asset.archivedAt)
1090
+ throw new MockApiError(
1091
+ 'CONFLICT',
1092
+ 'This item is archived. Restore it before sending it for repair.',
1093
+ 'inventory.repair.archived',
1094
+ )
1095
+ if (openRepairFor(id))
1096
+ throw new MockApiError(
1097
+ 'CONFLICT',
1098
+ 'This item is already away for repair. Log that one as returned first.',
1099
+ 'inventory.repair.already_open',
1100
+ )
1101
+ const now = new Date().toISOString()
1102
+ const repair: Repair = {
1103
+ id: repairId(),
1104
+ workspaceId: '' as Asset['workspaceId'],
1105
+ assetId: id,
1106
+ summary,
1107
+ detail: detail ?? null,
1108
+ vendor: vendor ?? null,
1109
+ costMinor: costMinor ?? null,
1110
+ // A cost with no currency inherits the asset's, exactly as the server does — a number
1111
+ // with no unit is not a cost.
1112
+ currency: currency ?? (costMinor !== null && costMinor !== undefined ? asset.currency : null),
1113
+ sentOn: sentOn ?? today(),
1114
+ returnedOn: null,
1115
+ createdBy: PEOPLE.maya,
1116
+ createdAt: now,
1117
+ updatedAt: now,
1118
+ }
1119
+ repairs.push(repair)
1120
+ restamp(asset)
1121
+ appendHistory(asset, 'repair_logged', {
1122
+ data: { repairId: repair.id, summary, ...(repair.vendor ? { vendor: repair.vendor } : {}) },
1123
+ })
1124
+ return { repair: stamp(repair), asset: stamp(asset) }
1125
+ },
1126
+
1127
+ update: async ({
1128
+ workspaceId,
1129
+ repairId: id,
1130
+ ...rest
1131
+ }: {
1132
+ workspaceId: string
1133
+ repairId: string
1134
+ } & Record<string, unknown>) => {
1135
+ remember(workspaceId)
1136
+ const repair = findRepair(id)
1137
+ // `returnedOn` is not patchable on the server: one column decides the derived status, so
1138
+ // exactly one procedure moves it.
1139
+ const { returnedOn: _ignored, ...patch } = rest
1140
+ Object.assign(repair, patch, { updatedAt: new Date().toISOString() })
1141
+ const asset = find(repair.assetId)
1142
+ // Same inheritance rule as `complete` and the server: only when an amount is arriving and
1143
+ // the repair has no currency at all.
1144
+ if (
1145
+ patch.currency === undefined &&
1146
+ repair.currency === null &&
1147
+ patch.costMinor !== undefined &&
1148
+ patch.costMinor !== null
1149
+ )
1150
+ repair.currency = asset.currency
1151
+ return { repair: stamp(repair), asset: stamp(asset) }
1152
+ },
1153
+
1154
+ complete: async ({
1155
+ workspaceId,
1156
+ repairId: id,
1157
+ returnedOn,
1158
+ costMinor,
1159
+ currency,
1160
+ }: {
1161
+ workspaceId: string
1162
+ repairId: string
1163
+ returnedOn?: string
1164
+ costMinor?: number | null
1165
+ currency?: string | null
1166
+ }) => {
1167
+ remember(workspaceId)
1168
+ const repair = findRepair(id)
1169
+ if (repair.returnedOn)
1170
+ throw new MockApiError(
1171
+ 'CONFLICT',
1172
+ 'This repair is already logged as finished.',
1173
+ 'inventory.repair.already_complete',
1174
+ )
1175
+ const back = returnedOn ?? today()
1176
+ if (back < repair.sentOn)
1177
+ throw new MockApiError(
1178
+ 'CONFLICT',
1179
+ 'A repair cannot come back before it was sent.',
1180
+ 'inventory.repair.returned_before_sent',
1181
+ )
1182
+ const asset = find(repair.assetId)
1183
+ repair.returnedOn = back
1184
+ if (costMinor !== undefined) repair.costMinor = costMinor
1185
+ // The server's one inheritance rule: an amount is *arriving* and the repair has no currency
1186
+ // at all. Not "a cost exists", which would give a deliberately cleared currency back.
1187
+ if (currency !== undefined) repair.currency = currency ?? null
1188
+ else if (repair.currency === null && costMinor !== undefined && costMinor !== null)
1189
+ repair.currency = asset.currency
1190
+ repair.updatedAt = new Date().toISOString()
1191
+ // Back to whoever still holds it, or into stock — never blindly into stock.
1192
+ restamp(asset)
1193
+ appendHistory(asset, 'repair_completed', {
1194
+ data: {
1195
+ repairId: repair.id,
1196
+ summary: repair.summary,
1197
+ ...(repair.costMinor !== null ? { costMinor: repair.costMinor, currency: repair.currency } : {}),
1198
+ },
1199
+ })
1200
+ return { repair: stamp(repair), asset: stamp(asset) }
1201
+ },
1202
+ },
1203
+
1204
+ /**
1205
+ * Files, recorded against an asset or one of its repairs.
1206
+ *
1207
+ * The bytes went to the shell's own mock file store through `uploadFile`; this only records
1208
+ * that the asset has them. The name comes from `describeFile`, because the real server asks
1209
+ * core for it and this file has no core to ask.
1210
+ */
1211
+ attachments: {
1212
+ list: async ({ workspaceId, assetId: id }: { workspaceId: string; assetId: string }) => {
1213
+ remember(workspaceId)
1214
+ find(id)
1215
+ return attachments
1216
+ .filter((row) => row.assetId === id)
1217
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
1218
+ .map(stamp)
1219
+ },
1220
+
1221
+ add: async ({
1222
+ workspaceId,
1223
+ assetId: id,
1224
+ fileIds,
1225
+ repairId: forRepair,
1226
+ }: {
1227
+ workspaceId: string
1228
+ assetId: string
1229
+ fileIds: string[]
1230
+ repairId?: string | null
1231
+ }) => {
1232
+ remember(workspaceId)
1233
+ find(id)
1234
+ if (forRepair) findRepair(forRepair)
1235
+ const added: Attachment[] = []
1236
+ for (const fileId of [...new Set(fileIds)]) {
1237
+ // The same file twice on one asset adds nothing rather than failing, exactly as the
1238
+ // server's `on conflict do nothing` does.
1239
+ if (attachments.some((row) => row.assetId === id && row.fileId === fileId)) continue
1240
+ const facts = await describeFile(fileId)
1241
+ const row: Attachment = {
1242
+ id: attachmentId(),
1243
+ workspaceId: '' as Asset['workspaceId'],
1244
+ assetId: id,
1245
+ repairId: forRepair ?? null,
1246
+ fileId,
1247
+ name: facts.name,
1248
+ mimeType: facts.mimeType,
1249
+ size: facts.size,
1250
+ uploadedBy: PEOPLE.maya,
1251
+ createdAt: new Date().toISOString(),
1252
+ }
1253
+ attachments.push(row)
1254
+ added.push(row)
1255
+ appendHistory(find(id), 'attachment_added', {
1256
+ data: { attachmentId: row.id, name: row.name, ...(forRepair ? { repairId: forRepair } : {}) },
1257
+ })
1258
+ }
1259
+ return added.map(stamp)
1260
+ },
1261
+
1262
+ remove: async ({ workspaceId, attachmentId: id }: { workspaceId: string; attachmentId: string }) => {
1263
+ remember(workspaceId)
1264
+ const index = attachments.findIndex((row) => row.id === id)
1265
+ if (index < 0) throw new MockApiError('NOT_FOUND', 'Attachment not found')
1266
+ const [row] = attachments.splice(index, 1)
1267
+ appendHistory(find(row!.assetId), 'attachment_removed', {
1268
+ data: { attachmentId: row!.id, name: row!.name },
1269
+ })
1270
+ return { id: row!.id }
1271
+ },
1272
+ },
1273
+
1274
+ /**
1275
+ * The register in numbers.
1276
+ *
1277
+ * `outForRepair` is a number here rather than null because the shell's mock has every
1278
+ * capability on; the *shape* is what matters — a screen that reads it has to handle both.
1279
+ */
1280
+ stats: {
1281
+ summary: async ({ workspaceId }: { workspaceId: string }) => {
1282
+ remember(workspaceId)
1283
+ const live = assets.filter((asset) => !asset.archivedAt)
1284
+ const byStatus = {
1285
+ in_stock: 0,
1286
+ assigned: 0,
1287
+ reserved: 0,
1288
+ under_repair: 0,
1289
+ lost: 0,
1290
+ retired: 0,
1291
+ } satisfies Record<AssetStatus, number>
1292
+ for (const asset of live) byStatus[asset.status] += 1
1293
+ const summary: InventoryStats = {
1294
+ total: live.length,
1295
+ archived: assets.length - live.length,
1296
+ byStatus,
1297
+ outForRepair: repairs.filter((repair) => repair.returnedOn === null).length,
1298
+ unassigned: live.filter((asset) => !asset.custodianUserId).length,
1299
+ }
1300
+ return summary
1301
+ },
1302
+ },
316
1303
  }
317
1304
  }