@kelpie/server 0.7.0 → 0.8.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 (185) hide show
  1. package/dist/lib/consumerEmailDomains.d.ts +2 -0
  2. package/dist/lib/consumerEmailDomains.d.ts.map +1 -0
  3. package/dist/lib/consumerEmailDomains.js +62 -0
  4. package/dist/lib/consumerEmailDomains.js.map +1 -0
  5. package/dist/lib/email.d.ts +7 -0
  6. package/dist/lib/email.d.ts.map +1 -1
  7. package/dist/lib/email.js.map +1 -1
  8. package/dist/lib/emailContent.d.ts +33 -0
  9. package/dist/lib/emailContent.d.ts.map +1 -0
  10. package/dist/lib/emailContent.js +54 -0
  11. package/dist/lib/emailContent.js.map +1 -0
  12. package/dist/lib/emailDomainAutoLink.d.ts +27 -0
  13. package/dist/lib/emailDomainAutoLink.d.ts.map +1 -0
  14. package/dist/lib/emailDomainAutoLink.js +119 -0
  15. package/dist/lib/emailDomainAutoLink.js.map +1 -0
  16. package/dist/lib/ids.d.ts +2 -0
  17. package/dist/lib/ids.d.ts.map +1 -1
  18. package/dist/lib/ids.js +2 -0
  19. package/dist/lib/ids.js.map +1 -1
  20. package/dist/modules/attachedRecords.d.ts.map +1 -1
  21. package/dist/modules/attachedRecords.js +4 -2
  22. package/dist/modules/attachedRecords.js.map +1 -1
  23. package/dist/modules/auth/preferences.d.ts +11 -1
  24. package/dist/modules/auth/preferences.d.ts.map +1 -1
  25. package/dist/modules/auth/preferences.js +11 -0
  26. package/dist/modules/auth/preferences.js.map +1 -1
  27. package/dist/modules/auth/routes.d.ts.map +1 -1
  28. package/dist/modules/auth/routes.js +18 -0
  29. package/dist/modules/auth/routes.js.map +1 -1
  30. package/dist/modules/auth/schema.d.ts +20 -0
  31. package/dist/modules/auth/schema.d.ts.map +1 -1
  32. package/dist/modules/auth/schema.js +11 -1
  33. package/dist/modules/auth/schema.js.map +1 -1
  34. package/dist/modules/auth/service.d.ts.map +1 -1
  35. package/dist/modules/auth/service.js +27 -3
  36. package/dist/modules/auth/service.js.map +1 -1
  37. package/dist/modules/companies/repository.d.ts +2 -0
  38. package/dist/modules/companies/repository.d.ts.map +1 -1
  39. package/dist/modules/companies/repository.js +1 -0
  40. package/dist/modules/companies/repository.js.map +1 -1
  41. package/dist/modules/companies/routes.d.ts +2 -0
  42. package/dist/modules/companies/routes.d.ts.map +1 -1
  43. package/dist/modules/companies/routes.js +11 -0
  44. package/dist/modules/companies/routes.js.map +1 -1
  45. package/dist/modules/companies/schema.d.ts +17 -0
  46. package/dist/modules/companies/schema.d.ts.map +1 -1
  47. package/dist/modules/companies/schema.js +5 -1
  48. package/dist/modules/companies/schema.js.map +1 -1
  49. package/dist/modules/companies/service.d.ts +1 -0
  50. package/dist/modules/companies/service.d.ts.map +1 -1
  51. package/dist/modules/companies/service.js +22 -0
  52. package/dist/modules/companies/service.js.map +1 -1
  53. package/dist/modules/core.d.ts.map +1 -1
  54. package/dist/modules/core.js +11 -0
  55. package/dist/modules/core.js.map +1 -1
  56. package/dist/modules/email-domain-linker/index.d.ts +12 -0
  57. package/dist/modules/email-domain-linker/index.d.ts.map +1 -0
  58. package/dist/modules/email-domain-linker/index.js +34 -0
  59. package/dist/modules/email-domain-linker/index.js.map +1 -0
  60. package/dist/modules/email-domain-linker/routes.d.ts +14 -0
  61. package/dist/modules/email-domain-linker/routes.d.ts.map +1 -0
  62. package/dist/modules/email-domain-linker/routes.js +15 -0
  63. package/dist/modules/email-domain-linker/routes.js.map +1 -0
  64. package/dist/modules/email-domain-linker/service.d.ts +37 -0
  65. package/dist/modules/email-domain-linker/service.d.ts.map +1 -0
  66. package/dist/modules/email-domain-linker/service.js +63 -0
  67. package/dist/modules/email-domain-linker/service.js.map +1 -0
  68. package/dist/modules/eventCatalogs.d.ts +1 -0
  69. package/dist/modules/eventCatalogs.d.ts.map +1 -1
  70. package/dist/modules/eventCatalogs.js +1 -0
  71. package/dist/modules/eventCatalogs.js.map +1 -1
  72. package/dist/modules/lists/events.d.ts +42 -0
  73. package/dist/modules/lists/events.d.ts.map +1 -0
  74. package/dist/modules/lists/events.js +16 -0
  75. package/dist/modules/lists/events.js.map +1 -0
  76. package/dist/modules/lists/index.d.ts +10 -0
  77. package/dist/modules/lists/index.d.ts.map +1 -0
  78. package/dist/modules/lists/index.js +35 -0
  79. package/dist/modules/lists/index.js.map +1 -0
  80. package/dist/modules/lists/repository.d.ts +69 -0
  81. package/dist/modules/lists/repository.d.ts.map +1 -0
  82. package/dist/modules/lists/repository.js +165 -0
  83. package/dist/modules/lists/repository.js.map +1 -0
  84. package/dist/modules/lists/routes.d.ts +51 -0
  85. package/dist/modules/lists/routes.d.ts.map +1 -0
  86. package/dist/modules/lists/routes.js +160 -0
  87. package/dist/modules/lists/routes.js.map +1 -0
  88. package/dist/modules/lists/schema.d.ts +264 -0
  89. package/dist/modules/lists/schema.d.ts.map +1 -0
  90. package/dist/modules/lists/schema.js +73 -0
  91. package/dist/modules/lists/schema.js.map +1 -0
  92. package/dist/modules/lists/service.d.ts +70 -0
  93. package/dist/modules/lists/service.d.ts.map +1 -0
  94. package/dist/modules/lists/service.js +214 -0
  95. package/dist/modules/lists/service.js.map +1 -0
  96. package/dist/modules/lists/tools.d.ts +4 -0
  97. package/dist/modules/lists/tools.d.ts.map +1 -0
  98. package/dist/modules/lists/tools.js +86 -0
  99. package/dist/modules/lists/tools.js.map +1 -0
  100. package/dist/modules/people/repository.d.ts +11 -0
  101. package/dist/modules/people/repository.d.ts.map +1 -1
  102. package/dist/modules/people/repository.js +16 -0
  103. package/dist/modules/people/repository.js.map +1 -1
  104. package/dist/modules/people/service.d.ts.map +1 -1
  105. package/dist/modules/people/service.js +18 -0
  106. package/dist/modules/people/service.js.map +1 -1
  107. package/dist/modules/positions/routes.d.ts.map +1 -1
  108. package/dist/modules/positions/routes.js +4 -2
  109. package/dist/modules/positions/routes.js.map +1 -1
  110. package/dist/modules/sample-data/fixture.d.ts +139 -0
  111. package/dist/modules/sample-data/fixture.d.ts.map +1 -0
  112. package/dist/modules/sample-data/fixture.js +461 -0
  113. package/dist/modules/sample-data/fixture.js.map +1 -0
  114. package/dist/modules/sample-data/index.d.ts +11 -0
  115. package/dist/modules/sample-data/index.d.ts.map +1 -0
  116. package/dist/modules/sample-data/index.js +43 -0
  117. package/dist/modules/sample-data/index.js.map +1 -0
  118. package/dist/modules/sample-data/routes.d.ts +15 -0
  119. package/dist/modules/sample-data/routes.d.ts.map +1 -0
  120. package/dist/modules/sample-data/routes.js +24 -0
  121. package/dist/modules/sample-data/routes.js.map +1 -0
  122. package/dist/modules/sample-data/service.d.ts +38 -0
  123. package/dist/modules/sample-data/service.d.ts.map +1 -0
  124. package/dist/modules/sample-data/service.js +412 -0
  125. package/dist/modules/sample-data/service.js.map +1 -0
  126. package/dist/modules/sample-data/tools.d.ts +4 -0
  127. package/dist/modules/sample-data/tools.d.ts.map +1 -0
  128. package/dist/modules/sample-data/tools.js +22 -0
  129. package/dist/modules/sample-data/tools.js.map +1 -0
  130. package/dist/modules/smtp-email/index.d.ts +1 -0
  131. package/dist/modules/smtp-email/index.d.ts.map +1 -1
  132. package/dist/modules/smtp-email/index.js +1 -0
  133. package/dist/modules/smtp-email/index.js.map +1 -1
  134. package/dist/modules/workspace/service.d.ts.map +1 -1
  135. package/dist/modules/workspace/service.js +10 -1
  136. package/dist/modules/workspace/service.js.map +1 -1
  137. package/dist/schema/index.d.ts +1 -0
  138. package/dist/schema/index.d.ts.map +1 -1
  139. package/dist/schema/index.js +1 -0
  140. package/dist/schema/index.js.map +1 -1
  141. package/migrations/0021_certain_maggott.sql +30 -0
  142. package/migrations/0022_stormy_manta.sql +1 -0
  143. package/migrations/0023_funny_maddog.sql +1 -0
  144. package/migrations/meta/0021_snapshot.json +5389 -0
  145. package/migrations/meta/0022_snapshot.json +5396 -0
  146. package/migrations/meta/0023_snapshot.json +5403 -0
  147. package/migrations/meta/_journal.json +21 -0
  148. package/package.json +3 -2
  149. package/src/lib/consumerEmailDomains.ts +62 -0
  150. package/src/lib/email.ts +7 -0
  151. package/src/lib/emailContent.ts +91 -0
  152. package/src/lib/emailDomainAutoLink.ts +165 -0
  153. package/src/lib/ids.ts +2 -0
  154. package/src/modules/attachedRecords.ts +9 -2
  155. package/src/modules/auth/preferences.ts +13 -1
  156. package/src/modules/auth/routes.ts +22 -0
  157. package/src/modules/auth/schema.ts +12 -2
  158. package/src/modules/auth/service.ts +37 -3
  159. package/src/modules/companies/repository.ts +3 -0
  160. package/src/modules/companies/routes.ts +13 -0
  161. package/src/modules/companies/schema.ts +5 -1
  162. package/src/modules/companies/service.ts +25 -0
  163. package/src/modules/core.ts +11 -0
  164. package/src/modules/email-domain-linker/index.ts +40 -0
  165. package/src/modules/email-domain-linker/routes.ts +40 -0
  166. package/src/modules/email-domain-linker/service.ts +122 -0
  167. package/src/modules/eventCatalogs.ts +1 -0
  168. package/src/modules/lists/events.ts +41 -0
  169. package/src/modules/lists/index.ts +41 -0
  170. package/src/modules/lists/repository.ts +313 -0
  171. package/src/modules/lists/routes.ts +247 -0
  172. package/src/modules/lists/schema.ts +85 -0
  173. package/src/modules/lists/service.ts +404 -0
  174. package/src/modules/lists/tools.ts +128 -0
  175. package/src/modules/people/repository.ts +26 -0
  176. package/src/modules/people/service.ts +20 -0
  177. package/src/modules/positions/routes.ts +4 -2
  178. package/src/modules/sample-data/fixture.ts +627 -0
  179. package/src/modules/sample-data/index.ts +50 -0
  180. package/src/modules/sample-data/routes.ts +49 -0
  181. package/src/modules/sample-data/service.ts +590 -0
  182. package/src/modules/sample-data/tools.ts +32 -0
  183. package/src/modules/smtp-email/index.ts +8 -1
  184. package/src/modules/workspace/service.ts +13 -1
  185. package/src/schema/index.ts +1 -0
