@kernhq/module-inventory 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -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 +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -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 +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -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 +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -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 +169 -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 +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -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/0008_category_order_unique.sql +71 -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 +49 -0
- package/package.json +2 -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 +395 -0
- package/src/client/errors.ts +229 -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 +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- 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/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -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 +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -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 +337 -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
|
@@ -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,31 @@
|
|
|
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
|
+
MAX_LIVE_CATEGORIES,
|
|
17
|
+
RepairInput,
|
|
18
|
+
RepairListItem,
|
|
19
|
+
RepairPatchInput,
|
|
20
|
+
RepairResult,
|
|
21
|
+
ws,
|
|
22
|
+
} from './models.js'
|
|
4
23
|
|
|
5
24
|
const t = ['inventory'] as const
|
|
6
25
|
|
|
26
|
+
/** A handover note is optional everywhere and shaped the same everywhere. */
|
|
27
|
+
const custodyNote = z.string().max(500).nullish()
|
|
28
|
+
|
|
7
29
|
export const inventoryContract = {
|
|
8
30
|
assets: {
|
|
9
31
|
list: baseContract
|
|
@@ -41,6 +63,267 @@ export const inventoryContract = {
|
|
|
41
63
|
.route({ method: 'POST', path: '/assets/{assetId}/archive', tags: t })
|
|
42
64
|
.input(ws.extend({ assetId: z.uuid(), archived: z.boolean().default(true) }))
|
|
43
65
|
.output(Asset),
|
|
66
|
+
/**
|
|
67
|
+
* The asset's own timeline, newest first.
|
|
68
|
+
*
|
|
69
|
+
* Paged with the same keyset discipline `assets.list` uses and the same cursor codec, because a
|
|
70
|
+
* second bookmark format is a second set of the three bugs the first one had. The bookmark is a
|
|
71
|
+
* row id; the ordering is by id, which for a uuidv7 is the clock and, unlike `created_at`, is
|
|
72
|
+
* unique — two entries written in one transaction (a create and its first custody row) share a
|
|
73
|
+
* timestamp and a page boundary between them would repeat or drop one.
|
|
74
|
+
*
|
|
75
|
+
* Reads on `inventory.asset.view`. See `permissions.ts` for why custody is not gated separately.
|
|
76
|
+
*/
|
|
77
|
+
history: baseContract
|
|
78
|
+
.route({ method: 'GET', path: '/assets/{assetId}/history', tags: t })
|
|
79
|
+
.input(ws.extend({ assetId: z.uuid(), ...PageInput.shape }))
|
|
80
|
+
.output(page(AssetHistoryEntry)),
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Who is holding what.
|
|
85
|
+
*
|
|
86
|
+
* Four verbs rather than one, because "hand it to somebody" and "hand it on to somebody else" are
|
|
87
|
+
* different questions with different failure modes: assigning something already out is a mistake
|
|
88
|
+
* worth refusing, and transferring something nobody holds is a different mistake. One procedure
|
|
89
|
+
* taking `userId | null` would answer both by silently doing whatever the row happened to allow.
|
|
90
|
+
*/
|
|
91
|
+
custody: {
|
|
92
|
+
/** Hand a free item to a member. Refuses if somebody already has it — that is `transfer`. */
|
|
93
|
+
assign: baseContract
|
|
94
|
+
.route({ method: 'POST', path: '/assets/{assetId}/custody', tags: t })
|
|
95
|
+
.input(ws.extend({ assetId: z.uuid(), userId: z.uuid(), note: custodyNote }))
|
|
96
|
+
.output(CustodyResult),
|
|
97
|
+
/** Take it back. Closes the open period and puts the asset back in stock. */
|
|
98
|
+
return: baseContract
|
|
99
|
+
.route({ method: 'POST', path: '/assets/{assetId}/custody/return', tags: t })
|
|
100
|
+
.input(ws.extend({ assetId: z.uuid(), note: custodyNote }))
|
|
101
|
+
.output(CustodyResult),
|
|
102
|
+
/**
|
|
103
|
+
* Hand it straight on: one transaction, not a return followed by an assign.
|
|
104
|
+
*
|
|
105
|
+
* Two calls would leave the asset `in_stock` with no custodian in between — visible to anybody
|
|
106
|
+
* reading the list at that moment, and permanently visible in the timeline as a return that
|
|
107
|
+
* nobody performed.
|
|
108
|
+
*/
|
|
109
|
+
transfer: baseContract
|
|
110
|
+
.route({ method: 'POST', path: '/assets/{assetId}/custody/transfer', tags: t })
|
|
111
|
+
.input(ws.extend({ assetId: z.uuid(), userId: z.uuid(), note: custodyNote }))
|
|
112
|
+
.output(CustodyResult),
|
|
113
|
+
/**
|
|
114
|
+
* Every period for one asset, newest first — "who had this laptop before me".
|
|
115
|
+
*
|
|
116
|
+
* An array rather than a page, like HR's `employment.history`: the rows are bounded by how many
|
|
117
|
+
* times one item changed hands, which is tens over its life. `limit` caps it rather than
|
|
118
|
+
* paging, so the answer is never unbounded and the caller never has a cursor to keep.
|
|
119
|
+
*/
|
|
120
|
+
history: baseContract
|
|
121
|
+
.route({ method: 'GET', path: '/assets/{assetId}/custody', tags: t })
|
|
122
|
+
.input(ws.extend({ assetId: z.uuid(), limit: z.number().int().min(1).max(200).default(100) }))
|
|
123
|
+
.output(z.array(CustodyPeriod)),
|
|
124
|
+
/**
|
|
125
|
+
* What one person is holding right now.
|
|
126
|
+
*
|
|
127
|
+
* Answered from `assets.custodian_user_id` — denormalised inside the same transaction that
|
|
128
|
+
* writes the period, and indexed — rather than from an open-period join, so the offboarding
|
|
129
|
+
* question ("what does Ada still have?") is one indexed read.
|
|
130
|
+
*/
|
|
131
|
+
byUser: baseContract
|
|
132
|
+
.route({ method: 'GET', path: '/custody/by-user/{userId}', tags: t })
|
|
133
|
+
.input(ws.extend({ userId: z.uuid(), ...PageInput.shape }))
|
|
134
|
+
.output(page(Asset)),
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* How a workspace groups what it owns.
|
|
139
|
+
*
|
|
140
|
+
* Not paged: a workspace has tens of categories, and every one of them has to be in the picker
|
|
141
|
+
* anyway. A cursor here would be a page boundary in a dropdown.
|
|
142
|
+
*/
|
|
143
|
+
categories: {
|
|
144
|
+
list: baseContract
|
|
145
|
+
.route({ method: 'GET', path: '/categories', tags: t })
|
|
146
|
+
.input(ws.extend({ archived: z.boolean().default(false) }))
|
|
147
|
+
.output(z.array(Category)),
|
|
148
|
+
create: baseContract
|
|
149
|
+
.route({ method: 'POST', path: '/categories', tags: t })
|
|
150
|
+
.input(ws.extend(CategoryInput.shape))
|
|
151
|
+
.output(Category),
|
|
152
|
+
update: baseContract
|
|
153
|
+
.route({ method: 'PATCH', path: '/categories/{categoryId}', tags: t })
|
|
154
|
+
.input(ws.extend({ categoryId: z.uuid(), ...CategoryInput.partial().shape }))
|
|
155
|
+
.output(Category),
|
|
156
|
+
/**
|
|
157
|
+
* Archive, not delete — and the same procedure restores.
|
|
158
|
+
*
|
|
159
|
+
* `assets.category_id` carries no foreign key, so a delete would leave every asset filed under
|
|
160
|
+
* it pointing at nothing: a blank column, and a timeline entry that recorded the move losing
|
|
161
|
+
* the name it recorded. Archiving is reversible and destroys nothing.
|
|
162
|
+
*/
|
|
163
|
+
archive: baseContract
|
|
164
|
+
.route({ method: 'POST', path: '/categories/{categoryId}/archive', tags: t })
|
|
165
|
+
.input(ws.extend({ categoryId: z.uuid(), archived: z.boolean().default(true) }))
|
|
166
|
+
.output(Category),
|
|
167
|
+
/**
|
|
168
|
+
* The whole sequence, in the order somebody put it in — the only thing that writes `order`.
|
|
169
|
+
*
|
|
170
|
+
* **The ids, not positions.** A position number is a database column with a form around it: two
|
|
171
|
+
* categories can hold the same one, nobody thinks about their filing as integers, and the screen
|
|
172
|
+
* that asked for one had to explain how ties break. A list of ids says exactly what the person
|
|
173
|
+
* did, whatever they did it with — a drag, or the move-up and move-down buttons beside it.
|
|
174
|
+
*
|
|
175
|
+
* **It must name every live category the workspace has, exactly once.** A partial list is not
|
|
176
|
+
* treated as "leave the rest alone": somebody added a category in another tab while this page
|
|
177
|
+
* was open, and the ordering in hand no longer describes the workspace. Renumbering what it does
|
|
178
|
+
* name would drop the new one somewhere nobody chose, silently — so the whole call is refused
|
|
179
|
+
* with `inventory.category.order_stale` and the page reloads and asks again. Naming an archived
|
|
180
|
+
* category is the same mistake from the other side and gets the same answer; an id from another
|
|
181
|
+
* workspace is `NOT_FOUND`, exactly as `update` and `archive` answer for one.
|
|
182
|
+
*
|
|
183
|
+
* Answers the live sequence as it now stands, so a caller needs no second read.
|
|
184
|
+
*
|
|
185
|
+
* **The bound is `MAX_LIVE_CATEGORIES`, and it is the same number `create` refuses at.** A bound
|
|
186
|
+
* on this array with nothing enforcing it elsewhere is a silent ceiling: a workspace could grow
|
|
187
|
+
* past it one category at a time and then find that the only procedure that can order them is
|
|
188
|
+
* the one it can no longer call. Held to the same number at the point of creation, the array can
|
|
189
|
+
* always name every live category a workspace is allowed to have.
|
|
190
|
+
*/
|
|
191
|
+
reorder: baseContract
|
|
192
|
+
.route({ method: 'POST', path: '/categories/reorder', tags: t })
|
|
193
|
+
.input(ws.extend({ categoryIds: z.array(z.uuid()).min(1).max(MAX_LIVE_CATEGORIES) }))
|
|
194
|
+
.output(z.array(Category)),
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* What went away to be fixed.
|
|
199
|
+
*
|
|
200
|
+
* **Behind the `repairs` capability**, which is this module's first switchable one — so every
|
|
201
|
+
* procedure here answers **404** rather than 403 in a workspace that has it off. 403 would say
|
|
202
|
+
* "this exists and you may not have it", which is false for a company that does not record
|
|
203
|
+
* repairs, and it would contradict a panel that has already hidden the tab.
|
|
204
|
+
*
|
|
205
|
+
* Writing takes `inventory.repair.manage`; reading rides `inventory.asset.view`, for the reason
|
|
206
|
+
* custody does — "where is the projector" is the question the register exists to answer.
|
|
207
|
+
*/
|
|
208
|
+
repairs: {
|
|
209
|
+
/**
|
|
210
|
+
* One asset's repairs, or the whole workspace's — the same procedure, because they are the same
|
|
211
|
+
* query with one filter and a second one would be a second thing to keep in step.
|
|
212
|
+
*
|
|
213
|
+
* `open: true` is the "what is away right now" question a dashboard card asks, and it is a
|
|
214
|
+
* filter rather than a procedure of its own for the same reason.
|
|
215
|
+
*
|
|
216
|
+
* Paged, unlike `custody.history`: one asset's repairs are bounded by how often it breaks, but
|
|
217
|
+
* a workspace's are not. Ordered newest-logged first, by row id — a uuidv7 already carries the
|
|
218
|
+
* clock and is unique, where `sent_on` is a date two repairs logged the same day share.
|
|
219
|
+
*/
|
|
220
|
+
list: baseContract
|
|
221
|
+
.route({ method: 'GET', path: '/repairs', tags: t })
|
|
222
|
+
.input(
|
|
223
|
+
ws.extend({
|
|
224
|
+
...PageInput.shape,
|
|
225
|
+
assetId: z.uuid().optional(),
|
|
226
|
+
/** `true` for still away, `false` for finished, absent for both. */
|
|
227
|
+
open: z.boolean().optional(),
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
.output(page(RepairListItem)),
|
|
231
|
+
/**
|
|
232
|
+
* Send it away. Refuses when the item is already at a repairer — one open repair per asset, and
|
|
233
|
+
* `inventory_repairs_one_open_uq` is what makes that true in the database rather than merely
|
|
234
|
+
* likely in the service.
|
|
235
|
+
*
|
|
236
|
+
* `sentOn` is optional and defaults to today **on the server**: a browser clock is not a fact
|
|
237
|
+
* this module is willing to record, and the same reasoning already keeps asset tags server-side.
|
|
238
|
+
*/
|
|
239
|
+
create: baseContract
|
|
240
|
+
.route({ method: 'POST', path: '/assets/{assetId}/repairs', tags: t })
|
|
241
|
+
.input(ws.extend({ assetId: z.uuid(), ...RepairInput.shape }))
|
|
242
|
+
.output(RepairResult),
|
|
243
|
+
/**
|
|
244
|
+
* Correct what was recorded — a vendor, a cost that arrived with the invoice a week later.
|
|
245
|
+
*
|
|
246
|
+
* Deliberately cannot set `returnedOn`: that one column decides whether the asset reads as
|
|
247
|
+
* `under_repair`, so exactly one procedure moves it and the derived status has one door rather
|
|
248
|
+
* than two.
|
|
249
|
+
*/
|
|
250
|
+
update: baseContract
|
|
251
|
+
.route({ method: 'PATCH', path: '/repairs/{repairId}', tags: t })
|
|
252
|
+
.input(ws.extend({ repairId: z.uuid(), ...RepairPatchInput.shape }))
|
|
253
|
+
.output(RepairResult),
|
|
254
|
+
/**
|
|
255
|
+
* It came back. Closes the repair and puts the asset back to `assigned` if somebody still holds
|
|
256
|
+
* it, or `in_stock` if nobody does — never blindly to `in_stock`, which would quietly release
|
|
257
|
+
* whoever is answerable for it.
|
|
258
|
+
*
|
|
259
|
+
* Takes the cost, because that is when the invoice usually arrives.
|
|
260
|
+
*/
|
|
261
|
+
complete: baseContract
|
|
262
|
+
.route({ method: 'POST', path: '/repairs/{repairId}/complete', tags: t })
|
|
263
|
+
.input(
|
|
264
|
+
ws.extend({
|
|
265
|
+
repairId: z.uuid(),
|
|
266
|
+
returnedOn: z.iso.date().optional(),
|
|
267
|
+
costMinor: z.number().int().min(0).nullish(),
|
|
268
|
+
currency: z.string().length(3).nullish(),
|
|
269
|
+
}),
|
|
270
|
+
)
|
|
271
|
+
.output(RepairResult),
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Receipts, warranties, manuals — and the asset's photo, which is an `Asset` field rather than one
|
|
276
|
+
* of these.
|
|
277
|
+
*
|
|
278
|
+
* **Behind the `attachments` capability**, so these answer 404 in a workspace that has it off.
|
|
279
|
+
*
|
|
280
|
+
* **A module does not upload.** The browser sends the bytes to core's file service and hands this
|
|
281
|
+
* module the id core gave it; `add` records that this asset has that file. Nothing here streams,
|
|
282
|
+
* signs or stores anything, and `remove` detaches rather than deleting core's file — the same
|
|
283
|
+
* bytes may be attached elsewhere, and a module has no standing to destroy another module's row.
|
|
284
|
+
*/
|
|
285
|
+
attachments: {
|
|
286
|
+
/**
|
|
287
|
+
* Every file on one asset, its repairs' included, each carrying its own `repairId`.
|
|
288
|
+
*
|
|
289
|
+
* Not paged and not filtered by repair: one asset's files are bounded and entirely loaded, so
|
|
290
|
+
* the panel groups them in the browser. That is the one case where filtering client-side is
|
|
291
|
+
* right, and it is the opposite of what `assets.list` may do.
|
|
292
|
+
*/
|
|
293
|
+
list: baseContract
|
|
294
|
+
.route({ method: 'GET', path: '/assets/{assetId}/attachments', tags: t })
|
|
295
|
+
.input(ws.extend({ assetId: z.uuid() }))
|
|
296
|
+
.output(z.array(Attachment)),
|
|
297
|
+
/** Attach files core already holds. `repairId` files them under one repair instead of the asset. */
|
|
298
|
+
add: baseContract
|
|
299
|
+
.route({ method: 'POST', path: '/assets/{assetId}/attachments', tags: t })
|
|
300
|
+
.input(
|
|
301
|
+
ws.extend({
|
|
302
|
+
assetId: z.uuid(),
|
|
303
|
+
fileIds: z.array(z.uuid()).min(1).max(20),
|
|
304
|
+
repairId: z.uuid().nullish(),
|
|
305
|
+
}),
|
|
306
|
+
)
|
|
307
|
+
.output(z.array(Attachment)),
|
|
308
|
+
/** Answers with the id it detached, so a client can drop exactly that row without re-reading. */
|
|
309
|
+
remove: baseContract
|
|
310
|
+
.route({ method: 'DELETE', path: '/attachments/{attachmentId}', tags: t })
|
|
311
|
+
.input(ws.extend({ attachmentId: z.uuid() }))
|
|
312
|
+
.output(z.object({ id: z.uuid() })),
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The register in numbers.
|
|
317
|
+
*
|
|
318
|
+
* One procedure rather than a `total` on `assets.list`, because they answer different questions:
|
|
319
|
+
* a list's total describes the filter you asked for, and this describes the workspace. The assets
|
|
320
|
+
* page needs both — "showing 50 of 214" is two numbers from two places.
|
|
321
|
+
*
|
|
322
|
+
* Not behind a capability: it counts assets, which is `core`. `outForRepair` comes back null
|
|
323
|
+
* rather than 0 where `repairs` is off — see `InventoryStats`.
|
|
324
|
+
*/
|
|
325
|
+
stats: {
|
|
326
|
+
summary: baseContract.route({ method: 'GET', path: '/stats', tags: t }).input(ws).output(InventoryStats),
|
|
44
327
|
},
|
|
45
328
|
}
|
|
46
329
|
export type InventoryContract = typeof inventoryContract
|
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
|
-
*
|
|
208
|
+
* the only symptom is a workspace successfully calling a feature it switched off.
|
|
209
|
+
*
|
|
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.
|
|
173
216
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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,105 @@ 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
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The client manifest's `name` is read on screen, so it has to be a getter over `t`.
|
|
405
|
+
*
|
|
406
|
+
* The dashboard's widget picker heads this module's group with `mod.name` directly, and the shell's
|
|
407
|
+
* settings rail falls back to it for a module whose navigation it cannot read — so an English
|
|
408
|
+
* literal there is a Latin word sitting in an otherwise Persian panel. `name` is typed as a plain
|
|
409
|
+
* `string` on `ClientModule`, which is exactly why nothing else catches this: a literal and a
|
|
410
|
+
* getter are the same type.
|
|
411
|
+
*
|
|
412
|
+
* Read as text rather than imported, and honest about being crude. `src/client/module.ts` reaches
|
|
413
|
+
* `@kernhq/ui`, which drags a Svelte compiler into whatever imports it, and this package's vitest
|
|
414
|
+
* runs plain Node — the same reason `errors.ts` and `messages.ts` are kept importable and the
|
|
415
|
+
* components are not. It proves the shape, not the rendering.
|
|
416
|
+
*/
|
|
417
|
+
it('gives the client manifest a translated name rather than an English literal', () => {
|
|
418
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
419
|
+
const source = readFileSync(join(here, 'client', 'module.ts'), 'utf8')
|
|
420
|
+
expect(source, 'a getter, so the language is the one on screen at read time').toMatch(
|
|
421
|
+
/get name\(\)\s*\{\s*return t\('nav'\)/,
|
|
422
|
+
)
|
|
423
|
+
expect(source, 'and no literal left beside it').not.toMatch(/^\s*name: '/m)
|
|
424
|
+
})
|
|
425
|
+
})
|