@kernhq/module-inventory 0.2.0 → 0.3.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 (193) hide show
  1. package/README.md +104 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +322 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +213 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1052 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +225 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1337 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +363 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +158 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/meta/0002_snapshot.json +1054 -0
  117. package/migrations/meta/0003_snapshot.json +1070 -0
  118. package/migrations/meta/0004_snapshot.json +1130 -0
  119. package/migrations/meta/0005_snapshot.json +1135 -0
  120. package/migrations/meta/_journal.json +42 -0
  121. package/package.json +1 -1
  122. package/src/client/api-instance.ts +27 -2
  123. package/src/client/bidi.test.ts +148 -0
  124. package/src/client/bidi.ts +85 -0
  125. package/src/client/components/AssetDetailPanel.svelte +614 -0
  126. package/src/client/components/AssetFormDialog.svelte +71 -22
  127. package/src/client/components/AssetPhoto.svelte +178 -0
  128. package/src/client/components/AttachmentsSection.svelte +327 -0
  129. package/src/client/components/CustodyDialog.svelte +201 -0
  130. package/src/client/components/RepairDialog.svelte +271 -0
  131. package/src/client/components/RepairsSection.svelte +318 -0
  132. package/src/client/components/Timeline.svelte +347 -0
  133. package/src/client/components/TimelineText.svelte +124 -0
  134. package/src/client/core-api.ts +71 -0
  135. package/src/client/custody.test.ts +31 -0
  136. package/src/client/custody.ts +34 -0
  137. package/src/client/errors.test.ts +365 -0
  138. package/src/client/errors.ts +201 -0
  139. package/src/client/links.test.ts +74 -0
  140. package/src/client/links.ts +44 -0
  141. package/src/client/members.test.ts +132 -0
  142. package/src/client/members.ts +116 -0
  143. package/src/client/messages.test.ts +127 -0
  144. package/src/client/messages.ts +1043 -18
  145. package/src/client/mock.test.ts +395 -1
  146. package/src/client/mock.ts +1066 -79
  147. package/src/client/module.ts +54 -0
  148. package/src/client/pages/AssetsPage.svelte +317 -45
  149. package/src/client/permissions.ts +7 -0
  150. package/src/client/price.test.ts +151 -3
  151. package/src/client/price.ts +155 -11
  152. package/src/client/query.ts +36 -0
  153. package/src/client/repairs.test.ts +38 -0
  154. package/src/client/repairs.ts +38 -0
  155. package/src/client/settings/CategoriesSettings.svelte +421 -0
  156. package/src/client/settings/GeneralSettings.svelte +0 -0
  157. package/src/client/status.ts +29 -0
  158. package/src/client/timeline.test.ts +175 -0
  159. package/src/client/timeline.ts +206 -0
  160. package/src/client/widgets/OverviewWidget.svelte +127 -26
  161. package/src/client/widgets/RepairsWidget.svelte +124 -0
  162. package/src/contract/capabilities.ts +53 -9
  163. package/src/contract/events.ts +49 -0
  164. package/src/contract/index.ts +1 -0
  165. package/src/contract/models.ts +237 -0
  166. package/src/contract/notifications.ts +73 -0
  167. package/src/contract/permissions.ts +55 -2
  168. package/src/contract/router.ts +255 -1
  169. package/src/contract/settings.ts +24 -4
  170. package/src/module.test.ts +194 -4
  171. package/src/server/index.ts +311 -4
  172. package/src/server/inventory.int.test.ts +3573 -18
  173. package/src/server/jobs.ts +444 -0
  174. package/src/server/migrations.test.ts +113 -0
  175. package/src/server/router.ts +462 -6
  176. package/src/server/schema.ts +160 -3
  177. package/src/server/services/assets.ts +214 -54
  178. package/src/server/services/attachments.ts +215 -0
  179. package/src/server/services/audience.ts +77 -0
  180. package/src/server/services/categories.ts +136 -0
  181. package/src/server/services/cursor.ts +104 -0
  182. package/src/server/services/custody.ts +471 -0
  183. package/src/server/services/db-errors.ts +42 -0
  184. package/src/server/services/index.ts +34 -1
  185. package/src/server/services/members.ts +54 -0
  186. package/src/server/services/notify.ts +52 -7
  187. package/src/server/services/offboarding.ts +150 -0
  188. package/src/server/services/repairs.ts +567 -0
  189. package/src/server/services/search.ts +166 -0
  190. package/src/server/services/stats.ts +88 -0
  191. package/src/server/services/status.test.ts +34 -0
  192. package/src/server/services/status.ts +143 -0
  193. package/src/client/settings/core-api.ts +0 -32
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Turning a refusal from the server into a sentence in the reader's own language.
3
+ *
4
+ * **Every actionable failure this module could produce reached the user in English, in all five
5
+ * locales.** Eight call sites were written as `toast.error(error.message || t('common.error'))`,
6
+ * and `error.message` is prose the *server* wrote: "Somebody changed who is holding this a moment
7
+ * before you did. Reload to see where it is now." That is the most useful sentence in the module
8
+ * and a Persian reader was shown it in English, at exactly the moment they needed to understand it.
9
+ * The comments at those call sites even said so — that the server's message was "the actionable
10
+ * one" — which was true, and was an argument for translating it rather than for printing it.
11
+ *
12
+ * The thing a client can translate is the **reason**, not the message. `KernError.conflict(message,
13
+ * reason)` has carried one since it was written, and `kernErrorToORPC` puts it in `data.reason`
14
+ * precisely so a client can branch on it: `inventory.custody.already_held` is a stable token that
15
+ * ships with the contract, where the sentence is prose that changes when somebody rewords it. So:
16
+ *
17
+ * 1. a `reason` this module knows → its own translated sentence;
18
+ * 2. otherwise the error **code** → the sentence that class of failure deserves;
19
+ * 3. otherwise a generic failure, and the server's own words underneath it.
20
+ *
21
+ * Step three is not a shrug. Dropping the server's message entirely would hide the one clue
22
+ * somebody could act on when the reason is one this build has never heard of — a newer server, an
23
+ * error from core rather than from here. It is shown as *detail*, under a sentence that at least
24
+ * names what failed, rather than as the whole of what the interface has to say.
25
+ *
26
+ * Pure and string-free: this file decides *which* key, `i18n.ts` holds the words, and a `.svelte`
27
+ * file cannot be unit-tested. `errorMessage()` at the bottom is the one function the screens call.
28
+ */
29
+
30
+ /** What oRPC hands a screen. `code` is the contract's `ErrorCode`; `data` is what the server put in it. */
31
+ export interface ServerError {
32
+ code?: unknown
33
+ message?: unknown
34
+ data?: unknown
35
+ status?: unknown
36
+ }
37
+
38
+ /**
39
+ * The reason token, out of wherever this particular transport put it.
40
+ *
41
+ * `data.reason` is the real one — that is what `kernErrorToORPC` folds it into. `reason` on the
42
+ * error itself is read too, because that is where a `KernError` thrown in-process carries it, and
43
+ * the mock in this package is neither: a demo that could not reproduce a translated conflict would
44
+ * be a demo that hides the branch this file exists for.
45
+ */
46
+ export function reasonOf(err: unknown): string | null {
47
+ if (!err || typeof err !== 'object') return null
48
+ const e = err as ServerError & { reason?: unknown }
49
+ const data = e.data as { reason?: unknown } | undefined
50
+ const found = (data && typeof data === 'object' ? data.reason : undefined) ?? e.reason
51
+ return typeof found === 'string' && found ? found : null
52
+ }
53
+
54
+ /** The `ErrorCode` the server refused with, if this is one of its errors at all. */
55
+ export function codeOf(err: unknown): string | null {
56
+ if (!err || typeof err !== 'object') return null
57
+ const { code } = err as ServerError
58
+ return typeof code === 'string' && code ? code : null
59
+ }
60
+
61
+ /**
62
+ * Whether a `BAD_REQUEST` is oRPC saying the *input* was malformed, or a service refusing.
63
+ *
64
+ * **Both arrive as `BAD_REQUEST`, and only one of them is about the form.** oRPC throws
65
+ * `ORPCError('BAD_REQUEST', { message: 'Input validation failed', data: { issues } })` when a zod
66
+ * schema rejects the input — that one really is "check what you entered". The ones this module's
67
+ * own services throw are not:
68
+ *
69
+ * - "That person is not a member of this workspace, so nothing can be handed to them."
70
+ * - "That file has not finished uploading yet."
71
+ * - "That file is not one this workspace can attach."
72
+ * - "That page marker is not one this list issued"
73
+ * - "That category is not one this workspace has."
74
+ * - "A repair cannot be sent in the future."
75
+ *
76
+ * Every field on those forms is fine. Sending somebody to re-read them is worse than saying
77
+ * nothing, because it is a confident wrong instruction: nobody finds the mistake, because there
78
+ * isn't one. `issues` is the one structural difference between the two, so it is what decides.
79
+ *
80
+ * This is a stopgap, and the report that came with it says so. The right fix is a **stable reason
81
+ * token** on each of those four — `KernError.badRequest` takes `details` where `conflict` takes a
82
+ * `reason`, so today they carry nothing a client can branch on and the server's English prose is
83
+ * the only clue that survives. Once they carry one, they belong in `REASON_KEYS` with a sentence
84
+ * each in all five languages, and this branch goes back to being about validation alone.
85
+ */
86
+ export function isInputValidation(err: unknown): boolean {
87
+ if (!err || typeof err !== 'object') return false
88
+ const data = (err as ServerError).data as { issues?: unknown } | undefined
89
+ return Boolean(data && typeof data === 'object' && Array.isArray(data.issues))
90
+ }
91
+
92
+ /** Whatever the server said, for the detail line under a failure nothing here recognises. */
93
+ export function messageOf(err: unknown): string | null {
94
+ if (!err) return null
95
+ if (typeof err === 'string') return err.trim() || null
96
+ if (typeof err !== 'object') return null
97
+ const { message } = err as ServerError
98
+ return typeof message === 'string' && message.trim() ? message.trim() : null
99
+ }
100
+
101
+ /**
102
+ * Every reason this module's server actually throws, and the sentence each one earns.
103
+ *
104
+ * Not "every reason imaginable": each key here is one this package's own services produce, and
105
+ * `errors.test.ts` reads them out of `src/server` to hold the two lists together. A sentence for a
106
+ * refusal that cannot happen is dead weight nobody will ever notice is wrong; a refusal with no
107
+ * sentence is English on somebody's screen, which is the defect this file was written for.
108
+ *
109
+ * The wording is what the reader can *do*, in the same voice as the rest of the module. A lost race
110
+ * says to look again; a conflict of state says which handover to use instead.
111
+ */
112
+ const REASON_KEYS: Record<string, string> = {
113
+ 'inventory.custody.conflict': 'error_custody_conflict',
114
+ 'inventory.custody.archived': 'error_custody_archived',
115
+ 'inventory.custody.already_held': 'error_custody_already_held',
116
+ 'inventory.custody.not_held': 'error_custody_not_held',
117
+ 'inventory.asset.still_held': 'error_asset_still_held',
118
+ 'inventory.asset.under_repair': 'error_asset_under_repair',
119
+ 'inventory.repair.already_open': 'error_repair_already_open',
120
+ 'inventory.repair.archived': 'error_repair_archived',
121
+ 'inventory.repair.already_complete': 'error_repair_already_complete',
122
+ 'inventory.repair.returned_before_sent': 'error_repair_returned_before_sent',
123
+ 'inventory.category.name_taken': 'error_category_name_taken',
124
+ }
125
+
126
+ export const reasonKeys = (): readonly string[] => Object.keys(REASON_KEYS)
127
+
128
+ /**
129
+ * The sentence a whole class of failure earns, when no reason narrows it further.
130
+ *
131
+ * `NOT_FOUND` and `MODULE_DISABLED` are the two worth spelling out. A 404 here almost always means
132
+ * somebody else archived or removed the row while this screen was open, so "it is no longer there —
133
+ * reload" is both true and the next step. `MODULE_DISABLED` is what a workspace gets when Inventory
134
+ * is switched off underneath it, and "Forbidden" would be the wrong word for it entirely.
135
+ *
136
+ * A **disabled capability answers 404, not 403** — that is the module contract — so a capability
137
+ * switched off mid-session lands on `NOT_FOUND` here, which is the same "it is not there any more,
138
+ * look again" sentence, and correctly so.
139
+ */
140
+ const CODE_KEYS: Record<string, string> = {
141
+ NOT_FOUND: 'error_not_found',
142
+ FORBIDDEN: 'error_forbidden',
143
+ MODULE_DISABLED: 'error_module_disabled',
144
+ CONFLICT: 'error_conflict',
145
+ // Reached only when the request really did fail validation — `errorLine` peels off the refusals
146
+ // first. `VALIDATION` is here because the code itself is the claim; `BAD_REQUEST` is not.
147
+ BAD_REQUEST: 'error_bad_request',
148
+ VALIDATION: 'error_bad_request',
149
+ UNAUTHORIZED: 'error_unauthorized',
150
+ RATE_LIMITED: 'error_rate_limited',
151
+ UNAVAILABLE: 'error_unavailable',
152
+ }
153
+
154
+ /**
155
+ * Which sentence to show, and whether the server's own words go under it.
156
+ *
157
+ * `detail` is filled only for the fallback: when this module named the failure itself, repeating
158
+ * the server's English beneath its own Persian sentence would undo the translation.
159
+ */
160
+ export interface ErrorLine {
161
+ /** A key for `t()`, always one this module's catalogue has. */
162
+ key: string
163
+ /** The server's own sentence, for a failure nothing here recognised. Null otherwise. */
164
+ detail: string | null
165
+ }
166
+
167
+ export function errorLine(err: unknown): ErrorLine {
168
+ const reason = reasonOf(err)
169
+ const byReason = reason ? REASON_KEYS[reason] : undefined
170
+ if (byReason) return { key: byReason, detail: null }
171
+
172
+ const code = codeOf(err)
173
+
174
+ // A `BAD_REQUEST` with no validation issues on it is a service refusing something, not a schema
175
+ // rejecting a field — so it gets a sentence that does not send anybody back to a form that is
176
+ // fine, and the server's own explanation as the detail. That prose is English until those four
177
+ // refusals carry a reason token; an English clue under a translated sentence is worse than a
178
+ // translated one and much better than a confident wrong instruction.
179
+ if (code === 'BAD_REQUEST' && !isInputValidation(err))
180
+ return { key: 'error_refused', detail: messageOf(err) }
181
+
182
+ const byCode = code ? CODE_KEYS[code] : undefined
183
+ if (byCode) return { key: byCode, detail: null }
184
+
185
+ // Nothing recognised it — a newer server, a failure from core, or the network. The generic
186
+ // sentence names what happened and the server's words are kept as the only actionable clue left.
187
+ return { key: 'error_unknown', detail: messageOf(err) }
188
+ }
189
+
190
+ /**
191
+ * The one call a screen makes: `toast.error(errorMessage(error, t))`.
192
+ *
193
+ * `t` is passed in rather than imported so this file goes on importing nothing — `i18n.ts` reaches
194
+ * `@kernhq/ui`, and that entry point drags a Svelte compiler into whatever imports it, which is
195
+ * what makes a helper untestable.
196
+ */
197
+ export function errorMessage(err: unknown, translate: (key: string) => string): string {
198
+ const line = errorLine(err)
199
+ const sentence = translate(line.key)
200
+ return line.detail ? `${sentence} — ${line.detail}` : sentence
201
+ }
@@ -0,0 +1,74 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { ASSET_PARAM, assetHref, INVENTORY_PATH, inventoryHref } from './links.js'
6
+
7
+ /**
8
+ * The link a dashboard card follows, and the parameter the page reads.
9
+ *
10
+ * Both cards showed rows and did nothing with them — `kern-widget` §3, a table of contents rather
11
+ * than a card. A row now opens the asset's panel, which is a URL, which means three things have to
12
+ * agree: the route `module.ts` declares, the parameter `AssetsPage` reads, and the href built here.
13
+ * They are one constant each now, and this holds them to it: a link that is one character off does
14
+ * not fail, it silently lands on the list with nothing open, which looks like the panel is broken.
15
+ */
16
+ const HERE = dirname(fileURLToPath(import.meta.url))
17
+
18
+ describe('assetHref', () => {
19
+ it('scopes the link to the workspace, because the shell mounts every module under a slug', () => {
20
+ // Without the slug a card on one workspace's dashboard links into another's, or into nothing.
21
+ expect(inventoryHref('acme')).toBe('/acme/inventory')
22
+ expect(assetHref('acme', '0192-abc')).toBe('/acme/inventory?asset=0192-abc')
23
+ })
24
+
25
+ it('produces a URL whose parameter is the one the page reads back', () => {
26
+ const url = new URL(assetHref('acme', '0192-abc'), 'https://kern.example.com')
27
+ expect(url.pathname).toBe('/acme/inventory')
28
+ expect(url.searchParams.get(ASSET_PARAM)).toBe('0192-abc')
29
+ })
30
+
31
+ it('encodes the id rather than trusting it to be a uuid for ever', () => {
32
+ const href = assetHref('acme', 'a&b=c')
33
+ expect(href).toContain('asset=a%26b%3Dc')
34
+ expect(new URL(href, 'https://x.test').searchParams.get(ASSET_PARAM)).toBe('a&b=c')
35
+ })
36
+ })
37
+
38
+ describe('the route this links to is the route the module declares', () => {
39
+ /**
40
+ * `module.ts` cannot be imported here — it reaches `@kernhq/ui`, whose entry point pulls in Svelte
41
+ * components this package's test setup cannot transform. So the declaration is read as text,
42
+ * which is enough: what would break is a path renamed in one file and not the other.
43
+ */
44
+ it('matches the path in `module.ts`', () => {
45
+ const source = readFileSync(join(HERE, 'module.ts'), 'utf8')
46
+ expect(source).toContain(`path: '${INVENTORY_PATH}'`)
47
+ })
48
+
49
+ it('is the parameter `AssetsPage` opens its panel from', () => {
50
+ // The page imports `ASSET_PARAM` rather than repeating the literal, so this checks the import
51
+ // is actually what it reads — a re-introduced `params.get('asset')` would pass by accident and
52
+ // then drift the first time the name changed.
53
+ const page = readFileSync(join(HERE, 'pages', 'AssetsPage.svelte'), 'utf8')
54
+ expect(page).toContain('params.get(ASSET_PARAM)')
55
+ expect(page).toContain("from '../links.js'")
56
+ })
57
+
58
+ it('is what both dashboard cards send somebody to', () => {
59
+ // The defect being guarded: a widget row that is inert. Each card builds its href from this
60
+ // module, so neither can go back to being a list nobody can act on without this failing.
61
+ for (const widget of ['OverviewWidget.svelte', 'RepairsWidget.svelte']) {
62
+ const source = readFileSync(join(HERE, 'widgets', widget), 'utf8')
63
+ expect({ widget, links: source.includes('assetHref(workspaceSlug') }).toEqual({
64
+ widget,
65
+ links: true,
66
+ })
67
+ // An anchor, not a click handler: that is what makes the keyboard route the same route.
68
+ expect({ widget, anchor: /<a\s+class="row"\s+href=/.test(source) }).toEqual({
69
+ widget,
70
+ anchor: true,
71
+ })
72
+ }
73
+ })
74
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Where this module's screens live, as URLs.
3
+ *
4
+ * Two things need to agree and were three files apart: `module.ts` declares the route, `AssetsPage`
5
+ * reads `?asset=<id>` to decide which panel is open, and a dashboard card has to be able to send
6
+ * somebody to exactly that. A widget that only *shows* rows is a table of contents — `kern-widget`
7
+ * §3 — and both of this module's cards were exactly that, with no way to act on a row at all.
8
+ *
9
+ * A pure module rather than a constant inside a component, for the reason `custody.ts` and
10
+ * `price.ts` are: `module.ts` imports `@kernhq/ui`, so anything that reads its route declaration
11
+ * drags a Svelte compiler behind it, and "does this href open that panel" is a fact with a right
12
+ * answer.
13
+ */
14
+
15
+ /** The module's own route, as declared in `module.ts`. The workspace slug goes in front of it. */
16
+ export const INVENTORY_PATH = '/inventory'
17
+
18
+ /** The parameter `AssetsPage` reads to decide which asset's panel is open. */
19
+ export const ASSET_PARAM = 'asset'
20
+
21
+ /**
22
+ * The list, scoped to a workspace.
23
+ *
24
+ * The shell mounts every module route under `/<workspaceSlug>`, so a link built without the slug
25
+ * lands on the wrong workspace — or on nothing, which is what a widget on a second workspace's
26
+ * dashboard would have done.
27
+ */
28
+ export function inventoryHref(workspaceSlug: string): string {
29
+ return `/${workspaceSlug}${INVENTORY_PATH}`
30
+ }
31
+
32
+ /**
33
+ * One asset, open in its panel over the list.
34
+ *
35
+ * Deliberately the list URL with a parameter rather than a page of its own: that is the URL the
36
+ * page already owns, so following it from a dashboard card leaves somebody in the same place they
37
+ * would have reached by clicking the row — with the list behind the panel, and Back closing it.
38
+ *
39
+ * The id is encoded even though it is always a uuid. A raw value in a query string is how a link
40
+ * builder eventually ships an injection, and `encodeURIComponent` costs nothing on a uuid.
41
+ */
42
+ export function assetHref(workspaceSlug: string, assetId: string): string {
43
+ return `${inventoryHref(workspaceSlug)}?${ASSET_PARAM}=${encodeURIComponent(assetId)}`
44
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { directory, directoryStatus, displayName, type NameWords, nameOf, resolveName } from './members.js'
3
+ import { en } from './messages.js'
4
+
5
+ /**
6
+ * What a stored uuid reads as, and — the part that was wrong — *when* it is entitled to say so.
7
+ *
8
+ * `nameOf` resolved every id it could not find to "a former member". That is a claim about a
9
+ * person, and it was made in two states where nothing was known about anybody: while the members
10
+ * request was still in flight, and after it had failed. So an asset panel opened on a laptop that
11
+ * had changed hands twice read
12
+ *
13
+ * A former member handed it to A former member
14
+ * A former member took it back from A former member
15
+ *
16
+ * for the first moments of every load — and permanently whenever core was unreachable, which is
17
+ * exactly when somebody is most likely to be staring at the screen trying to work out what happened.
18
+ *
19
+ * Neither state can be told from an empty map, which is why the fix is on the `Directory` rather
20
+ * than at the call sites: the request's own state has to travel with the rows.
21
+ */
22
+ const member = (userId: string, name: string | null, email: string) => ({
23
+ userId,
24
+ user: { id: userId, name, email },
25
+ })
26
+
27
+ const PEOPLE = [
28
+ member('a', 'Ada Lovelace', 'ada@example.com'),
29
+ member('b', null, 'bruno@example.com'),
30
+ member('c', ' ', 'carla@example.com'),
31
+ ]
32
+
33
+ const words: NameWords = {
34
+ loading: '…',
35
+ unknown: 'Someone',
36
+ former: 'A former member',
37
+ system: 'The system',
38
+ }
39
+
40
+ describe('displayName', () => {
41
+ it('falls back to the email for somebody who has never signed in', () => {
42
+ const dir = directory(PEOPLE)
43
+ expect(displayName(dir.byId.get('b'))).toBe('bruno@example.com')
44
+ })
45
+
46
+ it('treats a blank name as absent, so no row has an empty person column', () => {
47
+ const dir = directory(PEOPLE)
48
+ expect(displayName(dir.byId.get('c'))).toBe('carla@example.com')
49
+ })
50
+ })
51
+
52
+ describe('directoryStatus', () => {
53
+ it('reads a query that has not answered as loading, not as an empty workspace', () => {
54
+ expect(directoryStatus({ isSuccess: false, isError: false })).toBe('loading')
55
+ })
56
+
57
+ it('reads a failure as its own state rather than as an absence', () => {
58
+ expect(directoryStatus({ isSuccess: false, isError: true })).toBe('error')
59
+ })
60
+
61
+ it('is ready only once the data is actually in hand', () => {
62
+ // `isSuccess`, never `!isPending`: a query with `enabled: false` reports `isPending` for ever,
63
+ // and the assets page disables this one until something is filtered by custodian.
64
+ expect(directoryStatus({ isSuccess: true, isError: false })).toBe('ready')
65
+ })
66
+ })
67
+
68
+ describe('resolveName tells the three unknowns apart', () => {
69
+ const ready = directory(PEOPLE, 'ready')
70
+ const loading = directory([], 'loading')
71
+ const failed = directory([], 'error')
72
+ const gone = '01920000-0000-7000-8000-00000000dead'
73
+
74
+ it('names somebody the workspace has', () => {
75
+ expect(resolveName('a', ready)).toEqual({ kind: 'person', name: 'Ada Lovelace' })
76
+ })
77
+
78
+ it('says nothing at all while the member list is still in flight', () => {
79
+ // The regression. This answered `former` — "A former member handed it to A former member" —
80
+ // for the first moments of every panel, about people who had not gone anywhere.
81
+ expect(resolveName('a', loading)).toEqual({ kind: 'loading', name: null })
82
+ expect(resolveName(gone, loading)).toEqual({ kind: 'loading', name: null })
83
+ })
84
+
85
+ it('says the lookup failed rather than that the person left', () => {
86
+ // The same claim, made permanent: with core unreachable, every name on the screen used to
87
+ // report that its owner had been removed from the workspace.
88
+ expect(resolveName('a', failed)).toEqual({ kind: 'unknown', name: null })
89
+ })
90
+
91
+ it('says somebody has left only once the list is in hand and does not contain them', () => {
92
+ expect(resolveName(gone, ready)).toEqual({ kind: 'former', name: null })
93
+ })
94
+
95
+ it('has no name for a row nothing signed, in every state', () => {
96
+ for (const dir of [ready, loading, failed])
97
+ expect(resolveName(null, dir)).toEqual({ kind: 'system', name: null })
98
+ })
99
+
100
+ it('carries a name only for somebody actually found, which is what the avatar reads', () => {
101
+ // An `Avatar` seeded with "…" would draw the initials of an ellipsis in a coloured square.
102
+ for (const resolved of [resolveName('a', loading), resolveName(gone, ready), resolveName(null, ready)])
103
+ if (resolved.kind !== 'person') expect(resolved.name).toBe(null)
104
+ })
105
+ })
106
+
107
+ describe('nameOf', () => {
108
+ const ready = directory(PEOPLE, 'ready')
109
+
110
+ it('gives each of the four unknowns its own word', () => {
111
+ expect(nameOf('a', ready, words)).toBe('Ada Lovelace')
112
+ expect(nameOf('a', directory([], 'loading'), words)).toBe('…')
113
+ expect(nameOf('a', directory([], 'error'), words)).toBe('Someone')
114
+ expect(nameOf('gone', ready, words)).toBe('A former member')
115
+ expect(nameOf(null, ready, words)).toBe('The system')
116
+ })
117
+
118
+ it('never renders a raw uuid', () => {
119
+ const uuid = '01920000-0000-7000-8000-00000000dead'
120
+ for (const dir of [ready, directory([], 'loading'), directory([], 'error')])
121
+ expect(nameOf(uuid, dir, words)).not.toContain(uuid)
122
+ })
123
+
124
+ /**
125
+ * The words are translated, and a missing one would render the key — `inventory.member_loading`
126
+ * — in the middle of a timeline entry, in every locale at once.
127
+ */
128
+ it('has a catalogue entry for every word it asks a screen for', () => {
129
+ for (const key of ['member_loading', 'member_unknown', 'member_former', 'member_system'])
130
+ expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
131
+ })
132
+ })
@@ -0,0 +1,116 @@
1
+ import type { CoreMember } from './core-api.js'
2
+
3
+ /**
4
+ * Turning a stored user id into a name a person recognises.
5
+ *
6
+ * Custody rows and history rows both carry plain uuids — a module keeps cross-schema ids plain, and
7
+ * a name copied into this module's tables would be a name that goes stale the day somebody marries.
8
+ * So every screen that shows *who* resolves the id here, against the members core says the workspace
9
+ * has right now.
10
+ *
11
+ * **The list is a request, and a request has three answers, not one.** This file resolved an
12
+ * unknown id straight to "a former member" without ever asking whether the list had arrived — so
13
+ * for the first moments of every panel, before the members request came back, an entire timeline
14
+ * read "A former member handed it to A former member", and it stayed that way for ever if the
15
+ * request failed. The claim was false in both cases: nobody had left. `status` is what separates
16
+ * *we do not know yet*, *we could not find out* and *this person really is gone*, and the three are
17
+ * rendered as three different things.
18
+ *
19
+ * Pure, and here rather than inside a component, for the reason `price.ts` and `timeline.ts` are:
20
+ * a `.svelte` file drags a compiler behind it and cannot be unit-tested, and "what does this id
21
+ * read as" is a fact with a right answer.
22
+ */
23
+
24
+ /** Where the members request has got to. `ready` is the only state whose absences mean anything. */
25
+ export type DirectoryStatus = 'loading' | 'error' | 'ready'
26
+
27
+ export interface Directory {
28
+ /** Everybody the workspace has, by user id. Empty until `status` is `ready`. */
29
+ byId: ReadonlyMap<string, CoreMember>
30
+ /** The same ids as a set, which is what `actorKind` asks about. */
31
+ ids: ReadonlySet<string>
32
+ /** Whether this directory is in a position to say that somebody is missing from it. */
33
+ status: DirectoryStatus
34
+ }
35
+
36
+ export function directory(members: readonly CoreMember[], status: DirectoryStatus = 'ready'): Directory {
37
+ const byId = new Map<string, CoreMember>()
38
+ for (const member of members) byId.set(member.userId, member)
39
+ return { byId, ids: new Set(byId.keys()), status }
40
+ }
41
+
42
+ /**
43
+ * A TanStack query's flags as the one word this file needs.
44
+ *
45
+ * Here rather than inlined into each screen because the mapping has a trap in it: a query with
46
+ * `enabled: false` reports `isPending` for ever, so "not pending" is not the same question as
47
+ * "arrived". `isSuccess` is the only flag that means the data is in hand.
48
+ */
49
+ export function directoryStatus(query: { isSuccess: boolean; isError: boolean }): DirectoryStatus {
50
+ if (query.isError) return 'error'
51
+ return query.isSuccess ? 'ready' : 'loading'
52
+ }
53
+
54
+ /**
55
+ * What to call somebody.
56
+ *
57
+ * `name` is nullable in core — an invited person who has never signed in has an email and nothing
58
+ * else — so the email is the fallback rather than an empty label. A blank name is treated as absent
59
+ * for the same reason: a row whose person column is empty reads as a bug in the register.
60
+ */
61
+ export function displayName(member: CoreMember | undefined): string | null {
62
+ if (!member) return null
63
+ const name = member.user.name?.trim()
64
+ return name || member.user.email
65
+ }
66
+
67
+ /**
68
+ * The five answers to "who is this id", before any of them is turned into words.
69
+ *
70
+ * `person` is the only one carrying a name. The other four each say something different and only
71
+ * one of them is a claim about the *person*:
72
+ *
73
+ * - `loading` — the workspace's members have not arrived. Nothing is known yet, and saying anything
74
+ * about who this is would be inventing it.
75
+ * - `unknown` — the members request failed. Somebody did this; which somebody cannot be found out
76
+ * right now, and that is a fact about the request rather than about them.
77
+ * - `former` — the list is in hand and this id is not in it. *Now* "a former member" is true.
78
+ * - `system` — there is no id at all: a nightly sweep, an import, an offboarding hook.
79
+ */
80
+ export type NameKind = 'person' | 'loading' | 'unknown' | 'former' | 'system'
81
+
82
+ export interface ResolvedName {
83
+ kind: NameKind
84
+ /** The name, and only for `person`. Everything else has no name to give. */
85
+ name: string | null
86
+ }
87
+
88
+ export function resolveName(userId: string | null, dir: Directory): ResolvedName {
89
+ if (!userId) return { kind: 'system', name: null }
90
+ const name = displayName(dir.byId.get(userId))
91
+ if (name) return { kind: 'person', name }
92
+ if (dir.status === 'loading') return { kind: 'loading', name: null }
93
+ if (dir.status === 'error') return { kind: 'unknown', name: null }
94
+ return { kind: 'former', name: null }
95
+ }
96
+
97
+ /** The words a screen supplies for the four answers that have no name of their own. */
98
+ export interface NameWords {
99
+ loading: string
100
+ unknown: string
101
+ former: string
102
+ system: string
103
+ }
104
+
105
+ /**
106
+ * The one place an unresolvable id is turned into words.
107
+ *
108
+ * The words are given by the caller because they are translated strings, and this file holds no
109
+ * strings — it decides *which* of the five answers applies and leaves the wording to the catalogue.
110
+ * Never the raw uuid: a sentence with a uuid in the middle of it is the interface admitting it does
111
+ * not know what it is talking about.
112
+ */
113
+ export function nameOf(userId: string | null, dir: Directory, words: NameWords): string {
114
+ const resolved = resolveName(userId, dir)
115
+ return resolved.kind === 'person' ? (resolved.name as string) : words[resolved.kind]
116
+ }