@@ -0,0 +1,404 @@
1
+ import { changedKeys } from '../../lib/changes.ts'
2
+ import { UNIQUE_VIOLATION, postgresErrorCode } from '../../lib/database.ts'
3
+ import type { Database } from '../../lib/database.ts'
4
+ import { AppError } from '../../lib/errors.ts'
5
+ import type { IdFactory } from '../../lib/ids.ts'
6
+ import { mapPage, readListWindow, toPage } from '../../lib/pagination.ts'
7
+ import type { ListQueryParameters, Page } from '../../lib/pagination.ts'
8
+ import type { TransactionScope } from '../../runtime/transaction.ts'
9
+ import { toEventActor } from '../../lib/actor.ts'
10
+ import type { Actor } from '../auth/actor.ts'
11
+ import { requireWorkspaceId } from '../auth/actor.ts'
12
+ import { missingTargets, resolveTargetNames, targetKey } from '../recordTargets.ts'
13
+ import type { RecordTargetType } from '../recordTargets.ts'
14
+ import './events.ts'
15
+ import * as repository from './repository.ts'
16
+ import {
17
+ DEFAULT_LIST_MEMBER_SORT,
18
+ DEFAULT_LIST_SORT,
19
+ LIST_MEMBER_SORTS,
20
+ LIST_SORTS,
21
+ } from './repository.ts'
22
+ import type {
23
+ ListFilters,
24
+ ListMemberFilters,
25
+ ListMemberRecord,
26
+ ListWithCount,
27
+ MembershipWithList,
28
+ } from './repository.ts'
29
+
30
+ /**
31
+ * Lists: named collections of records of one type.
32
+ *
33
+ * The type is fixed at creation. A member's type must match the parent list's,
34
+ * a rule the database enforces through a composite foreign key. The service
35
+ * checks the same before the insert so a mismatch produces a friendly 422
36
+ * rather than a driver error the caller has to decode.
37
+ */
38
+
39
+ export interface ListsDependencies {
40
+ readonly db: Database
41
+ readonly transaction: TransactionScope
42
+ readonly createId: IdFactory
43
+ readonly now: () => Date
44
+ }
45
+
46
+ /** A list as the API returns one: the stored row minus the tenancy column. */
47
+ export type ListView = Omit<ListWithCount, 'workspaceId'>
48
+
49
+ /** A membership plus the list it points at, as `/v1/list-memberships` returns one. */
50
+ export interface ListMembershipView {
51
+ readonly id: string
52
+ readonly listId: string
53
+ readonly listName: string
54
+ readonly listTargetType: RecordTargetType
55
+ readonly targetType: RecordTargetType
56
+ readonly targetId: string
57
+ readonly addedAt: Date
58
+ }
59
+
60
+ /** A member enriched with the resolved name of the record it points at. */
61
+ export interface ListMemberView {
62
+ readonly id: string
63
+ readonly listId: string
64
+ readonly targetType: RecordTargetType
65
+ readonly targetId: string
66
+ readonly targetName: string | null
67
+ readonly addedAt: Date
68
+ }
69
+
70
+ export interface CreateListInput {
71
+ readonly name: string
72
+ readonly targetType: RecordTargetType
73
+ readonly description: string | null
74
+ }
75
+
76
+ /** PATCH semantics: an absent field is left alone. The type never moves. */
77
+ export interface UpdateListInput {
78
+ readonly name?: string | undefined
79
+ readonly description?: string | null | undefined
80
+ }
81
+
82
+ export interface AddListMemberInput {
83
+ readonly targetType: RecordTargetType
84
+ readonly targetId: string
85
+ }
86
+
87
+ export interface ListsService {
88
+ list(actor: Actor, filters: ListFilters, query: ListQueryParameters): Promise<Page<ListView>>
89
+ get(actor: Actor, id: string): Promise<ListView>
90
+ create(actor: Actor, input: CreateListInput): Promise<ListView>
91
+ update(actor: Actor, id: string, changes: UpdateListInput): Promise<ListView>
92
+ remove(actor: Actor, id: string): Promise<void>
93
+ listMembers(
94
+ actor: Actor,
95
+ filters: ListMemberFilters,
96
+ query: ListQueryParameters,
97
+ ): Promise<Page<ListMemberView>>
98
+ addMember(actor: Actor, listId: string, input: AddListMemberInput): Promise<ListMemberView>
99
+ removeMember(actor: Actor, listId: string, id: string): Promise<void>
100
+ membershipsFor(
101
+ actor: Actor,
102
+ targetType: RecordTargetType,
103
+ targetId: string,
104
+ ): Promise<readonly ListMembershipView[]>
105
+ }
106
+
107
+ function toView(record: ListWithCount): ListView {
108
+ const { workspaceId: _workspaceId, ...view } = record
109
+
110
+ return view
111
+ }
112
+
113
+ function toMembershipView(row: MembershipWithList): ListMembershipView {
114
+ return {
115
+ id: row.id,
116
+ listId: row.listId,
117
+ listName: row.listName,
118
+ listTargetType: row.listTargetType as RecordTargetType,
119
+ targetType: row.targetType as RecordTargetType,
120
+ targetId: row.targetId,
121
+ addedAt: row.addedAt,
122
+ }
123
+ }
124
+
125
+ function toMemberView(record: ListMemberRecord, name: string | undefined): ListMemberView {
126
+ return {
127
+ id: record.id,
128
+ listId: record.listId,
129
+ targetType: record.targetType as RecordTargetType,
130
+ targetId: record.targetId,
131
+ targetName: name ?? null,
132
+ addedAt: record.addedAt,
133
+ }
134
+ }
135
+
136
+ function duplicateName(): AppError {
137
+ return AppError.conflict('Another list in this workspace already has that name', [
138
+ { field: 'name', message: 'Already in use' },
139
+ ])
140
+ }
141
+
142
+ function typeMismatch(expected: RecordTargetType, got: RecordTargetType): AppError {
143
+ return AppError.validationFailed(
144
+ `That record cannot join a ${expected} list`,
145
+ [
146
+ {
147
+ field: 'target_type',
148
+ message: `Expected ${expected} to match the list's type, got ${got}`,
149
+ },
150
+ ],
151
+ )
152
+ }
153
+
154
+ function alreadyMember(): AppError {
155
+ return AppError.conflict('That record is already on the list', [
156
+ { field: 'target_id', message: 'Already a member' },
157
+ ])
158
+ }
159
+
160
+ export function createListsService(dependencies: ListsDependencies): ListsService {
161
+ async function require(workspaceId: string, id: string): Promise<ListWithCount> {
162
+ const list = await repository.findList(dependencies.db, workspaceId, id)
163
+
164
+ if (list === undefined) {
165
+ throw AppError.notFound('List not found')
166
+ }
167
+
168
+ return list
169
+ }
170
+
171
+ async function requireTarget(
172
+ workspaceId: string,
173
+ targetType: RecordTargetType,
174
+ targetId: string,
175
+ ): Promise<void> {
176
+ const missing = await missingTargets(dependencies.db, workspaceId, targetType, [targetId])
177
+
178
+ if (missing.length > 0) {
179
+ throw AppError.notFound('Record not found')
180
+ }
181
+ }
182
+
183
+ return {
184
+ async list(actor, filters, query) {
185
+ const workspaceId = requireWorkspaceId(actor)
186
+ const window = readListWindow(query, LIST_SORTS, DEFAULT_LIST_SORT)
187
+ const rows = await repository.listLists(dependencies.db, workspaceId, filters, window)
188
+
189
+ return mapPage(toPage(rows, window, (row) => row.id), toView)
190
+ },
191
+
192
+ async get(actor, id) {
193
+ return toView(await require(requireWorkspaceId(actor), id))
194
+ },
195
+
196
+ async create(actor, input) {
197
+ const workspaceId = requireWorkspaceId(actor)
198
+ const id = dependencies.createId('list')
199
+
200
+ return dependencies.transaction(
201
+ async ({ tx, events }) => {
202
+ let created
203
+ try {
204
+ created = await repository.insertList(tx, {
205
+ id,
206
+ workspaceId,
207
+ name: input.name,
208
+ description: input.description,
209
+ targetType: input.targetType,
210
+ })
211
+ } catch (error: unknown) {
212
+ if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
213
+ throw duplicateName()
214
+ }
215
+
216
+ throw error
217
+ }
218
+
219
+ events.emit('lists.list.created', { type: 'list', id: created.id }, {})
220
+
221
+ return toView({ ...created, memberCount: 0 })
222
+ },
223
+ { workspaceId, actor: toEventActor(actor) },
224
+ )
225
+ },
226
+
227
+ async update(actor, id, changes) {
228
+ const workspaceId = requireWorkspaceId(actor)
229
+ const existing = await require(workspaceId, id)
230
+ const columns: Partial<repository.ListColumns> = {
231
+ ...(changes.name === undefined ? {} : { name: changes.name }),
232
+ ...(changes.description === undefined ? {} : { description: changes.description }),
233
+ }
234
+ const changed = changedKeys(existing, columns)
235
+
236
+ if (changed.length === 0) {
237
+ return toView(existing)
238
+ }
239
+
240
+ return dependencies.transaction(
241
+ async ({ tx, events }) => {
242
+ let updated
243
+ try {
244
+ updated = await repository.updateList(tx, workspaceId, id, {
245
+ ...columns,
246
+ updatedAt: dependencies.now(),
247
+ })
248
+ } catch (error: unknown) {
249
+ if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
250
+ throw duplicateName()
251
+ }
252
+
253
+ throw error
254
+ }
255
+
256
+ if (updated === undefined) {
257
+ throw AppError.notFound('List not found')
258
+ }
259
+
260
+ events.emit('lists.list.updated', { type: 'list', id }, { changed })
261
+
262
+ return toView({ ...updated, memberCount: existing.memberCount })
263
+ },
264
+ { workspaceId, actor: toEventActor(actor) },
265
+ )
266
+ },
267
+
268
+ async remove(actor, id) {
269
+ const workspaceId = requireWorkspaceId(actor)
270
+
271
+ await dependencies.transaction(
272
+ async ({ tx, events }) => {
273
+ await require(workspaceId, id)
274
+ // Members cascade with the list; nothing else references it.
275
+ await repository.deleteList(tx, workspaceId, id)
276
+
277
+ events.emit('lists.list.deleted', { type: 'list', id }, {})
278
+ },
279
+ { workspaceId, actor: toEventActor(actor) },
280
+ )
281
+ },
282
+
283
+ async listMembers(actor, filters, query) {
284
+ const workspaceId = requireWorkspaceId(actor)
285
+ // The list must exist. A caller reading members of a missing list gets a
286
+ // 404, not a silent empty page.
287
+ await require(workspaceId, filters.listId)
288
+
289
+ const window = readListWindow(query, LIST_MEMBER_SORTS, DEFAULT_LIST_MEMBER_SORT)
290
+ const rows = await repository.listListMembers(dependencies.db, workspaceId, filters, window)
291
+ const names = await resolveTargetNames(
292
+ dependencies.db,
293
+ workspaceId,
294
+ rows.map((row) => ({
295
+ targetType: row.targetType as RecordTargetType,
296
+ targetId: row.targetId,
297
+ })),
298
+ )
299
+ const page = toPage(rows, window, (row) => row.id)
300
+
301
+ return mapPage(page, (row) =>
302
+ toMemberView(
303
+ row,
304
+ names.get(
305
+ targetKey({
306
+ targetType: row.targetType as RecordTargetType,
307
+ targetId: row.targetId,
308
+ }),
309
+ ),
310
+ ),
311
+ )
312
+ },
313
+
314
+ async addMember(actor, listId, input) {
315
+ const workspaceId = requireWorkspaceId(actor)
316
+ const list = await require(workspaceId, listId)
317
+
318
+ if (list.targetType !== input.targetType) {
319
+ throw typeMismatch(list.targetType as RecordTargetType, input.targetType)
320
+ }
321
+
322
+ await requireTarget(workspaceId, input.targetType, input.targetId)
323
+
324
+ const id = dependencies.createId('listMember')
325
+
326
+ return dependencies.transaction(
327
+ async ({ tx, events }) => {
328
+ let created
329
+ try {
330
+ created = await repository.insertListMember(tx, {
331
+ id,
332
+ workspaceId,
333
+ listId,
334
+ targetType: input.targetType,
335
+ targetId: input.targetId,
336
+ })
337
+ } catch (error: unknown) {
338
+ if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
339
+ throw alreadyMember()
340
+ }
341
+
342
+ throw error
343
+ }
344
+
345
+ events.emit(
346
+ 'lists.member.added',
347
+ { type: input.targetType, id: input.targetId },
348
+ { listId },
349
+ )
350
+
351
+ const names = await resolveTargetNames(tx, workspaceId, [
352
+ { targetType: input.targetType, targetId: input.targetId },
353
+ ])
354
+
355
+ return toMemberView(
356
+ created,
357
+ names.get(targetKey({ targetType: input.targetType, targetId: input.targetId })),
358
+ )
359
+ },
360
+ { workspaceId, actor: toEventActor(actor) },
361
+ )
362
+ },
363
+
364
+ async membershipsFor(actor, targetType, targetId) {
365
+ const workspaceId = requireWorkspaceId(actor)
366
+ // The target must exist. A caller listing memberships of a missing record
367
+ // gets 404, not a silent empty list — same rule as notes/decisions.
368
+ await requireTarget(workspaceId, targetType, targetId)
369
+
370
+ const rows = await repository.listMembershipsForTarget(
371
+ dependencies.db,
372
+ workspaceId,
373
+ targetType,
374
+ targetId,
375
+ )
376
+
377
+ return rows.map(toMembershipView)
378
+ },
379
+
380
+ async removeMember(actor, listId, id) {
381
+ const workspaceId = requireWorkspaceId(actor)
382
+ await require(workspaceId, listId)
383
+
384
+ const existing = await repository.findListMember(dependencies.db, workspaceId, listId, id)
385
+
386
+ if (existing === undefined) {
387
+ throw AppError.notFound('List member not found')
388
+ }
389
+
390
+ await dependencies.transaction(
391
+ async ({ tx, events }) => {
392
+ await repository.deleteListMember(tx, workspaceId, listId, id)
393
+
394
+ events.emit(
395
+ 'lists.member.removed',
396
+ { type: existing.targetType as RecordTargetType, id: existing.targetId },
397
+ { listId },
398
+ )
399
+ },
400
+ { workspaceId, actor: toEventActor(actor) },
401
+ )
402
+ },
403
+ }
404
+ }
@@ -0,0 +1,128 @@
1
+ import { RECORD_TARGET_TYPES } from '@kelpie/schemas'
2
+ import { z } from 'zod'
3
+
4
+ import type { McpToolRegistry } from '../../runtime/module.ts'
5
+ import {
6
+ deleteResult,
7
+ idArg,
8
+ listWindowShape,
9
+ pageResult,
10
+ registerCrudTools,
11
+ toListQuery,
12
+ } from '../crudTools.ts'
13
+ import {
14
+ addMemberBody,
15
+ createBody,
16
+ listMemberResponse,
17
+ listMembershipResponse,
18
+ listResponse,
19
+ toAddMemberInput,
20
+ toCreateInput,
21
+ toUpdateInput,
22
+ updateBody,
23
+ } from './routes.ts'
24
+ import type { ListsService } from './service.ts'
25
+
26
+ /**
27
+ * `lists_*`. Same schemas and mappers as `/v1/lists`, so the two cannot drift.
28
+ *
29
+ * The list-level five come from `registerCrudTools`; the three membership tools
30
+ * are declared by hand because they hang off `/v1/lists/:id/members`, which does
31
+ * not fit the by-id CRUD shape.
32
+ */
33
+
34
+ const listArgs = z.strictObject({
35
+ ...listWindowShape,
36
+ q: z
37
+ .string()
38
+ .min(1)
39
+ .optional()
40
+ .describe('Free text. Matches list names.'),
41
+ target_type: z
42
+ .enum(RECORD_TARGET_TYPES)
43
+ .optional()
44
+ .describe('Only lists that hold this kind of record.'),
45
+ })
46
+
47
+ const membersListArgs = z.strictObject({
48
+ ...listWindowShape,
49
+ list_id: idArg.describe('The list whose members you want.'),
50
+ })
51
+
52
+ const addMemberArgs = addMemberBody.extend({
53
+ list_id: idArg.describe('The list to add to. Its type must match target_type.'),
54
+ })
55
+
56
+ const removeMemberArgs = z.strictObject({
57
+ list_id: idArg.describe('The list to remove from.'),
58
+ id: idArg.describe('The membership id, from list_members_list.'),
59
+ })
60
+
61
+ const membershipsForArgs = z.strictObject({
62
+ target_type: z.enum(RECORD_TARGET_TYPES).describe('The kind of record.'),
63
+ target_id: idArg.describe('The record whose list memberships you want.'),
64
+ })
65
+
66
+ export function registerListsTools(mcp: McpToolRegistry, service: ListsService): void {
67
+ registerCrudTools(mcp, {
68
+ resource: 'lists',
69
+ subject: 'list',
70
+ about:
71
+ 'A named collection of records of one type, chosen when the list is created and fixed for its lifetime.',
72
+ service,
73
+ render: listResponse,
74
+ listArgs,
75
+ toFilters: (args) => ({ term: args.q, targetType: args.target_type }),
76
+ createArgs: createBody,
77
+ toCreateInput,
78
+ updateArgs: updateBody.extend({ id: idArg }),
79
+ toUpdateInput,
80
+ })
81
+
82
+ mcp.tool({
83
+ name: 'list_members_list',
84
+ description:
85
+ 'List the records on one list. Cursor paged. Mirrors GET /v1/lists/{id}/members.',
86
+ inputSchema: membersListArgs,
87
+ invoke: async (args, actor) =>
88
+ pageResult(
89
+ await service.listMembers(actor, { listId: args.list_id }, toListQuery(args)),
90
+ listMemberResponse,
91
+ ),
92
+ })
93
+
94
+ mcp.tool({
95
+ name: 'list_members_add',
96
+ description:
97
+ 'Add a record to a list. The record\'s type must match the list\'s. ' +
98
+ 'Mirrors POST /v1/lists/{id}/members.',
99
+ inputSchema: addMemberArgs,
100
+ invoke: async (args, actor) =>
101
+ listMemberResponse(await service.addMember(actor, args.list_id, toAddMemberInput(args))),
102
+ })
103
+
104
+ mcp.tool({
105
+ name: 'list_members_remove',
106
+ description:
107
+ 'Remove a record from a list. Mirrors DELETE /v1/lists/{id}/members/{memberId}.',
108
+ inputSchema: removeMemberArgs,
109
+ invoke: async (args, actor) => {
110
+ await service.removeMember(actor, args.list_id, args.id)
111
+
112
+ return deleteResult(args.id)
113
+ },
114
+ })
115
+
116
+ mcp.tool({
117
+ name: 'list_memberships_for',
118
+ description:
119
+ 'Which lists is this record on? Returns one row per membership, with the ' +
120
+ 'list joined in. Mirrors GET /v1/list-memberships.',
121
+ inputSchema: membershipsForArgs,
122
+ invoke: async (args, actor) => {
123
+ const memberships = await service.membershipsFor(actor, args.target_type, args.target_id)
124
+
125
+ return { data: memberships.map(listMembershipResponse), next_cursor: null }
126
+ },
127
+ })
128
+ }
@@ -132,6 +132,32 @@ export async function findPersonByEmail(
132
132
  return found
133
133
  }
