@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.
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 +5 -4
  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
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatPrice, parsePrice } from './price.js'
3
+
4
+ /**
5
+ * The regression this file exists for.
6
+ *
7
+ * `raw.replace(',', '.')` replaced the first comma only, so `1.234,56` — how German, Turkish and
8
+ * Persian write twelve hundred — parsed as `1.23456` and was stored as €1.23. Three of the five
9
+ * languages this module ships, silently, with no error anywhere. `abc` and `-5` were worse: they
10
+ * came back `null` and the asset saved with no price at all.
11
+ */
12
+ const minor = (raw: string, locale: string) => {
13
+ const result = parsePrice(raw, locale)
14
+ return result.ok ? result.minor : 'invalid'
15
+ }
16
+
17
+ describe('parsePrice', () => {
18
+ it('reads a grouped price in each locale the module ships', () => {
19
+ expect(minor('1,234.56', 'en')).toBe(123456)
20
+ expect(minor('1.234,56', 'de')).toBe(123456)
21
+ expect(minor('1.234,56', 'tr')).toBe(123456)
22
+ // Persian groups with ٬ and points with ٫, in Persian digits.
23
+ expect(minor('۱٬۲۳۴٫۵۶', 'fa')).toBe(123456)
24
+ expect(minor('1,234.56', 'ar')).toBe(123456)
25
+ })
26
+
27
+ it('reads an ungrouped price in each locale', () => {
28
+ expect(minor('1234.56', 'en')).toBe(123456)
29
+ expect(minor('1234,56', 'de')).toBe(123456)
30
+ expect(minor('1234,56', 'tr')).toBe(123456)
31
+ expect(minor('۱۲۳۴٫۵۶', 'fa')).toBe(123456)
32
+ })
33
+
34
+ it('folds Persian and Arabic digits, so a person may type their own numerals', () => {
35
+ expect(minor('۱۹٫۹۹', 'fa')).toBe(1999)
36
+ expect(minor('١٩.٩٩', 'ar')).toBe(1999)
37
+ expect(minor('۲۵', 'fa')).toBe(2500)
38
+ })
39
+
40
+ it('treats an empty field as no price rather than a bad one', () => {
41
+ expect(minor('', 'en')).toBe(null)
42
+ expect(minor(' ', 'de')).toBe(null)
43
+ })
44
+
45
+ it('rejects what it cannot read instead of saving nothing', () => {
46
+ // Both of these used to return null, and the asset was filed with no price.
47
+ expect(minor('abc', 'en')).toBe('invalid')
48
+ expect(minor('-5', 'en')).toBe('invalid')
49
+ expect(minor('1.2.3', 'en')).toBe('invalid')
50
+ expect(minor('12,34', 'en')).toBe('invalid')
51
+ expect(minor('$12.00', 'en')).toBe('invalid')
52
+ })
53
+
54
+ /**
55
+ * The 100× that has no visible symptom: `.` is German's *group* separator, so stripping it would
56
+ * store 123456 for somebody who meant 1234.56. There is no way to tell which they meant, so the
57
+ * field asks rather than guesses.
58
+ */
59
+ it('rejects a group separator that does not group three digits', () => {
60
+ expect(minor('1234.56', 'de')).toBe('invalid')
61
+ expect(minor('1234.56', 'tr')).toBe('invalid')
62
+ expect(minor('1.23.456', 'de')).toBe('invalid')
63
+ expect(minor('1,2345', 'en')).toBe('invalid')
64
+ })
65
+
66
+ it('keeps a bare decimal, with or without its leading zero', () => {
67
+ expect(minor('0.5', 'en')).toBe(50)
68
+ expect(minor('.5', 'en')).toBe(50)
69
+ expect(minor(',5', 'de')).toBe(50)
70
+ })
71
+
72
+ it('does not lose a cent to floating point', () => {
73
+ expect(minor('19.99', 'en')).toBe(1999)
74
+ expect(minor('0.07', 'en')).toBe(7)
75
+ expect(minor('8.29', 'en')).toBe(829)
76
+ })
77
+ })
78
+
79
+ describe('formatPrice', () => {
80
+ it('has no price to show for no price', () => {
81
+ expect(formatPrice(null, 'en')).toBe('')
82
+ expect(formatPrice(undefined, 'de')).toBe('')
83
+ })
84
+
85
+ /**
86
+ * What the edit form seeds itself with has to be something the same locale can parse back, or
87
+ * opening an asset and saving it unchanged would report the stored price as invalid.
88
+ */
89
+ it('round-trips through parsePrice in every locale', () => {
90
+ for (const locale of ['en', 'de', 'tr', 'fa', 'ar']) {
91
+ for (const value of [0, 7, 2500, 123456, 99999999]) {
92
+ expect({ locale, value, back: minor(formatPrice(value, locale), locale) }).toEqual({
93
+ locale,
94
+ value,
95
+ back: value,
96
+ })
97
+ }
98
+ }
99
+ })
100
+
101
+ it('writes the reader’s own digits', () => {
102
+ expect(formatPrice(123456, 'fa')).toContain('۴')
103
+ expect(formatPrice(123456, 'de')).toBe('1234,56')
104
+ expect(formatPrice(123456, 'en')).toBe('1234.56')
105
+ })
106
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Reading and writing a price the way the person in front of the form writes it.
3
+ *
4
+ * **This is the file that shipped a silent 100× data loss.** The dialog parsed a price with
5
+ * `Number.parseFloat(raw.replace(',', '.'))`, and `String.replace` with a string pattern replaces
6
+ * only the *first* match — so a German, Turkish or Persian reader typing `1.234,56`, which is how
7
+ * three of the five languages this module ships write twelve hundred, got `1.23456` parsed to
8
+ * `1.23` and stored as €1.23. `1,234.56` in English became the same. Neither said anything: the
9
+ * old parser returned `null` for garbage and the asset was simply saved with no price at all, so
10
+ * `abc` and `-5` both vanished without an error.
11
+ *
12
+ * So: no separator is assumed, the locale is asked what its own are, and anything that is not a
13
+ * plain non-negative number in *that* locale comes back as a rejection the form can show. There is
14
+ * no third outcome where a wrong number is stored quietly.
15
+ *
16
+ * Pure on purpose — the locale arrives as an argument rather than being read from `@kernhq/ui`.
17
+ * That entry point reaches Svelte components, and a parser that drags a compiler behind it is a
18
+ * parser nothing can unit-test. The dialog passes `messageLocale()`.
19
+ */
20
+
21
+ /** `minor: null` is a real answer — an empty field means "no price", not a bad one. */
22
+ export type PriceResult = { ok: true; minor: number | null } | { ok: false }
23
+
24
+ interface Separators {
25
+ group: string
26
+ decimal: string
27
+ }
28
+
29
+ const SEPARATORS: Map<string, Separators> = new Map()
30
+
31
+ /**
32
+ * What this locale's own numbers look like, asked of `Intl` rather than tabulated here.
33
+ *
34
+ * A table of separators per language is a table that is wrong for the sixth language somebody adds.
35
+ * Measured for the five shipped today: en and ar group with `,` and point with `.`, de and tr are
36
+ * the other way round, and fa uses `٬` and `٫`.
37
+ */
38
+ function separatorsFor(locale: string): Separators {
39
+ const cached = SEPARATORS.get(locale)
40
+ if (cached) return cached
41
+ const found: Separators = { group: ',', decimal: '.' }
42
+ try {
43
+ const parts = new Intl.NumberFormat(locale).formatToParts(12345.6)
44
+ found.group = parts.find((part) => part.type === 'group')?.value ?? found.group
45
+ found.decimal = parts.find((part) => part.type === 'decimal')?.value ?? found.decimal
46
+ } catch {
47
+ // A runtime that cannot name this locale must not make the field unusable; English separators
48
+ // are the fallback, and a wrong guess shows an error rather than storing a wrong number.
49
+ }
50
+ SEPARATORS.set(locale, found)
51
+ return found
52
+ }
53
+
54
+ /**
55
+ * A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither.
56
+ *
57
+ * Same fold HR's employee counter does, and for the same reason: a person typing the digits of
58
+ * their own language into their own language's form must not be told their input is not a number.
59
+ */
60
+ const toLatinDigits = (value: string) =>
61
+ value
62
+ .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
63
+ .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
64
+
65
+ /** Bidi controls ride along on anything copied out of an RTL document and are never separators. */
66
+ const BIDI = /[‎‏؜⁦-⁩]/g
67
+
68
+ /**
69
+ * A group separator always precedes exactly three digits.
70
+ *
71
+ * This is the rule that stops the quiet 100×: in German `.` groups, so `1234.56` is not "one
72
+ * thousand two hundred and thirty-four point five six" — it is not a well-formed German number at
73
+ * all. Stripping the dot would store €12,345.60 for a €1,234.56 laptop and nothing would say so.
74
+ * Rejecting it puts the question back to the person, which is the only honest answer available.
75
+ */
76
+ function isGroupedInteger(text: string, group: string): boolean {
77
+ if (/^\d+$/.test(text)) return true
78
+ if (!group || !text.includes(group)) return false
79
+ const [first, ...rest] = text.split(group)
80
+ if (first === undefined || !/^\d{1,3}$/.test(first)) return false
81
+ return rest.every((chunk) => /^\d{3}$/.test(chunk))
82
+ }
83
+
84
+ /**
85
+ * Minor units are the wire format; the form takes what somebody reads off the receipt.
86
+ *
87
+ * Returns `{ ok: false }` for anything that is not a non-negative number in `locale` — including a
88
+ * negative one, which the contract has no way to mean and the old parser dropped in silence.
89
+ */
90
+ export function parsePrice(raw: string, locale: string): PriceResult {
91
+ const text = toLatinDigits(raw).replace(BIDI, '').trim()
92
+ if (!text) return { ok: true, minor: null }
93
+
94
+ const { group, decimal } = separatorsFor(locale)
95
+ const pieces = text.split(decimal)
96
+ if (pieces.length > 2) return { ok: false }
97
+
98
+ let whole = pieces[0] ?? ''
99
+ const frac = pieces[1]
100
+ // "‚50" is how somebody writes half a unit; the leading zero is implied, not missing.
101
+ if (whole === '' && frac !== undefined) whole = '0'
102
+
103
+ if (!isGroupedInteger(whole, group)) return { ok: false }
104
+ if (frac !== undefined && !/^\d+$/.test(frac)) return { ok: false }
105
+
106
+ const digits = group ? whole.split(group).join('') : whole
107
+ const value = Number(`${digits}.${frac ?? '0'}`)
108
+ if (!Number.isFinite(value)) return { ok: false }
109
+
110
+ const minor = Math.round(value * 100)
111
+ // A price that cannot survive the round trip through a double is not a price anybody typed.
112
+ if (!Number.isSafeInteger(minor)) return { ok: false }
113
+ return { ok: true, minor }
114
+ }
115
+
116
+ /**
117
+ * The stored price, back in the field, in the reader's own numbers.
118
+ *
119
+ * The edit form has to seed itself from `priceMinor`, and seeding it with `1234.56` for a German
120
+ * reader would make them look at a number their own locale says is €123,456 — then `parsePrice`
121
+ * would reject it on the grouping rule and they would be told their own data is invalid. What comes
122
+ * out of here is exactly what goes back in.
123
+ */
124
+ export function formatPrice(minor: number | null | undefined, locale: string): string {
125
+ if (minor === null || minor === undefined) return ''
126
+ try {
127
+ return new Intl.NumberFormat(locale, {
128
+ minimumFractionDigits: 2,
129
+ maximumFractionDigits: 2,
130
+ useGrouping: false,
131
+ }).format(minor / 100)
132
+ } catch {
133
+ return String(minor / 100)
134
+ }
135
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { inventoryKeys } from './query.js'
3
+
4
+ /**
5
+ * The realtime contract, held to by a test because nothing else can hold it.
6
+ *
7
+ * `realtime.svelte.ts` calls `invalidateQueries({ queryKey: [module, entity] })` with whatever the
8
+ * server put in the change event, and `src/server/router.ts` emits `entity: 'asset'`. A key whose
9
+ * second segment is anything else is simply never invalidated — no error, no warning, just a list
10
+ * that stops updating. That is what `['inventory', 'assets', …]` did: the detail panel refreshed
11
+ * and the list behind it did not, and the only way to see it was to write to the module from a
12
+ * second browser.
13
+ *
14
+ * Proven against `partialMatchKey` from `@tanstack/query-core`, which is what
15
+ * `invalidateQueries` filters with; asserted here on the segment itself, so the test does not need
16
+ * the query runtime to state the rule it is protecting.
17
+ */
18
+ const WS = '01920000-0000-7000-8000-000000000001'
19
+
20
+ /** Must equal the `entity` string in `src/server/router.ts`'s `notify.change` call. */
21
+ const ENTITY = 'asset'
22
+
23
+ describe('inventoryKeys', () => {
24
+ it('names the entity exactly as the server emits it, on every key', () => {
25
+ for (const [name, key] of [
26
+ ['assets', inventoryKeys.assets(WS)],
27
+ ['assets(filtered)', inventoryKeys.assets(WS, { archived: false })],
28
+ ['asset', inventoryKeys.asset(WS, 'some-id')],
29
+ ] as const)
30
+ expect({ name, module: key[0], entity: key[1] }).toEqual({
31
+ name,
32
+ module: 'inventory',
33
+ entity: ENTITY,
34
+ })
35
+ })
36
+
37
+ it('puts the list and the row under one prefix, so one change event reaches both', () => {
38
+ const list = inventoryKeys.assets(WS, { archived: false })
39
+ const row = inventoryKeys.asset(WS, 'some-id')
40
+ expect(list.slice(0, 2)).toEqual(row.slice(0, 2))
41
+ })
42
+
43
+ it('makes a filtered list a different question from an unfiltered one', () => {
44
+ expect(inventoryKeys.assets(WS, { archived: true })).not.toEqual(
45
+ inventoryKeys.assets(WS, { archived: false }),
46
+ )
47
+ expect(inventoryKeys.assets(WS, { archived: false })).not.toEqual(inventoryKeys.assets(WS))
48
+ })
49
+
50
+ it('does not let two workspaces share a cache entry', () => {
51
+ expect(inventoryKeys.assets('ws-a')).not.toEqual(inventoryKeys.assets('ws-b'))
52
+ })
53
+
54
+ it('starts every key with the module, so one invalidation can clear all of Inventory', () => {
55
+ for (const key of [inventoryKeys.all, inventoryKeys.assets(WS), inventoryKeys.asset(WS, 'id')])
56
+ expect(key[0]).toBe('inventory')
57
+ })
58
+ })
@@ -3,10 +3,23 @@
3
3
  *
4
4
  * `[module, entity, …scope]`, so a realtime `change` event invalidates precisely what it touched.
5
5
  * Filters are part of the key wherever a screen can ask the same question two ways — a cached list
6
- * for "all" must not be served when somebody asked for "assigned".
6
+ * for "all" must not be served when somebody asked for "assigned", and the archived list is a
7
+ * different question from the live one rather than the same answer filtered afterwards.
8
+ *
9
+ * **The entity segment is singular, and that is not a style choice.** `realtime.svelte.ts`
10
+ * invalidates `[module, entity]` with whatever the server put in the change event, and
11
+ * `src/server/router.ts` emits `entity: 'asset'` — so the prefix that arrives is
12
+ * `['inventory', 'asset']`. This file spelled the list key `['inventory', 'assets', …]`, which that
13
+ * prefix never matches: `partialMatchKey` compares segment by segment and `'assets' !== 'asset'`.
14
+ * The detail key was already singular, so a change refreshed the panel nobody was looking at while
15
+ * the list and the dashboard card behind it stayed stale until a reload. Every other module keys
16
+ * the list and the row off one singular entity name (`['tracker', 'issue', …]`); so does this one
17
+ * now.
7
18
  */
8
19
  export const inventoryKeys = {
20
+ /** Everything under this module, for the blunt invalidation after a write. */
21
+ all: ['inventory'] as const,
9
22
  assets: (ws: string, filters?: Record<string, unknown>) =>
10
- filters ? (['inventory', 'assets', ws, filters] as const) : (['inventory', 'assets', ws] as const),
23
+ filters ? (['inventory', 'asset', ws, filters] as const) : (['inventory', 'asset', ws] as const),
11
24
  asset: (ws: string, id: string) => ['inventory', 'asset', ws, id] as const,
12
25
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The slice of core's API this module reaches for, named by shape rather than imported.
3
+ *
4
+ * A module talks to another module through `kernel.call()` on the server; on the client the shell
5
+ * hands over its own configured core client, and typing the seam structurally keeps the dependency
6
+ * pointing one way — inventory does not import core's router type, and core does not know inventory
7
+ * exists.
8
+ *
9
+ * Keep it to what is actually called. A wide type here is a promise about core's surface that this
10
+ * module has no standing to make. Two procedures are all the settings page needs, and both are
11
+ * gated on `core.modules.manage` in `core/src/modules/core/router.ts`.
12
+ */
13
+ export interface CoreApi {
14
+ workspaces: {
15
+ modules: {
16
+ list(input: { workspaceId: string }): Promise<
17
+ Array<{
18
+ manifest: { id: string }
19
+ state: {
20
+ enabled: boolean
21
+ settings?: Record<string, unknown>
22
+ }
23
+ }>
24
+ >
25
+ updateSettings(input: {
26
+ workspaceId: string
27
+ moduleId: string
28
+ settings: Record<string, unknown>
29
+ }): Promise<unknown>
30
+ }
31
+ }
32
+ }
@@ -3,6 +3,7 @@ import { Badge, type BadgeTone, WidgetState } from '@kernhq/ui'
3
3
  import { createQuery } from '@tanstack/svelte-query'
4
4
  import { getInventoryApi } from '../api-instance.js'
5
5
  import { t } from '../i18n.js'
6
+ import { inventoryKeys } from '../query.js'
6
7
 
7
8
  /**
8
9
  * A dashboard card.
@@ -19,18 +20,30 @@ const { workspaceId, settings }: Props = $props()
19
20
  const limit = $derived(Number(settings?.limit ?? 5))
20
21
  const api = getInventoryApi()
21
22
 
23
+ /**
24
+ * The card asks for exactly what it shows.
25
+ *
26
+ * It used to fetch the default page and then drop archived rows and slice — so a workspace whose
27
+ * last twenty assets were all archived rendered an empty card that was not empty. The server knows
28
+ * how to exclude them and how many to send.
29
+ */
30
+ const filters = $derived({ archived: false, limit })
31
+
22
32
  const assets = createQuery(() => ({
23
- queryKey: ['inventory', 'assets', workspaceId],
24
- queryFn: () => api.assets.list({ workspaceId }),
33
+ queryKey: inventoryKeys.assets(workspaceId, filters),
34
+ queryFn: () => api.assets.list({ workspaceId, ...filters }),
25
35
  enabled: Boolean(workspaceId),
26
36
  }))
27
37
 
28
- const items = $derived((assets.data?.items ?? []).filter((a) => !a.archivedAt).slice(0, limit))
38
+ const items = $derived(assets.data?.items ?? [])
29
39
 
30
40
  function statusTone(status: string): BadgeTone {
31
41
  switch (status) {
32
42
  case 'assigned':
43
+ case 'reserved':
33
44
  return 'info'
45
+ case 'lost':
46
+ return 'danger'
34
47
  case 'under_repair':
35
48
  return 'warning'
36
49
  case 'retired':
@@ -0,0 +1,55 @@
1
+ import { defineCapabilities } from '@kernhq/contracts'
2
+
3
+ /**
4
+ * How much Inventory this workspace has.
5
+ *
6
+ * The module answers two questions that look like one: *what do we own and who has it* (an office
7
+ * with forty laptops) and *how much of this do we have and where* (a warehouse with bins and
8
+ * reorder points). Both are "things the company has", both want the same categories, locations,
9
+ * suppliers, attachments and search — and almost nobody wants both halves at once. Capabilities are
10
+ * what keep the small case small: an office switches on nothing beyond the register and never meets
11
+ * the word "bin".
12
+ *
13
+ * **Declared here only once something is behind it.** A switch that changes nothing teaches an
14
+ * administrator that the switchboard does not mean anything, so this list grows with the module.
15
+ *
16
+ * Two rules decide whether something belongs here at all:
17
+ *
18
+ * - **Not a permission.** "May Ada write off a laptop" is a permission — true for her, false for
19
+ * somebody else, in the same workspace. "Does this company track stock levels" is a capability:
20
+ * one answer for everyone, the owner included, and the answer is 404 rather than 403.
21
+ * - **Reversible without a migration.** Switching one off writes a boolean into module settings and
22
+ * the rows stay exactly where they are. Anything that would need data thrown away to reverse is
23
+ * not a capability, however much it looks like one.
24
+ *
25
+ * The rest arrive with the work that makes them mean something: `repairs`, `attachments` and
26
+ * `labels` with the finished register; `custom_fields` and `locations` beside them; `stock` and
27
+ * `procurement` with stock control; `depreciation` and `reservations` last. Adding one here before
28
+ * its procedures exist is the mistake this comment is here to prevent.
29
+ */
30
+ export const inventoryCapabilities = defineCapabilities([
31
+ {
32
+ id: 'core',
33
+ label: 'Assets',
34
+ // Says only what the module answers today. Custody and the change history are what the schema
35
+ // is shaped for and neither has a procedure yet, so naming them here promised a workspace two
36
+ // features it would then go looking for.
37
+ description: 'The asset register: what the company owns, item by item',
38
+ required: true,
39
+ level: 1,
40
+ },
41
+ ])
42
+
43
+ export type InventoryCapabilityId = (typeof inventoryCapabilities)[number]['id']
44
+
45
+ /**
46
+ * Which procedures sit behind which capability.
47
+ *
48
+ * Declared as data because a missing `requiresCapability` is invisible: the procedure compiles,
49
+ * every other test passes, and the only symptom is a workspace calling a feature it switched off.
50
+ * `module.test.ts` reads this and fails when a procedure named here is not carrying the middleware.
51
+ *
52
+ * A procedure absent from this map belongs to the module as a whole and is reachable whenever
53
+ * Inventory is on — which for `core` is always, because it is `required`.
54
+ */
55
+ export const inventoryCapabilityProcedures: Record<string, readonly string[]> = {}
@@ -0,0 +1,34 @@
1
+ import { defineEvent, WorkspaceId } from '@kernhq/contracts'
2
+ import { z } from 'zod'
3
+
4
+ /**
5
+ * `<module>.<entity>.<action>`. Anything that emits one declares it here.
6
+ *
7
+ * Payloads carry **ids, never rows**. A subscriber that needs the record asks for it with its own
8
+ * principal, so an event cannot become a way to read data past a permission check.
9
+ */
10
+ export const inventoryEvents = {
11
+ assetCreated: defineEvent(
12
+ 'inventory.asset.created',
13
+ z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
14
+ ),
15
+ assetUpdated: defineEvent(
16
+ 'inventory.asset.updated',
17
+ z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
18
+ ),
19
+ assetArchived: defineEvent(
20
+ 'inventory.asset.archived',
21
+ z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
22
+ ),
23
+ /**
24
+ * The other half of `archive`, which is also the restore path (`archived: false`).
25
+ *
26
+ * One procedure emitting `archived` whichever way the flag pointed told every subscriber that a
27
+ * restored item had just been retired — and the module's own `asset_history` row already knew
28
+ * better, recording `retired` and `restored` separately.
29
+ */
30
+ assetRestored: defineEvent(
31
+ 'inventory.asset.restored',
32
+ z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
33
+ ),
34
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What this module offers, as data.
3
+ *
4
+ * A barrel and nothing else: every symbol is defined in the file beside it. Imported by **both**
5
+ * halves — the server implements the contract, the client calls it — so nothing reachable from here
6
+ * may touch Node. A procedure that exists here and not in the router is a lie that compiles, and
7
+ * `module.test.ts` checks exactly that.
8
+ */
9
+
10
+ export * from './capabilities.js'
11
+ export * from './events.js'
12
+ export * from './models.js'
13
+ export * from './permissions.js'
14
+ export * from './router.js'
15
+ export * from './settings.js'
@@ -0,0 +1,123 @@
1
+ import { WorkspaceId } from '@kernhq/contracts'
2
+ import { z } from 'zod'
3
+
4
+ /**
5
+ * The nouns this module owns, and the shapes that cross the wire.
6
+ *
7
+ * Imported by **both** halves — the server implements against them, the client calls against them —
8
+ * so nothing here may touch Node. Types only; the procedures live in `router.ts`.
9
+ */
10
+
11
+ /** Lowercase, 2-32 characters. Names the API prefix, the Postgres schema `mod_<id>` and every event. */
12
+ export const MODULE_ID = 'inventory'
13
+
14
+ /**
15
+ * An asset's lifecycle. `in_stock` and `assigned` follow custody (an open row in `custody_periods`
16
+ * means assigned); `under_repair` follows an open repair; `reserved` follows a booking that has not
17
+ * been collected; `lost` and `retired` are set by hand when an item stops being usable.
18
+ *
19
+ * Stored rather than derived, because every list filter asks for it — and kept in step inside the
20
+ * same transaction that writes the row it derives from, never by a job afterwards.
21
+ */
22
+ export const AssetStatus = z.enum(['in_stock', 'assigned', 'reserved', 'under_repair', 'lost', 'retired'])
23
+ export type AssetStatus = z.infer<typeof AssetStatus>
24
+
25
+ /**
26
+ * A value a workspace defined for itself, stored under its `key` in `assets.custom`.
27
+ *
28
+ * `unknown` rather than a union: the field definition says what the type is, and validating a value
29
+ * against a definition the client may not have loaded yet would fail honest input. The server checks
30
+ * it against `field_defs` before writing.
31
+ */
32
+ export const CustomValues = z.record(z.string(), z.unknown())
33
+ export type CustomValues = z.infer<typeof CustomValues>
34
+
35
+ export const Asset = z.object({
36
+ id: z.uuid(),
37
+ workspaceId: WorkspaceId,
38
+ /** Human-facing asset tag (`INV-0001`), assigned by the server, unique per workspace. */
39
+ code: z.string().min(1).max(40),
40
+ name: z.string().min(1).max(200),
41
+ description: z.string().max(4000),
42
+ categoryId: z.uuid().nullable(),
43
+ status: AssetStatus,
44
+ /**
45
+ * The member currently holding the item. A plain uuid — cross-schema foreign keys are what the
46
+ * module boundary exists to prevent — resolved against core membership at read time.
47
+ */
48
+ custodianUserId: z.uuid().nullable(),
49
+ custodySince: z.string().nullable(),
50
+ serialNumber: z.string().max(200).nullable(),
51
+ location: z.string().max(200).nullable(),
52
+ purchasedOn: z.string().nullable(),
53
+ purchasedFrom: z.string().max(200).nullable(),
54
+ /** Minor units (cents), the convention billing established; formatted on the client. */
55
+ priceMinor: z.number().int().nullable(),
56
+ currency: z.string().length(3).nullable(),
57
+ warrantyUntil: z.string().nullable(),
58
+ photoFileId: z.uuid().nullable(),
59
+ custom: CustomValues,
60
+ createdAt: z.string(),
61
+ updatedAt: z.string(),
62
+ archivedAt: z.string().nullable(),
63
+ })
64
+ export type Asset = z.infer<typeof Asset>
65
+
66
+ /**
67
+ * How a list is ordered, and therefore what a page cursor is a bookmark *into*.
68
+ *
69
+ * Exported rather than written inline in `router.ts` because the server validates a cursor against
70
+ * this same list: a bookmark issued under one sort is meaningless under another, and the only way
71
+ * to say so is for both halves to read one enum.
72
+ */
73
+ export const AssetSort = z.enum(['recent', 'name', 'code'])
74
+ export type AssetSort = z.infer<typeof AssetSort>
75
+
76
+ /**
77
+ * Everything a person can say about an asset. `create` requires `name`; `update` takes any subset.
78
+ *
79
+ * **No field here carries `.default()`, and that is load-bearing.** `update` is built from
80
+ * `AssetInput.partial()`, and `.partial()` does not strip a default — it only wraps the field in
81
+ * `optional`, so zod still substitutes the default for a key the request never sent. `description`
82
+ * had `.default('')`, so `PATCH {assetId, name}` reached the handler as
83
+ * `{assetId, name, description: ''}`; the service correctly read a present value as "set it" and a
84
+ * rename destroyed the text, writing a bogus `description` diff into `asset_history` as it went.
85
+ * The care the service takes over `undefined` versus `null` is defeated one layer above it, here.
86
+ * A value `create` should fill in belongs on `AssetCreateInput` below, which is never partialled.
87
+ */
88
+ export const AssetInput = z.object({
89
+ name: z.string().min(1).max(200),
90
+ description: z.string().max(4000).optional(),
91
+ categoryId: z.uuid().nullish(),
92
+ serialNumber: z.string().max(200).nullish(),
93
+ location: z.string().max(200).nullish(),
94
+ purchasedFrom: z.string().max(200).nullish(),
95
+ purchasedOn: z.iso.date().nullish(),
96
+ warrantyUntil: z.iso.date().nullish(),
97
+ priceMinor: z.number().int().min(0).nullish(),
98
+ currency: z.string().length(3).nullish(),
99
+ photoFileId: z.uuid().nullish(),
100
+ // `custom` is deliberately absent: there is nothing to validate a value against until
101
+ // `fields.*` exists, and accepting arbitrary JSON into a column a workspace has not defined
102
+ // is how a schemaless field bag becomes permanent. It arrives with the field definitions.
103
+ })
104
+ export type AssetInput = z.infer<typeof AssetInput>
105
+
106
+ /**
107
+ * What `create` accepts: the same fields, with the one value a new row may not go without.
108
+ *
109
+ * `description` is `not null` in the database, so a create with no description needs *something*.
110
+ * Defaulting it here rather than in `AssetInput` is what keeps `update` able to tell "leave it
111
+ * alone" from "clear it" — see the note above.
112
+ */
113
+ export const AssetCreateInput = AssetInput.extend({
114
+ description: z.string().max(4000).default(''),
115
+ })
116
+ export type AssetCreateInput = z.infer<typeof AssetCreateInput>
117
+
118
+ /** What `update` accepts: any subset, and nothing filled in for a key that never arrived. */
119
+ export const AssetPatchInput = AssetInput.partial()
120
+ export type AssetPatchInput = z.infer<typeof AssetPatchInput>
121
+
122
+ /** Shared by every workspace-scoped procedure, which is all of them. */
123
+ export const ws = z.object({ workspaceId: WorkspaceId })
@@ -0,0 +1,26 @@
1
+ import { definePermissions } from '@kernhq/contracts'
2
+
3
+ /**
4
+ * `<module>.<resource>.<action>`, each with the narrowest scope that works and the roles that hold
5
+ * it by default. A workspace can add or remove any of them afterwards with a custom role.
6
+ *
7
+ * A key with nothing checking it is a role editor full of switches that do nothing, so these arrive
8
+ * with the procedures that enforce them — `custody.manage`, `repair.manage`, `category.manage`,
9
+ * `field.manage`, `value.view` and the stock and purchasing keys with their own phases.
10
+ */
11
+ export const inventoryPermissions = definePermissions([
12
+ {
13
+ key: 'inventory.asset.view',
14
+ label: 'View assets',
15
+ scope: 'workspace',
16
+ defaultRoles: ['owner', 'admin', 'member', 'guest'],
17
+ dangerous: false,
18
+ },
19
+ {
20
+ key: 'inventory.asset.manage',
21
+ label: 'Create and edit assets',
22
+ scope: 'workspace',
23
+ defaultRoles: ['owner', 'admin', 'member'],
24
+ dangerous: false,
25
+ },
26
+ ])