@kernhq/module-inventory 0.1.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/LICENSE +662 -0
- package/README.md +32 -0
- package/dist/contract.d.ts +387 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +119 -0
- package/dist/contract.js.map +1 -0
- package/dist/server/_impl.d.ts +427 -0
- package/dist/server/_impl.d.ts.map +1 -0
- package/dist/server/_impl.js +204 -0
- package/dist/server/_impl.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +32 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/schema.d.ts +1552 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +175 -0
- package/dist/server/schema.js.map +1 -0
- package/drizzle.config.ts +8 -0
- package/migrations/0000_init.sql +129 -0
- package/migrations/0001_rls.sql +62 -0
- package/migrations/meta/0000_snapshot.json +994 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +106 -0
- package/src/client/api-instance.ts +28 -0
- package/src/client/api.ts +10 -0
- package/src/client/components/AssetFormDialog.svelte +185 -0
- package/src/client/i18n.ts +169 -0
- package/src/client/index.ts +26 -0
- package/src/client/mock.ts +95 -0
- package/src/client/module.ts +96 -0
- package/src/client/pages/AssetsPage.svelte +266 -0
- package/src/client/permissions.ts +30 -0
- package/src/client/query.ts +12 -0
- package/src/client/widgets/OverviewWidget.svelte +92 -0
- package/src/contract.ts +143 -0
- package/src/module.test.ts +79 -0
- package/src/server/_impl.ts +275 -0
- package/src/server/index.ts +33 -0
- package/src/server/schema.ts +228 -0
- package/tsconfig.client.json +10 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +5 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This module's guard rails. Keep this file: it is what stops the contract and the router drifting.
|
|
3
|
+
*
|
|
4
|
+
* It needs no database and no running service: it walks the contract and the router as data and
|
|
5
|
+
* checks the two things that are easy to forget and impossible for `tsc` to see.
|
|
6
|
+
*
|
|
7
|
+
* 1. every procedure the contract promises is actually implemented — a contract entry with no
|
|
8
|
+
* router entry type-checks perfectly and 404s at runtime;
|
|
9
|
+
* 2. every implemented procedure is behind `workspaceScoped()` *and* a `requires()` — a procedure
|
|
10
|
+
* that forgets the second one is readable by any member of any workspace with the module on.
|
|
11
|
+
*
|
|
12
|
+
* Add your module's real tests next to it; this one keeps working as the contract grows.
|
|
13
|
+
*/
|
|
14
|
+
import type { Kernel } from '@kernhq/kernel'
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { inventoryContract, inventoryEvents, inventoryPermissions, MODULE_ID } from './contract.js'
|
|
17
|
+
import { implement_ } from './server/_impl.js'
|
|
18
|
+
import { inventoryModule } from './server/index.js'
|
|
19
|
+
|
|
20
|
+
/** An oRPC procedure (contract or implementation) carries `~orpc`; a router group does not. */
|
|
21
|
+
interface Leaf {
|
|
22
|
+
'~orpc': {
|
|
23
|
+
route?: { method?: string; path?: string }
|
|
24
|
+
middlewares?: unknown[]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const isLeaf = (node: unknown): node is Leaf => typeof node === 'object' && node !== null && '~orpc' in node
|
|
28
|
+
|
|
29
|
+
/** `{ widgets: { list, create } }` → `{ 'widgets.list': leaf, 'widgets.create': leaf }` */
|
|
30
|
+
function leaves(node: unknown, path: string[] = []): Record<string, Leaf> {
|
|
31
|
+
if (isLeaf(node)) return { [path.join('.')]: node }
|
|
32
|
+
if (typeof node !== 'object' || node === null) return {}
|
|
33
|
+
return Object.entries(node).reduce<Record<string, Leaf>>(
|
|
34
|
+
(acc, [key, value]) => Object.assign(acc, leaves(value, [...path, key])),
|
|
35
|
+
{},
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The router is only inspected, never called, so it needs no real kernel behind it.
|
|
40
|
+
const declared = leaves(inventoryContract)
|
|
41
|
+
const implemented = leaves(implement_({} as Kernel))
|
|
42
|
+
|
|
43
|
+
describe('the contract and the router agree', () => {
|
|
44
|
+
it('implements every declared procedure, and nothing that was never declared', () => {
|
|
45
|
+
expect(Object.keys(implemented).sort()).toEqual(Object.keys(declared).sort())
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps the REST route the contract published', () => {
|
|
49
|
+
for (const [name, leaf] of Object.entries(implemented)) {
|
|
50
|
+
const contractRoute = declared[name]?.['~orpc'].route
|
|
51
|
+
expect(leaf['~orpc'].route?.method, `${name} method`).toBe(contractRoute?.method)
|
|
52
|
+
expect(leaf['~orpc'].route?.path, `${name} path`).toBe(contractRoute?.path)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('every procedure is authorised', () => {
|
|
58
|
+
it('carries both the workspace/module gate and a permission check', () => {
|
|
59
|
+
for (const [name, leaf] of Object.entries(implemented)) {
|
|
60
|
+
// `workspaceScoped(MODULE_ID)` + `requires('<permission>')`
|
|
61
|
+
expect(leaf['~orpc'].middlewares?.length ?? 0, `${name} middlewares`).toBeGreaterThanOrEqual(2)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('the module declares what it uses', () => {
|
|
67
|
+
it('names its permissions and events under its own module id', () => {
|
|
68
|
+
for (const p of inventoryPermissions) expect(p.key.startsWith(`${MODULE_ID}.`), p.key).toBe(true)
|
|
69
|
+
for (const e of Object.values(inventoryEvents))
|
|
70
|
+
expect(e.name.startsWith(`${MODULE_ID}.`), e.name).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('registers those permissions and events on the server module', () => {
|
|
74
|
+
expect(inventoryModule.definition.id).toBe(MODULE_ID)
|
|
75
|
+
expect(inventoryModule.definition.permissions).toBe(inventoryPermissions)
|
|
76
|
+
expect(inventoryModule.definition.events).toBe(inventoryEvents)
|
|
77
|
+
expect(inventoryModule.router, 'a module with a contract has to mount a router').toBeTypeOf('function')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineModule,
|
|
3
|
+
defineServerModule,
|
|
4
|
+
KernError,
|
|
5
|
+
type Kernel,
|
|
6
|
+
packageVersion,
|
|
7
|
+
type RequestContext,
|
|
8
|
+
requires,
|
|
9
|
+
type Tx,
|
|
10
|
+
uuidv7,
|
|
11
|
+
workspaceScoped,
|
|
12
|
+
} from '@kernhq/kernel'
|
|
13
|
+
import { implement } from '@orpc/server'
|
|
14
|
+
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
|
|
15
|
+
import { type Asset as AssetModel, inventoryContract, inventoryEvents, MODULE_ID } from '../contract.js'
|
|
16
|
+
import { assetHistory, assets, counters } from './schema.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The router, kept apart from `index.ts` so `module.test.ts` can walk it without booting a kernel.
|
|
20
|
+
*
|
|
21
|
+
* Two middlewares on every procedure, and the test fails if either is missing:
|
|
22
|
+
* `workspaceScoped` (a real membership, and the module switched on for that workspace) and
|
|
23
|
+
* `requires` (the permission this particular call needs).
|
|
24
|
+
*/
|
|
25
|
+
export { defineModule, defineServerModule, packageVersion }
|
|
26
|
+
|
|
27
|
+
const os = implement(inventoryContract).$context<RequestContext>()
|
|
28
|
+
|
|
29
|
+
/** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
|
|
30
|
+
function toAsset(row: typeof assets.$inferSelect): AssetModel {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
workspaceId: row.workspaceId as AssetModel['workspaceId'],
|
|
34
|
+
code: row.code,
|
|
35
|
+
name: row.name,
|
|
36
|
+
description: row.description,
|
|
37
|
+
categoryId: row.categoryId,
|
|
38
|
+
status: row.status,
|
|
39
|
+
custodianUserId: row.custodianUserId,
|
|
40
|
+
custodySince: row.custodySince?.toISOString() ?? null,
|
|
41
|
+
serialNumber: row.serialNumber,
|
|
42
|
+
location: row.location,
|
|
43
|
+
purchasedOn: row.purchasedOn ?? null,
|
|
44
|
+
purchasedFrom: row.purchasedFrom,
|
|
45
|
+
priceMinor: row.priceMinor,
|
|
46
|
+
currency: row.currency,
|
|
47
|
+
warrantyUntil: row.warrantyUntil ?? null,
|
|
48
|
+
photoFileId: row.photoFileId,
|
|
49
|
+
createdAt: row.createdAt.toISOString(),
|
|
50
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
51
|
+
archivedAt: row.archivedAt?.toISOString() ?? null,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The next asset tag for a workspace. One narrow row per workspace and key, incremented under the
|
|
57
|
+
* insert's conflict lock — two concurrent creates each read the value their own update returned, so
|
|
58
|
+
* codes stay unique without a retry loop. `INV-0042`, because people say asset tags out loud.
|
|
59
|
+
*/
|
|
60
|
+
async function nextCode(
|
|
61
|
+
tx: Parameters<Parameters<Kernel['database']['withWorkspace']>[1]>[0],
|
|
62
|
+
workspaceId: string,
|
|
63
|
+
) {
|
|
64
|
+
const [row] = await tx
|
|
65
|
+
.insert(counters)
|
|
66
|
+
.values({ workspaceId, key: 'asset_code', value: 1 })
|
|
67
|
+
.onConflictDoUpdate({
|
|
68
|
+
target: [counters.workspaceId, counters.key],
|
|
69
|
+
set: { value: sql`${counters.value} + 1` },
|
|
70
|
+
})
|
|
71
|
+
.returning()
|
|
72
|
+
return `INV-${String(row!.value).padStart(4, '0')}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function implement_(kernel: Kernel) {
|
|
76
|
+
const scoped = os.use(workspaceScoped(MODULE_ID))
|
|
77
|
+
|
|
78
|
+
const changed = (workspaceId: string, id: string, op: 'created' | 'updated' | 'deleted') =>
|
|
79
|
+
kernel.realtime.change(workspaceId, { module: MODULE_ID, entity: 'asset', id, op })
|
|
80
|
+
|
|
81
|
+
async function record(
|
|
82
|
+
tx: Tx,
|
|
83
|
+
input: { workspaceId: string },
|
|
84
|
+
assetId: string,
|
|
85
|
+
actorId: string | null | undefined,
|
|
86
|
+
action: string,
|
|
87
|
+
changes: { field: string; from: unknown; to: unknown }[] = [],
|
|
88
|
+
data?: Record<string, unknown>,
|
|
89
|
+
) {
|
|
90
|
+
await tx.insert(assetHistory).values({
|
|
91
|
+
id: uuidv7(),
|
|
92
|
+
workspaceId: input.workspaceId,
|
|
93
|
+
assetId,
|
|
94
|
+
actorId: actorId ?? null,
|
|
95
|
+
action,
|
|
96
|
+
changes,
|
|
97
|
+
data: data ?? null,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return os.router({
|
|
102
|
+
assets: {
|
|
103
|
+
list: scoped.assets.list.use(requires('inventory.asset.view')).handler(({ input }) =>
|
|
104
|
+
kernel.database.withWorkspace(input.workspaceId, async (tx) => {
|
|
105
|
+
const filters = [eq(assets.workspaceId, input.workspaceId)]
|
|
106
|
+
if (input.q) {
|
|
107
|
+
// A code is something somebody reads off a sticker; matching name, code and serial
|
|
108
|
+
// loosely matters more than word-splitting here. Full-text arrives with the core indexer.
|
|
109
|
+
filters.push(
|
|
110
|
+
or(
|
|
111
|
+
ilike(assets.name, `%${input.q}%`),
|
|
112
|
+
ilike(assets.code, `%${input.q}%`),
|
|
113
|
+
ilike(sql`coalesce(${assets.serialNumber}, '')`, `%${input.q}%`),
|
|
114
|
+
)!,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
if (input.categoryId) filters.push(eq(assets.categoryId, input.categoryId))
|
|
118
|
+
if (input.status) filters.push(eq(assets.status, input.status))
|
|
119
|
+
|
|
120
|
+
const order =
|
|
121
|
+
input.sort === 'name' ? assets.name : input.sort === 'code' ? assets.code : desc(assets.createdAt)
|
|
122
|
+
|
|
123
|
+
const rows = await tx
|
|
124
|
+
.select()
|
|
125
|
+
.from(assets)
|
|
126
|
+
.where(and(...filters))
|
|
127
|
+
.orderBy(order)
|
|
128
|
+
.limit(input.limit)
|
|
129
|
+
return { items: rows.map(toAsset), nextCursor: null }
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
|
|
133
|
+
get: scoped.assets.get.use(requires('inventory.asset.view')).handler(({ input }) =>
|
|
134
|
+
kernel.database.withWorkspace(input.workspaceId, async (tx) => {
|
|
135
|
+
const [row] = await tx
|
|
136
|
+
.select()
|
|
137
|
+
.from(assets)
|
|
138
|
+
.where(and(eq(assets.workspaceId, input.workspaceId), eq(assets.id, input.assetId)))
|
|
139
|
+
if (!row) throw KernError.notFound('Asset')
|
|
140
|
+
return toAsset(row)
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
|
|
144
|
+
create: scoped.assets.create
|
|
145
|
+
.use(requires('inventory.asset.manage'))
|
|
146
|
+
.handler(async ({ input, context }) => {
|
|
147
|
+
const row = await kernel.database.withWorkspace(input.workspaceId, async (tx) => {
|
|
148
|
+
const [r] = await tx
|
|
149
|
+
.insert(assets)
|
|
150
|
+
.values({
|
|
151
|
+
id: uuidv7(),
|
|
152
|
+
workspaceId: input.workspaceId,
|
|
153
|
+
code: await nextCode(tx, input.workspaceId),
|
|
154
|
+
name: input.name,
|
|
155
|
+
description: input.description,
|
|
156
|
+
categoryId: input.categoryId ?? null,
|
|
157
|
+
serialNumber: input.serialNumber ?? null,
|
|
158
|
+
location: input.location ?? null,
|
|
159
|
+
purchasedFrom: input.purchasedFrom ?? null,
|
|
160
|
+
purchasedOn: input.purchasedOn ?? null,
|
|
161
|
+
warrantyUntil: input.warrantyUntil ?? null,
|
|
162
|
+
priceMinor: input.priceMinor ?? null,
|
|
163
|
+
currency: input.currency ?? null,
|
|
164
|
+
})
|
|
165
|
+
.returning()
|
|
166
|
+
await record(tx, input, r!.id, context.principal.userId, 'created')
|
|
167
|
+
return r!
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Both, every time. The event is for anything that reacts later; the realtime change is
|
|
171
|
+
// what redraws a screen somebody is looking at now. A mutation that does neither leaves
|
|
172
|
+
// the rest of the product believing the old answer.
|
|
173
|
+
await kernel.emit(
|
|
174
|
+
inventoryEvents.assetCreated,
|
|
175
|
+
{ assetId: row.id, workspaceId: input.workspaceId },
|
|
176
|
+
{ workspaceId: input.workspaceId, actorId: context.principal.userId },
|
|
177
|
+
)
|
|
178
|
+
await changed(input.workspaceId, row.id, 'created')
|
|
179
|
+
|
|
180
|
+
return toAsset(row)
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
update: scoped.assets.update
|
|
184
|
+
.use(requires('inventory.asset.manage'))
|
|
185
|
+
.handler(async ({ input, context }) => {
|
|
186
|
+
const row = await kernel.database.withWorkspace(input.workspaceId, async (tx) => {
|
|
187
|
+
const [prev] = await tx
|
|
188
|
+
.select()
|
|
189
|
+
.from(assets)
|
|
190
|
+
.where(and(eq(assets.workspaceId, input.workspaceId), eq(assets.id, input.assetId)))
|
|
191
|
+
.for('update')
|
|
192
|
+
if (!prev) throw KernError.notFound('Asset')
|
|
193
|
+
|
|
194
|
+
const patch = {
|
|
195
|
+
name: input.name ?? prev.name,
|
|
196
|
+
description: input.description ?? prev.description,
|
|
197
|
+
categoryId: input.categoryId !== undefined ? (input.categoryId ?? null) : prev.categoryId,
|
|
198
|
+
serialNumber:
|
|
199
|
+
input.serialNumber !== undefined ? (input.serialNumber ?? null) : prev.serialNumber,
|
|
200
|
+
location: input.location !== undefined ? (input.location ?? null) : prev.location,
|
|
201
|
+
purchasedFrom:
|
|
202
|
+
input.purchasedFrom !== undefined ? (input.purchasedFrom ?? null) : prev.purchasedFrom,
|
|
203
|
+
purchasedOn: input.purchasedOn !== undefined ? (input.purchasedOn ?? null) : prev.purchasedOn,
|
|
204
|
+
warrantyUntil:
|
|
205
|
+
input.warrantyUntil !== undefined ? (input.warrantyUntil ?? null) : prev.warrantyUntil,
|
|
206
|
+
priceMinor: input.priceMinor !== undefined ? (input.priceMinor ?? null) : prev.priceMinor,
|
|
207
|
+
currency: input.currency !== undefined ? (input.currency ?? null) : prev.currency,
|
|
208
|
+
updatedAt: new Date(),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const [r] = await tx.update(assets).set(patch).where(eq(assets.id, input.assetId)).returning()
|
|
212
|
+
|
|
213
|
+
// Field-level diffs are the timeline — write only what actually moved.
|
|
214
|
+
const fields = [
|
|
215
|
+
'name',
|
|
216
|
+
'description',
|
|
217
|
+
'categoryId',
|
|
218
|
+
'serialNumber',
|
|
219
|
+
'location',
|
|
220
|
+
'purchasedFrom',
|
|
221
|
+
'purchasedOn',
|
|
222
|
+
'warrantyUntil',
|
|
223
|
+
'priceMinor',
|
|
224
|
+
'currency',
|
|
225
|
+
] as const
|
|
226
|
+
const iso = (v: unknown) => (v instanceof Date ? v.toISOString() : v) ?? null
|
|
227
|
+
const changes = fields
|
|
228
|
+
.filter((f) => iso(patch[f]) !== iso(prev[f]))
|
|
229
|
+
.map((f) => ({ field: f, from: iso(prev[f]), to: iso(patch[f]) }))
|
|
230
|
+
if (changes.length > 0)
|
|
231
|
+
await record(tx, input, input.assetId, context.principal.userId, 'updated', changes)
|
|
232
|
+
|
|
233
|
+
return r!
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await kernel.emit(
|
|
237
|
+
inventoryEvents.assetUpdated,
|
|
238
|
+
{ assetId: row.id, workspaceId: input.workspaceId },
|
|
239
|
+
{ workspaceId: input.workspaceId, actorId: context.principal.userId },
|
|
240
|
+
)
|
|
241
|
+
await changed(input.workspaceId, row.id, 'updated')
|
|
242
|
+
return toAsset(row)
|
|
243
|
+
}),
|
|
244
|
+
|
|
245
|
+
archive: scoped.assets.archive
|
|
246
|
+
.use(requires('inventory.asset.manage'))
|
|
247
|
+
.handler(async ({ input, context }) => {
|
|
248
|
+
const row = await kernel.database.withWorkspace(input.workspaceId, async (tx) => {
|
|
249
|
+
const [r] = await tx
|
|
250
|
+
.update(assets)
|
|
251
|
+
.set({ archivedAt: input.archived ? new Date() : null, updatedAt: new Date() })
|
|
252
|
+
.where(and(eq(assets.workspaceId, input.workspaceId), eq(assets.id, input.assetId)))
|
|
253
|
+
.returning()
|
|
254
|
+
if (!r) throw KernError.notFound('Asset')
|
|
255
|
+
await record(
|
|
256
|
+
tx,
|
|
257
|
+
input,
|
|
258
|
+
input.assetId,
|
|
259
|
+
context.principal.userId,
|
|
260
|
+
input.archived ? 'retired' : 'restored',
|
|
261
|
+
)
|
|
262
|
+
return r
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
await kernel.emit(
|
|
266
|
+
inventoryEvents.assetArchived,
|
|
267
|
+
{ assetId: row.id, workspaceId: input.workspaceId },
|
|
268
|
+
{ workspaceId: input.workspaceId, actorId: context.principal.userId },
|
|
269
|
+
)
|
|
270
|
+
await changed(input.workspaceId, row.id, 'updated')
|
|
271
|
+
return toAsset(row)
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { inventoryContract, inventoryEvents, inventoryPermissions, MODULE_ID } from '../contract.js'
|
|
4
|
+
import { defineModule, defineServerModule, implement_, packageVersion } from './_impl.js'
|
|
5
|
+
import { schema } from './schema.js'
|
|
6
|
+
|
|
7
|
+
export const inventoryModule = defineServerModule({
|
|
8
|
+
definition: defineModule({
|
|
9
|
+
id: MODULE_ID,
|
|
10
|
+
name: 'Inventory',
|
|
11
|
+
version: packageVersion(import.meta.url),
|
|
12
|
+
description:
|
|
13
|
+
'The asset register: what the company owns, who holds each item, and everything that happened to it',
|
|
14
|
+
icon: 'briefcase',
|
|
15
|
+
permissions: inventoryPermissions,
|
|
16
|
+
events: inventoryEvents,
|
|
17
|
+
}),
|
|
18
|
+
/** Attached so the developer panel can check the router against what was promised. */
|
|
19
|
+
contract: inventoryContract,
|
|
20
|
+
schema,
|
|
21
|
+
migrationsFolder: join(dirname(fileURLToPath(import.meta.url)), '../../migrations'),
|
|
22
|
+
router: implement_,
|
|
23
|
+
/**
|
|
24
|
+
* What this module reacts to. The pattern may be an exact name, `module.*`, or `*`; handlers are
|
|
25
|
+
* durable consumers in production, so one that throws is retried rather than lost.
|
|
26
|
+
*/
|
|
27
|
+
subscriptions: {
|
|
28
|
+
'core.workspace.created': async (event, kernel) => {
|
|
29
|
+
kernel.log.info({ module: MODULE_ID, event: event.name }, 'a workspace was created')
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
export default inventoryModule
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { moduleSchema } from '@kernhq/kernel'
|
|
2
|
+
import { sql } from 'drizzle-orm'
|
|
3
|
+
import {
|
|
4
|
+
boolean,
|
|
5
|
+
char,
|
|
6
|
+
date,
|
|
7
|
+
index,
|
|
8
|
+
integer,
|
|
9
|
+
jsonb,
|
|
10
|
+
pgEnum,
|
|
11
|
+
text,
|
|
12
|
+
timestamp,
|
|
13
|
+
uniqueIndex,
|
|
14
|
+
uuid,
|
|
15
|
+
} from 'drizzle-orm/pg-core'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* This module's tables, in its own Postgres schema.
|
|
19
|
+
*
|
|
20
|
+
* Two rules, neither optional:
|
|
21
|
+
*
|
|
22
|
+
* - every tenant table carries `workspace_id` and an index that starts with it;
|
|
23
|
+
* - every tenant table gets a row-level security policy, hand-written in the migration, because
|
|
24
|
+
* drizzle-kit does not generate one. RLS is the last line — the API check is the first, and
|
|
25
|
+
* somebody will eventually write a query that skips it.
|
|
26
|
+
*/
|
|
27
|
+
export const schema = moduleSchema('inventory')
|
|
28
|
+
|
|
29
|
+
/** Local column factories, so the conventions stay in one place. */
|
|
30
|
+
const id = () => uuid('id').primaryKey().default(sql`uuidv7()`)
|
|
31
|
+
const ws = () => uuid('workspace_id').notNull()
|
|
32
|
+
const ts = (name: string) => timestamp(name, { withTimezone: true, mode: 'date' })
|
|
33
|
+
const created = () => ts('created_at').notNull().defaultNow()
|
|
34
|
+
const updated = () => ts('updated_at').notNull().defaultNow()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Asset lifecycle. Stored rather than derived because every list filter asks for it; every
|
|
38
|
+
* transition is written inside the same transaction as the row it derives from.
|
|
39
|
+
*/
|
|
40
|
+
export const assetStatus = pgEnum('asset_status', ['in_stock', 'assigned', 'under_repair', 'retired'])
|
|
41
|
+
|
|
42
|
+
export const counters = schema.table(
|
|
43
|
+
'counters',
|
|
44
|
+
/** Per-workspace sequence sources (`asset_code`). Narrow on purpose: one row per key. */
|
|
45
|
+
{
|
|
46
|
+
workspaceId: ws().primaryKey(),
|
|
47
|
+
key: text('key').primaryKey(),
|
|
48
|
+
value: integer('value').notNull(),
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
export const categories = schema.table(
|
|
53
|
+
'categories',
|
|
54
|
+
{
|
|
55
|
+
id: id(),
|
|
56
|
+
workspaceId: ws(),
|
|
57
|
+
name: text('name').notNull(),
|
|
58
|
+
order: integer('order').notNull().default(0),
|
|
59
|
+
createdAt: created(),
|
|
60
|
+
updatedAt: updated(),
|
|
61
|
+
},
|
|
62
|
+
(t) => [
|
|
63
|
+
uniqueIndex('inventory_categories_ws_name_uq').on(t.workspaceId, t.name),
|
|
64
|
+
index('inventory_categories_ws_idx').on(t.workspaceId, t.order),
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
export const assets = schema.table(
|
|
69
|
+
'assets',
|
|
70
|
+
{
|
|
71
|
+
id: id(),
|
|
72
|
+
workspaceId: ws(),
|
|
73
|
+
code: text('code').notNull(),
|
|
74
|
+
name: text('name').notNull(),
|
|
75
|
+
description: text('description').notNull().default(''),
|
|
76
|
+
categoryId: uuid('category_id'),
|
|
77
|
+
status: assetStatus('status').notNull().default('in_stock'),
|
|
78
|
+
/** Denormalized from `custody_periods`, which stays authoritative for history. */
|
|
79
|
+
custodianUserId: uuid('custodian_user_id'),
|
|
80
|
+
custodySince: ts('custody_since'),
|
|
81
|
+
serialNumber: text('serial_number'),
|
|
82
|
+
location: text('location'),
|
|
83
|
+
purchasedOn: date('purchased_on'),
|
|
84
|
+
purchasedFrom: text('purchased_from'),
|
|
85
|
+
priceMinor: integer('price_minor'),
|
|
86
|
+
currency: char('currency', { length: 3 }),
|
|
87
|
+
warrantyUntil: date('warranty_until'),
|
|
88
|
+
photoFileId: uuid('photo_file_id'),
|
|
89
|
+
createdAt: created(),
|
|
90
|
+
updatedAt: updated(),
|
|
91
|
+
archivedAt: ts('archived_at'),
|
|
92
|
+
},
|
|
93
|
+
(t) => [
|
|
94
|
+
uniqueIndex('inventory_assets_ws_code_uq').on(t.workspaceId, t.code),
|
|
95
|
+
index('inventory_assets_ws_created_idx').on(t.workspaceId, t.createdAt),
|
|
96
|
+
index('inventory_assets_ws_status_idx').on(t.workspaceId, t.status),
|
|
97
|
+
index('inventory_assets_ws_category_idx').on(t.workspaceId, t.categoryId),
|
|
98
|
+
// The "what leaves warranty this month" scan, before a job makes it a widget's cheap query.
|
|
99
|
+
index('inventory_assets_ws_warranty_idx')
|
|
100
|
+
.on(t.workspaceId, t.warrantyUntil)
|
|
101
|
+
.where(sql`warranty_until is not null`),
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Workspace-defined extra fields, copied from tracker's `field_defs` (which cannot be shared:
|
|
107
|
+
* cross-schema joins are what the module boundary exists to prevent). The `key` **is** the key a
|
|
108
|
+
* value lives under in `assets.custom`, hence unique per workspace whatever the category scope.
|
|
109
|
+
*/
|
|
110
|
+
export const fieldDefs = schema.table(
|
|
111
|
+
'field_defs',
|
|
112
|
+
{
|
|
113
|
+
id: id(),
|
|
114
|
+
workspaceId: ws(),
|
|
115
|
+
categoryId: uuid('category_id'),
|
|
116
|
+
key: text('key').notNull(),
|
|
117
|
+
name: text('name').notNull(),
|
|
118
|
+
description: text('description'),
|
|
119
|
+
type: text('type').notNull(), // text | number | date | select | multiselect | checkbox | url
|
|
120
|
+
options: jsonb('options').$type<string[]>(),
|
|
121
|
+
defaultValue: jsonb('default_value'),
|
|
122
|
+
required: boolean('required').notNull().default(false),
|
|
123
|
+
searchable: boolean('searchable').notNull().default(false),
|
|
124
|
+
showInList: boolean('show_in_list').notNull().default(false),
|
|
125
|
+
order: integer('order').notNull().default(0),
|
|
126
|
+
archivedAt: ts('archived_at'),
|
|
127
|
+
createdAt: created(),
|
|
128
|
+
updatedAt: updated(),
|
|
129
|
+
},
|
|
130
|
+
(t) => [
|
|
131
|
+
uniqueIndex('inventory_field_defs_ws_key_uq').on(t.workspaceId, t.key),
|
|
132
|
+
index('inventory_field_defs_ws_idx').on(t.workspaceId, t.order),
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Custody over time. Never updated in place — a change closes the open row and inserts a new one,
|
|
138
|
+
* the way HR keeps employments. The exclusion constraint (hand-written migration) makes two open
|
|
139
|
+
* periods for one asset impossible at the database level, not merely unlikely in the service.
|
|
140
|
+
*/
|
|
141
|
+
export const custodyPeriods = schema.table(
|
|
142
|
+
'custody_periods',
|
|
143
|
+
{
|
|
144
|
+
id: id(),
|
|
145
|
+
workspaceId: ws(),
|
|
146
|
+
assetId: uuid('asset_id').notNull(),
|
|
147
|
+
userId: uuid('user_id').notNull(),
|
|
148
|
+
note: text('note'),
|
|
149
|
+
effectiveFrom: ts('effective_from').notNull().defaultNow(),
|
|
150
|
+
effectiveTo: ts('effective_to'),
|
|
151
|
+
createdBy: uuid('created_by'),
|
|
152
|
+
createdAt: created(),
|
|
153
|
+
},
|
|
154
|
+
(t) => [index('inventory_custody_ws_asset_idx').on(t.workspaceId, t.assetId, t.effectiveFrom)],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Append-only. What changed, when, by whom — the timeline an asset page renders and the answer to
|
|
159
|
+
* "who had this laptop before me". Written inside the caller's transaction; nothing edits or
|
|
160
|
+
* deletes rows here, ever.
|
|
161
|
+
*/
|
|
162
|
+
export const assetHistory = schema.table(
|
|
163
|
+
'asset_history',
|
|
164
|
+
{
|
|
165
|
+
id: id(),
|
|
166
|
+
workspaceId: ws(),
|
|
167
|
+
assetId: uuid('asset_id').notNull(),
|
|
168
|
+
actorId: uuid('actor_id'),
|
|
169
|
+
action: text('action').notNull(), // created | updated | transferred | returned | repair_logged | repair_completed | attachment_added | retired | restored
|
|
170
|
+
changes: jsonb('changes').$type<{ field: string; from: unknown; to: unknown }[]>(),
|
|
171
|
+
data: jsonb('data'),
|
|
172
|
+
occurredAt: created(),
|
|
173
|
+
},
|
|
174
|
+
(t) => [index('inventory_asset_history_asset_idx').on(t.workspaceId, t.assetId, t.occurredAt)],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
export const repairs = schema.table(
|
|
178
|
+
'repairs',
|
|
179
|
+
{
|
|
180
|
+
id: id(),
|
|
181
|
+
workspaceId: ws(),
|
|
182
|
+
assetId: uuid('asset_id').notNull(),
|
|
183
|
+
summary: text('summary').notNull(),
|
|
184
|
+
detail: text('detail'),
|
|
185
|
+
vendor: text('vendor'),
|
|
186
|
+
costMinor: integer('cost_minor'),
|
|
187
|
+
currency: char('currency', { length: 3 }),
|
|
188
|
+
sentOn: date('sent_on').notNull(),
|
|
189
|
+
/** Null while the item is still away — also how `under_repair` is derived. */
|
|
190
|
+
returnedOn: date('returned_on'),
|
|
191
|
+
createdBy: uuid('created_by'),
|
|
192
|
+
createdAt: created(),
|
|
193
|
+
updatedAt: updated(),
|
|
194
|
+
},
|
|
195
|
+
(t) => [index('inventory_repairs_ws_asset_idx').on(t.workspaceId, t.assetId, t.sentOn)],
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
/** Bytes live in core object storage via `uploadFile`; this only records that an asset has one. */
|
|
199
|
+
export const attachments = schema.table(
|
|
200
|
+
'attachments',
|
|
201
|
+
{
|
|
202
|
+
id: id(),
|
|
203
|
+
workspaceId: ws(),
|
|
204
|
+
assetId: uuid('asset_id').notNull(),
|
|
205
|
+
repairId: uuid('repair_id'),
|
|
206
|
+
fileId: uuid('file_id').notNull(),
|
|
207
|
+
name: text('name').notNull(),
|
|
208
|
+
mimeType: text('mime_type'),
|
|
209
|
+
size: integer('size'),
|
|
210
|
+
uploadedBy: uuid('uploaded_by'),
|
|
211
|
+
createdAt: created(),
|
|
212
|
+
},
|
|
213
|
+
(t) => [
|
|
214
|
+
uniqueIndex('inventory_attachments_asset_file_uq').on(t.assetId, t.fileId),
|
|
215
|
+
index('inventory_attachments_ws_asset_idx').on(t.workspaceId, t.assetId, t.createdAt),
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
/** Every tenant table, so the RLS migration can be checked against one list rather than memory. */
|
|
220
|
+
export const TENANT_TABLES = [
|
|
221
|
+
'categories',
|
|
222
|
+
'assets',
|
|
223
|
+
'field_defs',
|
|
224
|
+
'custody_periods',
|
|
225
|
+
'asset_history',
|
|
226
|
+
'repairs',
|
|
227
|
+
'attachments',
|
|
228
|
+
] as const
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"]
|
|
8
|
+
},
|
|
9
|
+
"include": ["src/client/**/*.ts", "src/client/**/*.svelte", "src/contract.ts"]
|
|
10
|
+
}
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
// `passWithNoTests` so a copy of this package that has not written its first test yet still reports a
|
|
4
|
+
// green `pnpm test` for the whole workspace instead of failing on "no test files found".
|
|
5
|
+
export default defineConfig({ test: { include: ['src/**/*.test.ts'], passWithNoTests: true } })
|