@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
|
@@ -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
|
+
})
|
package/src/client/query.ts
CHANGED
|
@@ -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', '
|
|
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
|
}
|
|
Binary file
|
|
@@ -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:
|
|
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(
|
|
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
|
+
])
|