@kelpie/server 0.6.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 (266) hide show
  1. package/dist/app.d.ts +13 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +9 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/boot.d.ts +42 -0
  6. package/dist/boot.d.ts.map +1 -0
  7. package/dist/boot.js +57 -0
  8. package/dist/boot.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +5 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/config.d.ts +7 -0
  14. package/dist/lib/config.d.ts.map +1 -1
  15. package/dist/lib/config.js +2 -0
  16. package/dist/lib/config.js.map +1 -1
  17. package/dist/lib/consumerEmailDomains.d.ts +2 -0
  18. package/dist/lib/consumerEmailDomains.d.ts.map +1 -0
  19. package/dist/lib/consumerEmailDomains.js +62 -0
  20. package/dist/lib/consumerEmailDomains.js.map +1 -0
  21. package/dist/lib/email.d.ts +7 -0
  22. package/dist/lib/email.d.ts.map +1 -1
  23. package/dist/lib/email.js.map +1 -1
  24. package/dist/lib/emailContent.d.ts +33 -0
  25. package/dist/lib/emailContent.d.ts.map +1 -0
  26. package/dist/lib/emailContent.js +54 -0
  27. package/dist/lib/emailContent.js.map +1 -0
  28. package/dist/lib/emailDomainAutoLink.d.ts +27 -0
  29. package/dist/lib/emailDomainAutoLink.d.ts.map +1 -0
  30. package/dist/lib/emailDomainAutoLink.js +119 -0
  31. package/dist/lib/emailDomainAutoLink.js.map +1 -0
  32. package/dist/lib/ids.d.ts +2 -0
  33. package/dist/lib/ids.d.ts.map +1 -1
  34. package/dist/lib/ids.js +2 -0
  35. package/dist/lib/ids.js.map +1 -1
  36. package/dist/lib/kelpieConfigFile.d.ts +6 -0
  37. package/dist/lib/kelpieConfigFile.d.ts.map +1 -1
  38. package/dist/lib/kelpieConfigFile.js +1 -0
  39. package/dist/lib/kelpieConfigFile.js.map +1 -1
  40. package/dist/modules/attachedRecords.d.ts.map +1 -1
  41. package/dist/modules/attachedRecords.js +4 -2
  42. package/dist/modules/attachedRecords.js.map +1 -1
  43. package/dist/modules/auth/preferences.d.ts +11 -1
  44. package/dist/modules/auth/preferences.d.ts.map +1 -1
  45. package/dist/modules/auth/preferences.js +11 -0
  46. package/dist/modules/auth/preferences.js.map +1 -1
  47. package/dist/modules/auth/routes.d.ts.map +1 -1
  48. package/dist/modules/auth/routes.js +18 -0
  49. package/dist/modules/auth/routes.js.map +1 -1
  50. package/dist/modules/auth/schema.d.ts +20 -0
  51. package/dist/modules/auth/schema.d.ts.map +1 -1
  52. package/dist/modules/auth/schema.js +11 -1
  53. package/dist/modules/auth/schema.js.map +1 -1
  54. package/dist/modules/auth/service.d.ts.map +1 -1
  55. package/dist/modules/auth/service.js +27 -3
  56. package/dist/modules/auth/service.js.map +1 -1
  57. package/dist/modules/companies/repository.d.ts +2 -0
  58. package/dist/modules/companies/repository.d.ts.map +1 -1
  59. package/dist/modules/companies/repository.js +1 -0
  60. package/dist/modules/companies/repository.js.map +1 -1
  61. package/dist/modules/companies/routes.d.ts +2 -0
  62. package/dist/modules/companies/routes.d.ts.map +1 -1
  63. package/dist/modules/companies/routes.js +11 -0
  64. package/dist/modules/companies/routes.js.map +1 -1
  65. package/dist/modules/companies/schema.d.ts +17 -0
  66. package/dist/modules/companies/schema.d.ts.map +1 -1
  67. package/dist/modules/companies/schema.js +5 -1
  68. package/dist/modules/companies/schema.js.map +1 -1
  69. package/dist/modules/companies/service.d.ts +1 -0
  70. package/dist/modules/companies/service.d.ts.map +1 -1
  71. package/dist/modules/companies/service.js +22 -0
  72. package/dist/modules/companies/service.js.map +1 -1
  73. package/dist/modules/core.d.ts.map +1 -1
  74. package/dist/modules/core.js +11 -0
  75. package/dist/modules/core.js.map +1 -1
  76. package/dist/modules/email-domain-linker/index.d.ts +12 -0
  77. package/dist/modules/email-domain-linker/index.d.ts.map +1 -0
  78. package/dist/modules/email-domain-linker/index.js +34 -0
  79. package/dist/modules/email-domain-linker/index.js.map +1 -0
  80. package/dist/modules/email-domain-linker/routes.d.ts +14 -0
  81. package/dist/modules/email-domain-linker/routes.d.ts.map +1 -0
  82. package/dist/modules/email-domain-linker/routes.js +15 -0
  83. package/dist/modules/email-domain-linker/routes.js.map +1 -0
  84. package/dist/modules/email-domain-linker/service.d.ts +37 -0
  85. package/dist/modules/email-domain-linker/service.d.ts.map +1 -0
  86. package/dist/modules/email-domain-linker/service.js +63 -0
  87. package/dist/modules/email-domain-linker/service.js.map +1 -0
  88. package/dist/modules/eventCatalogs.d.ts +36 -0
  89. package/dist/modules/eventCatalogs.d.ts.map +1 -0
  90. package/dist/modules/eventCatalogs.js +36 -0
  91. package/dist/modules/eventCatalogs.js.map +1 -0
  92. package/dist/modules/import-export/drafts.d.ts +9 -0
  93. package/dist/modules/import-export/drafts.d.ts.map +1 -1
  94. package/dist/modules/import-export/drafts.js +25 -0
  95. package/dist/modules/import-export/drafts.js.map +1 -1
  96. package/dist/modules/import-export/exportRows.d.ts +9 -0
  97. package/dist/modules/import-export/exportRows.d.ts.map +1 -1
  98. package/dist/modules/import-export/exportRows.js +10 -1
  99. package/dist/modules/import-export/exportRows.js.map +1 -1
  100. package/dist/modules/import-export/plan.d.ts +23 -1
  101. package/dist/modules/import-export/plan.d.ts.map +1 -1
  102. package/dist/modules/import-export/plan.js +62 -3
  103. package/dist/modules/import-export/plan.js.map +1 -1
  104. package/dist/modules/import-export/presets.d.ts.map +1 -1
  105. package/dist/modules/import-export/presets.js +25 -0
  106. package/dist/modules/import-export/presets.js.map +1 -1
  107. package/dist/modules/import-export/repository.d.ts +17 -0
  108. package/dist/modules/import-export/repository.d.ts.map +1 -1
  109. package/dist/modules/import-export/repository.js +21 -1
  110. package/dist/modules/import-export/repository.js.map +1 -1
  111. package/dist/modules/import-export/routes.d.ts.map +1 -1
  112. package/dist/modules/import-export/routes.js +5 -1
  113. package/dist/modules/import-export/routes.js.map +1 -1
  114. package/dist/modules/import-export/schema.d.ts +55 -0
  115. package/dist/modules/import-export/schema.d.ts.map +1 -1
  116. package/dist/modules/import-export/schema.js +13 -1
  117. package/dist/modules/import-export/schema.js.map +1 -1
  118. package/dist/modules/import-export/service.d.ts +4 -1
  119. package/dist/modules/import-export/service.d.ts.map +1 -1
  120. package/dist/modules/import-export/service.js +51 -3
  121. package/dist/modules/import-export/service.js.map +1 -1
  122. package/dist/modules/import-export/tools.d.ts.map +1 -1
  123. package/dist/modules/import-export/tools.js +7 -1
  124. package/dist/modules/import-export/tools.js.map +1 -1
  125. package/dist/modules/import-export/writes.d.ts +16 -0
  126. package/dist/modules/import-export/writes.d.ts.map +1 -1
  127. package/dist/modules/import-export/writes.js +77 -2
  128. package/dist/modules/import-export/writes.js.map +1 -1
  129. package/dist/modules/lists/events.d.ts +42 -0
  130. package/dist/modules/lists/events.d.ts.map +1 -0
  131. package/dist/modules/lists/events.js +16 -0
  132. package/dist/modules/lists/events.js.map +1 -0
  133. package/dist/modules/lists/index.d.ts +10 -0
  134. package/dist/modules/lists/index.d.ts.map +1 -0
  135. package/dist/modules/lists/index.js +35 -0
  136. package/dist/modules/lists/index.js.map +1 -0
  137. package/dist/modules/lists/repository.d.ts +69 -0
  138. package/dist/modules/lists/repository.d.ts.map +1 -0
  139. package/dist/modules/lists/repository.js +165 -0
  140. package/dist/modules/lists/repository.js.map +1 -0
  141. package/dist/modules/lists/routes.d.ts +51 -0
  142. package/dist/modules/lists/routes.d.ts.map +1 -0
  143. package/dist/modules/lists/routes.js +160 -0
  144. package/dist/modules/lists/routes.js.map +1 -0
  145. package/dist/modules/lists/schema.d.ts +264 -0
  146. package/dist/modules/lists/schema.d.ts.map +1 -0
  147. package/dist/modules/lists/schema.js +73 -0
  148. package/dist/modules/lists/schema.js.map +1 -0
  149. package/dist/modules/lists/service.d.ts +70 -0
  150. package/dist/modules/lists/service.d.ts.map +1 -0
  151. package/dist/modules/lists/service.js +214 -0
  152. package/dist/modules/lists/service.js.map +1 -0
  153. package/dist/modules/lists/tools.d.ts +4 -0
  154. package/dist/modules/lists/tools.d.ts.map +1 -0
  155. package/dist/modules/lists/tools.js +86 -0
  156. package/dist/modules/lists/tools.js.map +1 -0
  157. package/dist/modules/people/repository.d.ts +11 -0
  158. package/dist/modules/people/repository.d.ts.map +1 -1
  159. package/dist/modules/people/repository.js +16 -0
  160. package/dist/modules/people/repository.js.map +1 -1
  161. package/dist/modules/people/service.d.ts.map +1 -1
  162. package/dist/modules/people/service.js +18 -0
  163. package/dist/modules/people/service.js.map +1 -1
  164. package/dist/modules/positions/routes.d.ts.map +1 -1
  165. package/dist/modules/positions/routes.js +4 -2
  166. package/dist/modules/positions/routes.js.map +1 -1
  167. package/dist/modules/sample-data/fixture.d.ts +139 -0
  168. package/dist/modules/sample-data/fixture.d.ts.map +1 -0
  169. package/dist/modules/sample-data/fixture.js +461 -0
  170. package/dist/modules/sample-data/fixture.js.map +1 -0
  171. package/dist/modules/sample-data/index.d.ts +11 -0
  172. package/dist/modules/sample-data/index.d.ts.map +1 -0
  173. package/dist/modules/sample-data/index.js +43 -0
  174. package/dist/modules/sample-data/index.js.map +1 -0
  175. package/dist/modules/sample-data/routes.d.ts +15 -0
  176. package/dist/modules/sample-data/routes.d.ts.map +1 -0
  177. package/dist/modules/sample-data/routes.js +24 -0
  178. package/dist/modules/sample-data/routes.js.map +1 -0
  179. package/dist/modules/sample-data/service.d.ts +38 -0
  180. package/dist/modules/sample-data/service.d.ts.map +1 -0
  181. package/dist/modules/sample-data/service.js +412 -0
  182. package/dist/modules/sample-data/service.js.map +1 -0
  183. package/dist/modules/sample-data/tools.d.ts +4 -0
  184. package/dist/modules/sample-data/tools.d.ts.map +1 -0
  185. package/dist/modules/sample-data/tools.js +22 -0
  186. package/dist/modules/sample-data/tools.js.map +1 -0
  187. package/dist/modules/smtp-email/index.d.ts +1 -0
  188. package/dist/modules/smtp-email/index.d.ts.map +1 -1
  189. package/dist/modules/smtp-email/index.js +1 -0
  190. package/dist/modules/smtp-email/index.js.map +1 -1
  191. package/dist/modules/workspace/service.d.ts.map +1 -1
  192. package/dist/modules/workspace/service.js +10 -1
  193. package/dist/modules/workspace/service.js.map +1 -1
  194. package/dist/schema/index.d.ts +1 -0
  195. package/dist/schema/index.d.ts.map +1 -1
  196. package/dist/schema/index.js +1 -0
  197. package/dist/schema/index.js.map +1 -1
  198. package/dist/testing/app.d.ts +5 -1
  199. package/dist/testing/app.d.ts.map +1 -1
  200. package/dist/testing/app.js +2 -0
  201. package/dist/testing/app.js.map +1 -1
  202. package/migrations/0019_sharp_red_skull.sql +4 -0
  203. package/migrations/0020_early_nemesis.sql +2 -0
  204. package/migrations/0021_certain_maggott.sql +30 -0
  205. package/migrations/0022_stormy_manta.sql +1 -0
  206. package/migrations/0023_funny_maddog.sql +1 -0
  207. package/migrations/meta/0019_snapshot.json +5138 -0
  208. package/migrations/meta/0020_snapshot.json +5138 -0
  209. package/migrations/meta/0021_snapshot.json +5389 -0
  210. package/migrations/meta/0022_snapshot.json +5396 -0
  211. package/migrations/meta/0023_snapshot.json +5403 -0
  212. package/migrations/meta/_journal.json +35 -0
  213. package/package.json +3 -2
  214. package/src/app.ts +26 -0
  215. package/src/boot.ts +89 -0
  216. package/src/index.ts +8 -0
  217. package/src/lib/config.ts +9 -0
  218. package/src/lib/consumerEmailDomains.ts +62 -0
  219. package/src/lib/email.ts +7 -0
  220. package/src/lib/emailContent.ts +91 -0
  221. package/src/lib/emailDomainAutoLink.ts +165 -0
  222. package/src/lib/ids.ts +2 -0
  223. package/src/lib/kelpieConfigFile.ts +8 -0
  224. package/src/modules/attachedRecords.ts +9 -2
  225. package/src/modules/auth/preferences.ts +13 -1
  226. package/src/modules/auth/routes.ts +22 -0
  227. package/src/modules/auth/schema.ts +12 -2
  228. package/src/modules/auth/service.ts +37 -3
  229. package/src/modules/companies/repository.ts +3 -0
  230. package/src/modules/companies/routes.ts +13 -0
  231. package/src/modules/companies/schema.ts +5 -1
  232. package/src/modules/companies/service.ts +25 -0
  233. package/src/modules/core.ts +11 -0
  234. package/src/modules/email-domain-linker/index.ts +40 -0
  235. package/src/modules/email-domain-linker/routes.ts +40 -0
  236. package/src/modules/email-domain-linker/service.ts +122 -0
  237. package/src/modules/eventCatalogs.ts +36 -0
  238. package/src/modules/import-export/drafts.ts +27 -0
  239. package/src/modules/import-export/exportRows.ts +10 -1
  240. package/src/modules/import-export/plan.ts +107 -5
  241. package/src/modules/import-export/presets.ts +25 -0
  242. package/src/modules/import-export/repository.ts +35 -1
  243. package/src/modules/import-export/routes.ts +5 -0
  244. package/src/modules/import-export/schema.ts +13 -0
  245. package/src/modules/import-export/service.ts +70 -4
  246. package/src/modules/import-export/tools.ts +9 -0
  247. package/src/modules/import-export/writes.ts +116 -3
  248. package/src/modules/lists/events.ts +41 -0
  249. package/src/modules/lists/index.ts +41 -0
  250. package/src/modules/lists/repository.ts +313 -0
  251. package/src/modules/lists/routes.ts +247 -0
  252. package/src/modules/lists/schema.ts +85 -0
  253. package/src/modules/lists/service.ts +404 -0
  254. package/src/modules/lists/tools.ts +128 -0
  255. package/src/modules/people/repository.ts +26 -0
  256. package/src/modules/people/service.ts +20 -0
  257. package/src/modules/positions/routes.ts +4 -2
  258. package/src/modules/sample-data/fixture.ts +627 -0
  259. package/src/modules/sample-data/index.ts +50 -0
  260. package/src/modules/sample-data/routes.ts +49 -0
  261. package/src/modules/sample-data/service.ts +590 -0
  262. package/src/modules/sample-data/tools.ts +32 -0
  263. package/src/modules/smtp-email/index.ts +8 -1
  264. package/src/modules/workspace/service.ts +13 -1
  265. package/src/schema/index.ts +1 -0
  266. package/src/testing/app.ts +7 -1
