@kernhq/module-inventory 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +106 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +345 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +245 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1108 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +253 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1392 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +385 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +169 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +157 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +309 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/0008_category_order_unique.sql +71 -0
  117. package/migrations/meta/0002_snapshot.json +1054 -0
  118. package/migrations/meta/0003_snapshot.json +1070 -0
  119. package/migrations/meta/0004_snapshot.json +1130 -0
  120. package/migrations/meta/0005_snapshot.json +1135 -0
  121. package/migrations/meta/_journal.json +49 -0
  122. package/package.json +2 -1
  123. package/src/client/api-instance.ts +27 -2
  124. package/src/client/bidi.test.ts +148 -0
  125. package/src/client/bidi.ts +85 -0
  126. package/src/client/components/AssetDetailPanel.svelte +614 -0
  127. package/src/client/components/AssetFormDialog.svelte +71 -22
  128. package/src/client/components/AssetPhoto.svelte +178 -0
  129. package/src/client/components/AttachmentsSection.svelte +327 -0
  130. package/src/client/components/CustodyDialog.svelte +201 -0
  131. package/src/client/components/RepairDialog.svelte +271 -0
  132. package/src/client/components/RepairsSection.svelte +318 -0
  133. package/src/client/components/Timeline.svelte +347 -0
  134. package/src/client/components/TimelineText.svelte +124 -0
  135. package/src/client/core-api.ts +71 -0
  136. package/src/client/custody.test.ts +31 -0
  137. package/src/client/custody.ts +34 -0
  138. package/src/client/errors.test.ts +395 -0
  139. package/src/client/errors.ts +229 -0
  140. package/src/client/links.test.ts +74 -0
  141. package/src/client/links.ts +44 -0
  142. package/src/client/members.test.ts +132 -0
  143. package/src/client/members.ts +116 -0
  144. package/src/client/messages.test.ts +127 -0
  145. package/src/client/messages.ts +1106 -18
  146. package/src/client/mock.test.ts +465 -1
  147. package/src/client/mock.ts +1105 -79
  148. package/src/client/module.ts +73 -1
  149. package/src/client/pages/AssetsPage.svelte +317 -45
  150. package/src/client/permissions.ts +7 -0
  151. package/src/client/price.test.ts +151 -3
  152. package/src/client/price.ts +155 -11
  153. package/src/client/query.ts +36 -0
  154. package/src/client/reorder.test.ts +100 -0
  155. package/src/client/reorder.ts +79 -0
  156. package/src/client/repairs.test.ts +38 -0
  157. package/src/client/repairs.ts +38 -0
  158. package/src/client/sequence.test.ts +248 -0
  159. package/src/client/sequence.ts +185 -0
  160. package/src/client/settings/CategoriesSettings.svelte +746 -0
  161. package/src/client/settings/GeneralSettings.svelte +0 -0
  162. package/src/client/status.ts +29 -0
  163. package/src/client/timeline.test.ts +175 -0
  164. package/src/client/timeline.ts +206 -0
  165. package/src/client/widgets/OverviewWidget.svelte +127 -26
  166. package/src/client/widgets/RepairsWidget.svelte +124 -0
  167. package/src/contract/capabilities.ts +53 -9
  168. package/src/contract/events.ts +49 -0
  169. package/src/contract/index.ts +1 -0
  170. package/src/contract/models.ts +270 -0
  171. package/src/contract/notifications.ts +73 -0
  172. package/src/contract/permissions.ts +55 -2
  173. package/src/contract/router.ts +284 -1
  174. package/src/contract/settings.ts +24 -4
  175. package/src/module.test.ts +217 -4
  176. package/src/server/index.ts +311 -4
  177. package/src/server/inventory.int.test.ts +4109 -19
  178. package/src/server/jobs.ts +444 -0
  179. package/src/server/migrations.test.ts +253 -2
  180. package/src/server/router.ts +486 -6
  181. package/src/server/schema.ts +171 -3
  182. package/src/server/services/assets.ts +214 -54
  183. package/src/server/services/attachments.ts +215 -0
  184. package/src/server/services/audience.ts +77 -0
  185. package/src/server/services/categories.ts +337 -0
  186. package/src/server/services/cursor.ts +104 -0
  187. package/src/server/services/custody.ts +471 -0
  188. package/src/server/services/db-errors.ts +42 -0
  189. package/src/server/services/index.ts +34 -1
  190. package/src/server/services/members.ts +54 -0
  191. package/src/server/services/notify.ts +52 -7
  192. package/src/server/services/offboarding.ts +150 -0
  193. package/src/server/services/repairs.ts +567 -0
  194. package/src/server/services/search.ts +166 -0
  195. package/src/server/services/stats.ts +88 -0
  196. package/src/server/services/status.test.ts +34 -0
  197. package/src/server/services/status.ts +143 -0
  198. package/src/client/settings/core-api.ts +0 -32
