@kernhq/module-inventory 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +322 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +213 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1052 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +225 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1337 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +363 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +158 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +42 -0
- package/package.json +1 -1
- package/src/client/api-instance.ts +27 -2
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +71 -22
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +127 -0
- package/src/client/messages.ts +1043 -18
- package/src/client/mock.test.ts +395 -1
- package/src/client/mock.ts +1066 -79
- package/src/client/module.ts +54 -0
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +237 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +255 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +194 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +3573 -18
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +113 -0
- package/src/server/router.ts +462 -6
- package/src/server/schema.ts +160 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
package/src/server/schema.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { moduleSchema } from '@kernhq/kernel'
|
|
2
2
|
import { sql } from 'drizzle-orm'
|
|
3
3
|
import {
|
|
4
|
+
bigint,
|
|
4
5
|
boolean,
|
|
5
6
|
char,
|
|
7
|
+
check,
|
|
6
8
|
date,
|
|
7
9
|
index,
|
|
8
10
|
integer,
|
|
@@ -33,6 +35,35 @@ const ts = (name: string) => timestamp(name, { withTimezone: true, mode: 'date'
|
|
|
33
35
|
const created = () => ts('created_at').notNull().defaultNow()
|
|
34
36
|
const updated = () => ts('updated_at').notNull().defaultNow()
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Workspaces this module is switched on in, so scheduled work can find them.
|
|
40
|
+
*
|
|
41
|
+
* A cron handler starts with no workspace: it is woken by a clock, not by a request, so there is
|
|
42
|
+
* nothing to derive a tenant from and no `withWorkspace` it could already be inside. Core knows the
|
|
43
|
+
* answer and would give it (`core.workspaces.list`), but asking it every tick makes the sweeps fail
|
|
44
|
+
* whenever core is briefly away — for a job whose whole point is to run unattended overnight. So the
|
|
45
|
+
* module keeps the one fact it needs: an id, written when the module is switched on for a workspace
|
|
46
|
+
* and when a workspace is created. `module-tracker` keeps the same table for the same reason.
|
|
47
|
+
*
|
|
48
|
+
* **It carries `workspace_id`, so it is a tenant table and has a policy like every other** — and the
|
|
49
|
+
* one reader that cannot honour that policy is the enumeration itself, because `app.workspace_id` is
|
|
50
|
+
* unset in a job by definition. That was left resting on "the connection is the schema's owner, and
|
|
51
|
+
* an owner bypasses RLS", which is **false here**: every table in this schema carries `force row
|
|
52
|
+
* level security`, and forcing it subjects the owner to the policies too. So `select workspace_id
|
|
53
|
+
* from workspaces` with no workspace bound answered zero rows — not an error, not a warning, simply
|
|
54
|
+
* nothing to sweep, every night, for ever.
|
|
55
|
+
*
|
|
56
|
+
* `0006_workspace_registry_read.sql` is the fix and it is scoped as narrowly as the problem: one
|
|
57
|
+
* extra `for select` policy, on this table only, admitting a session that has **no** workspace bound.
|
|
58
|
+
* A request-bound session still sees exactly its own row, and this table holds nothing but workspace
|
|
59
|
+
* ids anyway — it is the module's own bookkeeping, not tenant data anybody reads. Added in `0004`,
|
|
60
|
+
* because 0.2.0 is published.
|
|
61
|
+
*/
|
|
62
|
+
export const workspaces = schema.table('workspaces', {
|
|
63
|
+
workspaceId: uuid('workspace_id').primaryKey(),
|
|
64
|
+
createdAt: created(),
|
|
65
|
+
})
|
|
66
|
+
|
|
36
67
|
export const counters = schema.table(
|
|
37
68
|
'counters',
|
|
38
69
|
/** Per-workspace sequence sources (`asset_code`). Narrow on purpose: one row per key. */
|
|
@@ -59,10 +90,25 @@ export const categories = schema.table(
|
|
|
59
90
|
workspaceId: ws(),
|
|
60
91
|
name: text('name').notNull(),
|
|
61
92
|
order: integer('order').notNull().default(0),
|
|
93
|
+
/**
|
|
94
|
+
* Archived rather than deleted, and the reason is `assets.category_id`.
|
|
95
|
+
*
|
|
96
|
+
* There is no foreign key — a module keeps its joins inside its own schema and its ids plain —
|
|
97
|
+
* so deleting a row here would leave every asset filed under it pointing at nothing. The
|
|
98
|
+
* category column on the row would go blank and the timeline entry that says "category changed
|
|
99
|
+
* to Laptops" would lose the word "Laptops": data destroyed by a settings screen, silently, and
|
|
100
|
+
* with no way back. Archiving takes the category out of every picker and every filter and
|
|
101
|
+
* leaves each asset able to say what it is.
|
|
102
|
+
*
|
|
103
|
+
* Added in `0002`, because 0.2.0 is published.
|
|
104
|
+
*/
|
|
105
|
+
archivedAt: ts('archived_at'),
|
|
62
106
|
createdAt: created(),
|
|
63
107
|
updatedAt: updated(),
|
|
64
108
|
},
|
|
65
109
|
(t) => [
|
|
110
|
+
// Unique across archived rows too: two categories called "Laptops", one of them archived, is
|
|
111
|
+
// two rows a picker cannot tell apart the moment somebody restores the second.
|
|
66
112
|
uniqueIndex('inventory_categories_ws_name_uq').on(t.workspaceId, t.name),
|
|
67
113
|
index('inventory_categories_ws_idx').on(t.workspaceId, t.order),
|
|
68
114
|
],
|
|
@@ -91,6 +137,18 @@ export const assets = schema.table(
|
|
|
91
137
|
priceMinor: integer('price_minor'),
|
|
92
138
|
currency: char('currency', { length: 3 }),
|
|
93
139
|
warrantyUntil: date('warranty_until'),
|
|
140
|
+
/**
|
|
141
|
+
* When the warranty sweep last told somebody this one is about to run out.
|
|
142
|
+
*
|
|
143
|
+
* The idempotency row `kern-service` asks scheduled work for, as a column. A warranty falls
|
|
144
|
+
* inside the notice window for every one of the thirty days before it expires, so a sweep with
|
|
145
|
+
* no marker sends the same notice thirty times — and `groupKey` collapses that in the
|
|
146
|
+
* notification centre while still sending thirty emails. Cleared by `assets.update` whenever
|
|
147
|
+
* `warranty_until` itself moves, so extending a warranty earns a fresh notice at the new date.
|
|
148
|
+
*
|
|
149
|
+
* Nullable and added in `0004`, so the 0.2.0 image reads the table straight past it.
|
|
150
|
+
*/
|
|
151
|
+
warrantyNotifiedAt: ts('warranty_notified_at'),
|
|
94
152
|
photoFileId: uuid('photo_file_id'),
|
|
95
153
|
/** Values for this workspace's own `field_defs`, keyed by their `key`. */
|
|
96
154
|
custom: jsonb('custom').$type<Record<string, unknown>>().notNull().default({}),
|
|
@@ -178,12 +236,52 @@ export const assetHistory = schema.table(
|
|
|
178
236
|
workspaceId: ws(),
|
|
179
237
|
assetId: uuid('asset_id').notNull(),
|
|
180
238
|
actorId: uuid('actor_id'),
|
|
181
|
-
action: text('action').notNull(), // created | updated | transferred | returned | repair_logged | repair_completed | attachment_added | retired | restored
|
|
239
|
+
action: text('action').notNull(), // created | updated | assigned | transferred | returned | repair_logged | repair_completed | attachment_added | retired | restored
|
|
182
240
|
changes: jsonb('changes').$type<{ field: string; from: unknown; to: unknown }[]>(),
|
|
183
241
|
data: jsonb('data'),
|
|
184
242
|
occurredAt: created(),
|
|
243
|
+
/**
|
|
244
|
+
* The order these entries actually happened in — a sequence, not a clock and not the id.
|
|
245
|
+
*
|
|
246
|
+
* **A uuidv7 is only ordered to the millisecond.** The kernel's `uuidv7()` puts the clock in
|
|
247
|
+
* bytes 0-5 and fills bytes 6-15 from `randomUUID()` with no intra-millisecond counter, so two
|
|
248
|
+
* entries written in the same millisecond sort by ten random bytes. That is *stable* for a
|
|
249
|
+
* given set of rows, which is why keyset paging never dropped or repeated one and why the
|
|
250
|
+
* defect hid for so long — what it produced instead was a timeline rendering "Bruno removed the
|
|
251
|
+
* file" above "Bruno added the file". A register's whole value is that it says what happened in
|
|
252
|
+
* the order it happened.
|
|
253
|
+
*
|
|
254
|
+
* `occurred_at` cannot stand in for it either: it defaults to `now()`, which is the
|
|
255
|
+
* *transaction* timestamp, so a create and its first history row share it exactly.
|
|
256
|
+
*
|
|
257
|
+
* A sequence is exact rather than probabilistic — `nextval` is strictly increasing whatever the
|
|
258
|
+
* clock does — and it is the one ordering key a module owns outright. Filled by the column
|
|
259
|
+
* default, so nothing in the service has to remember it and the previous image's inserts get
|
|
260
|
+
* one too. Added in `0007`.
|
|
261
|
+
*/
|
|
262
|
+
seq: bigint('seq', { mode: 'number' }).notNull().default(sql`nextval('mod_inventory.asset_history_seq')`),
|
|
185
263
|
},
|
|
186
|
-
(t) => [
|
|
264
|
+
(t) => [
|
|
265
|
+
index('inventory_asset_history_asset_idx').on(t.workspaceId, t.assetId, t.occurredAt),
|
|
266
|
+
/**
|
|
267
|
+
* Superseded by the index below, and left here on purpose.
|
|
268
|
+
*
|
|
269
|
+
* The timeline used to be keyset-paged by **id**, on the reasoning `assets.list` still gives for
|
|
270
|
+
* `sort: 'recent'` — an id is uuidv7, so it carries the clock, and it is unique where
|
|
271
|
+
* `occurred_at` is not. Unique it is; ordered it is only to the millisecond. `seq` replaced it.
|
|
272
|
+
*
|
|
273
|
+
* Dropping this is a schema change and a migration here only adds, so it goes one release from
|
|
274
|
+
* now rather than in the one that stopped reading it. Added in `0002`.
|
|
275
|
+
*/
|
|
276
|
+
index('inventory_asset_history_ws_asset_row_idx').on(t.workspaceId, t.assetId, t.id),
|
|
277
|
+
/**
|
|
278
|
+
* What `assets.history` pages on: one asset's entries, newest first, bounded by `seq`.
|
|
279
|
+
*
|
|
280
|
+
* Unique, because the sequence makes it so and saying so lets Postgres stop at the first match
|
|
281
|
+
* for a cursor. Added in `0007`.
|
|
282
|
+
*/
|
|
283
|
+
uniqueIndex('inventory_asset_history_ws_asset_seq_uq').on(t.workspaceId, t.assetId, t.seq),
|
|
284
|
+
],
|
|
187
285
|
)
|
|
188
286
|
|
|
189
287
|
export const repairs = schema.table(
|
|
@@ -200,11 +298,66 @@ export const repairs = schema.table(
|
|
|
200
298
|
sentOn: date('sent_on').notNull(),
|
|
201
299
|
/** Null while the item is still away — also how `under_repair` is derived. */
|
|
202
300
|
returnedOn: date('returned_on'),
|
|
301
|
+
/**
|
|
302
|
+
* When the overdue sweep last said this one has been away too long.
|
|
303
|
+
*
|
|
304
|
+
* Same job as `assets.warranty_notified_at` and for the same reason: a repair that passed the
|
|
305
|
+
* threshold yesterday has passed it again today, so a sweep with no marker chases the same
|
|
306
|
+
* vendor every morning until the item comes back. Cleared by `repairs.update` when `sent_on`
|
|
307
|
+
* moves, because that is the date the threshold is measured from.
|
|
308
|
+
*
|
|
309
|
+
* Nullable and added in `0004`, so the 0.2.0 image reads the table straight past it.
|
|
310
|
+
*/
|
|
311
|
+
overdueNotifiedAt: ts('overdue_notified_at'),
|
|
203
312
|
createdBy: uuid('created_by'),
|
|
204
313
|
createdAt: created(),
|
|
205
314
|
updatedAt: updated(),
|
|
206
315
|
},
|
|
207
|
-
(t) => [
|
|
316
|
+
(t) => [
|
|
317
|
+
index('inventory_repairs_ws_asset_idx').on(t.workspaceId, t.assetId, t.sentOn),
|
|
318
|
+
/**
|
|
319
|
+
* What the overdue sweep reads: one workspace's still-open repairs, oldest first.
|
|
320
|
+
*
|
|
321
|
+
* The index above starts (workspace_id, asset_id, sent_on) and cannot answer "every open repair
|
|
322
|
+
* in this workspace sent before a date" without visiting every asset; `inventory_repairs_one_
|
|
323
|
+
* open_uq` is partial on the right predicate but keyed by `asset_id`, so it cannot be scoped to
|
|
324
|
+
* a workspace either. Added in `0004`.
|
|
325
|
+
*/
|
|
326
|
+
index('inventory_repairs_ws_open_idx').on(t.workspaceId, t.sentOn).where(sql`returned_on is null`),
|
|
327
|
+
/**
|
|
328
|
+
* One open repair per asset, decided by the database rather than by the service.
|
|
329
|
+
*
|
|
330
|
+
* Custody leans on a GiST exclusion constraint for the same reason and this is the cheaper
|
|
331
|
+
* version of it: a repair has no range to overlap, only a flag, so a partial unique index says
|
|
332
|
+
* exactly the same thing. Two people pressing *Send for repair* on the same laptop in the same
|
|
333
|
+
* instant both read "it is here", both insert, and Postgres refuses one of them — where a
|
|
334
|
+
* `select … for update` would serialise them into two open repairs, which is a worse answer
|
|
335
|
+
* wearing the clothes of a safer one. `RepairService` turns the 23505 into a sentence.
|
|
336
|
+
*
|
|
337
|
+
* Not scoped by `workspace_id`: an asset id belongs to exactly one workspace, and adding the
|
|
338
|
+
* column would let the same asset be open in two of them if an id ever leaked across.
|
|
339
|
+
*
|
|
340
|
+
* Added in `0003`, because 0.2.0 is published.
|
|
341
|
+
*/
|
|
342
|
+
uniqueIndex('inventory_repairs_one_open_uq').on(t.assetId).where(sql`returned_on is null`),
|
|
343
|
+
/**
|
|
344
|
+
* A repair cannot come back before it was sent.
|
|
345
|
+
*
|
|
346
|
+
* Two dates a person types, and nothing but arithmetic decides whether the pair means anything:
|
|
347
|
+
* stored the wrong way round, every "how long was it away" answer is negative and the overdue
|
|
348
|
+
* sweep measures from a date in the future. `repairs.complete` refused it from the start;
|
|
349
|
+
* `repairs.update` did not, and could move `sent_on` past the `returned_on` of a repair that had
|
|
350
|
+
* already come back. The service checks first so a person gets a sentence, and this is what
|
|
351
|
+
* makes the rule true of the table rather than of the code paths somebody remembered.
|
|
352
|
+
*
|
|
353
|
+
* Added in `0005` as `not valid`: the constraint is enforced on every insert and update from
|
|
354
|
+
* that moment, and existing rows are not scanned. That is deliberate — a module's migrations
|
|
355
|
+
* are the first thing the kernel runs, so a validating constraint that met one bad row left
|
|
356
|
+
* over from this defect would not degrade repairs, it would stop the whole host service
|
|
357
|
+
* booting. `RepairService` translates the resulting 23514 into a sentence.
|
|
358
|
+
*/
|
|
359
|
+
check('inventory_repairs_returned_after_sent', sql`returned_on is null or returned_on >= sent_on`),
|
|
360
|
+
],
|
|
208
361
|
)
|
|
209
362
|
|
|
210
363
|
/** Bytes live in core object storage via `uploadFile`; this only records that an asset has one. */
|
|
@@ -235,6 +388,10 @@ export const TENANT_TABLES = [
|
|
|
235
388
|
// and therefore out of `0001_rls.sql` — because it holds no asset data, which is not the rule
|
|
236
389
|
// the file states at the top. Tracker's structurally identical `issue_counters` is covered.
|
|
237
390
|
'counters',
|
|
391
|
+
// Holds nothing but tenant ids, and is still a tenant table: the rule this list encodes is "has a
|
|
392
|
+
// `workspace_id` column", not "holds asset data". `module-tracker`'s equivalent is deliberately
|
|
393
|
+
// unpolicied and that is the one shape of exception this module does not make.
|
|
394
|
+
'workspaces',
|
|
238
395
|
'categories',
|
|
239
396
|
'assets',
|
|
240
397
|
'field_defs',
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { KernError, type Kernel, type Tx, uuidv7 } from '@kernhq/kernel'
|
|
2
|
-
import { and, asc, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm'
|
|
2
|
+
import { and, asc, desc, eq, ilike, isNull, lt, or, sql } from 'drizzle-orm'
|
|
3
3
|
import { alias, type PgColumn } from 'drizzle-orm/pg-core'
|
|
4
4
|
import type {
|
|
5
5
|
AssetCreateInput,
|
|
6
|
+
AssetHistoryEntry,
|
|
6
7
|
Asset as AssetModel,
|
|
7
8
|
AssetPatchInput,
|
|
8
9
|
AssetSort,
|
|
9
10
|
} from '../../contract/models.js'
|
|
10
11
|
import { InventorySettings } from '../../contract/settings.js'
|
|
11
|
-
import { assets, counters } from '../schema.js'
|
|
12
|
+
import { assetHistory, assets, categories, counters } from '../schema.js'
|
|
13
|
+
import { decodeMark, decodeSeqMark, encodeMark, encodeSeqMark } from './cursor.js'
|
|
12
14
|
import type { HistoryInput, NotifyService } from './notify.js'
|
|
15
|
+
import { openRepairId } from './status.js'
|
|
13
16
|
|
|
14
17
|
type Row = typeof assets.$inferSelect
|
|
15
18
|
|
|
@@ -62,52 +65,20 @@ export function toAsset(row: Row): AssetModel {
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* That earlier shape (`{k: <sort key>, i: <id>}`) was wrong three separate ways, each of them
|
|
69
|
-
* reachable by anyone who could type into the address bar:
|
|
70
|
-
*
|
|
71
|
-
* - **It could not be trusted.** `decode` checked only that `i` was a *string*, and `i` is
|
|
72
|
-
* interpolated into `(col, id) < ($1, $2::uuid)`. `{"k":"x","i":"not-a-uuid"}` therefore
|
|
73
|
-
* reached Postgres as a 22P02 nobody caught — an unhandled 500 and an error-level log line per
|
|
74
|
-
* request, at the 600-a-minute the rate limiter allows.
|
|
75
|
-
* - **It was not bound to its sort.** A cursor issued under `sort=recent` replayed under
|
|
76
|
-
* `sort=code` compared a uuid against a code, so page two came back equal to page one and
|
|
77
|
-
* "Load more" never ended.
|
|
78
|
-
* - **It was unbounded.** `sort=name` on a 200-character Persian name encoded to 602 characters,
|
|
79
|
-
* and `Cursor` in `@kernhq/contracts` is `max(512)` — so a long enough name broke "Load more"
|
|
80
|
-
* with a validation error, in exactly the locales least likely to be tested.
|
|
81
|
-
*
|
|
82
|
-
* Carrying the id alone and reading the sort key back from that row in SQL answers all three: the
|
|
83
|
-
* cursor is a fixed ~60 characters whatever the name, a value that is not a uuid is refused before
|
|
84
|
-
* it is anywhere near the database, and a cursor whose sort disagrees with the request is refused
|
|
85
|
-
* rather than quietly misread. Base64 so nothing in the product is tempted to read it — it is a
|
|
86
|
-
* bookmark, not an offset, and its shape is this file's business.
|
|
68
|
+
* The wire shape of one timeline entry. Drizzle's `jsonb` columns are nullable in the row and never
|
|
69
|
+
* null in the contract — `changes: []` and `data: {}` are what "nothing to say" looks like to a
|
|
70
|
+
* screen, where `null` is one more thing every caller has to remember to handle.
|
|
87
71
|
*/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
function decode(cursor: string, sort: AssetSort): Bookmark {
|
|
98
|
-
const refuse = () => KernError.badRequest('That page marker is not one this list issued')
|
|
99
|
-
let parsed: Partial<Bookmark> | null
|
|
100
|
-
try {
|
|
101
|
-
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Partial<Bookmark> | null
|
|
102
|
-
} catch {
|
|
103
|
-
throw refuse()
|
|
72
|
+
export function toHistoryEntry(row: typeof assetHistory.$inferSelect): AssetHistoryEntry {
|
|
73
|
+
return {
|
|
74
|
+
id: row.id,
|
|
75
|
+
assetId: row.assetId,
|
|
76
|
+
actorId: row.actorId,
|
|
77
|
+
action: row.action,
|
|
78
|
+
changes: row.changes ?? [],
|
|
79
|
+
data: (row.data as Record<string, unknown> | null) ?? {},
|
|
80
|
+
occurredAt: row.occurredAt.toISOString(),
|
|
104
81
|
}
|
|
105
|
-
if (typeof parsed?.i !== 'string' || !UUID.test(parsed.i)) throw refuse()
|
|
106
|
-
// One comparison covers both a sort this list never issues and a sort it issued under a
|
|
107
|
-
// *different* request. Either way the bookmark cannot be read against the ordering asked for,
|
|
108
|
-
// and pretending otherwise is what made "Load more" loop for ever.
|
|
109
|
-
if (parsed.s !== sort) throw refuse()
|
|
110
|
-
return { i: parsed.i, s: sort }
|
|
111
82
|
}
|
|
112
83
|
|
|
113
84
|
/**
|
|
@@ -119,6 +90,15 @@ function decode(cursor: string, sort: AssetSort): Bookmark {
|
|
|
119
90
|
*/
|
|
120
91
|
const contains = (q: string) => `%${q.replace(/[\\%_]/g, '\\$&')}%`
|
|
121
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The one ordering a timeline has, named rather than left as a literal.
|
|
95
|
+
*
|
|
96
|
+
* The cursor codec binds a bookmark to the sort it was issued under and refuses it under any other,
|
|
97
|
+
* so a timeline needs a name for its sort even while there is only one — and the day a second one
|
|
98
|
+
* exists, every cursor already in a browser tab is refused rather than misread.
|
|
99
|
+
*/
|
|
100
|
+
const HISTORY_SORT = 'recent'
|
|
101
|
+
|
|
122
102
|
/** Which column an ordering sorts on, for the list itself and for the bookmarked row alike. */
|
|
123
103
|
const sortKey = <T extends { id: PgColumn; name: PgColumn; code: PgColumn }>(t: T, sort: AssetSort) =>
|
|
124
104
|
sort === 'name' ? t.name : sort === 'code' ? t.code : t.id
|
|
@@ -194,17 +174,30 @@ export class AssetService {
|
|
|
194
174
|
if (input.custodianUserId) filters.push(eq(assets.custodianUserId, input.custodianUserId))
|
|
195
175
|
if (!input.archived) filters.push(isNull(assets.archivedAt))
|
|
196
176
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
177
|
+
/**
|
|
178
|
+
* `recent` sorts by id rather than by `created_at`: ids are uuidv7, so they carry the clock, and
|
|
179
|
+
* one indexed unique column is a cheaper and unambiguous page boundary than a timestamp two rows
|
|
180
|
+
* can share. Ordered to the millisecond and no finer — the kernel's `uuidv7()` fills its last
|
|
181
|
+
* ten bytes from `randomUUID()` with no counter — so two assets created inside one millisecond
|
|
182
|
+
* come back in a stable but arbitrary order relative to each other.
|
|
183
|
+
*
|
|
184
|
+
* **The timeline deliberately stopped accepting that and this list has not**, so the difference
|
|
185
|
+
* is worth stating rather than leaving as an inconsistency. `asset_history` is a record somebody
|
|
186
|
+
* argues from, and a millisecond there holds a create and its own first entry, so an arbitrary
|
|
187
|
+
* order is a lie about causation — it has a sequence of its own now (`schema.ts`, `seq`). A list
|
|
188
|
+
* is an ordering of things a person is browsing: nothing hangs on which of two assets imported
|
|
189
|
+
* in the same millisecond is shown first, and paging stays gapless either way because the id is
|
|
190
|
+
* unique. If that ever stops being true — a bulk import whose order somebody relies on — the
|
|
191
|
+
* fix is the same sequence, not a timestamp: `created_at` defaults to the *transaction's*
|
|
192
|
+
* `now()`, so one import shares it exactly.
|
|
193
|
+
*
|
|
194
|
+
* It is fatal for a test that expects creation order; see `inventory.int.test.ts`.
|
|
195
|
+
*/
|
|
203
196
|
const column = sortKey(assets, input.sort)
|
|
204
197
|
const descending = input.sort === 'recent'
|
|
205
198
|
|
|
206
199
|
if (input.cursor) {
|
|
207
|
-
const mark =
|
|
200
|
+
const mark = decodeMark(input.cursor, input.sort)
|
|
208
201
|
/**
|
|
209
202
|
* The sort key comes back out of the bookmarked row rather than out of the cursor, which is
|
|
210
203
|
* what keeps the cursor small and stops it disagreeing with the row it names. Scoped by
|
|
@@ -239,10 +232,91 @@ export class AssetService {
|
|
|
239
232
|
|
|
240
233
|
const items = rows.slice(0, input.limit)
|
|
241
234
|
const last = items.at(-1)
|
|
242
|
-
const nextCursor = rows.length > input.limit && last ?
|
|
235
|
+
const nextCursor = rows.length > input.limit && last ? encodeMark({ i: last.id, s: input.sort }) : null
|
|
243
236
|
return { items: items.map(toAsset), nextCursor }
|
|
244
237
|
}
|
|
245
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The asset's own timeline, newest first, paged on the sequence the entries carry.
|
|
241
|
+
*
|
|
242
|
+
* **The ordering is `seq`, not `occurred_at` and not the id**, and the middle one of those three
|
|
243
|
+
* is the interesting correction.
|
|
244
|
+
*
|
|
245
|
+
* `occurred_at` was never a candidate: it defaults to `now()`, which is the *transaction*
|
|
246
|
+
* timestamp, so `create` writes the asset and its `created` entry with the same value and a page
|
|
247
|
+
* boundary between two entries that share a sort key repeats one and drops the other.
|
|
248
|
+
*
|
|
249
|
+
* The id looked like the answer and was only half of one. A uuidv7 is unique, so paging on it is
|
|
250
|
+
* gapless — but the kernel's `uuidv7()` carries the clock only to the millisecond and fills the
|
|
251
|
+
* rest from `randomUUID()` with no counter, so two entries written inside one millisecond sort by
|
|
252
|
+
* ten random bytes. Stable, so nothing was ever dropped or repeated; and arbitrary, so a timeline
|
|
253
|
+
* could show "Bruno removed the file" above "Bruno added the file". `seq` is a sequence: strictly
|
|
254
|
+
* increasing whatever the clock does, at whatever rate. `schema.ts` argues it at the column.
|
|
255
|
+
*
|
|
256
|
+
* The comparison is a single column because the sort key *is* the bookmark, so there is no
|
|
257
|
+
* bookmarked-row subquery here. The bookmark is still checked as a bounded positive integer and
|
|
258
|
+
* still bound to the sort it was issued under, because that is the codec's job and not this
|
|
259
|
+
* query's.
|
|
260
|
+
*/
|
|
261
|
+
async history(
|
|
262
|
+
tx: Tx,
|
|
263
|
+
workspaceId: string,
|
|
264
|
+
assetId: string,
|
|
265
|
+
input: { limit: number; cursor?: string },
|
|
266
|
+
): Promise<{ items: AssetHistoryEntry[]; nextCursor: string | null }> {
|
|
267
|
+
// 404 before paging: a timeline for an asset in another workspace must not answer with an
|
|
268
|
+
// empty list, which reads as "nothing has happened to it" rather than "it is not yours".
|
|
269
|
+
await this.get(tx, workspaceId, assetId)
|
|
270
|
+
|
|
271
|
+
const filters = [eq(assetHistory.workspaceId, workspaceId), eq(assetHistory.assetId, assetId)]
|
|
272
|
+
if (input.cursor) filters.push(lt(assetHistory.seq, decodeSeqMark(input.cursor, HISTORY_SORT).n))
|
|
273
|
+
|
|
274
|
+
const rows = await tx
|
|
275
|
+
.select()
|
|
276
|
+
.from(assetHistory)
|
|
277
|
+
.where(and(...filters))
|
|
278
|
+
.orderBy(desc(assetHistory.seq))
|
|
279
|
+
.limit(input.limit + 1)
|
|
280
|
+
|
|
281
|
+
const items = rows.slice(0, input.limit)
|
|
282
|
+
const last = items.at(-1)
|
|
283
|
+
const nextCursor =
|
|
284
|
+
rows.length > input.limit && last ? encodeSeqMark({ n: last.seq, s: HISTORY_SORT }) : null
|
|
285
|
+
return { items: items.map(toHistoryEntry), nextCursor }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A category id in a request is a claim about a row in this workspace, and it is checked before
|
|
290
|
+
* it is stored.
|
|
291
|
+
*
|
|
292
|
+
* The same defect `photoFileId` had, one field over: `assets.create` and `assets.update` took
|
|
293
|
+
* `categoryId` on trust, so any uuid at all went into the column. Two things follow from that,
|
|
294
|
+
* and neither is theoretical. An id belonging to **another workspace** files an asset under a
|
|
295
|
+
* category this one cannot see, name or unfile — the picker resolves names from its own
|
|
296
|
+
* `categories.list`, so the field renders blank and the filter it sits behind matches nothing
|
|
297
|
+
* anybody can select. An id belonging to **nobody** does the same thing with no other workspace
|
|
298
|
+
* involved. Either way the register holds a reference to a row that is not there, which is
|
|
299
|
+
* exactly the state `categories.archive` exists to prevent from the other end.
|
|
300
|
+
*
|
|
301
|
+
* **Checked inside the caller's transaction, unlike `checkPhoto`.** A file lives in core and has
|
|
302
|
+
* to be asked for over the broker, so that check happens before a connection is taken — the rule
|
|
303
|
+
* `codeFormat` documents. A category is this module's own table one join away, so the honest
|
|
304
|
+
* place for it is the transaction that writes the row: it costs one indexed read, and it cannot
|
|
305
|
+
* be raced by somebody deleting the category, because nothing deletes one.
|
|
306
|
+
*
|
|
307
|
+
* An **archived** category is deliberately allowed. Archiving takes it out of every picker and
|
|
308
|
+
* leaves every asset already filed under it still naming it; refusing it here would mean an edit
|
|
309
|
+
* to an asset's location failed because of a category somebody tidied away last year, and it
|
|
310
|
+
* would make re-filing a batch of assets under an old category impossible for no gain.
|
|
311
|
+
*/
|
|
312
|
+
private async requireCategory(tx: Tx, workspaceId: string, categoryId: string): Promise<void> {
|
|
313
|
+
const [row] = await tx
|
|
314
|
+
.select({ id: categories.id })
|
|
315
|
+
.from(categories)
|
|
316
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
|
|
317
|
+
if (!row) throw KernError.badRequest('That category is not one this workspace has.')
|
|
318
|
+
}
|
|
319
|
+
|
|
246
320
|
async get(tx: Tx, workspaceId: string, assetId: string): Promise<Row> {
|
|
247
321
|
const [row] = await tx
|
|
248
322
|
.select()
|
|
@@ -259,6 +333,7 @@ export class AssetService {
|
|
|
259
333
|
input: AssetCreateInput,
|
|
260
334
|
format: CodeFormat,
|
|
261
335
|
): Promise<Written> {
|
|
336
|
+
if (input.categoryId) await this.requireCategory(tx, workspaceId, input.categoryId)
|
|
262
337
|
const [row] = await tx
|
|
263
338
|
.insert(assets)
|
|
264
339
|
.values({
|
|
@@ -305,6 +380,11 @@ export class AssetService {
|
|
|
305
380
|
assetId: string,
|
|
306
381
|
input: AssetPatchInput,
|
|
307
382
|
): Promise<Written> {
|
|
383
|
+
// Before the row is locked, because a refusal here has nothing to do with this asset and
|
|
384
|
+
// holding a row lock across a check that can throw is a lock held for no reason. `undefined`
|
|
385
|
+
// means the patch never mentioned a category and `null` means "unfile it"; neither is an id.
|
|
386
|
+
if (input.categoryId) await this.requireCategory(tx, workspaceId, input.categoryId)
|
|
387
|
+
|
|
308
388
|
const [prev] = await tx
|
|
309
389
|
.select()
|
|
310
390
|
.from(assets)
|
|
@@ -320,6 +400,16 @@ export class AssetService {
|
|
|
320
400
|
patch[field] = value !== undefined ? (value ?? null) : prev[field]
|
|
321
401
|
}
|
|
322
402
|
|
|
403
|
+
/**
|
|
404
|
+
* A new warranty date earns a new notice.
|
|
405
|
+
*
|
|
406
|
+
* `warranty_notified_at` is the marker that stops the sweep saying the same thing every morning
|
|
407
|
+
* for a month; left alone here, extending a warranty by two years would mean nobody is ever told
|
|
408
|
+
* about the new date, because the row is already marked. Cleared only when the date itself
|
|
409
|
+
* moved — an edit to the name must not re-arm a notice somebody has already had.
|
|
410
|
+
*/
|
|
411
|
+
if (patch.warrantyUntil !== prev.warrantyUntil) patch.warrantyNotifiedAt = null
|
|
412
|
+
|
|
323
413
|
// Filtered by workspace as well as by id. The `select … for update` three statements up already
|
|
324
414
|
// carries the predicate, so this is not reachable today — but `core` connects as a superuser
|
|
325
415
|
// with RLS bypassed, which makes the predicate in the statement the only barrier there is. A
|
|
@@ -343,13 +433,83 @@ export class AssetService {
|
|
|
343
433
|
return { row: row!, activity }
|
|
344
434
|
}
|
|
345
435
|
|
|
436
|
+
/**
|
|
437
|
+
* @param repairsOn whether the workspace records repairs, read before the transaction opened.
|
|
438
|
+
* The "it is away for repair" refusal below tells somebody to go and use `repairs.complete`, and
|
|
439
|
+
* that procedure answers 404 in a workspace with the capability off — so the refusal would be an
|
|
440
|
+
* instruction to open a door that is not there, and the asset could never leave the register.
|
|
441
|
+
*/
|
|
346
442
|
async archive(
|
|
347
443
|
tx: Tx,
|
|
348
444
|
workspaceId: string,
|
|
349
445
|
actorId: string | null,
|
|
350
446
|
assetId: string,
|
|
351
447
|
archived: boolean,
|
|
448
|
+
repairsOn: boolean,
|
|
352
449
|
): Promise<Written> {
|
|
450
|
+
/**
|
|
451
|
+
* **Locked before either refusal is decided, because both of them are about a race.**
|
|
452
|
+
*
|
|
453
|
+
* The two checks below used to read the facts with a plain select, so the state they call
|
|
454
|
+
* impossible was reachable by simply doing the two things at once: an archive and a handover
|
|
455
|
+
* each read "nobody is holding it", both were allowed, and the register was left with an
|
|
456
|
+
* archived asset and an open custody period — the timeline showing a handover that never ended,
|
|
457
|
+
* and "what is Ada still holding?" quietly not counting it, because that question excludes
|
|
458
|
+
* archived rows. A check whose answer another transaction may already have changed is not a
|
|
459
|
+
* check; it is a comment.
|
|
460
|
+
*
|
|
461
|
+
* The lock is the same one `CustodyService.stamp` and `RepairService.restamp` take before they
|
|
462
|
+
* write `status`, which is what makes the ordering complete rather than one-sided: whichever
|
|
463
|
+
* transaction gets it second re-reads what the first committed, so an archive that arrives
|
|
464
|
+
* first makes the handover refuse, and a handover that arrives first makes the archive refuse.
|
|
465
|
+
* `lockAsset` in `status.ts` argues it in full.
|
|
466
|
+
*/
|
|
467
|
+
const [locked] = await tx
|
|
468
|
+
.select()
|
|
469
|
+
.from(assets)
|
|
470
|
+
.where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
|
|
471
|
+
.for('update')
|
|
472
|
+
if (!locked) throw KernError.notFound('Asset')
|
|
473
|
+
|
|
474
|
+
if (archived) {
|
|
475
|
+
/**
|
|
476
|
+
* An item somebody is holding cannot leave the register.
|
|
477
|
+
*
|
|
478
|
+
* Somebody is answerable for the thing; taking it out of the register does not make them not
|
|
479
|
+
* answerable, it only stops anybody being able to find out. Two steps instead of one, and
|
|
480
|
+
* both of them mean something.
|
|
481
|
+
*/
|
|
482
|
+
if (locked.custodianUserId)
|
|
483
|
+
throw KernError.conflict(
|
|
484
|
+
'Somebody is still holding this item. Take it back before archiving it.',
|
|
485
|
+
'inventory.asset.still_held',
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* An item that is at a repairer cannot leave the register either, and for the same reason as
|
|
490
|
+
* custody: money is committed and the thing is out of the building, so taking it out of the
|
|
491
|
+
* register does not settle that — it only stops anybody being able to find out.
|
|
492
|
+
*
|
|
493
|
+
* It also keeps two counts that must agree from drifting. `stats.summary` reports
|
|
494
|
+
* `byStatus.under_repair` from the cached status column, over live rows only, and
|
|
495
|
+
* `outForRepair` from the repair rows themselves; an archived asset with an open repair
|
|
496
|
+
* would appear in one and not the other, and neither number would be wrong.
|
|
497
|
+
*
|
|
498
|
+
* **Only while the workspace records repairs.** With the `repairs` capability off, this
|
|
499
|
+
* refusal names a procedure that answers 404 — so it was not a two-step instruction, it was a
|
|
500
|
+
* dead end: the repair could not be completed, so the asset could not be archived, so the
|
|
501
|
+
* item was stuck in the register for as long as the capability stayed off. A refusal that
|
|
502
|
+
* points at a door has to be withdrawn when the door is taken away. `stats.away` counts only
|
|
503
|
+
* live assets for the same reason, so the two counts still cannot disagree.
|
|
504
|
+
*/
|
|
505
|
+
const open = repairsOn ? await openRepairId(tx, workspaceId, assetId) : undefined
|
|
506
|
+
if (open)
|
|
507
|
+
throw KernError.conflict(
|
|
508
|
+
'This item is away for repair. Log the repair as returned before archiving it.',
|
|
509
|
+
'inventory.asset.under_repair',
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
353
513
|
const [row] = await tx
|
|
354
514
|
.update(assets)
|
|
355
515
|
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
|