@kernhq/module-inventory 0.1.1 → 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.
Files changed (103) hide show
  1. package/README.md +55 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +52 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +33 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +22 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +15 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +15 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +146 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +107 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/permissions.d.ts +22 -0
  19. package/dist/contract/permissions.d.ts.map +1 -0
  20. package/dist/contract/permissions.js +26 -0
  21. package/dist/contract/permissions.js.map +1 -0
  22. package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
  23. package/dist/contract/router.d.ts.map +1 -0
  24. package/dist/contract/router.js +42 -0
  25. package/dist/contract/router.js.map +1 -0
  26. package/dist/contract/settings.d.ts +18 -0
  27. package/dist/contract/settings.d.ts.map +1 -0
  28. package/dist/contract/settings.js +29 -0
  29. package/dist/contract/settings.js.map +1 -0
  30. package/dist/server/index.d.ts +4 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +9 -13
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
  35. package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
  36. package/dist/server/router.js +83 -0
  37. package/dist/server/router.js.map +1 -0
  38. package/dist/server/schema.d.ts +25 -11
  39. package/dist/server/schema.d.ts.map +1 -1
  40. package/dist/server/schema.js +28 -10
  41. package/dist/server/schema.js.map +1 -1
  42. package/dist/server/services/assets.d.ts +73 -0
  43. package/dist/server/services/assets.d.ts.map +1 -0
  44. package/dist/server/services/assets.js +261 -0
  45. package/dist/server/services/assets.js.map +1 -0
  46. package/dist/server/services/index.d.ts +10 -0
  47. package/dist/server/services/index.d.ts.map +1 -0
  48. package/dist/server/services/index.js +15 -0
  49. package/dist/server/services/index.js.map +1 -0
  50. package/dist/server/services/notify.d.ts +63 -0
  51. package/dist/server/services/notify.d.ts.map +1 -0
  52. package/dist/server/services/notify.js +105 -0
  53. package/dist/server/services/notify.js.map +1 -0
  54. package/migrations/0000_init.sql +12 -3
  55. package/migrations/0001_rls.sql +24 -0
  56. package/migrations/meta/0000_snapshot.json +40 -13
  57. package/migrations/meta/_journal.json +2 -2
  58. package/package.json +12 -9
  59. package/src/client/api.ts +1 -1
  60. package/src/client/components/AssetFormDialog.svelte +130 -47
  61. package/src/client/i18n.ts +11 -166
  62. package/src/client/index.ts +8 -1
  63. package/src/client/messages.test.ts +169 -0
  64. package/src/client/messages.ts +399 -0
  65. package/src/client/mock.test.ts +161 -0
  66. package/src/client/mock.ts +267 -45
  67. package/src/client/module.ts +22 -2
  68. package/src/client/pages/AssetsPage.svelte +358 -137
  69. package/src/client/permissions.ts +1 -1
  70. package/src/client/price.test.ts +106 -0
  71. package/src/client/price.ts +135 -0
  72. package/src/client/query.test.ts +58 -0
  73. package/src/client/query.ts +15 -2
  74. package/src/client/settings/GeneralSettings.svelte +0 -0
  75. package/src/client/settings/core-api.ts +32 -0
  76. package/src/client/widgets/OverviewWidget.svelte +16 -3
  77. package/src/contract/capabilities.ts +55 -0
  78. package/src/contract/events.ts +34 -0
  79. package/src/contract/index.ts +15 -0
  80. package/src/contract/models.ts +123 -0
  81. package/src/contract/permissions.ts +26 -0
  82. package/src/contract/router.ts +46 -0
  83. package/src/contract/settings.ts +30 -0
  84. package/src/module.test.ts +140 -7
  85. package/src/server/index.ts +16 -13
  86. package/src/server/inventory.int.test.ts +819 -0
  87. package/src/server/migrations.test.ts +138 -0
  88. package/src/server/router.ts +118 -0
  89. package/src/server/schema.ts +27 -10
  90. package/src/server/services/assets.ts +368 -0
  91. package/src/server/services/index.ts +23 -0
  92. package/src/server/services/notify.ts +151 -0
  93. package/tsconfig.base.json +22 -0
  94. package/tsconfig.client.json +1 -1
  95. package/tsconfig.json +1 -1
  96. package/vitest.config.ts +18 -3
  97. package/dist/contract.d.ts.map +0 -1
  98. package/dist/contract.js +0 -119
  99. package/dist/contract.js.map +0 -1
  100. package/dist/server/_impl.js +0 -204
  101. package/dist/server/_impl.js.map +0 -1
  102. package/src/contract.ts +0 -143
  103. package/src/server/_impl.ts +0 -275
@@ -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
- }