@@ -0,0 +1,476 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, desc, eq, isNotNull, isNull, lt } from 'drizzle-orm';
3
+ import { assets, repairs } from '../schema.js';
4
+ import { decodeMark, encodeMark } from './cursor.js';
5
+ import { violated } from './db-errors.js';
6
+ import { awayForRepair, deriveStatus, lockAsset } from './status.js';
7
+ /** The partial unique index `0003_repairs.sql` added. Two open repairs for one asset are it. */
8
+ const ONE_OPEN = 'inventory_repairs_one_open_uq';
9
+ /** The CHECK `0005_repair_dates.sql` added. A repair that came back before it was sent is it. */
10
+ const IN_ORDER = 'inventory_repairs_returned_after_sent';
11
+ /**
12
+ * The one ordering a repair list has, named rather than left as a literal.
13
+ *
14
+ * The cursor codec binds a bookmark to the sort it was issued under and refuses it under any other,
15
+ * so this list needs a name for its sort even while there is only one — and the day a second one
16
+ * exists, every cursor already in a browser tab is refused rather than misread.
17
+ */
18
+ const REPAIR_SORT = 'recent';
19
+ /** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
20
+ export function toRepair(row) {
21
+ return {
22
+ id: row.id,
23
+ workspaceId: row.workspaceId,
24
+ assetId: row.assetId,
25
+ summary: row.summary,
26
+ detail: row.detail,
27
+ vendor: row.vendor,
28
+ costMinor: row.costMinor,
29
+ currency: row.currency,
30
+ sentOn: row.sentOn,
31
+ returnedOn: row.returnedOn ?? null,
32
+ createdBy: row.createdBy,
33
+ createdAt: row.createdAt.toISOString(),
34
+ updatedAt: row.updatedAt.toISOString(),
35
+ };
36
+ }
37
+ /**
38
+ * What went away to be fixed, and what came back.
39
+ *
40
+ * Three things happen in one transaction on every change here, and the point of the class is that
41
+ * they cannot come apart: the repair row moves, `assets.status` is brought into step with it through
42
+ * `deriveStatus`, and an `asset_history` entry records it.
43
+ *
44
+ * **The database is the arbiter of "already away", not this file.** There is deliberately no
45
+ * `select … for update` on the asset *before* the repair row is written:
46
+ * `inventory_repairs_one_open_uq` — a unique index on `(asset_id) where returned_on is null` — is
47
+ * what makes two open repairs impossible, so two people pressing *Send for repair* on the same
48
+ * laptop in the same instant both read "it is here", both insert, and Postgres refuses exactly one
49
+ * of them. Checking first and inserting after is the race, not the fix. What this file owes the
50
+ * loser is a sentence they can act on rather than drizzle's "Failed query: insert into
51
+ * mod_inventory.repairs …".
52
+ *
53
+ * **`restamp` does lock it, afterwards, and that is a different job.** `assets.status` is derived
54
+ * from this module's repair rows *and* from custody, which another service writes, so the winner
55
+ * still has to compute an answer nobody can overwrite from a stale snapshot. See `lockAsset` in
56
+ * `status.ts`: the index decides the contest, the lock orders the bookkeeping that follows it.
57
+ *
58
+ * **Repairs do not touch custody.** An item at the repairer is still somebody's responsibility; see
59
+ * `status.ts`, where that rule is argued in full.
60
+ */
61
+ export class RepairService {
62
+ notify;
63
+ constructor(notify) {
64
+ this.notify = notify;
65
+ }
66
+ /**
67
+ * Today, as this module means it: the UTC date.
68
+ *
69
+ * Not the browser's date, and not a workspace time zone — a repair is dated to the day, and the
70
+ * two hours a workspace in Istanbul is ahead of UTC would put an evening repair on tomorrow for
71
+ * one reader and today for another. One clock, the server's, the same reasoning that keeps asset
72
+ * tags server-side. A workspace that needs the exact day sends `sentOn` itself.
73
+ */
74
+ static today() {
75
+ return new Date().toISOString().slice(0, 10);
76
+ }
77
+ /** The one error a lost race produces, in every place a race can be lost. */
78
+ static alreadyAway() {
79
+ return KernError.conflict('This item is already away for repair. Log that one as returned first.', 'inventory.repair.already_open');
80
+ }
81
+ /**
82
+ * A repair cannot come back before it was sent — checked on every path that writes either date.
83
+ *
84
+ * `complete` has always refused a return date before the send date, and `update` did not: it took
85
+ * `sentOn` from the patch and wrote it whatever the row already said, so correcting the send date
86
+ * of a *finished* repair could move it past the day the item came back. What that stores is a
87
+ * repair that ended before it started — every "how long was it away" answer negative, the overdue
88
+ * sweep measuring from a date in the future, and nothing anywhere to say which of the two dates is
89
+ * the wrong one.
90
+ *
91
+ * Both are `date` columns, so they read back as `YYYY-MM-DD` and compare correctly as text.
92
+ *
93
+ * The database holds the same rule (`inventory_repairs_returned_after_sent`), because two
94
+ * transactions can each pass this check and still write a pair that fails it: one moving `sent_on`
95
+ * while the other logs the item back. This runs first so the ordinary case gets a sentence rather
96
+ * than a constraint violation; `outOfOrder` is what the loser of that race gets.
97
+ */
98
+ static requireInOrder(sentOn, returnedOn) {
99
+ if (returnedOn !== null && returnedOn < sentOn)
100
+ throw RepairService.outOfOrder();
101
+ }
102
+ static outOfOrder() {
103
+ return KernError.conflict('A repair cannot come back before it was sent.', 'inventory.repair.returned_before_sent');
104
+ }
105
+ /**
106
+ * One day past today, which is the whole tolerance a date typed by a person needs.
107
+ *
108
+ * `today()` is UTC — one clock, the server's, for the reason it documents — and a workspace in
109
+ * Auckland is up to fourteen hours ahead of it, so *their* today is UTC's tomorrow for a large
110
+ * part of their working day. Refusing at exactly UTC-today would refuse the ordinary case in half
111
+ * the world's offices. A day is enough for every real time zone and is nowhere near enough to be
112
+ * the defect below.
113
+ */
114
+ static FUTURE_GRACE_DAYS = 1;
115
+ /**
116
+ * A repair cannot be sent from the future — checked on every path that writes `sent_on`.
117
+ *
118
+ * `sentOn` is a date a person types, and nothing bounded it. A repair dated 2030 is one the
119
+ * overdue sweep can never find: it looks for `sent_on <= today - repairOverdueDays`, so a send
120
+ * date years ahead is permanently outside the window and the chase never fires. Not for a while —
121
+ * **ever**, for the life of that row, and silently, because a sweep that finds nothing looks
122
+ * exactly like a sweep with nothing to do. It also makes every "how long has it been away" answer
123
+ * negative and puts the item at the top of a list ordered by when it left.
124
+ *
125
+ * A typo is the likely cause and a deliberate one is the dangerous case: this is the one field
126
+ * that decides whether anybody is ever reminded that a vendor still has the company's laptop.
127
+ *
128
+ * Bounded on the *server*, not in the contract, and both halves of that are deliberate. The
129
+ * contract is shared with the browser, so a `refine` there would compare against the reader's own
130
+ * clock — and a client whose date is a day ahead would refuse a date the server accepts, or the
131
+ * other way round. And it is a sentence rather than a bare `invalid_string`, because "a repair
132
+ * cannot be sent in the future" is something a person can act on.
133
+ *
134
+ * **A `BAD_REQUEST` with no `reason`, like `checkPhoto`'s refusal and the category check in
135
+ * `AssetService`.** The date is malformed rather than contested — nothing raced, no state
136
+ * changed underneath anybody, the value simply cannot be true — and `CONFLICT` is this module's
137
+ * word for losing a race. The cost is that the sentence reaches a Persian or Turkish reader in
138
+ * English: a translated refusal needs a stable `reason` **and** its five bundles in
139
+ * `src/client/errors.ts`, and the two have to arrive together or `errors.test.ts` fails from
140
+ * whichever side is ahead.
141
+ */
142
+ static requireNotFuture(sentOn) {
143
+ const limit = new Date(`${RepairService.today()}T00:00:00Z`);
144
+ limit.setUTCDate(limit.getUTCDate() + RepairService.FUTURE_GRACE_DAYS);
145
+ if (sentOn > limit.toISOString().slice(0, 10))
146
+ throw KernError.badRequest('A repair cannot be sent in the future.');
147
+ }
148
+ async asset(tx, workspaceId, assetId) {
149
+ const [row] = await tx
150
+ .select()
151
+ .from(assets)
152
+ .where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)));
153
+ if (!row)
154
+ throw KernError.notFound('Asset');
155
+ return row;
156
+ }
157
+ async get(tx, workspaceId, repairId) {
158
+ const [row] = await tx
159
+ .select()
160
+ .from(repairs)
161
+ .where(and(eq(repairs.workspaceId, workspaceId), eq(repairs.id, repairId)));
162
+ if (!row)
163
+ throw KernError.notFound('Repair');
164
+ return row;
165
+ }
166
+ /**
167
+ * One asset's repairs, or the whole workspace's — one query with one filter, because they are one
168
+ * question asked at two scopes and a second query answering it would be a second one to keep in
169
+ * step.
170
+ *
171
+ * **Paged by id, newest logged first.** An id is uuidv7, so it already carries the clock, and it
172
+ * is unique where `sent_on` is a date two repairs logged on the same day share — a page boundary
173
+ * between two rows that share a sort key repeats one and drops the other. The same reasoning
174
+ * `assets.list` gives for `sort: 'recent'` and `assets.history` gives for ordering on the row id.
175
+ */
176
+ async list(tx, workspaceId, input) {
177
+ const filters = [eq(repairs.workspaceId, workspaceId)];
178
+ if (input.assetId)
179
+ filters.push(eq(repairs.assetId, input.assetId));
180
+ if (input.open === true)
181
+ filters.push(isNull(repairs.returnedOn));
182
+ if (input.open === false)
183
+ filters.push(isNotNull(repairs.returnedOn));
184
+ if (input.cursor)
185
+ filters.push(lt(repairs.id, decodeMark(input.cursor, REPAIR_SORT).i));
186
+ /**
187
+ * Joined to the asset for its tag and name — inside one schema, which is the join a module is
188
+ * allowed to make. A workspace-wide list of what is away is unreadable without them, and
189
+ * copying them into `repairs` would be a label that goes stale the first time somebody renames
190
+ * an asset.
191
+ */
192
+ const rows = await tx
193
+ .select({ repair: repairs, code: assets.code, name: assets.name })
194
+ .from(repairs)
195
+ .innerJoin(assets, and(eq(assets.id, repairs.assetId), eq(assets.workspaceId, repairs.workspaceId)))
196
+ .where(and(...filters))
197
+ .orderBy(desc(repairs.id))
198
+ .limit(input.limit + 1);
199
+ const window = rows.slice(0, input.limit);
200
+ const last = window.at(-1);
201
+ const nextCursor = rows.length > input.limit && last ? encodeMark({ i: last.repair.id, s: REPAIR_SORT }) : null;
202
+ const items = window.map((row) => ({
203
+ ...toRepair(row.repair),
204
+ assetCode: row.code,
205
+ assetName: row.name,
206
+ }));
207
+ return { items, nextCursor };
208
+ }
209
+ /**
210
+ * The currency to store, given what the caller said and what is already there.
211
+ *
212
+ * A cost with no unit is not a cost, so an amount recorded with no currency inherits the asset's.
213
+ * **Inheritance fires in exactly one case** — an amount is being recorded and the repair has no
214
+ * currency at all — and getting that wrong is silent in both directions:
215
+ *
216
+ * - inheriting whenever a cost *exists* rather than whenever one *arrives* means an unrelated
217
+ * edit (correcting a vendor) silently gives the asset's currency back to a repair whose
218
+ * currency somebody deliberately cleared;
219
+ * - inheriting over a currency the repair already has means recording an amount in dollars on an
220
+ * asset priced in euros quietly relabels it as euros.
221
+ *
222
+ * `undefined` means "not mentioned" and an explicit `null` means "no currency", which a workspace
223
+ * that records amounts and not currencies genuinely means. Collapsing the two is the mistake
224
+ * `assets.update` documents one file over.
225
+ */
226
+ static currencyFor(opts) {
227
+ if (opts.patch !== undefined)
228
+ return opts.patch ?? null;
229
+ if (opts.previous !== null)
230
+ return opts.previous;
231
+ return opts.cost === null || opts.cost === undefined ? null : (opts.asset.currency ?? null);
232
+ }
233
+ /** Send it away. Refuses when it is already at a repairer — that is what `complete` is for. */
234
+ async create(tx, workspaceId, actorId, assetId, input) {
235
+ const asset = await this.asset(tx, workspaceId, assetId);
236
+ // An archived asset is one the workspace has said it no longer tracks. Paying to fix something
237
+ // that is not in the register is a mistake worth naming rather than recording.
238
+ if (asset.archivedAt)
239
+ throw KernError.conflict('This item is archived. Restore it before sending it for repair.', 'inventory.repair.archived');
240
+ const sentOn = input.sentOn ?? RepairService.today();
241
+ // Vacuous today, and deliberately here rather than reasoned about: `RepairInput` carries no
242
+ // `returnedOn`, so a new repair is always still away. The day it carries one, this is already
243
+ // the check, instead of being the one write path somebody forgot.
244
+ RepairService.requireInOrder(sentOn, null);
245
+ RepairService.requireNotFuture(sentOn);
246
+ let row;
247
+ try {
248
+ const inserted = await tx
249
+ .insert(repairs)
250
+ .values({
251
+ id: uuidv7(),
252
+ workspaceId,
253
+ assetId,
254
+ summary: input.summary,
255
+ detail: input.detail ?? null,
256
+ vendor: input.vendor ?? null,
257
+ costMinor: input.costMinor ?? null,
258
+ currency: RepairService.currencyFor({
259
+ patch: input.currency,
260
+ cost: input.costMinor,
261
+ previous: null,
262
+ asset,
263
+ }),
264
+ sentOn,
265
+ createdBy: actorId,
266
+ })
267
+ .returning();
268
+ row = inserted[0];
269
+ }
270
+ catch (err) {
271
+ // The index bit: another transaction opened a repair for this asset between our read and our
272
+ // insert. Anything else is a real fault and must not be disguised as a lost race.
273
+ if (violated(err, ONE_OPEN))
274
+ throw RepairService.alreadyAway();
275
+ throw err;
276
+ }
277
+ const updated = await this.restamp(tx, workspaceId, assetId);
278
+ const activity = {
279
+ workspaceId,
280
+ assetId,
281
+ actorId,
282
+ action: 'repair_logged',
283
+ data: { repairId: row.id, summary: row.summary, ...(row.vendor ? { vendor: row.vendor } : {}) },
284
+ };
285
+ await this.notify.history(tx, activity);
286
+ return { repair: row, asset: updated, activity };
287
+ }
288
+ /**
289
+ * Correct what was recorded — a vendor, or a cost that arrived with the invoice a week later.
290
+ *
291
+ * **`returnedOn` is deliberately not patchable.** That one column decides whether the asset reads
292
+ * as `under_repair`, so exactly one procedure moves it and the derived status has one door rather
293
+ * than two. Editing a finished repair is allowed all the same: the invoice usually arrives after
294
+ * the item does.
295
+ *
296
+ * **No `actorId`, because nothing here records one.** This writes no timeline entry — see the
297
+ * bottom of the method — and a parameter kept for symmetry with `create` and `complete` would be
298
+ * a parameter every caller has to supply and nothing reads.
299
+ */
300
+ async update(tx, workspaceId, repairId, patch) {
301
+ const previous = await this.get(tx, workspaceId, repairId);
302
+ const asset = await this.asset(tx, workspaceId, previous.assetId);
303
+ // `undefined` means "not mentioned"; `null` means "clear it". Collapsing the two is how an edit
304
+ // of one field quietly wipes the others. `cost` is the patch's own value and not the merged
305
+ // one, so correcting a vendor cannot give the asset's currency back to a repair whose currency
306
+ // somebody deliberately cleared.
307
+ const sentOn = patch.sentOn ?? previous.sentOn;
308
+ // `returnedOn` is not patchable, so the row's own value is the one this has to stay behind. An
309
+ // edit that moves the send date past the day the item came back is refused rather than stored.
310
+ RepairService.requireInOrder(sentOn, previous.returnedOn);
311
+ // And the same bound `create` applies, because this is the other door onto the same column —
312
+ // and the more dangerous one: moving `sent_on` forward also clears `overdue_notified_at`, so a
313
+ // correction into the future re-arms a chase that can then never fire.
314
+ RepairService.requireNotFuture(sentOn);
315
+ const values = {
316
+ summary: patch.summary ?? previous.summary,
317
+ detail: patch.detail !== undefined ? (patch.detail ?? null) : previous.detail,
318
+ vendor: patch.vendor !== undefined ? (patch.vendor ?? null) : previous.vendor,
319
+ costMinor: patch.costMinor !== undefined ? (patch.costMinor ?? null) : previous.costMinor,
320
+ currency: RepairService.currencyFor({
321
+ patch: patch.currency,
322
+ cost: patch.costMinor,
323
+ previous: previous.currency,
324
+ asset,
325
+ }),
326
+ sentOn,
327
+ /**
328
+ * Correcting the send date re-arms the overdue notice.
329
+ *
330
+ * `overdue_notified_at` marks that somebody has already been asked to chase this one, and the
331
+ * threshold is measured from `sent_on` — so a repair re-dated a month earlier is overdue for
332
+ * the first time and nobody would ever hear about it if the marker survived. Only when the
333
+ * date actually moved: correcting a vendor must not send the same chase again.
334
+ */
335
+ overdueNotifiedAt: sentOn === previous.sentOn ? previous.overdueNotifiedAt : null,
336
+ updatedAt: new Date(),
337
+ };
338
+ let row;
339
+ try {
340
+ const written = await tx
341
+ .update(repairs)
342
+ .set(values)
343
+ .where(and(eq(repairs.workspaceId, workspaceId), eq(repairs.id, repairId)))
344
+ .returning();
345
+ row = written[0];
346
+ }
347
+ catch (err) {
348
+ // The CHECK bit: another transaction logged the item back between the check above and this
349
+ // statement, so the pair being written is out of order after all. A sentence, not a driver
350
+ // dump — the same debt every other constraint in this module is paid.
351
+ if (violated(err, IN_ORDER))
352
+ throw RepairService.outOfOrder();
353
+ throw err;
354
+ }
355
+ /**
356
+ * **No timeline entry, and that is a decision.**
357
+ *
358
+ * The asset's timeline records what happened *to the asset*: it went away, and it came back.
359
+ * Correcting a vendor or filling in an invoice a week later did not happen to the asset, and a
360
+ * timeline that reported every such edit would bury the two entries that matter under the
361
+ * paperwork around them. The repair row's own `updated_at` is where "this was edited" lives,
362
+ * and the row is on screen beside it.
363
+ */
364
+ return { repair: row, asset, activity: null };
365
+ }
366
+ /**
367
+ * It came back.
368
+ *
369
+ * `and returned_on is null` in the predicate is the optimistic guard, exactly as
370
+ * `CustodyService.close` uses: under READ COMMITTED a concurrent complete blocks this statement,
371
+ * and when it resumes the row no longer matches, so zero rows come back rather than a second
372
+ * completion silently overwriting the first one's date.
373
+ */
374
+ async complete(tx, workspaceId, actorId, repairId, input) {
375
+ const previous = await this.get(tx, workspaceId, repairId);
376
+ if (previous.returnedOn)
377
+ throw KernError.conflict('This repair is already logged as finished.', 'inventory.repair.already_complete');
378
+ const asset = await this.asset(tx, workspaceId, previous.assetId);
379
+ const returnedOn = input.returnedOn ?? RepairService.today();
380
+ // A date is a fact somebody typed, and a person deserves a sentence about it rather than a
381
+ // constraint violation — the database refuses this pair too, and this is what stops it having
382
+ // to.
383
+ RepairService.requireInOrder(previous.sentOn, returnedOn);
384
+ let row;
385
+ try {
386
+ const written = await tx
387
+ .update(repairs)
388
+ .set({
389
+ returnedOn,
390
+ costMinor: input.costMinor !== undefined ? (input.costMinor ?? null) : previous.costMinor,
391
+ // The invoice usually arrives with the item, so this is where a cost is most often first
392
+ // recorded — and therefore where the currency is most often inherited.
393
+ currency: RepairService.currencyFor({
394
+ patch: input.currency,
395
+ cost: input.costMinor,
396
+ previous: previous.currency,
397
+ asset,
398
+ }),
399
+ updatedAt: new Date(),
400
+ })
401
+ .where(and(eq(repairs.workspaceId, workspaceId), eq(repairs.id, repairId), isNull(repairs.returnedOn)))
402
+ .returning();
403
+ row = written[0];
404
+ }
405
+ catch (err) {
406
+ // Another transaction moved `sent_on` past this return date between the check above and this
407
+ // statement. See `update`, which has the same guard for the same race seen from the other end.
408
+ if (violated(err, IN_ORDER))
409
+ throw RepairService.outOfOrder();
410
+ throw err;
411
+ }
412
+ if (!row)
413
+ throw KernError.conflict('Somebody logged this repair as finished a moment before you did. Reload to see where it is now.', 'inventory.repair.already_complete');
414
+ const updated = await this.restamp(tx, workspaceId, previous.assetId);
415
+ const activity = {
416
+ workspaceId,
417
+ assetId: previous.assetId,
418
+ actorId,
419
+ action: 'repair_completed',
420
+ data: {
421
+ repairId: row.id,
422
+ summary: row.summary,
423
+ ...(row.costMinor !== null ? { costMinor: row.costMinor, currency: row.currency } : {}),
424
+ },
425
+ };
426
+ await this.notify.history(tx, activity);
427
+ return { repair: row, asset: updated, activity };
428
+ }
429
+ /**
430
+ * Bring `assets.status` back into step with the facts, inside the same transaction.
431
+ *
432
+ * Both facts are **read** rather than assumed — the open repair as it now stands, and the
433
+ * custodian as the asset row carries it — and `deriveStatus` decides. Assuming `under_repair`
434
+ * after an insert and `in_stock` after a completion is the version of this that silently released
435
+ * whoever was still holding the item.
436
+ *
437
+ * **The asset is locked and re-read here, rather than passed in.** It used to take the row the
438
+ * caller had already fetched, which is a snapshot from before the repair was written and before
439
+ * anything else that touched the asset in between: a handover committing in that window was
440
+ * invisible, so this derived a status from a custodian that had stopped being current and wrote it
441
+ * over the handover's answer. Taking the row under the lock is what makes "read both facts, then
442
+ * derive" true rather than merely intended. `lockAsset` argues it in full.
443
+ */
444
+ async restamp(tx, workspaceId, assetId) {
445
+ const asset = await lockAsset(tx, workspaceId, assetId);
446
+ // `true` rather than a parameter: every caller of this file is behind `requiresCapability
447
+ // ('repairs')`, so a workspace reaching here is a workspace that records repairs by
448
+ // construction. The two paths that are *not* behind it — custody and archive — take the switch
449
+ // as an argument, and `deriveStatus` says why.
450
+ const away = await awayForRepair(tx, workspaceId, assetId, true);
451
+ /**
452
+ * An archived item cannot be away for repair, and this is the other half of the refusal that
453
+ * says so.
454
+ *
455
+ * `assets.archive` reads "nothing open against it" under this same lock, so an archive racing a
456
+ * *new* repair is ordered against it — and whichever arrives second has to lose, or the register
457
+ * ends up holding exactly the state the refusal claims is impossible. Only while the repair is
458
+ * open: logging one back on a row somebody archived anyway has to stay possible, or the item is
459
+ * trapped away for ever.
460
+ */
461
+ if (away && asset.archivedAt)
462
+ throw KernError.conflict('This item is archived. Restore it before sending it for repair.', 'inventory.repair.archived');
463
+ const status = deriveStatus({ custodianUserId: asset.custodianUserId, awayForRepair: away });
464
+ if (status === asset.status)
465
+ return asset;
466
+ const [row] = await tx
467
+ .update(assets)
468
+ .set({ status, updatedAt: new Date() })
469
+ .where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
470
+ .returning();
471
+ if (!row)
472
+ throw KernError.notFound('Asset');
473
+ return row;
474
+ }
475
+ }
476
+ //# sourceMappingURL=repairs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repairs.js","sourceRoot":"","sources":["../../../src/server/services/repairs.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,85 @@
1
+ import type { core } from '@kernhq/contracts';
2
+ import type { Kernel } from '@kernhq/kernel';
3
+ import { assets } from '../schema.js';
4
+ import type { NotifyService } from './notify.js';
5
+ /**
6
+ * How an asset is referred to from outside this module.
7
+ *
8
+ * One function, because a search hit and a resolved `inventory:asset:<id>` reference have to be the
9
+ * same thing — somebody who finds a laptop in the command palette and somebody who follows a link to
10
+ * it from a chat message are looking at the same row and must see the same words for it.
11
+ */
12
+ export declare const assetUrl: (assetId: string) => string;
13
+ export declare const ASSET_ICON = "briefcase";
14
+ /** The columns a document is built from, and the one join it needs. */
15
+ export interface IndexableAsset {
16
+ asset: typeof assets.$inferSelect;
17
+ /** The category's name, resolved at read time — never copied into `assets`, so a rename lands. */
18
+ categoryName: string | null;
19
+ }
20
+ /**
21
+ * What a person types into the command palette, and what an asset has to answer it with.
22
+ *
23
+ * An asset tag read off a sticker is the first of these — `INV-0042` is what somebody has in their
24
+ * hand — so it leads the title, exactly as tracker leads with an issue key. The rest is everything
25
+ * printed on or near the thing: its name, its serial number, where it is kept, and what the
26
+ * workspace files it under.
27
+ *
28
+ * **The category is indexed by name, not by id.** "chair" finds every chair filed under Furniture
29
+ * only if the word Furniture is in the document; an id in `attributes` is a filter, not a search
30
+ * term. The cost is that renaming a category leaves its assets' documents naming the old word until
31
+ * each one is next written, which is a staleness worth stating and not worth a workspace-wide
32
+ * rewrite on every rename — `core.search.reindex` is the thing that fixes it, and it exists.
33
+ *
34
+ * `acl` is null: an asset is visible to anybody with `inventory.asset.view`, which is a workspace
35
+ * permission and not a per-row one, so there is no narrower audience to name.
36
+ */
37
+ export declare function assetSearchDocument(workspaceId: string, row: IndexableAsset): core.SearchDocument;
38
+ /**
39
+ * Putting assets into the workspace-wide search index, and taking them out again.
40
+ *
41
+ * `NotifyService` has carried `index`/`unindex` since the module was written and nothing called
42
+ * either, because `objectTypes` had been taken off the manifest — a declared type with no indexer
43
+ * and no resolver renders a link to nothing, which reads to a person as a broken product rather
44
+ * than as a feature that has not shipped. Both halves arrive together here.
45
+ *
46
+ * Every method is best-effort by way of `NotifyService`: an asset must not fail to save because the
47
+ * core search service is briefly away. The register's own row is authoritative and a later
48
+ * `core.search.reindex` repairs whatever was missed, which is exactly what that job is for.
49
+ */
50
+ export declare class SearchService {
51
+ private readonly kernel;
52
+ private readonly notify;
53
+ constructor(kernel: Kernel, notify: NotifyService);
54
+ /**
55
+ * One asset as a document, or `null` for one the index should not hold.
56
+ *
57
+ * `null` for a row that is gone **and** for one that is archived, because the indexer treats null
58
+ * as "remove this": an archived asset is one the workspace has said it no longer tracks, so
59
+ * finding it in the command palette would offer a door to something deliberately put away.
60
+ * Restoring it writes the row again, which reindexes it.
61
+ */
62
+ load(workspaceId: string, assetId: string): Promise<core.SearchDocument | null>;
63
+ /**
64
+ * Every live asset of a workspace, in pages, for a full reindex.
65
+ *
66
+ * Keyset by id rather than `offset`: a scan of a big workspace runs for a while, and an offset
67
+ * walk over a table somebody is writing to repeats and drops rows. Ids are uuidv7 and unique, so
68
+ * one column is a page boundary two rows cannot share — the same reasoning `assets.list` gives.
69
+ *
70
+ * One transaction per page rather than one for the whole scan: holding a pooled connection open
71
+ * across a reindex of ten thousand rows is how a service runs out of connections doing
72
+ * maintenance.
73
+ */
74
+ scan(workspaceId: string): AsyncIterable<core.SearchDocument>;
75
+ /**
76
+ * Bring the index into step with one asset, whichever direction that means.
77
+ *
78
+ * Called after the transaction has committed, never inside it — the index is another service's
79
+ * table, and telling it about a row a rollback then took away cannot be retracted. It is the
80
+ * reason `router.ts` announces from outside the transaction and this is announced beside the
81
+ * event and the realtime change.
82
+ */
83
+ reindex(workspaceId: string, assetId: string): Promise<void>;
84
+ }
85
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/server/services/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAA;AAC7C,OAAO,KAAK,EAAE,MAAM,EAAM,MAAM,gBAAgB,CAAA;AAGhD,OAAO,EAAE,MAAM,EAAc,MAAM,cAAc,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAKhD;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,GAAI,SAAS,MAAM,WAAkC,CAAA;AAC1E,eAAO,MAAM,UAAU,cAAc,CAAA;AAErC,uEAAuE;AACvE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,OAAO,MAAM,CAAC,YAAY,CAAA;IACjC,kGAAkG;IAClG,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAAC,cAAc,CAqBjG;AAYD;;;;;;;;;;;GAWG;AACH,qBAAa,aAAa;IAEtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,aAAa;IAGxC;;;;;;;OAOG;IACG,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC;IAUrF;;;;;;;;;;OAUG;IACI,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,CAAC,IAAI,CAAC,cAAc,CAAC;IAqBpE;;;;;;;OAOG;IACG,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYnE"}