@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/settings.ts
CHANGED
|
@@ -22,9 +22,29 @@ export const InventorySettings = z.object({
|
|
|
22
22
|
*/
|
|
23
23
|
assetCodePrefix: z.string().max(8).default('INV-'),
|
|
24
24
|
assetCodePad: z.number().int().min(1).max(10).default(4),
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
/**
|
|
26
|
+
* How far ahead of a warranty running out somebody is told.
|
|
27
|
+
*
|
|
28
|
+
* This spent a release describing a sweep that did not exist — no job, no subscription, nothing
|
|
29
|
+
* reading the number — and was taken out for it. It is back because `warranty-sweep` reads it
|
|
30
|
+
* every morning: a live asset whose `warranty_until` falls inside this many days earns exactly
|
|
31
|
+
* one notice, marked on the row so the next morning does not send it again.
|
|
32
|
+
*
|
|
33
|
+
* A month by default, which is about how long it takes to decide whether to extend a warranty or
|
|
34
|
+
* budget for a replacement. Capped at a year: further out than that is not a notice, it is a
|
|
35
|
+
* report.
|
|
36
|
+
*/
|
|
37
|
+
warrantyNoticeDays: z.number().int().min(1).max(365).default(30),
|
|
38
|
+
/**
|
|
39
|
+
* How long an item may be at a repairer before somebody is asked to chase it.
|
|
40
|
+
*
|
|
41
|
+
* Read by `repair-overdue`, which tells the person who logged the repair — and whoever is still
|
|
42
|
+
* holding the item — once, and then leaves them alone. Two weeks by default: long enough that an
|
|
43
|
+
* ordinary screen replacement never trips it, short enough that a laptop nobody chased does.
|
|
44
|
+
*
|
|
45
|
+
* A workspace with the `repairs` capability off never meets this: the sweep asks first, and a
|
|
46
|
+
* setting whose feature is switched off changes nothing rather than firing quietly.
|
|
47
|
+
*/
|
|
48
|
+
repairOverdueDays: z.number().int().min(1).max(365).default(14),
|
|
29
49
|
})
|
|
30
50
|
export type InventorySettings = z.infer<typeof InventorySettings>
|
package/src/module.test.ts
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Add your module's real tests next to it; this one keeps working as the contract grows.
|
|
13
13
|
*/
|
|
14
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
14
17
|
import type { Kernel } from '@kernhq/kernel'
|
|
15
18
|
import { describe, expect, it } from 'vitest'
|
|
16
19
|
import {
|
|
@@ -122,6 +125,40 @@ async function reachedFor(middleware: unknown): Promise<Reached> {
|
|
|
122
125
|
|
|
123
126
|
const chainOf = (name: string): unknown[] => implemented[name]?.['~orpc'].middlewares ?? []
|
|
124
127
|
|
|
128
|
+
/** What every implemented procedure's middleware chain reached for, resolved once. */
|
|
129
|
+
async function resolveChains(): Promise<Record<string, Reached[]>> {
|
|
130
|
+
const entries = await Promise.all(
|
|
131
|
+
Object.keys(implemented).map(
|
|
132
|
+
async (name) => [name, await Promise.all(chainOf(name).map(reachedFor))] as const,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
return Object.fromEntries(entries)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Which of `names` the router does **not** put behind `capability`, in the right place.
|
|
140
|
+
*
|
|
141
|
+
* A pure function of the chains rather than an assertion inside a loop, so the check can be aimed at
|
|
142
|
+
* a chain this file builds by hand — which is the only way to show that it rejects what it claims
|
|
143
|
+
* to. An assertion that has never been seen to fail is a comment with a green tick beside it.
|
|
144
|
+
*
|
|
145
|
+
* Two ways to fail, and both matter: no capability gate at all, and a gate that sits *after* the
|
|
146
|
+
* permission check. The order is the whole point of `workspaceScoped` → `requiresCapability` →
|
|
147
|
+
* `requires`: a workspace with the module off must be refused before anything reveals which
|
|
148
|
+
* capabilities it would have had, and a workspace with the capability off must get 404 rather than
|
|
149
|
+
* the 403 a permission check would produce first.
|
|
150
|
+
*/
|
|
151
|
+
function ungated(capability: string, names: readonly string[], chains: Record<string, Reached[]>): string[] {
|
|
152
|
+
return names.filter((name) => {
|
|
153
|
+
const chain = chains[name] ?? []
|
|
154
|
+
const gate = chain.findIndex((r) => r.capability === `${MODULE_ID}.${capability}`)
|
|
155
|
+
const permission = chain.findIndex((r) => r.permission !== undefined)
|
|
156
|
+
// Index 0 is `workspaceScoped`'s place, so a gate there is a gate that displaced it.
|
|
157
|
+
if (gate < 1) return true
|
|
158
|
+
return permission !== -1 && gate > permission
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
describe('every procedure is authorised', () => {
|
|
126
163
|
const declaredKeys = new Set(inventoryPermissions.map((p) => p.key))
|
|
127
164
|
|
|
@@ -168,15 +205,89 @@ describe('the module declares what it uses', () => {
|
|
|
168
205
|
* Capabilities, which are the one thing here that cannot be seen by reading a handler.
|
|
169
206
|
*
|
|
170
207
|
* A missing `requiresCapability` is invisible: the procedure compiles, every other test passes, and
|
|
171
|
-
* the only symptom is a workspace successfully calling a feature it switched off.
|
|
172
|
-
* declared as data in the contract and checked against the router here.
|
|
208
|
+
* the only symptom is a workspace successfully calling a feature it switched off.
|
|
173
209
|
*
|
|
174
|
-
* The
|
|
175
|
-
*
|
|
210
|
+
* **The expectation is derived from the contract, not opted into.** This used to check only the
|
|
211
|
+
* procedures named in `inventoryCapabilityProcedures` — so the map was both the claim and the
|
|
212
|
+
* evidence for it, and the regression it exists to catch walked straight past: add
|
|
213
|
+
* `repairs.cancel` to the contract, forget the middleware, forget the map, and every test here is
|
|
214
|
+
* green while a workspace with repairs switched off can call it. A list that has to be updated by
|
|
215
|
+
* the same person who forgot the thing it is guarding is not a guard.
|
|
216
|
+
*
|
|
217
|
+
* So the rule is read off the module's own declarations instead: **a switchable capability owns the
|
|
218
|
+
* router group named after it**, and every procedure in that group is gated on it. `repairs.*`
|
|
219
|
+
* belongs to `repairs`; `attachments.*` belongs to `attachments`. Adding a procedure to either group
|
|
220
|
+
* fails this file until it carries the middleware, whatever the map says. The map is still checked —
|
|
221
|
+
* it is what the client reads — but now against the group rather than against itself.
|
|
176
222
|
*/
|
|
177
223
|
describe('capabilities are enforced where they are declared', () => {
|
|
178
224
|
const gated = new Set(Object.values(inventoryCapabilityProcedures).flat())
|
|
179
225
|
|
|
226
|
+
/** The capabilities a workspace can actually switch: `core` is `required` and owns nothing. */
|
|
227
|
+
const switchable = inventoryCapabilities.filter((c) => !c.required).map((c) => c.id)
|
|
228
|
+
|
|
229
|
+
/** The contract's procedures under a capability's own name — the derived expectation. */
|
|
230
|
+
const groupOf = (capability: string) =>
|
|
231
|
+
Object.keys(declared)
|
|
232
|
+
.filter((name) => name.startsWith(`${capability}.`))
|
|
233
|
+
.sort()
|
|
234
|
+
|
|
235
|
+
it('gates every procedure in a switchable capability’s own group, named in the map or not', async () => {
|
|
236
|
+
const chains = await resolveChains()
|
|
237
|
+
for (const capability of switchable) {
|
|
238
|
+
expect(
|
|
239
|
+
groupOf(capability).length,
|
|
240
|
+
`${capability}: declared as a switch with no procedures behind it — a switch that changes nothing teaches an administrator that the switchboard does not mean anything`,
|
|
241
|
+
).toBeGreaterThan(0)
|
|
242
|
+
expect(
|
|
243
|
+
ungated(capability, groupOf(capability), chains),
|
|
244
|
+
`these need requiresCapability('${MODULE_ID}', '${capability}') between the workspace gate and the permission check`,
|
|
245
|
+
).toEqual([])
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('names that whole group in the map, so the client and the router cannot drift', () => {
|
|
250
|
+
// The client reads this map to decide what to hide; the router decides what to answer. A
|
|
251
|
+
// procedure gated in one and not the other is a tab that is there and 404s, or hidden and works.
|
|
252
|
+
for (const capability of switchable)
|
|
253
|
+
expect([...(inventoryCapabilityProcedures[capability] ?? [])].sort()).toEqual(groupOf(capability))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('rejects an ungated procedure, and one gated in the wrong place', () => {
|
|
257
|
+
/**
|
|
258
|
+
* The assertion aimed at chains built here, because a check nobody has watched fail is a check
|
|
259
|
+
* nobody knows the shape of. Four procedures, one correct and three not, and `ungated` has to
|
|
260
|
+
* name exactly the three.
|
|
261
|
+
*/
|
|
262
|
+
const chains: Record<string, Reached[]> = {
|
|
263
|
+
'repairs.list': [
|
|
264
|
+
{ module: MODULE_ID },
|
|
265
|
+
{ capability: `${MODULE_ID}.repairs` },
|
|
266
|
+
{ permission: 'inventory.asset.view' },
|
|
267
|
+
],
|
|
268
|
+
// The regression this file exists for: somebody added a procedure and no middleware.
|
|
269
|
+
'repairs.create': [{ module: MODULE_ID }, { permission: 'inventory.repair.manage' }],
|
|
270
|
+
// Gated, but after the permission — so a workspace with the capability off gets 403 from the
|
|
271
|
+
// permission check before the 404 that is the honest answer.
|
|
272
|
+
'repairs.update': [
|
|
273
|
+
{ module: MODULE_ID },
|
|
274
|
+
{ permission: 'inventory.repair.manage' },
|
|
275
|
+
{ capability: `${MODULE_ID}.repairs` },
|
|
276
|
+
],
|
|
277
|
+
// Gated on a different capability, which the identity check is what catches.
|
|
278
|
+
'repairs.complete': [
|
|
279
|
+
{ module: MODULE_ID },
|
|
280
|
+
{ capability: `${MODULE_ID}.attachments` },
|
|
281
|
+
{ permission: 'inventory.repair.manage' },
|
|
282
|
+
],
|
|
283
|
+
}
|
|
284
|
+
expect(ungated('repairs', Object.keys(chains), chains)).toEqual([
|
|
285
|
+
'repairs.create',
|
|
286
|
+
'repairs.update',
|
|
287
|
+
'repairs.complete',
|
|
288
|
+
])
|
|
289
|
+
})
|
|
290
|
+
|
|
180
291
|
it('names only capabilities the module actually declares', () => {
|
|
181
292
|
const declaredIds = new Set(inventoryCapabilities.map((c) => c.id))
|
|
182
293
|
for (const id of Object.keys(inventoryCapabilityProcedures))
|
|
@@ -210,3 +321,82 @@ describe('capabilities are enforced where they are declared', () => {
|
|
|
210
321
|
expect(inventoryCapabilities.find((c) => c.id === 'core')?.required).toBe(true)
|
|
211
322
|
})
|
|
212
323
|
})
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The manifest's other declarations — the ones that are handlers rather than middleware.
|
|
327
|
+
*
|
|
328
|
+
* Every entry here is a promise to the rest of the product, and each has exactly one shape of lie
|
|
329
|
+
* available to it: an object type with no resolver renders a link to nothing, a search indexer for a
|
|
330
|
+
* type nobody declared indexes documents nothing can open, a notification type nothing sends is a
|
|
331
|
+
* row in everybody's preferences that changes nothing, and a job with no cron never runs. All four
|
|
332
|
+
* compile. `objectTypes` was removed from this module in 0.2.0 for precisely the first of them.
|
|
333
|
+
*/
|
|
334
|
+
describe('the platform surfaces the module declares', () => {
|
|
335
|
+
const objectTypes = inventoryModule.definition.objectTypes ?? []
|
|
336
|
+
|
|
337
|
+
it('backs every object type with a resolver and an indexer', () => {
|
|
338
|
+
const resolved = new Set((inventoryModule.resolvers ?? []).map((r) => r.type))
|
|
339
|
+
const indexed = new Set((inventoryModule.search ?? []).flatMap((s) => s.types))
|
|
340
|
+
for (const { type } of objectTypes)
|
|
341
|
+
expect({ type, resolved: resolved.has(type), indexed: indexed.has(type) }).toEqual({
|
|
342
|
+
type,
|
|
343
|
+
resolved: true,
|
|
344
|
+
indexed: true,
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('declares every type it resolves or indexes, so nothing points at an undeclared noun', () => {
|
|
349
|
+
const declared = new Set(objectTypes.map((o) => o.type))
|
|
350
|
+
for (const resolver of inventoryModule.resolvers ?? [])
|
|
351
|
+
expect({ type: resolver.type, declared: declared.has(resolver.type) }).toEqual({
|
|
352
|
+
type: resolver.type,
|
|
353
|
+
declared: true,
|
|
354
|
+
})
|
|
355
|
+
for (const type of (inventoryModule.search ?? []).flatMap((s) => s.types))
|
|
356
|
+
expect({ type, declared: declared.has(type) }).toEqual({ type, declared: true })
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('offers a full reindex as well as a single load', () => {
|
|
360
|
+
// Without `scan`, `core.search.reindex` walks this module and finds nothing to do — silently,
|
|
361
|
+
// which is the worst way for a repair mechanism to be missing.
|
|
362
|
+
for (const indexer of inventoryModule.search ?? [])
|
|
363
|
+
expect({ types: indexer.types, scan: typeof indexer.scan }).toEqual({
|
|
364
|
+
types: indexer.types,
|
|
365
|
+
scan: 'function',
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('gives every scheduled job a name of its own and a schedule', () => {
|
|
370
|
+
const jobs = inventoryModule.jobs ?? []
|
|
371
|
+
expect(jobs.length, 'a module with sweeps declares them').toBeGreaterThan(0)
|
|
372
|
+
expect(new Set(jobs.map((j) => j.name)).size, 'two jobs sharing a name share a queue').toBe(jobs.length)
|
|
373
|
+
// Every job this module has is a sweep; one without a cron would be a queue nothing enqueues.
|
|
374
|
+
for (const job of jobs)
|
|
375
|
+
expect({ job: job.name, cron: typeof job.cron }).toEqual({ job: job.name, cron: 'string' })
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('names every notification type under its own module id', () => {
|
|
379
|
+
for (const type of inventoryModule.definition.notificationTypes ?? [])
|
|
380
|
+
expect(type.type.startsWith(`${MODULE_ID}.`), type.type).toBe(true)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A notification type nothing sends is the same lie as a permission nothing checks — and unlike a
|
|
385
|
+
* permission there is no middleware to inspect, so this reads the server for the string.
|
|
386
|
+
*
|
|
387
|
+
* Crude on purpose, and honest about it: it proves the *literal* appears in a file that sends
|
|
388
|
+
* notifications, not that the branch is reachable. A type whose key was built by concatenation
|
|
389
|
+
* would slip past it. Every one of this module's four is a literal at its call site, and keeping
|
|
390
|
+
* them that way is the point — a notification type assembled at runtime is one nobody can grep
|
|
391
|
+
* for either.
|
|
392
|
+
*/
|
|
393
|
+
it('has a sender in the server for every notification type it declares', () => {
|
|
394
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
395
|
+
const sources = readdirSync(join(here, 'server'), { recursive: true, encoding: 'utf8' })
|
|
396
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
|
|
397
|
+
.map((f) => readFileSync(join(here, 'server', f), 'utf8'))
|
|
398
|
+
.join('\n')
|
|
399
|
+
for (const { type } of inventoryModule.definition.notificationTypes ?? [])
|
|
400
|
+
expect({ type, sent: sources.includes(`'${type}'`) }).toEqual({ type, sent: true })
|
|
401
|
+
})
|
|
402
|
+
})
|
package/src/server/index.ts
CHANGED
|
@@ -1,15 +1,54 @@
|
|
|
1
1
|
import { dirname, join } from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import type { core, Principal } from '@kernhq/contracts'
|
|
4
|
+
import { WorkspaceId } from '@kernhq/contracts'
|
|
5
|
+
import { KernError, type Kernel } from '@kernhq/kernel'
|
|
6
|
+
import { and, eq, inArray } from 'drizzle-orm'
|
|
7
|
+
import { z } from 'zod'
|
|
3
8
|
import {
|
|
9
|
+
Asset,
|
|
4
10
|
InventorySettings,
|
|
5
11
|
inventoryCapabilities,
|
|
6
12
|
inventoryContract,
|
|
7
13
|
inventoryEvents,
|
|
14
|
+
inventoryNotificationTypes,
|
|
8
15
|
inventoryPermissions,
|
|
9
16
|
MODULE_ID,
|
|
10
17
|
} from '../contract/index.js'
|
|
18
|
+
import { inventoryJobs } from './jobs.js'
|
|
11
19
|
import { defineModule, defineServerModule, inventoryRouter, packageVersion } from './router.js'
|
|
12
|
-
import { schema } from './schema.js'
|
|
20
|
+
import { assets, categories, schema, workspaces } from './schema.js'
|
|
21
|
+
import { toAsset } from './services/assets.js'
|
|
22
|
+
import { inventoryServices } from './services/index.js'
|
|
23
|
+
import { ASSET_ICON, assetUrl } from './services/search.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The categories a workspace starts with.
|
|
27
|
+
*
|
|
28
|
+
* Five, because an empty picker on the asset form is a question a new workspace cannot answer yet —
|
|
29
|
+
* "what do you file a laptop under" has an obvious answer and asking it costs somebody a trip to a
|
|
30
|
+
* settings page before they can add their first asset. Ordered rather than alphabetical: this is the
|
|
31
|
+
* order somebody scanning a list expects, and every one of them is renamable, archivable and
|
|
32
|
+
* ignorable.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_CATEGORIES = ['Laptops', 'Phones', 'Monitors', 'Furniture', 'Vehicles'] as const
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `procedures` below are this module's service-to-service surface, reachable only over
|
|
38
|
+
* `kernel.call`. They deliberately run with elevated access — no workspace membership is checked,
|
|
39
|
+
* because the caller is another service and has none — so they must never be callable by an end
|
|
40
|
+
* user: anything a person does goes through the oRPC router and its permission middleware.
|
|
41
|
+
*
|
|
42
|
+
* Copied deliberately from `module-tracker`, down to the shape of the check, so that the one line
|
|
43
|
+
* standing between "an internal read" and "a permission-free read of any workspace's register"
|
|
44
|
+
* looks the same in every module somebody audits.
|
|
45
|
+
*/
|
|
46
|
+
function requireService(principal: Principal): void {
|
|
47
|
+
if (principal.kind !== 'service' && !principal.instanceAdmin) throw KernError.forbidden()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** HR's statuses that mean somebody is on their way out. Their own enum; read, never guessed. */
|
|
51
|
+
const LEAVING_STATUSES = new Set(['offboarding', 'terminated'])
|
|
13
52
|
|
|
14
53
|
export const inventoryModule = defineServerModule({
|
|
15
54
|
definition: defineModule({
|
|
@@ -22,15 +61,283 @@ export const inventoryModule = defineServerModule({
|
|
|
22
61
|
permissions: inventoryPermissions,
|
|
23
62
|
capabilities: inventoryCapabilities,
|
|
24
63
|
events: inventoryEvents,
|
|
64
|
+
notificationTypes: inventoryNotificationTypes,
|
|
25
65
|
settings: InventorySettings,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
66
|
+
/**
|
|
67
|
+
* `inventory:asset:<id>` is a thing the rest of the product may point at.
|
|
68
|
+
*
|
|
69
|
+
* This came *off* the manifest in 0.2.0 and is back now, and the difference is the two handlers
|
|
70
|
+
* under it: `resolvers` turns the reference into a title, a URL and an icon, and `search` puts
|
|
71
|
+
* the row where somebody can find it in the first place. A declared type with neither renders a
|
|
72
|
+
* link to nothing, which reads to a person as a broken product rather than as a feature that
|
|
73
|
+
* has not shipped — the same lie as a permission nothing checks.
|
|
74
|
+
*
|
|
75
|
+
* `channelable: false`, unlike a tracker issue: a channel per laptop is not a thing anybody
|
|
76
|
+
* wants, and the conversation about an asset belongs wherever the work does.
|
|
77
|
+
*/
|
|
78
|
+
objectTypes: [{ type: 'asset', label: 'Asset', icon: ASSET_ICON, channelable: false }],
|
|
29
79
|
}),
|
|
30
80
|
/** Attached so the developer panel can check the router against what was promised. */
|
|
31
81
|
contract: inventoryContract,
|
|
32
82
|
schema,
|
|
33
83
|
migrationsFolder: join(dirname(fileURLToPath(import.meta.url)), '../../migrations'),
|
|
34
84
|
router: inventoryRouter,
|
|
85
|
+
|
|
86
|
+
jobs: inventoryJobs(),
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `inventory:asset:<id>` rendered wherever it is mentioned — a chat message, a tracker issue, a
|
|
90
|
+
* notification.
|
|
91
|
+
*
|
|
92
|
+
* The permission is checked **once for the batch**, not per id: `inventory.asset.view` is a
|
|
93
|
+
* workspace permission and every asset in a workspace has the same audience, so there is nothing
|
|
94
|
+
* per-row to decide. Somebody without it gets nulls, which the caller renders as plain text
|
|
95
|
+
* rather than as a link to a title they were not entitled to read — a resolver is a read like any
|
|
96
|
+
* other, and the fact that it is called by another module does not make it exempt.
|
|
97
|
+
*/
|
|
98
|
+
resolvers: [
|
|
99
|
+
{
|
|
100
|
+
type: 'asset',
|
|
101
|
+
resolve: async (workspaceId, ids, principal, kernel) => {
|
|
102
|
+
const may = await kernel.authz
|
|
103
|
+
.can(principal, 'inventory.asset.view', { kind: 'workspace', workspaceId })
|
|
104
|
+
.catch(() => false)
|
|
105
|
+
if (!may) return ids.map(() => null)
|
|
106
|
+
return kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
107
|
+
const rows = await tx
|
|
108
|
+
.select({
|
|
109
|
+
id: assets.id,
|
|
110
|
+
code: assets.code,
|
|
111
|
+
name: assets.name,
|
|
112
|
+
status: assets.status,
|
|
113
|
+
archivedAt: assets.archivedAt,
|
|
114
|
+
})
|
|
115
|
+
.from(assets)
|
|
116
|
+
.where(and(eq(assets.workspaceId, workspaceId), inArray(assets.id, ids)))
|
|
117
|
+
const byId = new Map(rows.map((row) => [row.id, row]))
|
|
118
|
+
return ids.map((id) => {
|
|
119
|
+
const row = byId.get(id)
|
|
120
|
+
if (!row) return null
|
|
121
|
+
return {
|
|
122
|
+
id,
|
|
123
|
+
// The tag first, because that is what is printed on the sticker somebody is holding.
|
|
124
|
+
title: `${row.code} ${row.name}`,
|
|
125
|
+
url: assetUrl(id),
|
|
126
|
+
icon: ASSET_ICON,
|
|
127
|
+
/**
|
|
128
|
+
* An archived asset still resolves — a link written last year must not turn into
|
|
129
|
+
* nothing — and says so, where the search index drops it. A reference is a fact about
|
|
130
|
+
* the past; a search hit is an offer to go somewhere now.
|
|
131
|
+
*
|
|
132
|
+
* The value is the module's own status vocabulary, not a translated label, and
|
|
133
|
+
* `archived` is lowercase for the same reason `in_stock` is: a resolver runs in a
|
|
134
|
+
* service with no locale to render into, so it hands over the machine value and the
|
|
135
|
+
* caller decides. Every module's resolver does this today — `module-tracker` returns
|
|
136
|
+
* a raw `statusId`. Translating it needs the platform to give a resolver the reader's
|
|
137
|
+
* locale, which it does not, and inventing an English sentence here would look
|
|
138
|
+
* finished while being wrong in four of the five languages this module ships.
|
|
139
|
+
*/
|
|
140
|
+
subtitle: row.archivedAt ? 'archived' : row.status,
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The register in the workspace-wide search index, so an asset tag read off a sticker finds the
|
|
150
|
+
* item from the command palette.
|
|
151
|
+
*
|
|
152
|
+
* Both halves are `SearchService`'s, so the document a mutation writes and the document a full
|
|
153
|
+
* reindex writes cannot drift: `load` answers `null` for an archived or missing row, which is what
|
|
154
|
+
* takes it back out, and `scan` pages by keyset for `core.search.reindex`.
|
|
155
|
+
*/
|
|
156
|
+
search: [
|
|
157
|
+
{
|
|
158
|
+
types: ['asset'],
|
|
159
|
+
load: (workspaceId, id, kernel): Promise<core.SearchDocument | null> =>
|
|
160
|
+
inventoryServices(kernel).search.load(workspaceId, id),
|
|
161
|
+
scan: (workspaceId, kernel) => inventoryServices(kernel).search.scan(workspaceId),
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
|
|
165
|
+
/** Answers other modules and services may ask, without reaching into `mod_inventory`. */
|
|
166
|
+
procedures: {
|
|
167
|
+
/**
|
|
168
|
+
* One asset, for whatever holds an id and needs to say what it is — an automation, a report, a
|
|
169
|
+
* module that recorded an `ObjectRef` and now has to act on it.
|
|
170
|
+
*/
|
|
171
|
+
'asset.byId': {
|
|
172
|
+
input: z.object({ workspaceId: WorkspaceId, assetId: z.uuid() }),
|
|
173
|
+
output: Asset,
|
|
174
|
+
handler: async (input, { kernel, principal }) => {
|
|
175
|
+
requireService(principal)
|
|
176
|
+
return kernel.database.withWorkspace(input.workspaceId, async (tx) =>
|
|
177
|
+
toAsset(await inventoryServices(kernel).assets.get(tx, input.workspaceId, input.assetId)),
|
|
178
|
+
)
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
/**
|
|
182
|
+
* What one person is holding — the offboarding question, asked from outside.
|
|
183
|
+
*
|
|
184
|
+
* This module answers it for itself in the subscription below; the procedure exists because it
|
|
185
|
+
* is the question *other* modules ask about a person, and the alternative is each of them
|
|
186
|
+
* learning the shape of `mod_inventory.assets`. Answered through `AssetService.list` with the
|
|
187
|
+
* custodian filter fixed, which is the same one query path `custody.byUser` uses rather than a
|
|
188
|
+
* second one to keep in step.
|
|
189
|
+
*/
|
|
190
|
+
'assets.byCustodian': {
|
|
191
|
+
input: z.object({
|
|
192
|
+
workspaceId: WorkspaceId,
|
|
193
|
+
userId: z.uuid(),
|
|
194
|
+
limit: z.number().int().min(1).max(200).default(100),
|
|
195
|
+
cursor: z.string().max(500).optional(),
|
|
196
|
+
}),
|
|
197
|
+
output: z.object({ items: z.array(Asset), nextCursor: z.string().nullable() }),
|
|
198
|
+
handler: async (input, { kernel, principal }) => {
|
|
199
|
+
requireService(principal)
|
|
200
|
+
return kernel.database.withWorkspace(input.workspaceId, (tx) =>
|
|
201
|
+
inventoryServices(kernel).assets.list(tx, {
|
|
202
|
+
workspaceId: input.workspaceId,
|
|
203
|
+
limit: input.limit,
|
|
204
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
205
|
+
custodianUserId: input.userId,
|
|
206
|
+
archived: false,
|
|
207
|
+
sort: 'recent',
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
subscriptions: {
|
|
215
|
+
/**
|
|
216
|
+
* Register the workspace so the sweeps can find it.
|
|
217
|
+
*
|
|
218
|
+
* Both halves are needed and neither is redundant: `onWorkspaceEnabled` covers somebody
|
|
219
|
+
* switching the module on for an existing workspace, and this covers a workspace created while
|
|
220
|
+
* the module is already on by default. A workspace registered twice is one row either way.
|
|
221
|
+
*/
|
|
222
|
+
'core.workspace.created': async (event, kernel) => {
|
|
223
|
+
const { workspaceId } = event.payload as { workspaceId: string }
|
|
224
|
+
await registerWorkspace(kernel, workspaceId)
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A member removed from the workspace does **not** silently vanish from the register.
|
|
229
|
+
*
|
|
230
|
+
* The obvious implementation — clear `custodian_user_id` for everything they held — is the one
|
|
231
|
+
* this module refuses to write, and the reason is the same one `OffboardingService` gives:
|
|
232
|
+
* custody is an effective-dated record of who was answerable for what, and an item does not
|
|
233
|
+
* come back because an account was closed. Clearing the column would say the laptop is in stock
|
|
234
|
+
* while it is in somebody's bag, and it would do it *without a custody period closing*, so the
|
|
235
|
+
* timeline would show a handover that never ended and a return that never happened. `assets
|
|
236
|
+
* .archive` already refuses to archive a held item for exactly this reason.
|
|
237
|
+
*
|
|
238
|
+
* So: nothing moves, and the people who can take the item back are told there is something to
|
|
239
|
+
* collect. The custodian field keeps naming somebody who is no longer a member, and every
|
|
240
|
+
* screen in this module already renders an unresolvable id as "a former member" rather than as
|
|
241
|
+
* a uuid — which is the honest thing for it to say.
|
|
242
|
+
*/
|
|
243
|
+
'core.member.removed': async (event, kernel) => {
|
|
244
|
+
const { workspaceId, userId } = event.payload as { workspaceId: string; userId: string }
|
|
245
|
+
await inventoryServices(kernel).offboarding.raise(workspaceId, userId, 'removed')
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* HR says somebody is on their way out.
|
|
250
|
+
*
|
|
251
|
+
* **Inert without HR, in three independent ways**, because this module installs, boots and works
|
|
252
|
+
* in a workspace that has never had HR and there is no `dependsOn: ['hr']` on the manifest:
|
|
253
|
+
*
|
|
254
|
+
* 1. the event only exists if HR is running — a subscription to a pattern nothing publishes is
|
|
255
|
+
* a subscription that never fires;
|
|
256
|
+
* 2. `OffboardingService.raise` asks whether *Inventory* is switched on for that workspace
|
|
257
|
+
* before it does anything, because an event bus is instance-wide and one workspace having HR
|
|
258
|
+
* says nothing about another;
|
|
259
|
+
* 3. the one thing it needs from HR — the account behind a `personId` — is a `kernel.call`, and
|
|
260
|
+
* a call to a procedure nothing hosts throws, which is caught here and logged as nothing to
|
|
261
|
+
* do. A module never imports another module.
|
|
262
|
+
*
|
|
263
|
+
* `offboarding` matters more than `terminated`: somebody in their notice period is still at
|
|
264
|
+
* their desk, which is when a list of things to collect is worth having. `terminated` is the
|
|
265
|
+
* backstop for a workspace that never uses the intermediate status.
|
|
266
|
+
*/
|
|
267
|
+
'hr.person.status_changed': async (event, kernel) => {
|
|
268
|
+
const payload = event.payload as { workspaceId: string; personId: string; to: string }
|
|
269
|
+
if (!LEAVING_STATUSES.has(payload.to)) return
|
|
270
|
+
const person = await kernel
|
|
271
|
+
.call<{ userId: string | null } | null>('hr.person.get', {
|
|
272
|
+
workspaceId: payload.workspaceId,
|
|
273
|
+
personId: payload.personId,
|
|
274
|
+
})
|
|
275
|
+
.catch((err: unknown) => {
|
|
276
|
+
/**
|
|
277
|
+
* `UNAVAILABLE` is what the broker answers for a procedure nothing hosts, which is the
|
|
278
|
+
* ordinary state of an instance without HR and not worth a line in a log. Anything else
|
|
279
|
+
* is HR being *present and failing*, and swallowing the two the same way would mean the
|
|
280
|
+
* one case somebody should fix looks exactly like the case they should ignore.
|
|
281
|
+
*/
|
|
282
|
+
if ((err as { code?: string }).code !== 'UNAVAILABLE')
|
|
283
|
+
kernel.log.warn(
|
|
284
|
+
{ err: err instanceof Error ? err.message : String(err), module: MODULE_ID },
|
|
285
|
+
'inventory: could not ask People who is behind a person leaving',
|
|
286
|
+
)
|
|
287
|
+
return null
|
|
288
|
+
})
|
|
289
|
+
// No account: `user_id` is legitimately null for somebody who never had a Kern account, and
|
|
290
|
+
// for somebody core has already removed — that path is the subscription above.
|
|
291
|
+
if (!person?.userId) return
|
|
292
|
+
await inventoryServices(kernel).offboarding.raise(payload.workspaceId, person.userId, 'leaving')
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A workspace that switches Inventory on gets a filing system and a place in the scheduler.
|
|
298
|
+
*
|
|
299
|
+
* Idempotent, because it runs again every time somebody switches the module off and back on.
|
|
300
|
+
* Neither half may double up:
|
|
301
|
+
*
|
|
302
|
+
* - the registry row is an `on conflict do nothing` on its own primary key;
|
|
303
|
+
* - the categories are seeded **only into a workspace that has none at all**, archived ones
|
|
304
|
+
* included. Keying on the names instead would re-create "Laptops" for a workspace that had
|
|
305
|
+
* renamed it to "Notebooks" — the second toggle would quietly hand somebody a duplicate of
|
|
306
|
+
* their own category under the name they had rejected.
|
|
307
|
+
*/
|
|
308
|
+
onWorkspaceEnabled: async (workspaceId, kernel) => {
|
|
309
|
+
await registerWorkspace(kernel, workspaceId)
|
|
310
|
+
await kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
311
|
+
const [existing] = await tx
|
|
312
|
+
.select({ id: categories.id })
|
|
313
|
+
.from(categories)
|
|
314
|
+
.where(eq(categories.workspaceId, workspaceId))
|
|
315
|
+
.limit(1)
|
|
316
|
+
if (existing) return
|
|
317
|
+
await tx.insert(categories).values(
|
|
318
|
+
DEFAULT_CATEGORIES.map((name, index) => ({
|
|
319
|
+
workspaceId,
|
|
320
|
+
name,
|
|
321
|
+
order: index + 1,
|
|
322
|
+
})),
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
},
|
|
35
326
|
})
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Remember that this workspace exists, so a job woken by a clock can find it.
|
|
330
|
+
*
|
|
331
|
+
* Written inside `withWorkspace` rather than through the unbound handle: the table carries a
|
|
332
|
+
* row-level security policy like every other tenant table, and a bound session satisfies it without
|
|
333
|
+
* needing the connection to be the schema's owner. The enumeration in `jobs.ts` is the one place
|
|
334
|
+
* that cannot do the same, and says so.
|
|
335
|
+
*/
|
|
336
|
+
async function registerWorkspace(kernel: Kernel, workspaceId: string): Promise<void> {
|
|
337
|
+
await kernel.database.withWorkspace(workspaceId, (tx) =>
|
|
338
|
+
tx.insert(workspaces).values({ workspaceId }).onConflictDoNothing({ target: workspaces.workspaceId }),
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
36
342
|
export default inventoryModule
|
|
343
|
+
export type InventoryModule = typeof inventoryModule
|