134
134
 
135
+ /**
136
+ * Every person in this workspace whose email is exactly at `domain`, case-
137
+ * insensitive. Used by the email-domain auto-linker when a Company is created
138
+ * or its domain is set.
139
+ *
140
+ * Not `ilike '%@domain'`: that also matches `alex@sub.domain`, which the
141
+ * person-side matcher (exact `email.slice(at + 1) = companies.domain`) would
142
+ * never link. `split_part` peels off the address at the last `@`, keeping the
143
+ * two directions consistent.
144
+ */
145
+ export async function findPeopleByEmailDomain(
146
+ db: Queryable,
147
+ workspaceId: string,
148
+ domain: string,
149
+ ): Promise<PersonRecord[]> {
150
+ return db
151
+ .select()
152
+ .from(people)
153
+ .where(
154
+ and(
155
+ eq(people.workspaceId, workspaceId),
156
+ sql`split_part(${people.email}::text, '@', 2) = lower(${domain})`,
157
+ ),
158
+ )
159
+ }
160
+
135
161
  export async function insertPerson(db: Queryable, values: PersonColumns): Promise<PersonRecord> {
136
162
  const [created] = await db.insert(people).values(values).returning()
137
163
 
@@ -1,6 +1,7 @@
1
1
  import { changedKeys } from '../../lib/changes.ts'
2
2
  import { UNIQUE_VIOLATION, isReferenceViolation, postgresErrorCode } from '../../lib/database.ts'
3
3
  import type { Database } from '../../lib/database.ts'
4
+ import { autoLinkPersonByEmailDomain } from '../../lib/emailDomainAutoLink.ts'
4
5
  import { AppError } from '../../lib/errors.ts'
5
6
  import type { IdFactory } from '../../lib/ids.ts'
6
7
  import { normaliseEmail } from '../../lib/normalisation.ts'
@@ -200,6 +201,15 @@ export function createPeopleService(dependencies: PeopleDependencies): PeopleSer
200
201
 
201
202
  events.emit('people.person.created', { type: 'person', id: created.id }, {})
202
203
 
204
+ // Sync auto-link. Inside the same transaction so the response returns
205
+ // with the Position already visible to a follow-up read; without this
206
+ // the UI would not see the new Position until a manual refresh.
207
+ await autoLinkPersonByEmailDomain(tx, events, workspaceId, created, {
208
+ createId: dependencies.createId,
209
+ now: dependencies.now,
210
+ recordActivity: dependencies.recordActivity,
211
+ })
212
+
203
213
  return toView(created)
204
214
  },
