@kernhq/module-inventory 0.1.2 → 0.2.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 +55 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +52 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +33 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +22 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +146 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +107 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +22 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +26 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +42 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +18 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +29 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +4 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +9 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
- package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
- package/dist/server/router.js +83 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +25 -11
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +28 -10
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +73 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +261 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/index.d.ts +10 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +15 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/notify.d.ts +63 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +105 -0
- package/dist/server/services/notify.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/_journal.json +2 -2
- package/package.json +5 -4
- package/src/client/api.ts +1 -1
- package/src/client/components/AssetFormDialog.svelte +130 -47
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/messages.test.ts +169 -0
- package/src/client/messages.ts +399 -0
- package/src/client/mock.test.ts +161 -0
- package/src/client/mock.ts +267 -45
- package/src/client/module.ts +22 -2
- package/src/client/pages/AssetsPage.svelte +358 -137
- package/src/client/permissions.ts +1 -1
- package/src/client/price.test.ts +106 -0
- package/src/client/price.ts +135 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +15 -2
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/settings/core-api.ts +32 -0
- package/src/client/widgets/OverviewWidget.svelte +16 -3
- package/src/contract/capabilities.ts +55 -0
- package/src/contract/events.ts +34 -0
- package/src/contract/index.ts +15 -0
- package/src/contract/models.ts +123 -0
- package/src/contract/permissions.ts +26 -0
- package/src/contract/router.ts +46 -0
- package/src/contract/settings.ts +30 -0
- package/src/module.test.ts +140 -7
- package/src/server/index.ts +16 -13
- package/src/server/inventory.int.test.ts +819 -0
- package/src/server/migrations.test.ts +138 -0
- package/src/server/router.ts +118 -0
- package/src/server/schema.ts +27 -10
- package/src/server/services/assets.ts +368 -0
- package/src/server/services/index.ts +23 -0
- package/src/server/services/notify.ts +151 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
package/src/server/_impl.ts
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
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
|
-
}
|