@kernhq/module-inventory 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +322 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +213 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1052 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +225 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1337 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +363 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +158 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +42 -0
- package/package.json +1 -1
- package/src/client/api-instance.ts +27 -2
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +71 -22
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +127 -0
- package/src/client/messages.ts +1043 -18
- package/src/client/mock.test.ts +395 -1
- package/src/client/mock.ts +1066 -79
- package/src/client/module.ts +54 -0
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +237 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +255 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +194 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +3573 -18
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +113 -0
- package/src/server/router.ts +462 -6
- package/src/server/schema.ts +160 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
package/src/server/router.ts
CHANGED
|
@@ -6,13 +6,19 @@ import {
|
|
|
6
6
|
packageVersion,
|
|
7
7
|
type RequestContext,
|
|
8
8
|
requires,
|
|
9
|
+
requiresCapability,
|
|
9
10
|
type Tx,
|
|
10
11
|
workspaceScoped,
|
|
11
12
|
} from '@kernhq/kernel'
|
|
12
13
|
import { implement } from '@orpc/server'
|
|
13
14
|
import { inventoryContract, inventoryEvents, MODULE_ID } from '../contract/index.js'
|
|
14
15
|
import { toAsset, type Written } from './services/assets.js'
|
|
16
|
+
import { toAttachment } from './services/attachments.js'
|
|
17
|
+
import { toCategory } from './services/categories.js'
|
|
18
|
+
import { type CustodyWritten, toCustodyPeriod } from './services/custody.js'
|
|
15
19
|
import { inventoryServices } from './services/index.js'
|
|
20
|
+
import { requireWorkspaceMember } from './services/members.js'
|
|
21
|
+
import { type RepairWritten, toRepair } from './services/repairs.js'
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* oRPC router for `/api/inventory`.
|
|
@@ -32,15 +38,29 @@ const os = implement(inventoryContract).$context<RequestContext>()
|
|
|
32
38
|
export function inventoryRouter(kernel: Kernel) {
|
|
33
39
|
const svc = inventoryServices(kernel)
|
|
34
40
|
const scoped = os.use(workspaceScoped(MODULE_ID))
|
|
41
|
+
/**
|
|
42
|
+
* The capability gate, between the workspace gate and the permission check.
|
|
43
|
+
*
|
|
44
|
+
* That order is the whole point: a workspace with Inventory switched off is refused before
|
|
45
|
+
* anything reveals which capabilities it would have had, and a workspace with `repairs` off
|
|
46
|
+
* answers **404** rather than 403 — the surface is not part of its API, so it answers like
|
|
47
|
+
* anything else that is not there. `module.test.ts` checks the identity and the position of all
|
|
48
|
+
* three, because a missing one is invisible to `tsc`.
|
|
49
|
+
*/
|
|
50
|
+
const cap = (id: string) => requiresCapability(MODULE_ID, id)
|
|
35
51
|
|
|
36
52
|
/**
|
|
37
53
|
* Everything a write announces, once the transaction that wrote it has actually committed.
|
|
38
54
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* old answer; a mutation that fires them *inside* the transaction tells the rest of
|
|
43
|
-
* about a row a rollback then took away, which is worse — it cannot be retracted.
|
|
55
|
+
* Four things, and all four are after the commit on purpose: the core activity feed's copy of the
|
|
56
|
+
* history row, the event for whatever reacts later, the realtime change that redraws a screen
|
|
57
|
+
* somebody is looking at now, and the search index. A mutation that skips them leaves the product
|
|
58
|
+
* believing the old answer; a mutation that fires them *inside* the transaction tells the rest of
|
|
59
|
+
* the instance about a row a rollback then took away, which is worse — it cannot be retracted.
|
|
60
|
+
*
|
|
61
|
+
* `reindex` needs no direction argument: it re-reads the row, and an archived one comes back as
|
|
62
|
+
* `null`, which is what takes it out of the index. So archive and restore are the same call here,
|
|
63
|
+
* exactly as they are the same procedure.
|
|
44
64
|
*/
|
|
45
65
|
const announce = async (
|
|
46
66
|
workspaceId: WorkspaceId,
|
|
@@ -53,12 +73,129 @@ export function inventoryRouter(kernel: Kernel) {
|
|
|
53
73
|
if (written.activity) await svc.notify.activity(written.activity)
|
|
54
74
|
await kernel.emit(event, { assetId, workspaceId }, { workspaceId, actorId: actorId ?? undefined })
|
|
55
75
|
await svc.notify.change(workspaceId, 'asset', assetId, op)
|
|
76
|
+
await svc.search.reindex(workspaceId, assetId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The same three, plus the one interruption this module makes.
|
|
81
|
+
*
|
|
82
|
+
* Custody is announced as a change to the **asset** rather than to a custody entity, and that is
|
|
83
|
+
* deliberate: `src/client/query.ts` keys the timeline and the period list under the asset's own
|
|
84
|
+
* prefix, so one `entity: 'asset'` invalidates the row, the list, the panel and both of its tabs.
|
|
85
|
+
* A second entity name would need a second subscription for no gain.
|
|
86
|
+
*/
|
|
87
|
+
const announceCustody = async (
|
|
88
|
+
workspaceId: WorkspaceId,
|
|
89
|
+
written: CustodyWritten,
|
|
90
|
+
actorId: string | null,
|
|
91
|
+
) => {
|
|
92
|
+
const assetId = written.asset.id
|
|
93
|
+
await svc.notify.activity(written.activity)
|
|
94
|
+
await kernel.emit(
|
|
95
|
+
inventoryEvents.custodyChanged,
|
|
96
|
+
{
|
|
97
|
+
assetId,
|
|
98
|
+
workspaceId,
|
|
99
|
+
userId: written.userId,
|
|
100
|
+
previousUserId: written.previousUserId,
|
|
101
|
+
},
|
|
102
|
+
{ workspaceId, actorId: actorId ?? undefined },
|
|
103
|
+
)
|
|
104
|
+
await svc.notify.change(workspaceId, 'asset', assetId, 'updated')
|
|
105
|
+
// The document carries `custodianUserId` and `status`, and both just moved.
|
|
106
|
+
await svc.search.reindex(workspaceId, assetId)
|
|
107
|
+
if (written.notifyUserId)
|
|
108
|
+
await svc.notify.notify({
|
|
109
|
+
workspaceId,
|
|
110
|
+
userIds: [written.notifyUserId],
|
|
111
|
+
type: 'inventory.custody.assigned',
|
|
112
|
+
// The tag first, because that is what is printed on the sticker the person is looking for.
|
|
113
|
+
title: `${written.asset.code} was handed to you`,
|
|
114
|
+
body: written.asset.name,
|
|
115
|
+
object: { module: MODULE_ID, type: 'asset', id: assetId },
|
|
116
|
+
url: `/inventory?asset=${assetId}`,
|
|
117
|
+
groupKey: assetId,
|
|
118
|
+
actorId,
|
|
119
|
+
exclude: [actorId],
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A repair's three, and the second entity it has to invalidate.
|
|
125
|
+
*
|
|
126
|
+
* `entity: 'asset'` refreshes the panel, the row and the list — the query keys hang the asset's
|
|
127
|
+
* repairs one segment under the asset's own key for exactly that reason. The workspace-wide
|
|
128
|
+
* repair list (the *what is away* card) cannot hang there, because it belongs to no single asset,
|
|
129
|
+
* so it is keyed under `['inventory', 'repair', …]` and needs its own change. Two changes for one
|
|
130
|
+
* write is the honest cost of one entity being asked about at two scopes.
|
|
131
|
+
*/
|
|
132
|
+
const announceRepair = async (
|
|
133
|
+
workspaceId: WorkspaceId,
|
|
134
|
+
written: RepairWritten,
|
|
135
|
+
event: typeof inventoryEvents.repairOpened | typeof inventoryEvents.repairCompleted | null,
|
|
136
|
+
op: 'created' | 'updated',
|
|
137
|
+
actorId: string | null,
|
|
138
|
+
) => {
|
|
139
|
+
if (written.activity) await svc.notify.activity(written.activity)
|
|
140
|
+
if (event)
|
|
141
|
+
await kernel.emit(
|
|
142
|
+
event,
|
|
143
|
+
{ assetId: written.asset.id, repairId: written.repair.id, workspaceId },
|
|
144
|
+
{ workspaceId, actorId: actorId ?? undefined },
|
|
145
|
+
)
|
|
146
|
+
await svc.notify.change(workspaceId, 'asset', written.asset.id, 'updated')
|
|
147
|
+
await svc.notify.change(workspaceId, 'repair', written.repair.id, op)
|
|
148
|
+
// Sending an item away and logging it back both move `assets.status`, which the document
|
|
149
|
+
// carries. An edit does not, and reindexing anyway is one cheap call rather than a fourth
|
|
150
|
+
// branch that has to stay right.
|
|
151
|
+
await svc.search.reindex(workspaceId, written.asset.id)
|
|
56
152
|
}
|
|
57
153
|
|
|
58
154
|
/** Every handler is this: a workspace-bound transaction, tagged with who is asking, and a service. */
|
|
59
155
|
const run = <T>(context: RequestContext, workspaceId: string, fn: (tx: Tx) => Promise<T>): Promise<T> =>
|
|
60
156
|
kernel.database.withWorkspace(workspaceId, fn, { userId: context.principal.userId })
|
|
61
157
|
|
|
158
|
+
/**
|
|
159
|
+
* A file id in a request is a claim about somebody else's row, and it is checked before it is
|
|
160
|
+
* stored.
|
|
161
|
+
*
|
|
162
|
+
* `attachments.add` has always asked core whose file an id belongs to; `assets.create` and
|
|
163
|
+
* `assets.update` did not, so `photoFileId` was the one file id this module accepted on trust —
|
|
164
|
+
* and a member of one workspace could point an asset at another workspace's file and read its
|
|
165
|
+
* name and size back out through the panel. The module boundary is no help: core answers this
|
|
166
|
+
* module as a service, so the id is the only thing standing between the two workspaces.
|
|
167
|
+
*
|
|
168
|
+
* The same check, deliberately, rather than a second one that might drift: `describe` is what
|
|
169
|
+
* refuses a foreign file and an upload that never finished, and it is called **before** the
|
|
170
|
+
* transaction opens for the reason its own docblock gives — a broker round trip while holding a
|
|
171
|
+
* pooled connection starves the pool and fails the write whenever core is briefly away.
|
|
172
|
+
*
|
|
173
|
+
* `undefined` means the request never mentioned a photo and `null` means "take it off", and
|
|
174
|
+
* neither is a file to check.
|
|
175
|
+
*/
|
|
176
|
+
const checkPhoto = async (workspaceId: WorkspaceId, photoFileId?: string | null): Promise<void> => {
|
|
177
|
+
if (typeof photoFileId === 'string') await svc.attachments.describe(workspaceId, [photoFileId])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Does this workspace record repairs? Asked by the procedures that are **not** behind the
|
|
182
|
+
* capability but whose answer depends on it.
|
|
183
|
+
*
|
|
184
|
+
* `under_repair` is a status the `repairs` capability owns, and custody and archiving both decide
|
|
185
|
+
* it — custody because `deriveStatus` reads the open repair, archiving because it refuses an item
|
|
186
|
+
* that is away. With the capability off both of those were reading a fact the workspace can no
|
|
187
|
+
* longer act on: `repairs.complete` answers 404, so an asset went `under_repair` and stayed there,
|
|
188
|
+
* behind an archive that refused it and told the person to complete a repair they cannot reach.
|
|
189
|
+
*
|
|
190
|
+
* Read **before** the transaction opens, like every other settings lookup in this module and for
|
|
191
|
+
* the reason `AssetService.codeFormat` gives — `kernel.capabilities` resolves module settings over
|
|
192
|
+
* the broker, and awaiting one while holding a pooled connection starves the pool and fails the
|
|
193
|
+
* write whenever core is briefly away. The result is cached by the kernel for fifteen seconds, so
|
|
194
|
+
* this is not a round trip per request.
|
|
195
|
+
*/
|
|
196
|
+
const recordsRepairs = async (workspaceId: WorkspaceId): Promise<boolean> =>
|
|
197
|
+
(await kernel.capabilities(workspaceId, MODULE_ID)).has('repairs')
|
|
198
|
+
|
|
62
199
|
return os.router({
|
|
63
200
|
assets: {
|
|
64
201
|
list: scoped.assets.list
|
|
@@ -73,6 +210,14 @@ export function inventoryRouter(kernel: Kernel) {
|
|
|
73
210
|
),
|
|
74
211
|
),
|
|
75
212
|
|
|
213
|
+
history: scoped.assets.history
|
|
214
|
+
.use(requires('inventory.asset.view'))
|
|
215
|
+
.handler(({ input, context }) =>
|
|
216
|
+
run(context, input.workspaceId, (tx) =>
|
|
217
|
+
svc.assets.history(tx, input.workspaceId, input.assetId, input),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
|
|
76
221
|
create: scoped.assets.create
|
|
77
222
|
.use(requires('inventory.asset.manage'))
|
|
78
223
|
.handler(async ({ input, context }) => {
|
|
@@ -80,6 +225,7 @@ export function inventoryRouter(kernel: Kernel) {
|
|
|
80
225
|
// Module settings are read *before* the transaction opens. They come from core over the
|
|
81
226
|
// broker, and awaiting that inside the transaction holds a pooled connection and the
|
|
82
227
|
// counter row lock across a network call to another service.
|
|
228
|
+
await checkPhoto(input.workspaceId, input.photoFileId)
|
|
83
229
|
const format = await svc.assets.codeFormat(input.workspaceId)
|
|
84
230
|
const written = await run(context, input.workspaceId, (tx) =>
|
|
85
231
|
svc.assets.create(tx, input.workspaceId, actorId, input, format),
|
|
@@ -92,6 +238,7 @@ export function inventoryRouter(kernel: Kernel) {
|
|
|
92
238
|
.use(requires('inventory.asset.manage'))
|
|
93
239
|
.handler(async ({ input, context }) => {
|
|
94
240
|
const actorId = context.principal.userId
|
|
241
|
+
await checkPhoto(input.workspaceId, input.photoFileId)
|
|
95
242
|
const written = await run(context, input.workspaceId, (tx) =>
|
|
96
243
|
svc.assets.update(tx, input.workspaceId, actorId, input.assetId, input),
|
|
97
244
|
)
|
|
@@ -103,8 +250,9 @@ export function inventoryRouter(kernel: Kernel) {
|
|
|
103
250
|
.use(requires('inventory.asset.manage'))
|
|
104
251
|
.handler(async ({ input, context }) => {
|
|
105
252
|
const actorId = context.principal.userId
|
|
253
|
+
const repairsOn = await recordsRepairs(input.workspaceId)
|
|
106
254
|
const written = await run(context, input.workspaceId, (tx) =>
|
|
107
|
-
svc.assets.archive(tx, input.workspaceId, actorId, input.assetId, input.archived),
|
|
255
|
+
svc.assets.archive(tx, input.workspaceId, actorId, input.assetId, input.archived, repairsOn),
|
|
108
256
|
)
|
|
109
257
|
// This procedure is the restore path too, and it used to announce `archived` either way —
|
|
110
258
|
// telling every subscriber that a restored item had just been retired, while the module's
|
|
@@ -114,5 +262,313 @@ export function inventoryRouter(kernel: Kernel) {
|
|
|
114
262
|
return toAsset(written.row)
|
|
115
263
|
}),
|
|
116
264
|
},
|
|
265
|
+
|
|
266
|
+
custody: {
|
|
267
|
+
/**
|
|
268
|
+
* Reads ride `inventory.asset.view`, writes take `inventory.custody.manage`.
|
|
269
|
+
*
|
|
270
|
+
* The split is argued where the keys are declared: `custodianUserId` is already a field of
|
|
271
|
+
* `Asset` under `asset.view`, so gating the timeline behind a second key would refuse the
|
|
272
|
+
* history while the row above it named the same person.
|
|
273
|
+
*/
|
|
274
|
+
history: scoped.custody.history.use(requires('inventory.asset.view')).handler(({ input, context }) =>
|
|
275
|
+
run(context, input.workspaceId, async (tx) => {
|
|
276
|
+
// 404 first: a timeline for an asset in another workspace must answer "not yours"
|
|
277
|
+
// rather than an empty list, which reads as "nothing has ever happened to it".
|
|
278
|
+
await svc.assets.get(tx, input.workspaceId, input.assetId)
|
|
279
|
+
const rows = await svc.custody.history(tx, input.workspaceId, input.assetId, input.limit)
|
|
280
|
+
return rows.map(toCustodyPeriod)
|
|
281
|
+
}),
|
|
282
|
+
),
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* What one person holds, answered by the asset list with the custodian filter fixed.
|
|
286
|
+
*
|
|
287
|
+
* One query path rather than two: `assets.list` already has the filter, the index and the
|
|
288
|
+
* keyset cursor, and a second query answering the same question is a second query to keep in
|
|
289
|
+
* step. Archived rows are out, because an item somebody still holds cannot be archived at
|
|
290
|
+
* all — `assets.archive` refuses it — so an archived row here would be one this module has
|
|
291
|
+
* already made impossible.
|
|
292
|
+
*/
|
|
293
|
+
byUser: scoped.custody.byUser.use(requires('inventory.asset.view')).handler(({ input, context }) =>
|
|
294
|
+
run(context, input.workspaceId, (tx) =>
|
|
295
|
+
svc.assets.list(tx, {
|
|
296
|
+
workspaceId: input.workspaceId,
|
|
297
|
+
limit: input.limit,
|
|
298
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
299
|
+
custodianUserId: input.userId,
|
|
300
|
+
archived: false,
|
|
301
|
+
sort: 'recent',
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
304
|
+
),
|
|
305
|
+
|
|
306
|
+
assign: scoped.custody.assign
|
|
307
|
+
.use(requires('inventory.custody.manage'))
|
|
308
|
+
.handler(async ({ input, context }) => {
|
|
309
|
+
const actorId = context.principal.userId
|
|
310
|
+
// Core is asked whether this person is really a member **before** the transaction opens,
|
|
311
|
+
// for the reason `AssetService.codeFormat` documents — and because writing the row first
|
|
312
|
+
// and checking afterwards would mean rolling back a handover somebody has already been
|
|
313
|
+
// notified about. `requireWorkspaceMember` argues what it refuses and why it refuses
|
|
314
|
+
// rather than swallowing a failure.
|
|
315
|
+
await requireWorkspaceMember(kernel, input.workspaceId, input.userId)
|
|
316
|
+
const repairsOn = await recordsRepairs(input.workspaceId)
|
|
317
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
318
|
+
svc.custody.assign(
|
|
319
|
+
tx,
|
|
320
|
+
input.workspaceId,
|
|
321
|
+
actorId,
|
|
322
|
+
input.assetId,
|
|
323
|
+
input.userId,
|
|
324
|
+
input.note ?? null,
|
|
325
|
+
repairsOn,
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
await announceCustody(input.workspaceId, written, actorId)
|
|
329
|
+
return { asset: toAsset(written.asset), period: written.period && toCustodyPeriod(written.period) }
|
|
330
|
+
}),
|
|
331
|
+
|
|
332
|
+
transfer: scoped.custody.transfer
|
|
333
|
+
.use(requires('inventory.custody.manage'))
|
|
334
|
+
.handler(async ({ input, context }) => {
|
|
335
|
+
const actorId = context.principal.userId
|
|
336
|
+
// The same check as `assign`, for the same reason: this is the other verb that writes a
|
|
337
|
+
// person into `custody_periods.user_id` and then sends them a notification.
|
|
338
|
+
await requireWorkspaceMember(kernel, input.workspaceId, input.userId)
|
|
339
|
+
const repairsOn = await recordsRepairs(input.workspaceId)
|
|
340
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
341
|
+
svc.custody.transfer(
|
|
342
|
+
tx,
|
|
343
|
+
input.workspaceId,
|
|
344
|
+
actorId,
|
|
345
|
+
input.assetId,
|
|
346
|
+
input.userId,
|
|
347
|
+
input.note ?? null,
|
|
348
|
+
repairsOn,
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
await announceCustody(input.workspaceId, written, actorId)
|
|
352
|
+
return { asset: toAsset(written.asset), period: written.period && toCustodyPeriod(written.period) }
|
|
353
|
+
}),
|
|
354
|
+
|
|
355
|
+
return: scoped.custody.return
|
|
356
|
+
.use(requires('inventory.custody.manage'))
|
|
357
|
+
.handler(async ({ input, context }) => {
|
|
358
|
+
const actorId = context.principal.userId
|
|
359
|
+
const repairsOn = await recordsRepairs(input.workspaceId)
|
|
360
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
361
|
+
svc.custody.return(tx, input.workspaceId, actorId, input.assetId, input.note ?? null, repairsOn),
|
|
362
|
+
)
|
|
363
|
+
await announceCustody(input.workspaceId, written, actorId)
|
|
364
|
+
return { asset: toAsset(written.asset), period: null }
|
|
365
|
+
}),
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* A workspace's own filing, and the one entity name every change here carries.
|
|
370
|
+
*
|
|
371
|
+
* **`update` and `archive` used to announce a second change as `entity: 'asset'` carrying the
|
|
372
|
+
* category's id.** No asset has that id, so the change described a row that does not exist: a
|
|
373
|
+
* subscriber that acts on `{entity, id}` — patching a cached row, logging what moved, deciding
|
|
374
|
+
* what to refetch — is acting on a lie, and the client's own
|
|
375
|
+
* `invalidateQueries(['inventory', 'asset', id])` matched nothing, because the segment after
|
|
376
|
+
* the entity in this module's keys is the *workspace*. What it did was work by accident: the
|
|
377
|
+
* blunter `['inventory', 'asset']` invalidation fires on any asset change whatever its id.
|
|
378
|
+
*
|
|
379
|
+
* It is not needed either, and that is why it is gone rather than corrected. A category's name
|
|
380
|
+
* is resolved on the client from the categories query itself — `AssetsPage` and
|
|
381
|
+
* `AssetDetailPanel` both build their `id → name` map from `inventoryKeys.categories(ws, true)`
|
|
382
|
+
* — so refreshing that one query is what makes every asset row on screen say the new name. The
|
|
383
|
+
* asset rows themselves did not change: archiving a category leaves `assets.category_id`
|
|
384
|
+
* exactly where it was, which is the whole reason categories archive rather than delete.
|
|
385
|
+
*/
|
|
386
|
+
categories: {
|
|
387
|
+
/**
|
|
388
|
+
* Reading rides `inventory.asset.view` because the picker on the asset form needs it — a
|
|
389
|
+
* `category.manage` on the read would leave everybody who may edit an asset unable to see
|
|
390
|
+
* what to file it under.
|
|
391
|
+
*/
|
|
392
|
+
list: scoped.categories.list
|
|
393
|
+
.use(requires('inventory.asset.view'))
|
|
394
|
+
.handler(({ input, context }) =>
|
|
395
|
+
run(context, input.workspaceId, (tx) => svc.categories.list(tx, input.workspaceId, input.archived)),
|
|
396
|
+
),
|
|
397
|
+
|
|
398
|
+
create: scoped.categories.create
|
|
399
|
+
.use(requires('inventory.category.manage'))
|
|
400
|
+
.handler(async ({ input, context }) => {
|
|
401
|
+
const row = await run(context, input.workspaceId, (tx) =>
|
|
402
|
+
svc.categories.create(tx, input.workspaceId, input.name, input.order ?? 0),
|
|
403
|
+
)
|
|
404
|
+
// No event: nothing outside this module has an opinion about a workspace's own filing.
|
|
405
|
+
// The realtime change is what the settings page, the filter and the form picker need.
|
|
406
|
+
await svc.notify.change(input.workspaceId, 'category', row.id, 'created')
|
|
407
|
+
return toCategory(row)
|
|
408
|
+
}),
|
|
409
|
+
|
|
410
|
+
update: scoped.categories.update
|
|
411
|
+
.use(requires('inventory.category.manage'))
|
|
412
|
+
.handler(async ({ input, context }) => {
|
|
413
|
+
const row = await run(context, input.workspaceId, (tx) =>
|
|
414
|
+
svc.categories.update(tx, input.workspaceId, input.categoryId, input),
|
|
415
|
+
)
|
|
416
|
+
// One change, naming the row that actually moved. Every screen that prints a category's
|
|
417
|
+
// name resolves it from the categories query, so this is what re-renders the asset list,
|
|
418
|
+
// the filter and the picker as well as the settings page.
|
|
419
|
+
await svc.notify.change(input.workspaceId, 'category', row.id, 'updated')
|
|
420
|
+
return toCategory(row)
|
|
421
|
+
}),
|
|
422
|
+
|
|
423
|
+
archive: scoped.categories.archive
|
|
424
|
+
.use(requires('inventory.category.manage'))
|
|
425
|
+
.handler(async ({ input, context }) => {
|
|
426
|
+
const row = await run(context, input.workspaceId, (tx) =>
|
|
427
|
+
svc.categories.archive(tx, input.workspaceId, input.categoryId, input.archived),
|
|
428
|
+
)
|
|
429
|
+
// Archiving takes the category out of every picker and every filter and leaves each asset
|
|
430
|
+
// still filed under it — so the row that changed is the category, and only the category.
|
|
431
|
+
await svc.notify.change(input.workspaceId, 'category', row.id, 'updated')
|
|
432
|
+
return toCategory(row)
|
|
433
|
+
}),
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Repairs, behind the `repairs` capability.
|
|
438
|
+
*
|
|
439
|
+
* Every one of these carries three middlewares in this order — `workspaceScoped`, `cap`,
|
|
440
|
+
* `requires` — so a workspace that does not record repairs gets 404 from all four, which is
|
|
441
|
+
* what makes the hidden tab and the API agree.
|
|
442
|
+
*/
|
|
443
|
+
repairs: {
|
|
444
|
+
list: scoped.repairs.list
|
|
445
|
+
.use(cap('repairs'))
|
|
446
|
+
.use(requires('inventory.asset.view'))
|
|
447
|
+
.handler(({ input, context }) =>
|
|
448
|
+
run(context, input.workspaceId, async (tx) => {
|
|
449
|
+
// 404 first when the question is about one asset: a repair list for an asset in another
|
|
450
|
+
// workspace must answer "not yours" rather than an empty list, which reads as "it has
|
|
451
|
+
// never been repaired". The same reasoning `custody.history` gives.
|
|
452
|
+
if (input.assetId) await svc.assets.get(tx, input.workspaceId, input.assetId)
|
|
453
|
+
return svc.repairs.list(tx, input.workspaceId, input)
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
|
|
457
|
+
create: scoped.repairs.create
|
|
458
|
+
.use(cap('repairs'))
|
|
459
|
+
.use(requires('inventory.repair.manage'))
|
|
460
|
+
.handler(async ({ input, context }) => {
|
|
461
|
+
const actorId = context.principal.userId
|
|
462
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
463
|
+
svc.repairs.create(tx, input.workspaceId, actorId, input.assetId, input),
|
|
464
|
+
)
|
|
465
|
+
await announceRepair(input.workspaceId, written, inventoryEvents.repairOpened, 'created', actorId)
|
|
466
|
+
return { repair: toRepair(written.repair), asset: toAsset(written.asset) }
|
|
467
|
+
}),
|
|
468
|
+
|
|
469
|
+
update: scoped.repairs.update
|
|
470
|
+
.use(cap('repairs'))
|
|
471
|
+
.use(requires('inventory.repair.manage'))
|
|
472
|
+
.handler(async ({ input, context }) => {
|
|
473
|
+
const actorId = context.principal.userId
|
|
474
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
475
|
+
svc.repairs.update(tx, input.workspaceId, input.repairId, input),
|
|
476
|
+
)
|
|
477
|
+
// No event: correcting a vendor or a cost is not a fact anything outside this module
|
|
478
|
+
// reacts to. The realtime change is what the panel and the card need.
|
|
479
|
+
await announceRepair(input.workspaceId, written, null, 'updated', actorId)
|
|
480
|
+
return { repair: toRepair(written.repair), asset: toAsset(written.asset) }
|
|
481
|
+
}),
|
|
482
|
+
|
|
483
|
+
complete: scoped.repairs.complete
|
|
484
|
+
.use(cap('repairs'))
|
|
485
|
+
.use(requires('inventory.repair.manage'))
|
|
486
|
+
.handler(async ({ input, context }) => {
|
|
487
|
+
const actorId = context.principal.userId
|
|
488
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
489
|
+
svc.repairs.complete(tx, input.workspaceId, actorId, input.repairId, input),
|
|
490
|
+
)
|
|
491
|
+
await announceRepair(
|
|
492
|
+
input.workspaceId,
|
|
493
|
+
written,
|
|
494
|
+
inventoryEvents.repairCompleted,
|
|
495
|
+
'updated',
|
|
496
|
+
actorId,
|
|
497
|
+
)
|
|
498
|
+
return { repair: toRepair(written.repair), asset: toAsset(written.asset) }
|
|
499
|
+
}),
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Files, behind the `attachments` capability.
|
|
504
|
+
*
|
|
505
|
+
* Writing takes `inventory.asset.manage` rather than a key of its own: filing a receipt against
|
|
506
|
+
* an asset is editing the asset's record, and a workspace that lets somebody correct a serial
|
|
507
|
+
* number lets them attach the warranty card it came with.
|
|
508
|
+
*/
|
|
509
|
+
attachments: {
|
|
510
|
+
list: scoped.attachments.list
|
|
511
|
+
.use(cap('attachments'))
|
|
512
|
+
.use(requires('inventory.asset.view'))
|
|
513
|
+
.handler(({ input, context }) =>
|
|
514
|
+
run(context, input.workspaceId, async (tx) => {
|
|
515
|
+
await svc.assets.get(tx, input.workspaceId, input.assetId)
|
|
516
|
+
return svc.attachments.list(tx, input.workspaceId, input.assetId)
|
|
517
|
+
}),
|
|
518
|
+
),
|
|
519
|
+
|
|
520
|
+
add: scoped.attachments.add
|
|
521
|
+
.use(cap('attachments'))
|
|
522
|
+
.use(requires('inventory.asset.manage'))
|
|
523
|
+
.handler(async ({ input, context }) => {
|
|
524
|
+
const actorId = context.principal.userId
|
|
525
|
+
// Core is asked what these files are **before** the transaction opens. It is a call over
|
|
526
|
+
// the broker, and awaiting one while holding a pooled connection is the failure
|
|
527
|
+
// `AssetService.codeFormat` documents — the pool starves under concurrent writes, and an
|
|
528
|
+
// attach fails outright whenever core is briefly away.
|
|
529
|
+
const files = await svc.attachments.describe(input.workspaceId, input.fileIds)
|
|
530
|
+
const written = await run(context, input.workspaceId, (tx) =>
|
|
531
|
+
svc.attachments.add(tx, input.workspaceId, actorId, input.assetId, input.repairId ?? null, files),
|
|
532
|
+
)
|
|
533
|
+
for (const activity of written.activities) await svc.notify.activity(activity)
|
|
534
|
+
// No event and no notification: a filed receipt is not something outside this module
|
|
535
|
+
// reacts to, and it is certainly not worth interrupting anybody about.
|
|
536
|
+
if (written.rows.length)
|
|
537
|
+
await svc.notify.change(input.workspaceId, 'asset', input.assetId, 'updated')
|
|
538
|
+
return written.rows.map(toAttachment)
|
|
539
|
+
}),
|
|
540
|
+
|
|
541
|
+
remove: scoped.attachments.remove
|
|
542
|
+
.use(cap('attachments'))
|
|
543
|
+
.use(requires('inventory.asset.manage'))
|
|
544
|
+
.handler(async ({ input, context }) => {
|
|
545
|
+
const actorId = context.principal.userId
|
|
546
|
+
const removed = await run(context, input.workspaceId, (tx) =>
|
|
547
|
+
svc.attachments.remove(tx, input.workspaceId, actorId, input.attachmentId),
|
|
548
|
+
)
|
|
549
|
+
await svc.notify.activity(removed.activity)
|
|
550
|
+
await svc.notify.change(input.workspaceId, 'asset', removed.row.assetId, 'updated')
|
|
551
|
+
return { id: removed.row.id }
|
|
552
|
+
}),
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
stats: {
|
|
556
|
+
/**
|
|
557
|
+
* Not behind a capability: it counts assets, which is `core`.
|
|
558
|
+
*
|
|
559
|
+
* The capability it does read is `repairs`, and it reads it here rather than as middleware —
|
|
560
|
+
* `outForRepair` comes back **null** for a workspace that does not track repairs, where 404
|
|
561
|
+
* would take the whole count line and the dashboard card away because a *different* feature
|
|
562
|
+
* is off. Read before the transaction opens, like every other settings lookup in this module.
|
|
563
|
+
*/
|
|
564
|
+
summary: scoped.stats.summary
|
|
565
|
+
.use(requires('inventory.asset.view'))
|
|
566
|
+
.handler(async ({ input, context }) => {
|
|
567
|
+
const on = await kernel.capabilities(input.workspaceId, MODULE_ID)
|
|
568
|
+
return run(context, input.workspaceId, (tx) =>
|
|
569
|
+
svc.stats.summary(tx, input.workspaceId, on.has('repairs')),
|
|
570
|
+
)
|
|
571
|
+
}),
|
|
572
|
+
},
|
|
117
573
|
})
|
|
118
574
|
}
|