@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
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
|