@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/contract/index.ts
CHANGED
package/src/contract/models.ts
CHANGED
|
@@ -18,6 +18,12 @@ export const MODULE_ID = 'inventory'
|
|
|
18
18
|
*
|
|
19
19
|
* Stored rather than derived, because every list filter asks for it — and kept in step inside the
|
|
20
20
|
* same transaction that writes the row it derives from, never by a job afterwards.
|
|
21
|
+
*
|
|
22
|
+
* **`under_repair` belongs to the `repairs` capability**, so a workspace with that switch off never
|
|
23
|
+
* has an asset in it: the procedure that ends a repair answers 404 there, and a status nothing can
|
|
24
|
+
* move an item out of is a register the workspace cannot correct. The one thing a job does here is
|
|
25
|
+
* bring the rows *nobody touches* back into step after that switch moves, in both directions —
|
|
26
|
+
* `deriveStatus` in `src/server/services/status.ts` argues it, and nothing is destroyed either way.
|
|
21
27
|
*/
|
|
22
28
|
export const AssetStatus = z.enum(['in_stock', 'assigned', 'reserved', 'under_repair', 'lost', 'retired'])
|
|
23
29
|
export type AssetStatus = z.infer<typeof AssetStatus>
|
|
@@ -32,6 +38,86 @@ export type AssetStatus = z.infer<typeof AssetStatus>
|
|
|
32
38
|
export const CustomValues = z.record(z.string(), z.unknown())
|
|
33
39
|
export type CustomValues = z.infer<typeof CustomValues>
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A workspace's own grouping of what it owns — Laptops, Furniture, Cameras.
|
|
43
|
+
*
|
|
44
|
+
* Archived rather than deleted, and `archivedAt` is the whole reason: `assets.category_id` is a
|
|
45
|
+
* plain uuid with no foreign key, so a deleted category leaves every asset filed under it pointing
|
|
46
|
+
* at a row that is not there. The list column goes blank and the timeline entry that recorded the
|
|
47
|
+
* move loses the name it recorded. Archiving takes it out of every picker and every filter and
|
|
48
|
+
* leaves each asset able to say what it is.
|
|
49
|
+
*/
|
|
50
|
+
export const Category = z.object({
|
|
51
|
+
id: z.uuid(),
|
|
52
|
+
workspaceId: WorkspaceId,
|
|
53
|
+
name: z.string().min(1).max(120),
|
|
54
|
+
/** Where it sits in a picker. Equal orders fall back to the name, so a workspace that never
|
|
55
|
+
* reorders anything still gets an alphabetical list rather than an arbitrary one. */
|
|
56
|
+
order: z.number().int(),
|
|
57
|
+
createdAt: z.string(),
|
|
58
|
+
updatedAt: z.string(),
|
|
59
|
+
archivedAt: z.string().nullable(),
|
|
60
|
+
})
|
|
61
|
+
export type Category = z.infer<typeof Category>
|
|
62
|
+
|
|
63
|
+
export const CategoryInput = z.object({
|
|
64
|
+
name: z.string().trim().min(1).max(120),
|
|
65
|
+
order: z.number().int().min(0).max(9999).optional(),
|
|
66
|
+
})
|
|
67
|
+
export type CategoryInput = z.infer<typeof CategoryInput>
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One stretch of time during which one person held one asset.
|
|
71
|
+
*
|
|
72
|
+
* Effective-dated, exactly as HR keeps employments: nothing is ever updated in place, a change
|
|
73
|
+
* closes the open row and inserts a new one. `effectiveTo === null` means "still holding it", and
|
|
74
|
+
* `inventory_custody_no_overlap` — a GiST exclusion constraint on
|
|
75
|
+
* `(asset_id =, tstzrange(effective_from, effective_to, '[)') &&)` — makes two of those for one
|
|
76
|
+
* asset impossible in the database rather than merely unlikely in the service.
|
|
77
|
+
*/
|
|
78
|
+
export const CustodyPeriod = z.object({
|
|
79
|
+
id: z.uuid(),
|
|
80
|
+
workspaceId: WorkspaceId,
|
|
81
|
+
assetId: z.uuid(),
|
|
82
|
+
/**
|
|
83
|
+
* The member who held it. A plain uuid — a cross-schema foreign key is what the module boundary
|
|
84
|
+
* exists to prevent — resolved against core membership at read time, which is also why a person
|
|
85
|
+
* who has since left the workspace still has readable history.
|
|
86
|
+
*/
|
|
87
|
+
userId: z.uuid(),
|
|
88
|
+
note: z.string().max(500).nullable(),
|
|
89
|
+
effectiveFrom: z.string(),
|
|
90
|
+
effectiveTo: z.string().nullable(),
|
|
91
|
+
/** Who did the handing over, which is not always who received it. */
|
|
92
|
+
createdBy: z.uuid().nullable(),
|
|
93
|
+
createdAt: z.string(),
|
|
94
|
+
})
|
|
95
|
+
export type CustodyPeriod = z.infer<typeof CustodyPeriod>
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One entry in an asset's timeline.
|
|
99
|
+
*
|
|
100
|
+
* `action` is a **plain string, deliberately not an enum**. The rows are append-only and outlive
|
|
101
|
+
* the image that wrote them: an instance rolled back to a previous release would fail to parse its
|
|
102
|
+
* own history the moment a newer image had written an action the older enum does not list — a
|
|
103
|
+
* timeline that 500s rather than one that says a little less. The client renders a sentence per
|
|
104
|
+
* action it knows and a neutral one for anything else. What is written today is `created`,
|
|
105
|
+
* `updated`, `assigned`, `transferred`, `returned`, `retired`, `restored`, `repair_logged`,
|
|
106
|
+
* `repair_completed`, `attachment_added` and `attachment_removed`.
|
|
107
|
+
*/
|
|
108
|
+
export const AssetHistoryEntry = z.object({
|
|
109
|
+
id: z.uuid(),
|
|
110
|
+
assetId: z.uuid(),
|
|
111
|
+
/** Null for anything the platform did rather than a person. */
|
|
112
|
+
actorId: z.uuid().nullable(),
|
|
113
|
+
action: z.string().max(40),
|
|
114
|
+
changes: z.array(z.object({ field: z.string(), from: z.unknown(), to: z.unknown() })),
|
|
115
|
+
/** Action-specific ids — who received an item, who handed it over. Never a row. */
|
|
116
|
+
data: z.record(z.string(), z.unknown()),
|
|
117
|
+
occurredAt: z.string(),
|
|
118
|
+
})
|
|
119
|
+
export type AssetHistoryEntry = z.infer<typeof AssetHistoryEntry>
|
|
120
|
+
|
|
35
121
|
export const Asset = z.object({
|
|
36
122
|
id: z.uuid(),
|
|
37
123
|
workspaceId: WorkspaceId,
|
|
@@ -63,6 +149,157 @@ export const Asset = z.object({
|
|
|
63
149
|
})
|
|
64
150
|
export type Asset = z.infer<typeof Asset>
|
|
65
151
|
|
|
152
|
+
/**
|
|
153
|
+
* What a custody procedure answers with: the asset as it now stands, and the period this call
|
|
154
|
+
* opened.
|
|
155
|
+
*
|
|
156
|
+
* Both, because a screen needs both and fetching the asset again afterwards is a second round trip
|
|
157
|
+
* that can read a row somebody else has changed in between — the panel would then show a handover
|
|
158
|
+
* that has already been undone. `period` is null on a return: something closed, nothing opened.
|
|
159
|
+
*/
|
|
160
|
+
export const CustodyResult = z.object({
|
|
161
|
+
asset: Asset,
|
|
162
|
+
period: CustodyPeriod.nullable(),
|
|
163
|
+
})
|
|
164
|
+
export type CustodyResult = z.infer<typeof CustodyResult>
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One trip an item made to a repairer.
|
|
168
|
+
*
|
|
169
|
+
* `returnedOn === null` means it is still away, and that single fact is what puts the asset in
|
|
170
|
+
* `under_repair` — see `deriveStatus` in `src/server/services/status.ts`, which is the only place
|
|
171
|
+
* the three columns that drive a status are read together.
|
|
172
|
+
*
|
|
173
|
+
* **A repair is not custody, and neither one cancels the other.** A laptop assigned to Dan that goes
|
|
174
|
+
* to the repairer is still Dan's responsibility: the custody period stays open, `custodianUserId`
|
|
175
|
+
* stays set, and only `status` moves. That is why a repair does not touch custody and custody does
|
|
176
|
+
* not refuse a repaired item — the two answer different questions ("who is answerable for it" and
|
|
177
|
+
* "where is it"), and collapsing them would mean an item could not come back to the person who sent
|
|
178
|
+
* it.
|
|
179
|
+
*/
|
|
180
|
+
export const Repair = z.object({
|
|
181
|
+
id: z.uuid(),
|
|
182
|
+
workspaceId: WorkspaceId,
|
|
183
|
+
assetId: z.uuid(),
|
|
184
|
+
summary: z.string().min(1).max(200),
|
|
185
|
+
detail: z.string().max(4000).nullable(),
|
|
186
|
+
vendor: z.string().max(200).nullable(),
|
|
187
|
+
/** Minor units, like `Asset.priceMinor` — one convention for money across the module. */
|
|
188
|
+
costMinor: z.number().int().nullable(),
|
|
189
|
+
currency: z.string().length(3).nullable(),
|
|
190
|
+
sentOn: z.string(),
|
|
191
|
+
/** Null while it is still away. Set by `repairs.complete` and by nothing else. */
|
|
192
|
+
returnedOn: z.string().nullable(),
|
|
193
|
+
createdBy: z.uuid().nullable(),
|
|
194
|
+
createdAt: z.string(),
|
|
195
|
+
updatedAt: z.string(),
|
|
196
|
+
})
|
|
197
|
+
export type Repair = z.infer<typeof Repair>
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A repair in a list, with the two things a reader needs to know *which item* it is about.
|
|
201
|
+
*
|
|
202
|
+
* **Joined at read time, never stored here.** `mod_inventory.repairs` holds no asset name, so
|
|
203
|
+
* renaming an asset renames it everywhere at once — a copied label is a label that goes stale, and
|
|
204
|
+
* this module already refuses to copy a person's name for the same reason. The join is inside one
|
|
205
|
+
* schema, which is the kind a module is allowed to make; the kind it is not is a join across the
|
|
206
|
+
* boundary into another module's tables.
|
|
207
|
+
*
|
|
208
|
+
* It extends `Repair` rather than replacing it, so a component that renders a repair renders one of
|
|
209
|
+
* these without knowing the difference. `create`, `update` and `complete` answer with a plain
|
|
210
|
+
* `Repair`: the caller of those already knows which asset it asked about.
|
|
211
|
+
*/
|
|
212
|
+
export const RepairListItem = Repair.extend({
|
|
213
|
+
assetCode: z.string().min(1).max(40),
|
|
214
|
+
assetName: z.string().min(1).max(200),
|
|
215
|
+
})
|
|
216
|
+
export type RepairListItem = z.infer<typeof RepairListItem>
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Everything a person can say about a repair.
|
|
220
|
+
*
|
|
221
|
+
* **No field here carries `.default()`, for the reason `AssetInput` spells out**: `update` is built
|
|
222
|
+
* from `.partial()`, and `.partial()` does not strip a default — zod still substitutes it for a key
|
|
223
|
+
* the request never sent, so a patch that renames a repair would silently re-date it. `sentOn` is
|
|
224
|
+
* optional rather than defaulted for the same reason; `repairs.create` fills today's date on the
|
|
225
|
+
* server, which is the only clock this module trusts.
|
|
226
|
+
*/
|
|
227
|
+
export const RepairInput = z.object({
|
|
228
|
+
summary: z.string().trim().min(1).max(200),
|
|
229
|
+
detail: z.string().max(4000).nullish(),
|
|
230
|
+
vendor: z.string().max(200).nullish(),
|
|
231
|
+
costMinor: z.number().int().min(0).nullish(),
|
|
232
|
+
currency: z.string().length(3).nullish(),
|
|
233
|
+
/** `sent_on` is `not null` in the database, so this is optional but never nullable. */
|
|
234
|
+
sentOn: z.iso.date().optional(),
|
|
235
|
+
})
|
|
236
|
+
export type RepairInput = z.infer<typeof RepairInput>
|
|
237
|
+
|
|
238
|
+
export const RepairPatchInput = RepairInput.partial()
|
|
239
|
+
export type RepairPatchInput = z.infer<typeof RepairPatchInput>
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What a repair mutation answers with: the repair, and the asset as it now stands.
|
|
243
|
+
*
|
|
244
|
+
* Both, for the reason `CustodyResult` carries both — sending an item away moves `assets.status`,
|
|
245
|
+
* and a screen that had to fetch the asset again afterwards would be reading a row somebody else
|
|
246
|
+
* may have changed in between.
|
|
247
|
+
*/
|
|
248
|
+
export const RepairResult = z.object({
|
|
249
|
+
repair: Repair,
|
|
250
|
+
asset: Asset,
|
|
251
|
+
})
|
|
252
|
+
export type RepairResult = z.infer<typeof RepairResult>
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A file recorded against an asset — a receipt, a warranty card, a manual.
|
|
256
|
+
*
|
|
257
|
+
* **The bytes are core's, not this module's.** A module does not upload: the browser uploads through
|
|
258
|
+
* core's file service and hands this module the id it was given, and reading one back is a download
|
|
259
|
+
* URL core signs. All that lives here is the fact that this asset has that file, plus the name and
|
|
260
|
+
* size copied at attach time so a list can be drawn without asking core once per row.
|
|
261
|
+
*
|
|
262
|
+
* `repairId` is what separates "the invoice for the screen replacement" from "the purchase receipt":
|
|
263
|
+
* null means it belongs to the asset itself.
|
|
264
|
+
*/
|
|
265
|
+
export const Attachment = z.object({
|
|
266
|
+
id: z.uuid(),
|
|
267
|
+
workspaceId: WorkspaceId,
|
|
268
|
+
assetId: z.uuid(),
|
|
269
|
+
repairId: z.uuid().nullable(),
|
|
270
|
+
fileId: z.uuid(),
|
|
271
|
+
name: z.string().max(300),
|
|
272
|
+
mimeType: z.string().max(200).nullable(),
|
|
273
|
+
/** Bytes, as core reported them when the file was attached. */
|
|
274
|
+
size: z.number().int().nullable(),
|
|
275
|
+
uploadedBy: z.uuid().nullable(),
|
|
276
|
+
createdAt: z.string(),
|
|
277
|
+
})
|
|
278
|
+
export type Attachment = z.infer<typeof Attachment>
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The register in numbers: what a page's count line and a dashboard card need in one request.
|
|
282
|
+
*
|
|
283
|
+
* `total` counts what the list shows by default — live rows — and `archived` is beside it rather
|
|
284
|
+
* than inside it, because a count line that silently included archived rows would disagree with the
|
|
285
|
+
* list under it.
|
|
286
|
+
*
|
|
287
|
+
* **`outForRepair` is null when the workspace does not track repairs.** Zero would be a claim
|
|
288
|
+
* ("nothing is away"), and a workspace with the `repairs` capability off has not made that claim —
|
|
289
|
+
* it has no opinion at all. The screens show the tile only when a number arrives, which is the same
|
|
290
|
+
* rule as hiding the Repairs tab, expressed in the data instead of in a second capability lookup.
|
|
291
|
+
*/
|
|
292
|
+
export const InventoryStats = z.object({
|
|
293
|
+
total: z.number().int().nonnegative(),
|
|
294
|
+
archived: z.number().int().nonnegative(),
|
|
295
|
+
/** Every status, zero-filled, so a screen can render the set without knowing which exist. */
|
|
296
|
+
byStatus: z.record(AssetStatus, z.number().int().nonnegative()),
|
|
297
|
+
outForRepair: z.number().int().nonnegative().nullable(),
|
|
298
|
+
/** Live assets nobody is holding — what is actually available to hand out. */
|
|
299
|
+
unassigned: z.number().int().nonnegative(),
|
|
300
|
+
})
|
|
301
|
+
export type InventoryStats = z.infer<typeof InventoryStats>
|
|
302
|
+
|
|
66
303
|
/**
|
|
67
304
|
* How a list is ordered, and therefore what a page cursor is a bookmark *into*.
|
|
68
305
|
*
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { core } from '@kernhq/contracts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What Inventory will interrupt somebody about.
|
|
5
|
+
*
|
|
6
|
+
* Four things, and each of them changes what somebody has to *do*. That is the test: a rename, a new
|
|
7
|
+
* category, an item coming back — those are facts a screen shows when somebody looks, and a
|
|
8
|
+
* notification type nobody wants is a notification type everybody switches off along with the one
|
|
9
|
+
* that mattered.
|
|
10
|
+
*
|
|
11
|
+
* A type declared here and never sent is the same lie as a permission nothing checks. Each of these
|
|
12
|
+
* names its sender, and every one of them exists:
|
|
13
|
+
*
|
|
14
|
+
* - `custody.assigned` — `CustodyService`, on `assign` and `transfer`.
|
|
15
|
+
* - `warranty.expiring` — the `warranty-sweep` job, once per asset.
|
|
16
|
+
* - `repair.overdue` — the `repair-overdue` job, once per repair.
|
|
17
|
+
* - `custody.return_due` — the offboarding subscription, when HR says somebody has left or core says
|
|
18
|
+
* they have been removed from the workspace.
|
|
19
|
+
*
|
|
20
|
+
* The keys keep their `inventory.custody.*` spelling rather than being renamed to
|
|
21
|
+
* `inventory.asset.*`: a notification type is the key a person's own preferences are stored under,
|
|
22
|
+
* so renaming one silently resets everybody who had switched it off.
|
|
23
|
+
*/
|
|
24
|
+
export const inventoryNotificationTypes: core.NotificationTypeDef[] = [
|
|
25
|
+
{
|
|
26
|
+
/**
|
|
27
|
+
* Everybody who has ever found a laptop on their desk with no idea it was now theirs is the
|
|
28
|
+
* reason this is `urgent` and reaches email — an asset register that records a handover the
|
|
29
|
+
* recipient never learns about has recorded an argument for later.
|
|
30
|
+
*/
|
|
31
|
+
type: 'inventory.custody.assigned',
|
|
32
|
+
label: 'An item was handed to you',
|
|
33
|
+
description: 'Somebody made you the holder of an asset in the register.',
|
|
34
|
+
defaults: { inapp: true, push: true, email: true },
|
|
35
|
+
urgent: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
/**
|
|
39
|
+
* Not urgent, and email is off by default. A warranty running out in a month is a thing to plan
|
|
40
|
+
* for, not a thing to stop for — and it arrives on a schedule rather than because somebody did
|
|
41
|
+
* something, which is exactly the kind of message that trains people to ignore the rest.
|
|
42
|
+
*/
|
|
43
|
+
type: 'inventory.warranty.expiring',
|
|
44
|
+
label: 'A warranty is about to run out',
|
|
45
|
+
description: "An item's warranty expires soon. Sent once per item, to whoever is holding it.",
|
|
46
|
+
defaults: { inapp: true, push: false, email: false },
|
|
47
|
+
urgent: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
/**
|
|
51
|
+
* Push, because this one is an action somebody has to take — ring the repairer — and nobody
|
|
52
|
+
* goes looking for it. Still not urgent: a laptop that has been away a fortnight can wait until
|
|
53
|
+
* the phone is picked up.
|
|
54
|
+
*/
|
|
55
|
+
type: 'inventory.repair.overdue',
|
|
56
|
+
label: 'A repair has been away too long',
|
|
57
|
+
description: 'An item sent for repair has not been logged as returned. Sent once per repair.',
|
|
58
|
+
defaults: { inapp: true, push: true, email: false },
|
|
59
|
+
urgent: false,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
/**
|
|
63
|
+
* Somebody is leaving and still has company property. Email, because it is a list of things to
|
|
64
|
+
* collect from a person who may not be at their desk much longer, and an in-app badge nobody
|
|
65
|
+
* opens until Monday is how a laptop leaves the building.
|
|
66
|
+
*/
|
|
67
|
+
type: 'inventory.custody.return_due',
|
|
68
|
+
label: 'Somebody leaving still holds company property',
|
|
69
|
+
description: 'A member has left, or is leaving, and items are still recorded as theirs.',
|
|
70
|
+
defaults: { inapp: true, push: true, email: true },
|
|
71
|
+
urgent: true,
|
|
72
|
+
},
|
|
73
|
+
]
|
|
@@ -5,8 +5,19 @@ import { definePermissions } from '@kernhq/contracts'
|
|
|
5
5
|
* it by default. A workspace can add or remove any of them afterwards with a custom role.
|
|
6
6
|
*
|
|
7
7
|
* A key with nothing checking it is a role editor full of switches that do nothing, so these arrive
|
|
8
|
-
* with the procedures that enforce them — `
|
|
9
|
-
*
|
|
8
|
+
* with the procedures that enforce them — `field.manage` and the stock and purchasing keys with
|
|
9
|
+
* their own phases.
|
|
10
|
+
*
|
|
11
|
+
* **Reading custody is `asset.view`, and that is a decision rather than an omission.** Who holds an
|
|
12
|
+
* item looks like a privacy question, and it is not one here: `custodianUserId` and `custodySince`
|
|
13
|
+
* are fields of `Asset`, returned by `assets.list` and `assets.get` under `asset.view` since the
|
|
14
|
+
* module existed, and `assets.list` has always taken a `custodianUserId` filter. A separate
|
|
15
|
+
* `custody.view` would be a lock on a door beside an open window — it would refuse the timeline
|
|
16
|
+
* while the row above it named the same person. "Who has the projector" is also the question an
|
|
17
|
+
* asset register exists to answer; a company where a member cannot find that out asks in chat
|
|
18
|
+
* instead, which is worse for whoever is holding it. A workspace that disagrees takes
|
|
19
|
+
* `inventory.asset.view` off `guest`, which is one switch and already there. Writing custody is
|
|
20
|
+
* `custody.manage`, because handing an item over makes somebody answerable for it.
|
|
10
21
|
*/
|
|
11
22
|
export const inventoryPermissions = definePermissions([
|
|
12
23
|
{
|
|
@@ -23,4 +34,46 @@ export const inventoryPermissions = definePermissions([
|
|
|
23
34
|
defaultRoles: ['owner', 'admin', 'member'],
|
|
24
35
|
dangerous: false,
|
|
25
36
|
},
|
|
37
|
+
{
|
|
38
|
+
/**
|
|
39
|
+
* Not `asset.manage`: correcting a serial number and making a colleague answerable for a
|
|
40
|
+
* £2,000 laptop are different acts, and plenty of workspaces want the first from everybody and
|
|
41
|
+
* the second from the office manager. Held by `member` by default all the same — an office
|
|
42
|
+
* where only an admin may hand over a charger keeps its register by not using it.
|
|
43
|
+
*/
|
|
44
|
+
key: 'inventory.custody.manage',
|
|
45
|
+
label: 'Hand assets over and take them back',
|
|
46
|
+
scope: 'workspace',
|
|
47
|
+
defaultRoles: ['owner', 'admin', 'member'],
|
|
48
|
+
dangerous: false,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
/**
|
|
52
|
+
* Sending an item away and recording what it cost, held by `member` for the same reason
|
|
53
|
+
* `custody.manage` is: the person who notices a broken screen is the person who should be able
|
|
54
|
+
* to log it, and an office where only an admin may do that keeps its repair record by not
|
|
55
|
+
* keeping one. Reading repairs rides `asset.view` — "where is the projector" is the question
|
|
56
|
+
* the register exists to answer, and a repair is one of the two answers.
|
|
57
|
+
*
|
|
58
|
+
* Not folded into `asset.manage`: correcting a serial number and committing the company to a
|
|
59
|
+
* £400 screen replacement are different acts, and the money is why.
|
|
60
|
+
*/
|
|
61
|
+
key: 'inventory.repair.manage',
|
|
62
|
+
label: 'Log repairs',
|
|
63
|
+
scope: 'workspace',
|
|
64
|
+
defaultRoles: ['owner', 'admin', 'member'],
|
|
65
|
+
dangerous: false,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
/**
|
|
69
|
+
* Categories are workspace configuration — one list everybody's assets are filed against — so
|
|
70
|
+
* this sits with the people who set the workspace up rather than with everybody who may edit an
|
|
71
|
+
* asset. Reading them rides `asset.view`, because the picker on the asset form needs them.
|
|
72
|
+
*/
|
|
73
|
+
key: 'inventory.category.manage',
|
|
74
|
+
label: 'Manage asset categories',
|
|
75
|
+
scope: 'workspace',
|
|
76
|
+
defaultRoles: ['owner', 'admin'],
|
|
77
|
+
dangerous: false,
|
|
78
|
+
},
|
|
26
79
|
])
|
package/src/contract/router.ts
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import { baseContract, PageInput, page } from '@kernhq/contracts'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Asset,
|
|
5
|
+
AssetCreateInput,
|
|
6
|
+
AssetHistoryEntry,
|
|
7
|
+
AssetPatchInput,
|
|
8
|
+
AssetSort,
|
|
9
|
+
AssetStatus,
|
|
10
|
+
Attachment,
|
|
11
|
+
Category,
|
|
12
|
+
CategoryInput,
|
|
13
|
+
CustodyPeriod,
|
|
14
|
+
CustodyResult,
|
|
15
|
+
InventoryStats,
|
|
16
|
+
RepairInput,
|
|
17
|
+
RepairListItem,
|
|
18
|
+
RepairPatchInput,
|
|
19
|
+
RepairResult,
|
|
20
|
+
ws,
|
|
21
|
+
} from './models.js'
|
|
4
22
|
|
|
5
23
|
const t = ['inventory'] as const
|
|
6
24
|
|
|
25
|
+
/** A handover note is optional everywhere and shaped the same everywhere. */
|
|
26
|
+
const custodyNote = z.string().max(500).nullish()
|
|
27
|
+
|
|
7
28
|
export const inventoryContract = {
|
|
8
29
|
assets: {
|
|
9
30
|
list: baseContract
|
|
@@ -41,6 +62,239 @@ export const inventoryContract = {
|
|
|
41
62
|
.route({ method: 'POST', path: '/assets/{assetId}/archive', tags: t })
|
|
42
63
|
.input(ws.extend({ assetId: z.uuid(), archived: z.boolean().default(true) }))
|
|
43
64
|
.output(Asset),
|
|
65
|
+
/**
|
|
66
|
+
* The asset's own timeline, newest first.
|
|
67
|
+
*
|
|
68
|
+
* Paged with the same keyset discipline `assets.list` uses and the same cursor codec, because a
|
|
69
|
+
* second bookmark format is a second set of the three bugs the first one had. The bookmark is a
|
|
70
|
+
* row id; the ordering is by id, which for a uuidv7 is the clock and, unlike `created_at`, is
|
|
71
|
+
* unique — two entries written in one transaction (a create and its first custody row) share a
|
|
72
|
+
* timestamp and a page boundary between them would repeat or drop one.
|
|
73
|
+
*
|
|
74
|
+
* Reads on `inventory.asset.view`. See `permissions.ts` for why custody is not gated separately.
|
|
75
|
+
*/
|
|
76
|
+
history: baseContract
|
|
77
|
+
.route({ method: 'GET', path: '/assets/{assetId}/history', tags: t })
|
|
78
|
+
.input(ws.extend({ assetId: z.uuid(), ...PageInput.shape }))
|
|
79
|
+
.output(page(AssetHistoryEntry)),
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Who is holding what.
|
|
84
|
+
*
|
|
85
|
+
* Four verbs rather than one, because "hand it to somebody" and "hand it on to somebody else" are
|
|
86
|
+
* different questions with different failure modes: assigning something already out is a mistake
|
|
87
|
+
* worth refusing, and transferring something nobody holds is a different mistake. One procedure
|
|
88
|
+
* taking `userId | null` would answer both by silently doing whatever the row happened to allow.
|
|
89
|
+
*/
|
|
90
|
+
custody: {
|
|
91
|
+
/** Hand a free item to a member. Refuses if somebody already has it — that is `transfer`. */
|
|
92
|
+
assign: baseContract
|
|
93
|
+
.route({ method: 'POST', path: '/assets/{assetId}/custody', tags: t })
|
|
94
|
+
.input(ws.extend({ assetId: z.uuid(), userId: z.uuid(), note: custodyNote }))
|
|
95
|
+
.output(CustodyResult),
|
|
96
|
+
/** Take it back. Closes the open period and puts the asset back in stock. */
|
|
97
|
+
return: baseContract
|
|
98
|
+
.route({ method: 'POST', path: '/assets/{assetId}/custody/return', tags: t })
|
|
99
|
+
.input(ws.extend({ assetId: z.uuid(), note: custodyNote }))
|
|
100
|
+
.output(CustodyResult),
|
|
101
|
+
/**
|
|
102
|
+
* Hand it straight on: one transaction, not a return followed by an assign.
|
|
103
|
+
*
|
|
104
|
+
* Two calls would leave the asset `in_stock` with no custodian in between — visible to anybody
|
|
105
|
+
* reading the list at that moment, and permanently visible in the timeline as a return that
|
|
106
|
+
* nobody performed.
|
|
107
|
+
*/
|
|
108
|
+
transfer: baseContract
|
|
109
|
+
.route({ method: 'POST', path: '/assets/{assetId}/custody/transfer', tags: t })
|
|
110
|
+
.input(ws.extend({ assetId: z.uuid(), userId: z.uuid(), note: custodyNote }))
|
|
111
|
+
.output(CustodyResult),
|
|
112
|
+
/**
|
|
113
|
+
* Every period for one asset, newest first — "who had this laptop before me".
|
|
114
|
+
*
|
|
115
|
+
* An array rather than a page, like HR's `employment.history`: the rows are bounded by how many
|
|
116
|
+
* times one item changed hands, which is tens over its life. `limit` caps it rather than
|
|
117
|
+
* paging, so the answer is never unbounded and the caller never has a cursor to keep.
|
|
118
|
+
*/
|
|
119
|
+
history: baseContract
|
|
120
|
+
.route({ method: 'GET', path: '/assets/{assetId}/custody', tags: t })
|
|
121
|
+
.input(ws.extend({ assetId: z.uuid(), limit: z.number().int().min(1).max(200).default(100) }))
|
|
122
|
+
.output(z.array(CustodyPeriod)),
|
|
123
|
+
/**
|
|
124
|
+
* What one person is holding right now.
|
|
125
|
+
*
|
|
126
|
+
* Answered from `assets.custodian_user_id` — denormalised inside the same transaction that
|
|
127
|
+
* writes the period, and indexed — rather than from an open-period join, so the offboarding
|
|
128
|
+
* question ("what does Ada still have?") is one indexed read.
|
|
129
|
+
*/
|
|
130
|
+
byUser: baseContract
|
|
131
|
+
.route({ method: 'GET', path: '/custody/by-user/{userId}', tags: t })
|
|
132
|
+
.input(ws.extend({ userId: z.uuid(), ...PageInput.shape }))
|
|
133
|
+
.output(page(Asset)),
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How a workspace groups what it owns.
|
|
138
|
+
*
|
|
139
|
+
* Not paged: a workspace has tens of categories, and every one of them has to be in the picker
|
|
140
|
+
* anyway. A cursor here would be a page boundary in a dropdown.
|
|
141
|
+
*/
|
|
142
|
+
categories: {
|
|
143
|
+
list: baseContract
|
|
144
|
+
.route({ method: 'GET', path: '/categories', tags: t })
|
|
145
|
+
.input(ws.extend({ archived: z.boolean().default(false) }))
|
|
146
|
+
.output(z.array(Category)),
|
|
147
|
+
create: baseContract
|
|
148
|
+
.route({ method: 'POST', path: '/categories', tags: t })
|
|
149
|
+
.input(ws.extend(CategoryInput.shape))
|
|
150
|
+
.output(Category),
|
|
151
|
+
update: baseContract
|
|
152
|
+
.route({ method: 'PATCH', path: '/categories/{categoryId}', tags: t })
|
|
153
|
+
.input(ws.extend({ categoryId: z.uuid(), ...CategoryInput.partial().shape }))
|
|
154
|
+
.output(Category),
|
|
155
|
+
/**
|
|
156
|
+
* Archive, not delete — and the same procedure restores.
|
|
157
|
+
*
|
|
158
|
+
* `assets.category_id` carries no foreign key, so a delete would leave every asset filed under
|
|
159
|
+
* it pointing at nothing: a blank column, and a timeline entry that recorded the move losing
|
|
160
|
+
* the name it recorded. Archiving is reversible and destroys nothing.
|
|
161
|
+
*/
|
|
162
|
+
archive: baseContract
|
|
163
|
+
.route({ method: 'POST', path: '/categories/{categoryId}/archive', tags: t })
|
|
164
|
+
.input(ws.extend({ categoryId: z.uuid(), archived: z.boolean().default(true) }))
|
|
165
|
+
.output(Category),
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What went away to be fixed.
|
|
170
|
+
*
|
|
171
|
+
* **Behind the `repairs` capability**, which is this module's first switchable one — so every
|
|
172
|
+
* procedure here answers **404** rather than 403 in a workspace that has it off. 403 would say
|
|
173
|
+
* "this exists and you may not have it", which is false for a company that does not record
|
|
174
|
+
* repairs, and it would contradict a panel that has already hidden the tab.
|
|
175
|
+
*
|
|
176
|
+
* Writing takes `inventory.repair.manage`; reading rides `inventory.asset.view`, for the reason
|
|
177
|
+
* custody does — "where is the projector" is the question the register exists to answer.
|
|
178
|
+
*/
|
|
179
|
+
repairs: {
|
|
180
|
+
/**
|
|
181
|
+
* One asset's repairs, or the whole workspace's — the same procedure, because they are the same
|
|
182
|
+
* query with one filter and a second one would be a second thing to keep in step.
|
|
183
|
+
*
|
|
184
|
+
* `open: true` is the "what is away right now" question a dashboard card asks, and it is a
|
|
185
|
+
* filter rather than a procedure of its own for the same reason.
|
|
186
|
+
*
|
|
187
|
+
* Paged, unlike `custody.history`: one asset's repairs are bounded by how often it breaks, but
|
|
188
|
+
* a workspace's are not. Ordered newest-logged first, by row id — a uuidv7 already carries the
|
|
189
|
+
* clock and is unique, where `sent_on` is a date two repairs logged the same day share.
|
|
190
|
+
*/
|
|
191
|
+
list: baseContract
|
|
192
|
+
.route({ method: 'GET', path: '/repairs', tags: t })
|
|
193
|
+
.input(
|
|
194
|
+
ws.extend({
|
|
195
|
+
...PageInput.shape,
|
|
196
|
+
assetId: z.uuid().optional(),
|
|
197
|
+
/** `true` for still away, `false` for finished, absent for both. */
|
|
198
|
+
open: z.boolean().optional(),
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
.output(page(RepairListItem)),
|
|
202
|
+
/**
|
|
203
|
+
* Send it away. Refuses when the item is already at a repairer — one open repair per asset, and
|
|
204
|
+
* `inventory_repairs_one_open_uq` is what makes that true in the database rather than merely
|
|
205
|
+
* likely in the service.
|
|
206
|
+
*
|
|
207
|
+
* `sentOn` is optional and defaults to today **on the server**: a browser clock is not a fact
|
|
208
|
+
* this module is willing to record, and the same reasoning already keeps asset tags server-side.
|
|
209
|
+
*/
|
|
210
|
+
create: baseContract
|
|
211
|
+
.route({ method: 'POST', path: '/assets/{assetId}/repairs', tags: t })
|
|
212
|
+
.input(ws.extend({ assetId: z.uuid(), ...RepairInput.shape }))
|
|
213
|
+
.output(RepairResult),
|
|
214
|
+
/**
|
|
215
|
+
* Correct what was recorded — a vendor, a cost that arrived with the invoice a week later.
|
|
216
|
+
*
|
|
217
|
+
* Deliberately cannot set `returnedOn`: that one column decides whether the asset reads as
|
|
218
|
+
* `under_repair`, so exactly one procedure moves it and the derived status has one door rather
|
|
219
|
+
* than two.
|
|
220
|
+
*/
|
|
221
|
+
update: baseContract
|
|
222
|
+
.route({ method: 'PATCH', path: '/repairs/{repairId}', tags: t })
|
|
223
|
+
.input(ws.extend({ repairId: z.uuid(), ...RepairPatchInput.shape }))
|
|
224
|
+
.output(RepairResult),
|
|
225
|
+
/**
|
|
226
|
+
* It came back. Closes the repair and puts the asset back to `assigned` if somebody still holds
|
|
227
|
+
* it, or `in_stock` if nobody does — never blindly to `in_stock`, which would quietly release
|
|
228
|
+
* whoever is answerable for it.
|
|
229
|
+
*
|
|
230
|
+
* Takes the cost, because that is when the invoice usually arrives.
|
|
231
|
+
*/
|
|
232
|
+
complete: baseContract
|
|
233
|
+
.route({ method: 'POST', path: '/repairs/{repairId}/complete', tags: t })
|
|
234
|
+
.input(
|
|
235
|
+
ws.extend({
|
|
236
|
+
repairId: z.uuid(),
|
|
237
|
+
returnedOn: z.iso.date().optional(),
|
|
238
|
+
costMinor: z.number().int().min(0).nullish(),
|
|
239
|
+
currency: z.string().length(3).nullish(),
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
.output(RepairResult),
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Receipts, warranties, manuals — and the asset's photo, which is an `Asset` field rather than one
|
|
247
|
+
* of these.
|
|
248
|
+
*
|
|
249
|
+
* **Behind the `attachments` capability**, so these answer 404 in a workspace that has it off.
|
|
250
|
+
*
|
|
251
|
+
* **A module does not upload.** The browser sends the bytes to core's file service and hands this
|
|
252
|
+
* module the id core gave it; `add` records that this asset has that file. Nothing here streams,
|
|
253
|
+
* signs or stores anything, and `remove` detaches rather than deleting core's file — the same
|
|
254
|
+
* bytes may be attached elsewhere, and a module has no standing to destroy another module's row.
|
|
255
|
+
*/
|
|
256
|
+
attachments: {
|
|
257
|
+
/**
|
|
258
|
+
* Every file on one asset, its repairs' included, each carrying its own `repairId`.
|
|
259
|
+
*
|
|
260
|
+
* Not paged and not filtered by repair: one asset's files are bounded and entirely loaded, so
|
|
261
|
+
* the panel groups them in the browser. That is the one case where filtering client-side is
|
|
262
|
+
* right, and it is the opposite of what `assets.list` may do.
|
|
263
|
+
*/
|
|
264
|
+
list: baseContract
|
|
265
|
+
.route({ method: 'GET', path: '/assets/{assetId}/attachments', tags: t })
|
|
266
|
+
.input(ws.extend({ assetId: z.uuid() }))
|
|
267
|
+
.output(z.array(Attachment)),
|
|
268
|
+
/** Attach files core already holds. `repairId` files them under one repair instead of the asset. */
|
|
269
|
+
add: baseContract
|
|
270
|
+
.route({ method: 'POST', path: '/assets/{assetId}/attachments', tags: t })
|
|
271
|
+
.input(
|
|
272
|
+
ws.extend({
|
|
273
|
+
assetId: z.uuid(),
|
|
274
|
+
fileIds: z.array(z.uuid()).min(1).max(20),
|
|
275
|
+
repairId: z.uuid().nullish(),
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
.output(z.array(Attachment)),
|
|
279
|
+
/** Answers with the id it detached, so a client can drop exactly that row without re-reading. */
|
|
280
|
+
remove: baseContract
|
|
281
|
+
.route({ method: 'DELETE', path: '/attachments/{attachmentId}', tags: t })
|
|
282
|
+
.input(ws.extend({ attachmentId: z.uuid() }))
|
|
283
|
+
.output(z.object({ id: z.uuid() })),
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The register in numbers.
|
|
288
|
+
*
|
|
289
|
+
* One procedure rather than a `total` on `assets.list`, because they answer different questions:
|
|
290
|
+
* a list's total describes the filter you asked for, and this describes the workspace. The assets
|
|
291
|
+
* page needs both — "showing 50 of 214" is two numbers from two places.
|
|
292
|
+
*
|
|
293
|
+
* Not behind a capability: it counts assets, which is `core`. `outForRepair` comes back null
|
|
294
|
+
* rather than 0 where `repairs` is off — see `InventoryStats`.
|
|
295
|
+
*/
|
|
296
|
+
stats: {
|
|
297
|
+
summary: baseContract.route({ method: 'GET', path: '/stats', tags: t }).input(ws).output(InventoryStats),
|
|
44
298
|
},
|
|
45
299
|
}
|
|
46
300
|
export type InventoryContract = typeof inventoryContract
|