@@ -0,0 +1,247 @@
1
+ import type { Context, Hono } from 'hono'
2
+ import { z } from 'zod'
3
+
4
+ import { AppError } from '../../lib/errors.ts'
5
+ import { pageBody, readJsonBody, readListParameters } from '../../lib/http.ts'
6
+ import type { Actor } from '../auth/actor.ts'
7
+ import { resolveActorFrom } from '../auth/credentials.ts'
8
+ import type { CredentialDependencies } from '../auth/credentials.ts'
9
+ import { isRecordTargetType } from '../recordTargets.ts'
10
+ import type { RecordTargetType } from '../recordTargets.ts'
11
+ import { RECORD_TARGET_TYPES } from './schema.ts'
12
+ import type {
13
+ AddListMemberInput,
14
+ CreateListInput,
15
+ ListMemberView,
16
+ ListMembershipView,
17
+ ListView,
18
+ ListsService,
19
+ UpdateListInput,
20
+ } from './service.ts'
21
+
22
+ /**
23
+ * Wire shapes for `/v1/lists`.
24
+ *
25
+ * `target_type` is required on create and absent from update: a list's type is
26
+ * fixed for its lifetime. Bodies are strict, so a caller sending `target_type`
27
+ * on PATCH hears about it as a 422 rather than silently ignored.
28
+ */
29
+
30
+ export const createBody = z.strictObject({
31
+ name: z.string().min(1),
32
+ target_type: z.enum(RECORD_TARGET_TYPES),
33
+ description: z.string().nullable().default(null),
34
+ })
35
+
36
+ export const updateBody = z
37
+ .strictObject({
38
+ name: z.string().min(1),
39
+ description: z.string().nullable(),
40
+ })
41
+ .partial()
42
+
43
+ export const addMemberBody = z.strictObject({
44
+ target_type: z.enum(RECORD_TARGET_TYPES),
45
+ target_id: z.string().min(1),
46
+ })
47
+
48
+ export interface ListsRoutesDependencies extends CredentialDependencies {
49
+ readonly service: ListsService
50
+ }
51
+
52
+ export function toCreateInput(body: z.infer<typeof createBody>): CreateListInput {
53
+ return {
54
+ name: body.name,
55
+ targetType: body.target_type,
56
+ description: body.description,
57
+ }
58
+ }
59
+
60
+ export function toUpdateInput(body: z.infer<typeof updateBody>): UpdateListInput {
61
+ return {
62
+ ...(body.name === undefined ? {} : { name: body.name }),
63
+ ...(body.description === undefined ? {} : { description: body.description }),
64
+ }
65
+ }
66
+
67
+ export function toAddMemberInput(body: z.infer<typeof addMemberBody>): AddListMemberInput {
68
+ return {
69
+ targetType: body.target_type,
70
+ targetId: body.target_id,
71
+ }
72
+ }
73
+
74
+ export function listResponse(list: ListView): Record<string, unknown> {
75
+ return {
76
+ id: list.id,
77
+ name: list.name,
78
+ description: list.description,
79
+ target_type: list.targetType,
80
+ member_count: list.memberCount,
81
+ created_at: list.createdAt.toISOString(),
82
+ updated_at: list.updatedAt.toISOString(),
83
+ }
84
+ }
85
+
86
+ export function listMembershipResponse(
87
+ membership: ListMembershipView,
88
+ ): Record<string, unknown> {
89
+ return {
90
+ id: membership.id,
91
+ list_id: membership.listId,
92
+ list_name: membership.listName,
93
+ list_target_type: membership.listTargetType,
94
+ target_type: membership.targetType,
95
+ target_id: membership.targetId,
96
+ added_at: membership.addedAt.toISOString(),
97
+ }
98
+ }
99
+
100
+ export function listMemberResponse(member: ListMemberView): Record<string, unknown> {
101
+ return {
102
+ id: member.id,
103
+ list_id: member.listId,
104
+ target_type: member.targetType,
105
+ target_id: member.targetId,
106
+ target_name: member.targetName,
107
+ added_at: member.addedAt.toISOString(),
108
+ }
109
+ }
110
+
111
+ function readTargetTypeFilter(context: Context): RecordTargetType | undefined {
112
+ const raw = context.req.query('target_type')
113
+
114
+ if (raw === undefined) {
115
+ return undefined
116
+ }
117
+
118
+ if (!isRecordTargetType(raw)) {
119
+ throw AppError.validationFailed('That is not a record type a list holds', [
120
+ { field: 'target_type', message: `Unknown target type "${raw}"` },
121
+ ])
122
+ }
123
+
124
+ return raw
125
+ }
126
+
127
+ function readMembershipTarget(context: Context): {
128
+ targetType: RecordTargetType
129
+ targetId: string
130
+ } {
131
+ const rawType = context.req.query('target_type')
132
+ const targetId = context.req.query('target_id')
133
+
134
+ if (rawType === undefined || targetId === undefined || targetId.length === 0) {
135
+ throw AppError.validationFailed(
136
+ 'A memberships list always names the record it is for',
137
+ [
138
+ { field: 'target_type', message: 'Required' },
139
+ { field: 'target_id', message: 'Required' },
140
+ ],
141
+ )
142
+ }
143
+
144
+ if (!isRecordTargetType(rawType)) {
145
+ throw AppError.validationFailed('That is not a record type a list holds', [
146
+ { field: 'target_type', message: `Unknown target type "${rawType}"` },
147
+ ])
148
+ }
149
+
150
+ return { targetType: rawType, targetId }
151
+ }
152
+
153
+ export function mountListsRoutes(router: Hono, dependencies: ListsRoutesDependencies): void {
154
+ const requireActor = (context: Context): Promise<Actor> =>
155
+ resolveActorFrom(dependencies, context)
156
+
157
+ router.get('/list-memberships', async (context) => {
158
+ const { targetType, targetId } = readMembershipTarget(context)
159
+ const memberships = await dependencies.service.membershipsFor(
160
+ await requireActor(context),
161
+ targetType,
162
+ targetId,
163
+ )
164
+
165
+ // Not paged. A record has a small number of memberships in practice; the
166
+ // `next_cursor: null` is here so the standard list envelope reader accepts
167
+ // this response without a special case.
168
+ return context.json({
169
+ data: memberships.map(listMembershipResponse),
170
+ next_cursor: null,
171
+ })
172
+ })
173
+
174
+ router.get('/lists', async (context) => {
175
+ const page = await dependencies.service.list(
176
+ await requireActor(context),
177
+ {
178
+ term: context.req.query('q'),
179
+ targetType: readTargetTypeFilter(context),
180
+ },
181
+ readListParameters(context),
182
+ )
183
+
184
+ return context.json(pageBody(page, listResponse))
185
+ })
186
+
187
+ router.post('/lists', async (context) => {
188
+ const body = await readJsonBody(context, createBody)
189
+ const list = await dependencies.service.create(await requireActor(context), toCreateInput(body))
190
+
191
+ return context.json(listResponse(list), 201)
192
+ })
193
+
194
+ router.get('/lists/:id', async (context) => {
195
+ const list = await dependencies.service.get(await requireActor(context), context.req.param('id'))
196
+
197
+ return context.json(listResponse(list))
198
+ })
199
+
200
+ router.patch('/lists/:id', async (context) => {
201
+ const body = await readJsonBody(context, updateBody)
202
+ const list = await dependencies.service.update(
203
+ await requireActor(context),
204
+ context.req.param('id'),
205
+ toUpdateInput(body),
206
+ )
207
+
208
+ return context.json(listResponse(list))
209
+ })
210
+
211
+ router.delete('/lists/:id', async (context) => {
212
+ await dependencies.service.remove(await requireActor(context), context.req.param('id'))
213
+
214
+ return context.body(null, 204)
215
+ })
216
+
217
+ router.get('/lists/:id/members', async (context) => {
218
+ const page = await dependencies.service.listMembers(
219
+ await requireActor(context),
220
+ { listId: context.req.param('id') },
221
+ readListParameters(context),
222
+ )
223
+
224
+ return context.json(pageBody(page, listMemberResponse))
225
+ })
226
+
227
+ router.post('/lists/:id/members', async (context) => {
228
+ const body = await readJsonBody(context, addMemberBody)
229
+ const member = await dependencies.service.addMember(
230
+ await requireActor(context),
231
+ context.req.param('id'),
232
+ toAddMemberInput(body),
233
+ )
234
+
235
+ return context.json(listMemberResponse(member), 201)
236
+ })
237
+
238
+ router.delete('/lists/:id/members/:memberId', async (context) => {
239
+ await dependencies.service.removeMember(
240
+ await requireActor(context),
241
+ context.req.param('id'),
242
+ context.req.param('memberId'),
243
+ )
244
+
245
+ return context.body(null, 204)
246
+ })
247
+ }
@@ -0,0 +1,85 @@
1
+ import { RECORD_TARGET_TYPES } from '@kelpie/schemas'
2
+ import { foreignKey, index, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core'
3
+
4
+ import { checkOneOf, createdAt, primaryId, updatedAt } from '../../lib/columns.ts'
5
+ import { workspaces } from '../workspace/schema.ts'
6
+
7
+ /**
8
+ * The target types come from `@kelpie/schemas`, so this check constraint, the
9
+ * route's Zod enum, and the browser's decoder are one list rather than three
10
+ * copies. Re-exported because `routes.ts` reads them from here, and the tables
11
+ * they constrain are the reason they matter.
12
+ */
13
+ export { RECORD_TARGET_TYPES } from '@kelpie/schemas'
14
+ export type { RecordTargetType } from '@kelpie/schemas'
15
+
16
+ /**
17
+ * A named collection of records of one type.
18
+ *
19
+ * The `target_type` is chosen at creation and never changes. A `list_members`
20
+ * row's `(list_id, target_type)` foreign-keys back into the composite unique on
21
+ * this table, so adding a person to a company list is refused by the database
22
+ * rather than trusted to the service.
23
+ *
24
+ * `(workspace_id, name)` is unique so the sidebar never shows two lists with the
25
+ * same label. Renaming to an in-use name returns 409.
26
+ */
27
+ export const lists = pgTable(
28
+ 'lists',
29
+ {
30
+ id: primaryId(),
31
+ workspaceId: text('workspace_id')
32
+ .notNull()
33
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
34
+ name: text('name').notNull(),
35
+ description: text('description'),
36
+ targetType: text('target_type').notNull(),
37
+ createdAt: createdAt(),
38
+ updatedAt: updatedAt(),
39
+ },
40
+ (table) => [
41
+ index('lists_workspace_idx').on(table.workspaceId),
42
+ unique('lists_workspace_name_key').on(table.workspaceId, table.name),
43
+ // The referent of the composite FK from `list_members`. Postgres requires a
44
+ // unique key over the referenced columns; this exists solely to satisfy it.
45
+ unique('lists_id_target_type_key').on(table.id, table.targetType),
46
+ checkOneOf('lists_target_type_check', table.targetType, RECORD_TARGET_TYPES),
47
+ ],
48
+ )
49
+
50
+ /**
51
+ * A record's membership in a list.
52
+ *
53
+ * `(list_id, target_type)` foreign-keys back to the list's own unique key, so
54
+ * the member's type must equal its parent list's. The target itself is
55
+ * polymorphic and carries no foreign key of its own; the owning service checks
56
+ * existence with `missingTargets` and removes the row when the target goes
57
+ * (`attachedRecords.ts`).
58
+ */
59
+ export const listMembers = pgTable(
60
+ 'list_members',
61
+ {
62
+ id: primaryId(),
63
+ workspaceId: text('workspace_id')
64
+ .notNull()
65
+ .references(() => workspaces.id, { onDelete: 'cascade' }),
66
+ listId: text('list_id').notNull(),
67
+ targetType: text('target_type').notNull(),
68
+ targetId: text('target_id').notNull(),
69
+ // A membership is not edited, only added or removed, so no `updated_at`.
70
+ addedAt: timestamp('added_at', { withTimezone: true, mode: 'date' })
71
+ .notNull()
72
+ .defaultNow(),
73
+ },
74
+ (table) => [
75
+ index('list_members_list_idx').on(table.workspaceId, table.listId),
76
+ index('list_members_target_idx').on(table.workspaceId, table.targetType, table.targetId),
77
+ unique('list_members_list_target_key').on(table.listId, table.targetId),
78
+ foreignKey({
79
+ name: 'list_members_list_target_type_fk',
80
+ columns: [table.listId, table.targetType],
81
+ foreignColumns: [lists.id, lists.targetType],
82
+ }).onDelete('cascade'),
83
+ checkOneOf('list_members_target_type_check', table.targetType, RECORD_TARGET_TYPES),
84
+ ],
85
+ )