@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,444 @@
1
+ import type { JobDef, Kernel } from '@kernhq/kernel'
2
+ import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm'
3
+ import { MODULE_ID } from '../contract/models.js'
4
+ import { InventorySettings } from '../contract/settings.js'
5
+ import { assets, repairs, workspaces } from './schema.js'
6
+ import { membersWithPermission } from './services/audience.js'
7
+ import { inventoryServices } from './services/index.js'
8
+ import { assetUrl } from './services/search.js'
9
+
10
+ /** More rows than any one workspace should reach in a day, and a bound all the same. */
11
+ const SWEEP_LIMIT = 500
12
+
13
+ /**
14
+ * Inventory's scheduled work.
15
+ *
16
+ * **A cron expression fires in UTC, and this module's users do not live there** — `module-hr` says
17
+ * that at the top of its own jobs file, and it is worth saying what follows from it here, because
18
+ * the answer is different. HR's calendar jobs fan out per office and ask each whether *that
19
+ * office's* local boundary has passed, since which month an accrual belongs to changes with the
20
+ * answer. Nothing here has that property:
21
+ *
22
+ * - what these two sweeps *find* is a date comparison against a window thirty days or a fortnight
23
+ * wide, so an item is inside it for weeks and the hour it is noticed cannot change whether it is
24
+ * noticed;
25
+ * - what they *send* is sent exactly once per row ever, because each writes a marker column, so
26
+ * firing on a different hour cannot send anything twice.
27
+ *
28
+ * So a daily UTC cron is honest here where it would have been a bug in HR. The one thing that must
29
+ * not be borrowed from the deployment is **today**: `now()::date` in Postgres is the database
30
+ * session's timezone, which is an accident of how the container was started. Today comes from
31
+ * `todayUtc()` below and is passed into the query as a value.
32
+ *
33
+ * Both handlers are idempotent by construction rather than by scheduling: a retried run re-reads the
34
+ * marker and finds nothing left to say. That matters, because pg-boss retries a throwing handler
35
+ * three times.
36
+ */
37
+
38
+ /** Today, as this module means it: the UTC date. The same clock `RepairService` dates a repair by. */
39
+ const todayUtc = (at: Date = new Date()): string => at.toISOString().slice(0, 10)
40
+
41
+ /** `YYYY-MM-DD`, `n` days either side. */
42
+ function shiftDays(date: string, days: number): string {
43
+ const d = new Date(`${date}T00:00:00Z`)
44
+ d.setUTCDate(d.getUTCDate() + days)
45
+ return d.toISOString().slice(0, 10)
46
+ }
47
+
48
+ /**
49
+ * Workspaces this module is switched on in.
50
+ *
51
+ * Read from the module's own table rather than asked of core every tick, for the reason
52
+ * `schema.ts` gives at `workspaces`: a sweep that depends on a broker round trip fails whenever
53
+ * core is briefly away, which is exactly the condition an unattended overnight job has to survive.
54
+ *
55
+ * **Unbound on purpose, and the table's policy is what makes that legal.** A scheduler is woken by a
56
+ * clock, so there is no workspace to bind to and `app.workspace_id` is unset here by definition. The
57
+ * per-workspace policy every tenant table carries therefore matches nothing, and `workspaces` — like
58
+ * every other table in this schema — carries `force row level security`, which subjects the schema's
59
+ * **owner** to its policies as well. So this read answered *zero rows, silently, for ever* on any
60
+ * deployment whose application role is not a superuser, and it would have gone on doing so with no
61
+ * error to notice. `0006_workspace_registry_read.sql` adds the one policy that admits it: a select
62
+ * policy on this table alone, for a session with no workspace bound. It is exported so the test
63
+ * suite can run the real enumeration rather than a query that resembles it.
64
+ */
65
+ export async function activeWorkspaces(kernel: Kernel): Promise<string[]> {
66
+ const rows = await kernel.database.db.select({ id: workspaces.workspaceId }).from(workspaces)
67
+ return rows.map((row) => row.id)
68
+ }
69
+
70
+ /**
71
+ * Run `fn` for every workspace that still has the module on, logging per-workspace failures rather
72
+ * than propagating them.
73
+ *
74
+ * One workspace's bad data must not stop the sweep for the next one — a job that throws half way
75
+ * through is a job that has notified an arbitrary prefix of the instance and will do it again on the
76
+ * retry. `isModuleEnabled` is asked because the row here outlives a workspace switching the module
77
+ * off: nothing deletes it, deliberately, so switching Inventory back on does not lose the
78
+ * registration.
79
+ */
80
+ async function forEachWorkspace(
81
+ kernel: Kernel,
82
+ job: string,
83
+ fn: (workspaceId: string) => Promise<void>,
84
+ ): Promise<void> {
85
+ for (const workspaceId of await activeWorkspaces(kernel)) {
86
+ if (!(await kernel.isModuleEnabled(workspaceId, MODULE_ID).catch(() => false))) continue
87
+ try {
88
+ await fn(workspaceId)
89
+ } catch (err) {
90
+ kernel.log.warn(
91
+ { err: err instanceof Error ? err.message : String(err), workspaceId, job, module: MODULE_ID },
92
+ 'inventory: scheduled job failed for a workspace',
93
+ )
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The three statuses this module derives, and therefore the only three it may overwrite.
100
+ *
101
+ * `reserved`, `lost` and `retired` are set by hand by features that do not exist yet — nothing
102
+ * writes them today — and a reconciliation that stamped over one would silently undo somebody's
103
+ * decision. `deriveStatus` says the same thing from the other side.
104
+ */
105
+ const DERIVED_STATUSES = ['in_stock', 'assigned', 'under_repair'] as const
106
+
107
+ /**
108
+ * Bring `assets.status` back into step with the facts, for one workspace.
109
+ *
110
+ * **Every write path already does this for the row it touches, and this is for the rows nobody
111
+ * touches.** `status` is stored because every list filter asks for it, and derived from two facts —
112
+ * custody, and an open repair *the workspace still records*. The second one has an input that no
113
+ * row change can be hung off: switching the `repairs` capability off changes the answer for every
114
+ * asset in the workspace at once, and switching it back on changes it back, without a single row
115
+ * being written either time.
116
+ *
117
+ * That is what stranded an asset. With repairs off, `repairs.complete` answers 404 and the row that
118
+ * decides `under_repair` can never be closed, so an item sat in a status nothing could move it out
119
+ * of, behind an archive that refused it for pointing at the same 404. The refusal is withdrawn now
120
+ * and every custody verb re-derives the status it writes — so anything anybody touches heals at
121
+ * once — and this is the sweep for the spare in the cupboard that nobody touches for a month.
122
+ *
123
+ * It runs **in both directions and on every tick**, which is what makes the capability reversible
124
+ * rather than one-way: with repairs off it releases anything still stamped `under_repair`, and with
125
+ * repairs on it stamps back anything that has an open repair and lost the status while the switch
126
+ * was off. Nothing is destroyed either way — the repair rows are untouched, and the status is a
127
+ * derivation, not a record.
128
+ *
129
+ * One statement, and normally it matches nothing: the `is distinct from` is what keeps a nightly
130
+ * job over every workspace from rewriting every asset row it has. Live rows only — an archived
131
+ * asset is out of the register, and churning its `updated_at` would say something moved.
132
+ */
133
+ export async function reconcileStatuses(
134
+ kernel: Kernel,
135
+ workspaceId: string,
136
+ repairsOn: boolean,
137
+ ): Promise<number> {
138
+ // Correlated to the asset row being updated. Skipped entirely when the workspace does not record
139
+ // repairs, which is the whole point — the same shape as `awayForRepair` in `status.ts`, expressed
140
+ // once over a set instead of once per row.
141
+ const away = repairsOn
142
+ ? sql`exists (select 1 from ${repairs}
143
+ where ${repairs.workspaceId} = ${assets.workspaceId}
144
+ and ${repairs.assetId} = ${assets.id}
145
+ and ${repairs.returnedOn} is null)`
146
+ : sql`false`
147
+ const derived = sql`case
148
+ when ${away} then 'under_repair'
149
+ when ${assets.custodianUserId} is not null then 'assigned'
150
+ else 'in_stock' end`
151
+
152
+ const rows = await kernel.database.withWorkspace(workspaceId, (tx) =>
153
+ tx
154
+ .update(assets)
155
+ .set({ status: derived, updatedAt: new Date() })
156
+ .where(
157
+ and(
158
+ eq(assets.workspaceId, workspaceId),
159
+ isNull(assets.archivedAt),
160
+ inArray(assets.status, [...DERIVED_STATUSES]),
161
+ sql`${assets.status} is distinct from (${derived})`,
162
+ ),
163
+ )
164
+ .returning({ id: assets.id }),
165
+ )
166
+ return rows.length
167
+ }
168
+
169
+ /** A `date` column reads back as `YYYY-MM-DD`; this is how it is spoken in a sentence. */
170
+ const readableDate = (date: string): string =>
171
+ new Date(`${date}T00:00:00Z`).toLocaleDateString('en-GB', {
172
+ day: 'numeric',
173
+ month: 'short',
174
+ year: 'numeric',
175
+ timeZone: 'UTC',
176
+ })
177
+
178
+ export function inventoryJobs(): JobDef<Record<string, unknown>>[] {
179
+ return [
180
+ {
181
+ /**
182
+ * Warranties about to run out.
183
+ *
184
+ * Told to whoever is holding the item, because they are the person who will be standing in
185
+ * front of a broken laptop the week after — and to whoever may replace it when nobody is
186
+ * holding it, since a spare in a cupboard has no other audience. One notice per asset, ever:
187
+ * `warranty_notified_at` is what stops a thirty-day window sending thirty emails, and
188
+ * `assets.update` clears it when the date itself moves.
189
+ */
190
+ name: 'warranty-sweep',
191
+ cron: '0 7 * * *',
192
+ handler: async (_input, { kernel }) => {
193
+ const svc = inventoryServices(kernel)
194
+ await forEachWorkspace(kernel, 'warranty-sweep', async (workspaceId) => {
195
+ // Settings come from core over the broker, so they are read before any transaction opens
196
+ // — the same rule `AssetService.codeFormat` documents.
197
+ const settings = await kernel.settings.module(workspaceId, MODULE_ID, InventorySettings)
198
+ const today = todayUtc()
199
+ const horizon = shiftDays(today, settings.warrantyNoticeDays)
200
+
201
+ const due = await kernel.database.withWorkspace(workspaceId, (tx) =>
202
+ tx
203
+ .select({
204
+ id: assets.id,
205
+ code: assets.code,
206
+ name: assets.name,
207
+ warrantyUntil: assets.warrantyUntil,
208
+ custodianUserId: assets.custodianUserId,
209
+ })
210
+ .from(assets)
211
+ .where(
212
+ and(
213
+ eq(assets.workspaceId, workspaceId),
214
+ isNull(assets.archivedAt),
215
+ isNull(assets.warrantyNotifiedAt),
216
+ // Inside the window and not already past it. An expiry that came and went is not
217
+ // "about to run out", and saying so weeks later reads as a broken clock.
218
+ sql`${assets.warrantyUntil} is not null
219
+ and ${assets.warrantyUntil} >= ${today}::date
220
+ and ${assets.warrantyUntil} <= ${horizon}::date`,
221
+ ),
222
+ )
223
+ .orderBy(asc(assets.warrantyUntil))
224
+ .limit(SWEEP_LIMIT),
225
+ )
226
+ if (!due.length) return
227
+
228
+ // Resolved once for the workspace rather than once per asset: it is a handful of broker
229
+ // calls, and a hundred expiring warranties must not become a hundred audience lookups.
230
+ let managers: string[] | null = null
231
+ const fallback = async () => {
232
+ managers ??= await membersWithPermission(kernel, workspaceId, 'inventory.asset.manage')
233
+ return managers
234
+ }
235
+
236
+ let notified = 0
237
+ let unheard = 0
238
+ let undelivered = 0
239
+ for (const asset of due) {
240
+ const userIds = asset.custodianUserId ? [asset.custodianUserId] : await fallback()
241
+ /**
242
+ * Nobody to tell is **not** the same as told, so the row is left unmarked.
243
+ *
244
+ * Marking it anyway would mean a workspace that has nobody holding
245
+ * `inventory.asset.manage` today silently loses the notice for ever — including for the
246
+ * person who is given that permission tomorrow. The cost of leaving it is one audience
247
+ * lookup per workspace per night, which is already resolved once and cached above.
248
+ */
249
+ if (!userIds.length) {
250
+ unheard++
251
+ continue
252
+ }
253
+
254
+ const delivered = await svc.notify.notify({
255
+ workspaceId,
256
+ userIds,
257
+ type: 'inventory.warranty.expiring',
258
+ // The tag leads, because that is what is printed on the sticker.
259
+ title: `${asset.code} is out of warranty on ${readableDate(asset.warrantyUntil as string)}`,
260
+ body: asset.name,
261
+ object: { module: MODULE_ID, type: 'asset', id: asset.id },
262
+ url: assetUrl(asset.id),
263
+ groupKey: asset.id,
264
+ })
265
+
266
+ /**
267
+ * **The marker means "everybody was told", so nothing is marked until everybody was.**
268
+ *
269
+ * `notify` is best-effort by design: a `core.notifications.create` that fails is logged
270
+ * and swallowed, and it used to come back looking exactly like one that succeeded. This
271
+ * column is written once per asset and never cleared, and the notice is sent once per
272
+ * asset ever — so a marker written over a swallowed failure is not a delayed notice, it
273
+ * is a notice nobody will ever receive, for the life of the row. Left unmarked the
274
+ * sweep simply says it again tomorrow, which is the whole reason it runs every day.
275
+ *
276
+ * **A partial delivery is a failure, and it used to be recorded as a success.** A spare
277
+ * with three managers in the audience needed one of the three writes to land for the row
278
+ * to be stamped *told*; the other two were never told and never would be. There is one
279
+ * column and it cannot hold two answers, so it holds the pessimistic one — whoever did
280
+ * hear it hears it again tomorrow, `groupKey` collapses that where a client groups, and
281
+ * nobody is silently left out.
282
+ */
283
+ if (delivered.delivered < delivered.targeted || !delivered.targeted) {
284
+ undelivered++
285
+ continue
286
+ }
287
+
288
+ await kernel.database.withWorkspace(workspaceId, (tx) =>
289
+ tx
290
+ .update(assets)
291
+ .set({ warrantyNotifiedAt: new Date() })
292
+ .where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, asset.id))),
293
+ )
294
+ notified++
295
+ }
296
+ if (unheard)
297
+ kernel.log.warn(
298
+ { module: MODULE_ID, workspaceId, silent: unheard },
299
+ 'inventory: warranties are running out and nobody in the workspace may replace them',
300
+ )
301
+ if (undelivered)
302
+ kernel.log.warn(
303
+ { module: MODULE_ID, workspaceId, undelivered },
304
+ 'inventory: warranty notices could not be delivered; they stay unmarked and go again tomorrow',
305
+ )
306
+ if (notified)
307
+ kernel.log.info(
308
+ { module: MODULE_ID, workspaceId, notified },
309
+ 'inventory: warranties about to expire',
310
+ )
311
+ })
312
+ },
313
+ },
314
+
315
+ {
316
+ /**
317
+ * Repairs nobody has chased.
318
+ *
319
+ * Told to the person who logged it — they committed the company to the money and they have the
320
+ * repairer's number — and to whoever is still holding the item, who is the one waiting for it
321
+ * back. Neither is a guess: both are columns. Only when the repair carries neither does this
322
+ * fall back to asking who may manage repairs at all.
323
+ *
324
+ * **Asks the capability first, and acts on the answer either way.** A workspace with `repairs`
325
+ * switched off has no repairs surface, so a sweep that notified about repair rows left over
326
+ * from before it was switched off would be the feature answering 404 to a person and sending
327
+ * them email about it. What it *does* still owe that workspace is a register that does not
328
+ * claim `under_repair` for an item behind a procedure that answers 404 — so the status
329
+ * reconciliation runs before the switch is consulted, in both directions. `reconcileStatuses`
330
+ * argues it in full.
331
+ */
332
+ name: 'repair-overdue',
333
+ cron: '20 7 * * *',
334
+ handler: async (_input, { kernel }) => {
335
+ const svc = inventoryServices(kernel)
336
+ await forEachWorkspace(kernel, 'repair-overdue', async (workspaceId) => {
337
+ const on = await kernel.capabilities(workspaceId, MODULE_ID)
338
+ const repairsOn = on.has('repairs')
339
+
340
+ const healed = await reconcileStatuses(kernel, workspaceId, repairsOn)
341
+ if (healed)
342
+ kernel.log.info(
343
+ { module: MODULE_ID, workspaceId, healed, repairsOn },
344
+ 'inventory: brought asset statuses back into step with the repairs capability',
345
+ )
346
+ if (!repairsOn) return
347
+
348
+ const settings = await kernel.settings.module(workspaceId, MODULE_ID, InventorySettings)
349
+ const cutoff = shiftDays(todayUtc(), -settings.repairOverdueDays)
350
+
351
+ const late = await kernel.database.withWorkspace(workspaceId, (tx) =>
352
+ tx
353
+ .select({
354
+ id: repairs.id,
355
+ assetId: repairs.assetId,
356
+ summary: repairs.summary,
357
+ vendor: repairs.vendor,
358
+ sentOn: repairs.sentOn,
359
+ createdBy: repairs.createdBy,
360
+ code: assets.code,
361
+ name: assets.name,
362
+ custodianUserId: assets.custodianUserId,
363
+ })
364
+ .from(repairs)
365
+ .innerJoin(
366
+ assets,
367
+ and(eq(assets.id, repairs.assetId), eq(assets.workspaceId, repairs.workspaceId)),
368
+ )
369
+ .where(
370
+ and(
371
+ eq(repairs.workspaceId, workspaceId),
372
+ isNull(repairs.returnedOn),
373
+ isNull(repairs.overdueNotifiedAt),
374
+ sql`${repairs.sentOn} <= ${cutoff}::date`,
375
+ ),
376
+ )
377
+ .orderBy(asc(repairs.sentOn))
378
+ .limit(SWEEP_LIMIT),
379
+ )
380
+ if (!late.length) return
381
+
382
+ let managers: string[] | null = null
383
+ const fallback = async () => {
384
+ managers ??= await membersWithPermission(kernel, workspaceId, 'inventory.repair.manage')
385
+ return managers
386
+ }
387
+
388
+ let notified = 0
389
+ let unheard = 0
390
+ let undelivered = 0
391
+ for (const repair of late) {
392
+ const named = [repair.createdBy, repair.custodianUserId].filter((id): id is string => Boolean(id))
393
+ const userIds = named.length ? named : await fallback()
394
+ // Nobody to tell is not the same as told; see the warranty sweep above.
395
+ if (!userIds.length) {
396
+ unheard++
397
+ continue
398
+ }
399
+
400
+ const delivered = await svc.notify.notify({
401
+ workspaceId,
402
+ userIds,
403
+ type: 'inventory.repair.overdue',
404
+ title: `${repair.code} has been away since ${readableDate(repair.sentOn)}`,
405
+ body: repair.vendor ? `${repair.summary} · ${repair.vendor}` : repair.summary,
406
+ object: { module: MODULE_ID, type: 'asset', id: repair.assetId },
407
+ url: assetUrl(repair.assetId),
408
+ groupKey: repair.id,
409
+ })
410
+ // Anybody left untold means nothing is marked: the same rule, and the same reason, as
411
+ // the warranty sweep above spells out in full.
412
+ if (delivered.delivered < delivered.targeted || !delivered.targeted) {
413
+ undelivered++
414
+ continue
415
+ }
416
+
417
+ await kernel.database.withWorkspace(workspaceId, (tx) =>
418
+ tx
419
+ .update(repairs)
420
+ .set({ overdueNotifiedAt: new Date() })
421
+ .where(and(eq(repairs.workspaceId, workspaceId), eq(repairs.id, repair.id))),
422
+ )
423
+ notified++
424
+ }
425
+ if (unheard)
426
+ kernel.log.warn(
427
+ { module: MODULE_ID, workspaceId, silent: unheard },
428
+ 'inventory: repairs are overdue and nobody in the workspace may chase them',
429
+ )
430
+ if (undelivered)
431
+ kernel.log.warn(
432
+ { module: MODULE_ID, workspaceId, undelivered },
433
+ 'inventory: overdue repair notices could not be delivered; they stay unmarked and go again tomorrow',
434
+ )
435
+ if (notified)
436
+ kernel.log.info(
437
+ { module: MODULE_ID, workspaceId, notified },
438
+ 'inventory: repairs away longer than the workspace allows',
439
+ )
440
+ })
441
+ },
442
+ },
443
+ ] as JobDef<Record<string, unknown>>[]
444
+ }