@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
@@ -50,6 +50,23 @@ export class NotifyService {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * `best`, asked the other question: **did the call actually happen**, rather than what it returned.
55
+ *
56
+ * `best` cannot answer that — a procedure that legitimately answers `null` or `undefined` is
57
+ * indistinguishable from one that threw and was swallowed, and a caller writing "I have told
58
+ * them" into a database on the strength of that is writing a lie. So the value is thrown away
59
+ * here and the boolean is the whole answer.
60
+ */
61
+ private async attempted(what: string, fn: () => Promise<unknown>): Promise<boolean> {
62
+ return (
63
+ (await this.best(what, async () => {
64
+ await fn()
65
+ return true as const
66
+ })) === true
67
+ )
68
+ }
69
+
53
70
  /**
54
71
  * Append to the asset's own history, inside the caller's transaction.
55
72
  *
@@ -87,13 +104,34 @@ export class NotifyService {
87
104
  )
88
105
  }
89
106
 
90
- async notify(input: NotifyInput): Promise<void> {
107
+ /**
108
+ * Tell these people, and answer **who it was meant for and how many of them were actually
109
+ * written** — both numbers, because one of them cannot be checked without the other.
110
+ *
111
+ * Everything this class does is best-effort, so a failed `core.notifications.create` is swallowed
112
+ * and logged; a caller that then records "this one has been notified" has recorded something that
113
+ * never happened. The two nightly sweeps write exactly such a marker, once per row for ever, so a
114
+ * swallowed failure there is not a delayed notice but a notice nobody will ever receive.
115
+ *
116
+ * This used to answer a single count, and the sweeps marked the row whenever it was above zero —
117
+ * so a notice that reached one of three recipients was stamped *told*, and the two who missed it
118
+ * never heard. **A partial delivery is a failure for the people it did not reach**, and the row
119
+ * cannot record two answers, so the honest one is the pessimistic one: the sweeps mark nothing
120
+ * unless `delivered === targeted`, and say it again in the morning. The cost is that whoever did
121
+ * hear it hears it twice; `groupKey` collapses that in the notification centre, and it is a
122
+ * strictly smaller price than one person never being told at all.
123
+ *
124
+ * `targeted` is what is left after the de-duplication and the exclusions, which is why the caller
125
+ * cannot work it out from the list it passed in. `targeted: 0` means there was nobody to tell,
126
+ * which is also not "told".
127
+ */
128
+ async notify(input: NotifyInput): Promise<{ targeted: number; delivered: number }> {
91
129
  const excluded = new Set([...(input.exclude ?? [])].filter(Boolean) as string[])
92
130
  const targets = [...new Set(input.userIds)].filter((id) => id && !excluded.has(id))
93
- if (!targets.length) return
94
- await Promise.all(
131
+ if (!targets.length) return { targeted: 0, delivered: 0 }
132
+ const written = await Promise.all(
95
133
  targets.map((userId) =>
96
- this.best('notifications.create', () =>
134
+ this.attempted('notifications.create', () =>
97
135
  this.kernel.call('core.notifications.create', {
98
136
  userId,
99
137
  workspaceId: input.workspaceId,
@@ -110,6 +148,7 @@ export class NotifyService {
110
148
  ),
111
149
  ),
112
150
  )
151
+ return { targeted: targets.length, delivered: written.filter(Boolean).length }
113
152
  }
114
153
 
115
154
  /** Realtime cache invalidation for connected clients. */
@@ -132,9 +171,15 @@ export class NotifyService {
132
171
  )
133
172
  }
134
173
 
135
- // Nothing calls `index`/`unindex` yet: `objectTypes` came off the module definition until there
136
- // is a resolver and an indexer to stand behind it. They are kept as they are, copied from
137
- // tracker, and are wired up by the change that declares the object type again.
174
+ /**
175
+ * The workspace-wide search index, written to by `SearchService` and by nothing else.
176
+ *
177
+ * These two spent a release with no caller: `objectTypes` had been taken off the module
178
+ * definition, because a declared type with no indexer and no resolver renders a link to nothing.
179
+ * Both halves exist now — `src/server/services/search.ts` builds the documents, `src/server/
180
+ * index.ts` declares the indexer and the resolver — and every asset mutation reindexes after it
181
+ * has committed.
182
+ */
138
183
  async index(documents: core.SearchDocument[]): Promise<void> {
139
184
  if (!documents.length) return
140
185
  await this.best('search.index', () => this.kernel.call('core.search.index', { documents }))
@@ -0,0 +1,150 @@
1
+ import type { Kernel } from '@kernhq/kernel'
2
+ import { and, asc, eq, isNull } from 'drizzle-orm'
3
+ import { MODULE_ID } from '../../contract/models.js'
4
+ import { assets } from '../schema.js'
5
+ import { membersWithPermission } from './audience.js'
6
+ import type { NotifyService } from './notify.js'
7
+ import { assetUrl } from './search.js'
8
+
9
+ /** How many tags fit in one sentence before it stops being readable. */
10
+ const TAGS_IN_BODY = 5
11
+
12
+ /**
13
+ * More than anybody is plausibly holding, and a bound all the same: this reads every live asset of
14
+ * one person, and an unbounded query in an event handler is an unbounded query.
15
+ */
16
+ const MAX_HELD = 200
17
+
18
+ /** Why the checklist is being raised, which is the only difference between the two callers. */
19
+ export type Departure =
20
+ /** `core.member.removed` — the account is out of the workspace already. */
21
+ | 'removed'
22
+ /** `hr.person.status_changed` to a status that ends employment — they may still be at their desk. */
23
+ | 'leaving'
24
+
25
+ export interface ReturnList {
26
+ items: Array<{ id: string; code: string; name: string }>
27
+ /** Who was told. Empty when there was nothing to return, or nobody who could act on it. */
28
+ recipients: string[]
29
+ }
30
+
31
+ /**
32
+ * Somebody is leaving, and the register still says things are theirs.
33
+ *
34
+ * **This raises a checklist and notifies. It never moves anything.** Custody changes because a
35
+ * person did something — handed an item over, handed it on, took it back — and every one of those
36
+ * writes a period row and a line of history saying who did it. A hook that quietly returned an
37
+ * item on somebody's last day would write a handover nobody performed, into the one record the
38
+ * company later argues from; and the laptop would still be in their bag. So the answer is a message
39
+ * to the people who can take it back, and the register stays exactly as true as it was.
40
+ *
41
+ * **It is inert without HR.** Nothing here imports HR, depends on it, or assumes it is installed:
42
+ * `hr.person.status_changed` simply never arrives in a workspace that has no HR, and the second
43
+ * caller — `core.member.removed` — is core's own event and needs nothing at all. Both paths check
44
+ * the *inventory* module is switched on for the workspace before doing anything, because an event
45
+ * bus is instance-wide and a workspace that has never enabled this module must not be sent its
46
+ * notifications.
47
+ */
48
+ export class OffboardingService {
49
+ constructor(
50
+ private readonly kernel: Kernel,
51
+ private readonly notify: NotifyService,
52
+ ) {}
53
+
54
+ /**
55
+ * What one person is still recorded as holding.
56
+ *
57
+ * Read from `assets.custodian_user_id` — denormalised inside the transaction that writes the
58
+ * custody period, and indexed — rather than from an open-period join, which is the same choice
59
+ * `custody.byUser` makes and for the same reason. Archived rows cannot appear: an item somebody
60
+ * holds cannot be archived at all, because `assets.archive` refuses it.
61
+ */
62
+ async held(workspaceId: string, userId: string): Promise<ReturnList['items']> {
63
+ return this.kernel.database.withWorkspace(workspaceId, (tx) =>
64
+ tx
65
+ .select({ id: assets.id, code: assets.code, name: assets.name })
66
+ .from(assets)
67
+ .where(
68
+ and(
69
+ eq(assets.workspaceId, workspaceId),
70
+ eq(assets.custodianUserId, userId),
71
+ isNull(assets.archivedAt),
72
+ ),
73
+ )
74
+ .orderBy(asc(assets.code))
75
+ .limit(MAX_HELD),
76
+ )
77
+ }
78
+
79
+ /**
80
+ * Raise the return list, if there is one.
81
+ *
82
+ * Answers with what it found and who it told, so a test can assert both and a caller can log the
83
+ * count. Doing nothing is the ordinary case — most people leave holding nothing — and it is not
84
+ * an error.
85
+ *
86
+ * Redelivery sends the same message again: an event handler is retried, and `core.notifications
87
+ * .create` has no idempotency key. The two events behind this are rare enough that the cost is a
88
+ * duplicate row rather than a stream, and `groupKey` collapses them where a client groups. That
89
+ * is the same trade `module-tracker`'s `due-soon` makes, written down rather than assumed.
90
+ */
91
+ async raise(workspaceId: string, userId: string, departure: Departure): Promise<ReturnList> {
92
+ if (!(await this.kernel.isModuleEnabled(workspaceId, MODULE_ID).catch(() => false)))
93
+ return { items: [], recipients: [] }
94
+
95
+ const items = await this.held(workspaceId, userId)
96
+ if (!items.length) return { items: [], recipients: [] }
97
+
98
+ // Whoever may take an item back, which is the thing this message is asking for. Not "the
99
+ // admins": a workspace that gave its office manager a custom role did that on purpose.
100
+ const recipients = await membersWithPermission(this.kernel, workspaceId, 'inventory.custody.manage')
101
+ if (!recipients.length) {
102
+ this.kernel.log.warn(
103
+ { module: MODULE_ID, workspaceId, held: items.length },
104
+ 'inventory: somebody left holding items and nobody in the workspace may take them back',
105
+ )
106
+ return { items, recipients: [] }
107
+ }
108
+
109
+ const who = await this.nameOf(userId)
110
+ const tags = items.slice(0, TAGS_IN_BODY).map((item) => `${item.code} ${item.name}`)
111
+ const rest = items.length - tags.length
112
+
113
+ await this.notify.notify({
114
+ workspaceId,
115
+ userIds: recipients,
116
+ type: 'inventory.custody.return_due',
117
+ title:
118
+ departure === 'removed'
119
+ ? `${who} has left the workspace still holding ${items.length === 1 ? 'an item' : `${items.length} items`}`
120
+ : `${who} is leaving and still holds ${items.length === 1 ? 'an item' : `${items.length} items`}`,
121
+ body: rest > 0 ? `${tags.join(', ')} and ${rest} more` : tags.join(', '),
122
+ // One item has an object worth pointing at; several do not, and naming the first would send
123
+ // everybody to one laptop out of five. The URL below is the list either way.
124
+ object: items.length === 1 ? { module: MODULE_ID, type: 'asset', id: items[0]!.id } : null,
125
+ url: items.length === 1 ? assetUrl(items[0]!.id) : `/inventory?custodian=${userId}`,
126
+ data: { userId, departure, assetIds: items.map((item) => item.id) },
127
+ // One group per person, so the two events cannot stack two separate piles about one leaver.
128
+ groupKey: `inventory.return_due.${userId}`,
129
+ // Never to the person leaving: they are being *chased*, and on the `removed` path they are no
130
+ // longer a member of this workspace at all.
131
+ exclude: [userId],
132
+ })
133
+
134
+ return { items, recipients }
135
+ }
136
+
137
+ /**
138
+ * What to call the person in the sentence.
139
+ *
140
+ * Core is asked, and a failure falls back to "a former member" rather than to the uuid: a
141
+ * notification with a uuid in the middle of it is the product admitting it does not know who it
142
+ * is talking about, and this module already refuses to print one on a screen for the same reason.
143
+ */
144
+ private async nameOf(userId: string): Promise<string> {
145
+ const user = await this.kernel
146
+ .call<{ name?: string | null; email?: string | null } | null>('core.users.get', { id: userId })
147
+ .catch(() => null)
148
+ return user?.name?.trim() || user?.email?.trim() || 'A former member'
149
+ }
150
+ }