@kernhq/module-inventory 0.1.2 → 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 (213) hide show
  1. package/README.md +134 -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 +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  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 +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  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 +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  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 +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  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 +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  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/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -1,169 +1,14 @@
1
- import { type Message, scopedT } from '@kernhq/ui'
2
-
3
- /**
4
- * This module's own strings, in every locale the platform ships.
5
- *
6
- * A module ships separately from the app, so Paraglide — which compiles only the app's
7
- * `messages/*.json` — cannot see these. The shell merges them into the framework's message runtime
8
- * when it registers the module, and `t()` resolves against the merged map. Keys are namespaced by
9
- * module id, which is what keeps two modules from colliding in that one map.
10
- *
11
- * A **counted** message is not a string with `{count}` in it: give it a map of CLDR plural category
12
- * to string and `t(key, { n })` picks the form. English has two and Arabic has six, and which one
13
- * applies is `Intl.PluralRules`' answer rather than yours.
14
- *
15
- * Words every module needs and none owns — Save, Cancel, Retry — come from the framework's `common`
16
- * bundle (`t('common.save')`). Do not copy them here; six translations of "Save" drift apart.
17
- */
18
-
19
- export const en: Record<string, Message> = {
20
- 'inventory.nav': 'Inventory',
21
- 'inventory.title': 'Assets',
22
- 'inventory.empty': 'No assets yet',
23
- 'inventory.empty_desc': 'Add the first laptop, phone or desk — anything the company owns.',
24
- 'inventory.new': 'New asset',
25
- 'inventory.edit': 'Edit asset',
26
- 'inventory.code': 'Asset tag',
27
- 'inventory.name': 'Name',
28
- 'inventory.name_placeholder': 'MacBook Pro 14", office chair…',
29
- 'inventory.description': 'Description',
30
- 'inventory.category': 'Category',
31
- 'inventory.serial_number': 'Serial number',
32
- 'inventory.location': 'Location',
33
- 'inventory.purchased_on': 'Purchase date',
34
- 'inventory.purchased_from': 'Purchased from',
35
- 'inventory.price': 'Price',
36
- 'inventory.warranty_until': 'Warranty until',
37
- 'inventory.status_in_stock': 'In stock',
38
- 'inventory.status_assigned': 'Assigned',
39
- 'inventory.status_under_repair': 'Under repair',
40
- 'inventory.status_retired': 'Retired',
41
- 'inventory.count': { one: '{n} asset', other: '{n} assets' },
42
- 'inventory.search_placeholder': 'Search by name, tag or serial…',
43
- 'inventory.filter_all_statuses': 'All statuses',
44
- 'inventory.archived': 'Archived',
45
- 'inventory.archive': 'Archive',
46
- 'inventory.restore': 'Restore',
47
- 'inventory.widget_title': 'Recent assets',
48
- 'inventory.widget_desc': 'The most recently added assets in this workspace.',
49
- }
50
-
51
- export type InventoryMessageKey = keyof typeof en
52
-
53
- export const fa: Record<string, Message> = {
54
- 'inventory.nav': 'اموال',
55
- 'inventory.title': 'اقلام',
56
- 'inventory.empty': 'هنوز قلمی ثبت نشده است',
57
- 'inventory.empty_desc': 'اولین لپ‌تاپ، گوشی یا میز را اضافه کنید — هر چیزی که شرکت دارد.',
58
- 'inventory.new': 'قلم جدید',
59
- 'inventory.edit': 'ویرایش قلم',
60
- 'inventory.code': 'کد قلم',
61
- 'inventory.name': 'نام',
62
- 'inventory.name_placeholder': 'مک‌بوک پرو ۱۴ اینچ، صندلی اداری…',
63
- 'inventory.description': 'توضیحات',
64
- 'inventory.category': 'دسته',
65
- 'inventory.serial_number': 'شماره سریال',
66
- 'inventory.location': 'محل استقرار',
67
- 'inventory.purchased_on': 'تاریخ خرید',
68
- 'inventory.purchased_from': 'خریداری از',
69
- 'inventory.price': 'قیمت',
70
- 'inventory.warranty_until': 'گارانتی تا',
71
- 'inventory.status_in_stock': 'در انبار',
72
- 'inventory.status_assigned': 'تحویل‌شده',
73
- 'inventory.status_under_repair': 'در تعمیر',
74
- 'inventory.status_retired': 'اسقاط‌شده',
75
- 'inventory.count': { one: '{n} قلم', other: '{n} قلم' },
76
- 'inventory.search_placeholder': 'جستجو بر اساس نام، کد یا سریال…',
77
- 'inventory.filter_all_statuses': 'همه وضعیت‌ها',
78
- 'inventory.archived': 'بایگانی‌شده',
79
- 'inventory.archive': 'بایگانی',
80
- 'inventory.restore': 'بازگردانی',
81
- 'inventory.widget_title': 'اقلام اخیر',
82
- 'inventory.widget_desc': 'آخرین اقلامی که به این فضای کاری اضافه شده‌اند.',
83
- }
84
-
85
- export const ar: Record<string, Message> = {
86
- 'inventory.nav': 'الأصول',
87
- 'inventory.title': 'الأصول',
88
- 'inventory.empty': 'لا أصول بعد',
89
- 'inventory.empty_desc': 'أضف أول حاسوب أو هاتف أو مكتب — أي شيء تملكه الشركة.',
90
- 'inventory.new': 'أصل جديد',
91
- 'inventory.edit': 'تعديل الأصل',
92
- 'inventory.code': 'رمز الأصل',
93
- 'inventory.name': 'الاسم',
94
- 'inventory.name_placeholder': 'حاسوب ماك بوك برو ١٤، كرسي مكتبي…',
95
- 'inventory.description': 'الوصف',
96
- 'inventory.category': 'الفئة',
97
- 'inventory.serial_number': 'الرقم التسلسلي',
98
- 'inventory.location': 'الموقع',
99
- 'inventory.purchased_on': 'تاريخ الشراء',
100
- 'inventory.purchased_from': 'تم الشراء من',
101
- 'inventory.price': 'السعر',
102
- 'inventory.warranty_until': 'الضمان حتى',
103
- 'inventory.status_in_stock': 'في المخزن',
104
- 'inventory.status_assigned': 'مُسلَّم',
105
- 'inventory.status_under_repair': 'قيد الإصلاح',
106
- 'inventory.status_retired': 'مستبعد',
107
- 'inventory.count': {
108
- zero: 'لا أصول',
109
- one: 'أصل واحد',
110
- two: 'أصلان',
111
- few: '{n} أصول',
112
- many: '{n} أصلاً',
113
- other: '{n} أصل',
114
- },
115
- 'inventory.search_placeholder': 'ابحث بالاسم أو الرمز أو الرقم التسلسلي…',
116
- 'inventory.filter_all_statuses': 'جميع الحالات',
117
- 'inventory.archived': 'مؤرشف',
118
- 'inventory.archive': 'أرشفة',
119
- 'inventory.restore': 'استعادة',
120
- 'inventory.widget_title': 'أصول حديثة',
121
- 'inventory.widget_desc': 'الأصول المضافة أخيرًا إلى مساحة العمل هذه.',
122
- }
123
-
124
- export const de: Record<string, Message> = {
125
- 'inventory.nav': 'Inventar',
126
- 'inventory.title': 'Gegenstände',
127
- 'inventory.empty': 'Noch keine Gegenstände',
128
- 'inventory.empty_desc':
129
- 'Fügen Sie den ersten Laptop, das erste Telefon oder den ersten Schreibtisch hinzu — alles, was die Firma besitzt.',
130
- 'inventory.new': 'Neuer Gegenstand',
131
- 'inventory.edit': 'Gegenstand bearbeiten',
132
- 'inventory.code': 'Inventarnummer',
133
- 'inventory.name': 'Name',
134
- 'inventory.name_placeholder': 'MacBook Pro 14", Bürostuhl…',
135
- 'inventory.description': 'Beschreibung',
136
- 'inventory.category': 'Kategorie',
137
- 'inventory.serial_number': 'Seriennummer',
138
- 'inventory.location': 'Standort',
139
- 'inventory.purchased_on': 'Kaufdatum',
140
- 'inventory.purchased_from': 'Gekauft bei',
141
- 'inventory.price': 'Preis',
142
- 'inventory.warranty_until': 'Garantie bis',
143
- 'inventory.status_in_stock': 'Auf Lager',
144
- 'inventory.status_assigned': 'Zugewiesen',
145
- 'inventory.status_under_repair': 'In Reparatur',
146
- 'inventory.status_retired': 'Ausgemustert',
147
- 'inventory.count': { one: '{n} Gegenstand', other: '{n} Gegenstände' },
148
- 'inventory.search_placeholder': 'Nach Name, Nummer oder Seriennummer suchen…',
149
- 'inventory.filter_all_statuses': 'Alle Zustände',
150
- 'inventory.archived': 'Archiviert',
151
- 'inventory.archive': 'Archivieren',
152
- 'inventory.restore': 'Wiederherstellen',
153
- 'inventory.widget_title': 'Neue Gegenstände',
154
- 'inventory.widget_desc': 'Die zuletzt hinzugefügten Gegenstände dieses Arbeitsbereichs.',
155
- }
156
-
157
- /**
158
- * Add a locale by adding a bundle here. Bundles are thunks so a locale is fetched only when it is
159
- * the one in use; English is the fallback and is therefore always loaded.
160
- */
161
- export const inventoryMessageBundles = {
162
- ar: async () => ar,
163
- de: async () => de,
164
- en: async () => en,
165
- fa: async () => fa,
166
- }
1
+ import { scopedT } from '@kernhq/ui'
2
+
3
+ export {
4
+ ar,
5
+ de,
6
+ en,
7
+ fa,
8
+ type InventoryMessageKey,
9
+ inventoryMessageBundles,
10
+ tr,
11
+ } from './messages.js'
167
12
 
168
13
  /** `t('nav')` — the module id is implied. `t('common.save')` still reaches the shared bundle. */
169
14
  export const t = scopedT('inventory')
@@ -17,7 +17,14 @@
17
17
  * `files` in package.json must cover every directory this entry reaches, contract source included.
18
18
  */
19
19
 
20
- export { type Asset, type AssetStatus, inventoryPermissions, MODULE_ID } from '../contract.js'
20
+ export {
21
+ type Asset,
22
+ type AssetStatus,
23
+ InventorySettings,
24
+ inventoryCapabilities,
25
+ inventoryPermissions,
26
+ MODULE_ID,
27
+ } from '../contract/index.js'
21
28
  export { createInventoryClient, type InventoryApi } from './api.js'
22
29
  export { __setInventoryApi, getInventoryApi } from './api-instance.js'
23
30
  export { type InventoryMessageKey, inventoryMessageBundles, t } from './i18n.js'
@@ -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
+ }