205
215
  { workspaceId, actor: toEventActor(actor) },
@@ -253,6 +263,16 @@ export function createPeopleService(dependencies: PeopleDependencies): PeopleSer
253
263
  { changed },
254
264
  )
255
265
 
266
+ // Sync auto-link when the email moved. Same-transaction rationale as
267
+ // the create path above.
268
+ if (changed.includes('email')) {
269
+ await autoLinkPersonByEmailDomain(tx, events, workspaceId, updated, {
270
+ createId: dependencies.createId,
271
+ now: dependencies.now,
272
+ recordActivity: dependencies.recordActivity,
273
+ })
274
+ }
275
+
256
276
  return toView(updated)
257
277
  },
258
278
  { workspaceId, actor: toEventActor(actor) },
@@ -17,11 +17,13 @@ import type {
17
17
  export const createBody = z.strictObject({
18
18
  person_id: z.string().min(1),
19
19
  company_id: z.string().min(1),
20
- title: z.string().min(1),
20
+ // An empty title records the link without a job title — a person known to be
21
+ // at the company, with the role not yet captured.
22
+ title: z.string(),
21
23
  })
22
24
 
23
25
  /** Only the title. Moving a link to a different person or company is a delete and a create. */
24
- export const updateBody = z.strictObject({ title: z.string().min(1) }).partial()
26
+ export const updateBody = z.strictObject({ title: z.string() }).partial()
25
27
 
26
28
  export interface PositionsRoutesDependencies extends CredentialDependencies {
27
29
  readonly service: PositionsService