@kernhq/module-inventory 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +134 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -1,4 +1,13 @@
1
- import type { Asset } from '../contract.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.
@@ -8,65 +17,679 @@ import type { Asset } from '../contract.js'
8
17
  * never checks an env var itself.
9
18
  *
10
19
  * Keep it in step with the contract. A module whose mock is missing a procedure has a working page
11
- * and a broken demo, in exactly the environment used to show the product.
20
+ * and a broken demo, in exactly the environment used to show the product — and one whose mock
21
+ * ignores a filter has a demo that contradicts its own controls.
22
+ *
23
+ * **It contradicted the product in five measured ways, and this file is the answer to all five.**
24
+ * It returned seed order while the server defaults to newest first; it issued `INV-0013` for the
25
+ * seventh asset because one counter was doing two jobs; it served page one for ever for any cursor
26
+ * it did not recognise, which is an endless "Load more" where the server answers `BAD_REQUEST`; and
27
+ * it ignored `sort`, `categoryId` and `custodianUserId` entirely. So the ordering, the paging and
28
+ * the cursor format below are deliberately the same as `src/server/services/assets.ts` — read them
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.
42
+ */
43
+ type AssetSort = 'recent' | 'name' | 'code'
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
+
76
+ interface Seed {
77
+ code: string
78
+ name: string
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
83
+ serialNumber?: string | null
84
+ location?: string | null
85
+ warrantyUntil?: string | null
86
+ archived?: boolean
87
+ }
88
+
89
+ const SEEDS: Seed[] = [
90
+ {
91
+ code: 'INV-0001',
92
+ name: 'MacBook Pro 14"',
93
+ status: 'assigned',
94
+ categoryKey: 'laptops',
95
+ custodian: PEOPLE.dan,
96
+ serialNumber: 'C02X1234JGH7',
97
+ location: 'Istanbul · 3rd floor',
98
+ warrantyUntil: '2027-03-14',
99
+ },
100
+ {
101
+ code: 'INV-0002',
102
+ name: 'Dell UltraSharp 27"',
103
+ status: 'in_stock',
104
+ categoryKey: 'displays',
105
+ location: 'Istanbul · store room',
106
+ },
107
+ {
108
+ code: 'INV-0003',
109
+ name: 'iPhone 15',
110
+ status: 'under_repair',
111
+ // Filed under the archived category on purpose: the row still names "Phones".
112
+ categoryKey: 'phones',
113
+ serialNumber: 'F17GX9QKLM',
114
+ location: 'With the repairer',
115
+ },
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
+ },
131
+ { code: 'INV-0006', name: 'ThinkPad X1 Carbon', status: 'retired', archived: true },
132
+ ]
133
+
134
+ /**
135
+ * An error shaped like one the real client surfaces.
136
+ *
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.
150
+ */
151
+ class MockApiError extends Error {
152
+ readonly data: { reason: string } | undefined
153
+ constructor(
154
+ readonly code: 'BAD_REQUEST' | 'NOT_FOUND' | 'CONFLICT',
155
+ message: string,
156
+ reason?: string,
157
+ ) {
158
+ super(message)
159
+ this.name = 'MockApiError'
160
+ this.data = reason ? { reason } : undefined
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The server's page boundary, byte for byte: base64url of `{i: <row id>, s: <sort>}`.
166
+ *
167
+ * `btoa` rather than `Buffer` because this runs in a browser, and it is safe here for the reason
168
+ * the server's version is small: the payload is a uuid and a sort name, so it is pure ASCII.
169
+ */
170
+ const base64url = (text: string) => btoa(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
171
+
172
+ const fromBase64url = (text: string) =>
173
+ atob(
174
+ text
175
+ .replace(/-/g, '+')
176
+ .replace(/_/g, '/')
177
+ .padEnd(Math.ceil(text.length / 4) * 4, '='),
178
+ )
179
+
180
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
181
+
182
+ const encodeCursor = (bookmark: { i: string; s: AssetSort }) => base64url(JSON.stringify(bookmark))
183
+
184
+ /**
185
+ * Refuse a marker this list did not issue, exactly where the server refuses it.
186
+ *
187
+ * The old mock read an unknown cursor as "start at 0", so `nextCursor` was non-null for ever and
188
+ * "Load more" served page one until somebody closed the tab. A cursor bound to the wrong sort is
189
+ * the same class of bug and is refused for the same reason.
190
+ */
191
+ function decodeCursor(cursor: string, sort: AssetSort): { i: string; s: AssetSort } {
192
+ const refuse = () => new MockApiError('BAD_REQUEST', 'That page marker is not one this list issued')
193
+ let parsed: { i?: unknown; s?: unknown } | null
194
+ try {
195
+ parsed = JSON.parse(fromBase64url(cursor)) as { i?: unknown; s?: unknown } | null
196
+ } catch {
197
+ throw refuse()
198
+ }
199
+ if (typeof parsed?.i !== 'string' || !UUID.test(parsed.i)) throw refuse()
200
+ if (parsed.s !== sort) throw refuse()
201
+ return { i: parsed.i, s: sort }
202
+ }
203
+
204
+ /** Which field an ordering sorts on. `recent` is the id, because a uuidv7 is already in time order. */
205
+ const sortKeyOf = (asset: Asset, sort: AssetSort) =>
206
+ sort === 'name' ? asset.name : sort === 'code' ? asset.code : asset.id
207
+
208
+ /**
209
+ * `(sortKey, id)` against `(sortKey, id)` — the tuple the server's row comparison uses.
210
+ *
211
+ * The id tiebreak is what makes the boundary total: two assets with the same name would otherwise
212
+ * have no defined order between them, and a page boundary that lands between them would repeat or
213
+ * skip a row. JS compares by code point where Postgres compares by collation, which is close enough
214
+ * for six seeded rows and is the one place this file is an approximation rather than a mirror.
12
215
  */
13
- interface MockAsset extends Asset {}
216
+ function compare(a: Asset, b: Asset, sort: AssetSort): number {
217
+ const ka = sortKeyOf(a, sort)
218
+ const kb = sortKeyOf(b, sort)
219
+ if (ka !== kb) return ka < kb ? -1 : 1
220
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
221
+ }
14
222
 
15
- export function createMockInventoryApi() {
16
- let counter = 2
17
- const assets: MockAsset[] = [
18
- seed('01920000-0000-7000-8000-000000000001', 'INV-0001', 'MacBook Pro 14"', 'assigned'),
19
- ]
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()
20
225
 
21
- function seed(id: string, code: string, name: string, status: Asset['status']): MockAsset {
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 }))
251
+ /**
252
+ * Four id sequences, and separate tag numbering, because they were one and it showed.
253
+ *
254
+ * `blank()` incremented the id counter and `create` then formatted a code out of it, so six seeds
255
+ * consumed six numbers and the seventh asset somebody added in a demo was filed as `INV-0013`
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.
261
+ */
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')
271
+
272
+ let nextCodeNumber = SEEDS.length + 1
273
+ const pad = (n: number) => `INV-${String(n).padStart(4, '0')}`
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
+
308
+ function blank(code: string, seed: Partial<Seed> & { name?: string } = {}): Asset {
22
309
  const now = new Date().toISOString()
23
310
  return {
24
- id,
311
+ id: assetId(),
25
312
  workspaceId: '' as Asset['workspaceId'],
26
- code,
27
- name,
313
+ code: seed.code ?? code,
314
+ name: seed.name ?? '',
28
315
  description: '',
29
- categoryId: null,
30
- status,
31
- custodianUserId: null,
32
- custodySince: null,
33
- serialNumber: null,
34
- location: null,
316
+ categoryId: seed.categoryKey ? (categoryByKey.get(seed.categoryKey) ?? null) : null,
317
+ status: seed.status ?? 'in_stock',
318
+ custodianUserId: seed.custodian ?? null,
319
+ custodySince: seed.custodian ? daysAgo(21) : null,
320
+ serialNumber: seed.serialNumber ?? null,
321
+ location: seed.location ?? null,
35
322
  purchasedOn: null,
36
323
  purchasedFrom: null,
37
324
  priceMinor: null,
38
325
  currency: null,
39
- warrantyUntil: null,
326
+ warrantyUntil: seed.warrantyUntil ?? null,
40
327
  photoFileId: null,
328
+ custom: {},
41
329
  createdAt: now,
42
330
  updatedAt: now,
43
- archivedAt: null,
331
+ archivedAt: seed.archived ? now : null,
44
332
  }
45
333
  }
46
334
 
335
+ const assets: Asset[] = SEEDS.map((seed, i) => blank(pad(i + 1), seed))
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
+
365
+ /**
366
+ * A seeded past, so the timeline and "previous holders" have something to draw.
367
+ *
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.
371
+ */
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
+ })
528
+ }
529
+
530
+ // -------------------------------------------------------------------------------- helpers
531
+
532
+ const matches = (a: Asset, q?: string) =>
533
+ !q ||
534
+ [a.name, a.code, a.serialNumber ?? ''].some((field) => field.toLowerCase().includes(q.toLowerCase()))
535
+
536
+ const find = (id: string) => {
537
+ const asset = assets.find((a) => a.id === id)
538
+ if (!asset) throw new MockApiError('NOT_FOUND', 'Asset not found')
539
+ return asset
540
+ }
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
+
47
634
  return {
48
635
  assets: {
49
- list: async ({ workspaceId, q }: { workspaceId: string; q?: string }) => ({
50
- items: assets
51
- .filter((a) => !q || a.name.includes(q) || a.code.includes(q) || (a.serialNumber ?? '').includes(q))
52
- .map((a) => ({ ...a, workspaceId })),
53
- nextCursor: null,
54
- }),
55
- get: async ({ assetId }: { assetId: string }) => {
56
- const asset = assets.find((a) => a.id === assetId)
57
- if (!asset) throw new Error('Asset not found')
58
- return { ...asset }
636
+ list: async ({
637
+ workspaceId,
638
+ ...rest
639
+ }: {
640
+ workspaceId: string
641
+ q?: string
642
+ status?: AssetStatus
643
+ categoryId?: string
644
+ custodianUserId?: string
645
+ archived?: boolean
646
+ sort?: AssetSort
647
+ limit?: number
648
+ cursor?: string
649
+ }) => {
650
+ remember(workspaceId)
651
+ return listAssets(rest)
652
+ },
653
+
654
+ get: async ({ workspaceId, assetId: id }: { workspaceId?: string; assetId: string }) => {
655
+ remember(workspaceId)
656
+ return stamp(find(id))
657
+ },
658
+
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
679
+ const window = rows.slice(0, limit + 1)
680
+ const items = window.slice(0, limit)
681
+ const last = items.at(-1)
682
+ const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
683
+ return { items, nextCursor }
59
684
  },
685
+
60
686
  create: async ({ workspaceId, ...rest }: { workspaceId: string } & Record<string, unknown>) => {
61
- const asset: MockAsset = {
62
- ...seed(
63
- crypto.randomUUID(),
64
- `INV-${String(counter++).padStart(4, '0')}`,
65
- String(rest.name),
66
- 'in_stock',
67
- ),
68
- workspaceId: workspaceId as Asset['workspaceId'],
687
+ remember(workspaceId)
688
+ const asset: Asset = {
689
+ ...blank(pad(nextCodeNumber++)),
690
+ name: String(rest.name ?? ''),
69
691
  description: String(rest.description ?? ''),
692
+ categoryId: (rest.categoryId as string | undefined) ?? null,
70
693
  serialNumber: (rest.serialNumber as string | undefined) ?? null,
71
694
  location: (rest.location as string | undefined) ?? null,
72
695
  purchasedFrom: (rest.purchasedFrom as string | undefined) ?? null,
@@ -75,20 +698,606 @@ export function createMockInventoryApi() {
75
698
  priceMinor: (rest.priceMinor as number | undefined) ?? null,
76
699
  currency: (rest.currency as string | undefined) ?? null,
77
700
  }
78
- assets.unshift(asset)
79
- return { ...asset }
80
- },
81
- update: async ({ assetId, ...rest }: { assetId: string } & Record<string, unknown>) => {
82
- const asset = assets.find((a) => a.id === assetId)
83
- if (!asset) throw new Error('Asset not found')
84
- Object.assign(asset, rest)
85
- return { ...asset }
86
- },
87
- archive: async ({ assetId, archived }: { assetId: string; archived?: boolean }) => {
88
- const asset = assets.find((a) => a.id === assetId)
89
- if (!asset) throw new Error('Asset not found')
90
- asset.archivedAt = archived === false ? null : new Date().toISOString()
91
- return { ...asset }
701
+ assets.push(asset)
702
+ appendHistory(asset, 'created')
703
+ return stamp(asset)
704
+ },
705
+
706
+ update: async ({
707
+ assetId: id,
708
+ workspaceId,
709
+ ...rest
710
+ }: { assetId: string; workspaceId?: string } & Record<string, unknown>) => {
711
+ remember(workspaceId)
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 }))
718
+ // `workspaceId` is not among the fields assigned: it is routing, not a field of the asset,
719
+ // and letting a patch carry it means a demo can move a row to a workspace that is not real.
720
+ Object.assign(asset, rest, { updatedAt: new Date().toISOString() })
721
+ if (changes.length) appendHistory(asset, 'updated', { changes })
722
+ return stamp(asset)
723
+ },
724
+
725
+ archive: async ({
726
+ workspaceId,
727
+ assetId: id,
728
+ archived,
729
+ }: {
730
+ workspaceId?: string
731
+ assetId: string
732
+ archived?: boolean
733
+ }) => {
734
+ remember(workspaceId)
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
754
+ asset.updatedAt = new Date().toISOString()
755
+ appendHistory(asset, archiving ? 'retired' : 'restored')
756
+ return stamp(asset)
757
+ },
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
92
1301
  },
93
1302
  },
94
1303
  }