@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
@@ -1,4 +1,4 @@
1
- import type { ThemePreference } from '@kelpie/schemas'
1
+ import type { ListViewPreference, ThemePreference } from '@kelpie/schemas'
2
2
 
3
3
  /**
4
4
  * What an account's preferences are before anyone has saved any, and how a
@@ -17,12 +17,17 @@ export interface PreferenceValues {
17
17
  readonly emailDigest: boolean
18
18
  readonly mentionEmails: boolean
19
19
  readonly productUpdates: boolean
20
+ readonly listViews: Readonly<Record<string, ListViewPreference>>
20
21
  }
21
22
 
22
23
  /**
23
24
  * UTC, not the workspace's zone. An account is global and may belong to several
24
25
  * workspaces in different zones, so deriving from one of them would be picking
25
26
  * arbitrarily and calling it the person's answer.
27
+ *
28
+ * `listViews` starts empty. Each list page falls back to its own default when
29
+ * the map holds no entry for it, so the person sees the page's built-in shape
30
+ * until they change it.
26
31
  */
27
32
  export const DEFAULT_PREFERENCES: PreferenceValues = {
28
33
  timezone: 'UTC',
@@ -30,6 +35,7 @@ export const DEFAULT_PREFERENCES: PreferenceValues = {
30
35
  emailDigest: true,
31
36
  mentionEmails: true,
32
37
  productUpdates: false,
38
+ listViews: {},
33
39
  }
34
40
 
35
41
  export type PreferenceChanges = Partial<PreferenceValues>
@@ -40,6 +46,11 @@ export type PreferenceChanges = Partial<PreferenceValues>
40
46
  * The result is always a complete row, which is what makes the write an upsert
41
47
  * with no read-modify-write hazard in the caller: the same request applied twice
42
48
  * produces the same row.
49
+ *
50
+ * `listViews` is treated as one field, so the caller replaces the whole map
51
+ * rather than merging by key. The UI already holds the current map in the
52
+ * account-preferences query, so a per-view change becomes a PATCH of the merged
53
+ * map — the merge lives where the choice was made.
43
54
  */
44
55
  export function applyPreferenceChanges(
45
56
  stored: PreferenceValues | undefined,
@@ -53,5 +64,6 @@ export function applyPreferenceChanges(
53
64
  emailDigest: changes.emailDigest ?? current.emailDigest,
54
65
  mentionEmails: changes.mentionEmails ?? current.mentionEmails,
55
66
  productUpdates: changes.productUpdates ?? current.productUpdates,
67
+ listViews: changes.listViews ?? current.listViews,
56
68
  }
57
69
  }
@@ -1,4 +1,5 @@
1
1
  import { THEME_PREFERENCES } from '@kelpie/schemas'
2
+ import type { ListViewPreference } from '@kelpie/schemas'
2
3
  import type { Context, Hono } from 'hono'
3
4
  import { z } from 'zod'
4
5
 
@@ -59,6 +60,24 @@ const updateAccountBody = z
59
60
  path: ['current_password'],
60
61
  })
61
62
 
63
+ const listViewBody: z.ZodType<ListViewPreference, unknown> = z
64
+ .strictObject({
65
+ columns: z.array(z.string()).optional(),
66
+ mode: z.enum(['list', 'columns']).optional(),
67
+ grouping: z.string().optional(),
68
+ scope: z.string().optional(),
69
+ sort: z.string().optional(),
70
+ })
71
+ .transform(
72
+ (wire): ListViewPreference => ({
73
+ ...(wire.columns === undefined ? {} : { columns: wire.columns }),
74
+ ...(wire.mode === undefined ? {} : { mode: wire.mode }),
75
+ ...(wire.grouping === undefined ? {} : { grouping: wire.grouping }),
76
+ ...(wire.scope === undefined ? {} : { scope: wire.scope }),
77
+ ...(wire.sort === undefined ? {} : { sort: wire.sort }),
78
+ }),
79
+ )
80
+
62
81
  const updatePreferencesBody = z
63
82
  .strictObject({
64
83
  timezone: timezoneSchema,
@@ -66,6 +85,7 @@ const updatePreferencesBody = z
66
85
  email_digest: z.boolean(),
67
86
  mention_emails: z.boolean(),
68
87
  product_updates: z.boolean(),
88
+ list_views: z.record(z.string(), listViewBody),
69
89
  })
70
90
  .partial()
71
91
 
@@ -118,6 +138,7 @@ function preferencesResponse(preferences: PreferenceValues): Record<string, unkn
118
138
  email_digest: preferences.emailDigest,
119
139
  mention_emails: preferences.mentionEmails,
120
140
  product_updates: preferences.productUpdates,
141
+ list_views: preferences.listViews,
121
142
  }
122
143
  }
123
144
 
@@ -213,6 +234,7 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
213
234
  ...(body.email_digest === undefined ? {} : { emailDigest: body.email_digest }),
214
235
  ...(body.mention_emails === undefined ? {} : { mentionEmails: body.mention_emails }),
215
236
  ...(body.product_updates === undefined ? {} : { productUpdates: body.product_updates }),
237
+ ...(body.list_views === undefined ? {} : { listViews: body.list_views }),
216
238
  })
217
239
 
218
240
  return context.json(preferencesResponse(preferences))
@@ -1,6 +1,6 @@
1
1
  import { THEME_PREFERENCES } from '@kelpie/schemas'
2
- import type { ThemePreference } from '@kelpie/schemas'
3
- import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
2
+ import type { ListViewPreference, ThemePreference } from '@kelpie/schemas'
3
+ import { boolean, jsonb, pgTable, text } from 'drizzle-orm/pg-core'
4
4
 
5
5
  import { checkOneOf, citext, createdAt, moment, primaryId, updatedAt } from '../../lib/columns.ts'
6
6
 
@@ -57,6 +57,16 @@ export const userPreferences = pgTable(
57
57
  emailDigest: boolean('email_digest').notNull(),
58
58
  mentionEmails: boolean('mention_emails').notNull(),
59
59
  productUpdates: boolean('product_updates').notNull(),
60
+ /**
61
+ * Per-list column choices, keyed on a stable view id (`people`, `deals`, …).
62
+ * A view id the client no longer recognises stays untouched here rather than
63
+ * being cleaned up: the map is small, and the write path is the client
64
+ * merging its current choice into what it read.
65
+ */
66
+ listViews: jsonb('list_views')
67
+ .$type<Readonly<Record<string, ListViewPreference>>>()
68
+ .notNull()
69
+ .default({}),
60
70
  updatedAt: updatedAt(),
61
71
  },
62
72
  (table) => [checkOneOf('user_preferences_theme_check', table.theme, THEME_PREFERENCES)],
@@ -1,5 +1,6 @@
1
1
  import { APP_LINK_PATHS, buildAppLink } from '../../lib/appUrl.ts'
2
2
  import type { EmailSender } from '../../lib/email.ts'
3
+ import { renderEmail } from '../../lib/emailContent.ts'
3
4
  import { AppError } from '../../lib/errors.ts'
4
5
  import type { IdFactory } from '../../lib/ids.ts'
5
6
  import { UNIQUE_VIOLATION, postgresErrorCode } from '../../lib/database.ts'
@@ -95,6 +96,7 @@ function toPreferenceValues(record: repository.UserPreferencesRecord): Preferenc
95
96
  emailDigest: record.emailDigest,
96
97
  mentionEmails: record.mentionEmails,
97
98
  productUpdates: record.productUpdates,
99
+ listViews: record.listViews,
98
100
  }
99
101
  }
100
102
 
@@ -192,11 +194,23 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
192
194
 
193
195
  function sendVerificationEmail(to: string, token: string): Promise<void> {
194
196
  const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.verifyEmail, token)
197
+ const { text, html } = renderEmail(
198
+ {
199
+ action: {
200
+ instructions: 'Confirm this address to finish setting up your account:',
201
+ buttonText: 'Verify email address',
202
+ link,
203
+ },
204
+ outro: 'The link expires in 24 hours.',
205
+ },
206
+ dependencies.appBaseUrl,
207
+ )
195
208
 
196
209
  return dependencies.email.send({
197
210
  to,
198
211
  subject: 'Verify your Kelpie email address',
199
- body: `Confirm this address to finish setting up your account:\n\n${link}\n\nThe link expires in 24 hours.`,
212
+ body: text,
213
+ html,
200
214
  })
201
215
  }
202
216
 
@@ -320,10 +334,18 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
320
334
  })
321
335
 
322
336
  if (changes.email !== undefined) {
337
+ const { text, html } = renderEmail(
338
+ {
339
+ intro: `Your Kelpie sign-in email changed from ${previousEmail} to ${updated.email}. If you did not make this change, reset your password right away.`,
340
+ },
341
+ dependencies.appBaseUrl,
342
+ )
343
+
323
344
  await dependencies.email.send({
324
345
  to: previousEmail,
325
346
  subject: 'Your Kelpie sign-in email changed',
326
- body: `Your Kelpie sign-in email changed from ${previousEmail} to ${updated.email}. If you did not make this change, reset your password right away.`,
347
+ body: text,
348
+ html,
327
349
  })
328
350
  }
329
351
 
@@ -418,11 +440,23 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
418
440
  })
419
441
 
420
442
  const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.passwordReset, token)
443
+ const { text, html } = renderEmail(
444
+ {
445
+ action: {
446
+ instructions: 'Use this link within the hour to choose a new password:',
447
+ buttonText: 'Reset password',
448
+ link,
449
+ },
450
+ outro: 'If you did not ask for this, ignore it.',
451
+ },
452
+ dependencies.appBaseUrl,
453
+ )
421
454
 
422
455
  await dependencies.email.send({
423
456
  to: user.email,
424
457
  subject: 'Reset your Kelpie password',
425
- body: `Use this link within the hour to choose a new password:\n\n${link}\n\nIf you did not ask for this, ignore it.`,
458
+ body: text,
459
+ html,
426
460
  })
427
461
  },
428
462
 
@@ -27,6 +27,8 @@ export interface CompanyFilters {
27
27
  readonly term?: string | undefined
28
28
  /** `?person_id=`, repeatable: companies where any of these people holds a position. */
29
29
  readonly personIds?: readonly string[] | undefined
30
+ /** `?is_own=true` returns only the workspace's own companies; `false` returns everything else. */
31
+ readonly isOwn?: boolean | undefined
30
32
  }
31
33
 
32
34
  /**
@@ -64,6 +66,7 @@ function conditionsFor(workspaceId: string, filters: CompanyFilters): (SQL | und
64
66
  eq(companies.workspaceId, workspaceId),
65
67
  filters.term === undefined ? undefined : matchesTerm(filters.term),
66
68
  filters.personIds === undefined ? undefined : employsAnyOf(filters.personIds),
69
+ filters.isOwn === undefined ? undefined : eq(companies.isOwn, filters.isOwn),
67
70
  ]
68
71
  }
69
72
 
@@ -30,6 +30,7 @@ const companyShape = {
30
30
  tech_stack: z.array(z.string().min(1)),
31
31
  summary: z.string(),
32
32
  tags: z.array(z.string().min(1)),
33
+ is_own: z.boolean(),
33
34
  }
34
35
 
35
36
  /**
@@ -51,6 +52,7 @@ export const createBody = z.strictObject({
51
52
  tech_stack: companyShape.tech_stack.default([]),
52
53
  summary: companyShape.summary.default(''),
53
54
  tags: companyShape.tags.default([]),
55
+ is_own: companyShape.is_own.default(false),
54
56
  })
55
57
 
56
58
  export const updateBody = z.strictObject(companyShape).partial()
@@ -74,6 +76,7 @@ export function toCreateInput(body: z.infer<typeof createBody>): CreateCompanyIn
74
76
  techStack: body.tech_stack,
75
77
  summary: body.summary,
76
78
  tags: body.tags,
79
+ isOwn: body.is_own,
77
80
  }
78
81
  }
79
82
 
@@ -92,6 +95,7 @@ export function toUpdateInput(body: z.infer<typeof updateBody>): UpdateCompanyIn
92
95
  ...(body.tech_stack === undefined ? {} : { techStack: body.tech_stack }),
93
96
  ...(body.summary === undefined ? {} : { summary: body.summary }),
94
97
  ...(body.tags === undefined ? {} : { tags: body.tags }),
98
+ ...(body.is_own === undefined ? {} : { isOwn: body.is_own }),
95
99
  }
96
100
  }
97
101
 
@@ -111,6 +115,7 @@ export function companyResponse(company: CompanyView): Record<string, unknown> {
111
115
  tech_stack: company.techStack,
112
116
  summary: company.summary,
113
117
  tags: company.tags,
118
+ is_own: company.isOwn,
114
119
  created_at: company.createdAt.toISOString(),
115
120
  updated_at: company.updatedAt.toISOString(),
116
121
  }
@@ -120,11 +125,19 @@ export function mountCompaniesRoutes(router: Hono, dependencies: CompaniesRoutes
120
125
  const requireActor = (context: Context): Promise<Actor> => resolveActorFrom(dependencies, context)
121
126
 
122
127
  router.get('/companies', async (context) => {
128
+ // `?is_own=true|false` narrows to the workspace's own companies (or the
129
+ // rest). Anything else is treated as absent, so an unknown value never
130
+ // returns an accidental "everything".
131
+ const isOwnParam = context.req.query('is_own')
132
+ const isOwn =
133
+ isOwnParam === 'true' ? true : isOwnParam === 'false' ? false : undefined
134
+
123
135
  const page = await dependencies.service.list(
124
136
  await requireActor(context),
125
137
  {
126
138
  term: context.req.query('q'),
127
139
  personIds: readIdFilter(context, 'person_id'),
140
+ isOwn,
128
141
  },
129
142
  readListParameters(context),
130
143
  )
@@ -1,5 +1,5 @@
1
1
  import { ACCOUNT_TYPES, COMPANY_STAGES, ICP_FITS, SIZE_BANDS } from '@kelpie/schemas'
2
- import { index, pgTable, text, unique } from 'drizzle-orm/pg-core'
2
+ import { boolean, index, pgTable, text, unique } from 'drizzle-orm/pg-core'
3
3
 
4
4
  import {
5
5
  checkOneOf,
@@ -42,6 +42,10 @@ export const companies = pgTable(
42
42
  techStack: text('tech_stack').array().notNull().default([]),
43
43
  summary: text('summary').notNull().default(''),
44
44
  tags: text('tags').array().notNull().default([]),
45
+ // "This is us": a workspace marks its own company (or several, e.g. parent
46
+ // and subsidiary) so downstream views can distinguish self from prospects.
47
+ // Loose semantics on purpose — zero or many rows may carry it.
48
+ isOwn: boolean('is_own').notNull().default(false),
45
49
  createdAt: createdAt(),
46
50
  updatedAt: updatedAt(),
47
51
  searchVector: searchVector((): readonly SearchVectorPart[] => [
@@ -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 { autoLinkCompanyByDomain } from '../../lib/emailDomainAutoLink.ts'
4
5
  import { AppError } from '../../lib/errors.ts'
5
6
  import type { IdFactory } from '../../lib/ids.ts'
6
7
  import { normaliseDomain } from '../../lib/normalisation.ts'
@@ -51,6 +52,7 @@ const COMPANY_FIELD_LABELS: FieldLabels = {
51
52
  techStack: 'Tech stack',
52
53
  summary: 'Summary',
53
54
  tags: 'Tags',
55
+ isOwn: 'Own company',
54
56
  }
55
57
 
56
58
  /** A company as the API returns one: the stored row minus the tenancy column. */
@@ -70,6 +72,7 @@ export interface CreateCompanyInput {
70
72
  readonly techStack: readonly string[]
71
73
  readonly summary: string
72
74
  readonly tags: readonly string[]
75
+ readonly isOwn: boolean
73
76
  }
74
77
 
75
78
  /** PATCH semantics: an absent field is left alone, and null clears a nullable one. */
@@ -112,6 +115,7 @@ function toStoredColumns(input: UpdateCompanyInput): Partial<repository.CompanyC
112
115
  ...(input.techStack === undefined ? {} : { techStack: [...input.techStack] }),
113
116
  ...(input.summary === undefined ? {} : { summary: input.summary }),
114
117
  ...(input.tags === undefined ? {} : { tags: [...input.tags] }),
118
+ ...(input.isOwn === undefined ? {} : { isOwn: input.isOwn }),
115
119
  }
116
120
  }
117
121
 
@@ -171,6 +175,7 @@ export function createCompaniesService(dependencies: CompaniesDependencies): Com
171
175
  techStack: [...input.techStack],
172
176
  summary: input.summary,
173
177
  tags: [...input.tags],
178
+ isOwn: input.isOwn,
174
179
  })
175
180
  } catch (error: unknown) {
176
181
  if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
@@ -189,6 +194,15 @@ export function createCompaniesService(dependencies: CompaniesDependencies): Com
189
194
 
190
195
  events.emit('companies.company.created', { type: 'company', id: created.id }, {})
191
196
 
197
+ // Sync sweep of the workspace's people: everyone whose email domain
198
+ // matches this Company gets a titleless Position where none exists yet.
199
+ // Inside the same transaction so a follow-up read sees the new links.
200
+ await autoLinkCompanyByDomain(tx, events, workspaceId, created, {
201
+ createId: dependencies.createId,
202
+ now: dependencies.now,
203
+ recordActivity: dependencies.recordActivity,
204
+ })
205
+
192
206
  return toView(created)
193
207
  }, { workspaceId, actor: toEventActor(actor) })
194
208
  },
@@ -232,6 +246,17 @@ export function createCompaniesService(dependencies: CompaniesDependencies): Com
232
246
 
233
247
  events.emit('companies.company.updated', { type: 'company', id }, { changed })
234
248
 
249
+ // Sync sweep when the domain moved. A newly-set domain gains matches;
250
+ // a changed domain gains new matches without touching the old links
251
+ // (never-remove — a person may still work at the previous domain).
252
+ if (changed.includes('domain')) {
253
+ await autoLinkCompanyByDomain(tx, events, workspaceId, updated, {
254
+ createId: dependencies.createId,
255
+ now: dependencies.now,
256
+ recordActivity: dependencies.recordActivity,
257
+ })
258
+ }
259
+
235
260
  return toView(updated)
236
261
  }, { workspaceId, actor: toEventActor(actor) })
237
262
  },
@@ -9,10 +9,12 @@ import { createCompaniesModule } from './companies/index.ts'
9
9
  import { createDashboardModule } from './dashboard/index.ts'
10
10
  import { createDealsModule } from './deals/index.ts'
11
11
  import { createDecisionsModule } from './decisions/index.ts'
12
+ import { createEmailDomainLinkerModule } from './email-domain-linker/index.ts'
12
13
  import { createFormsModule } from './forms/index.ts'
13
14
  import { createHandbookModule } from './handbook/index.ts'
14
15
  import { createHiringModule } from './hiring/index.ts'
15
16
  import { createImportExportModule } from './import-export/index.ts'
17
+ import { createListsModule } from './lists/index.ts'
16
18
  import { createNotesModule } from './notes/index.ts'
17
19
  import { createOpportunitiesModule } from './opportunities/index.ts'
18
20
  import { createPartnershipsModule } from './partnerships/index.ts'
@@ -21,6 +23,7 @@ import { createPipelinesModule } from './pipelines/index.ts'
21
23
  import { createPlansModule } from './plans/index.ts'
22
24
  import { createPositionsModule } from './positions/index.ts'
23
25
  import { createRaisesModule } from './raises/index.ts'
26
+ import { createSampleDataModule } from './sample-data/index.ts'
24
27
  import { createSearchModule } from './search/index.ts'
25
28
  import { createSmtpEmailModule } from './smtp-email/index.ts'
26
29
  import { createWebhooksModule } from './webhooks/index.ts'
@@ -57,6 +60,7 @@ export const coreModules: readonly KelpieModule[] = [
57
60
  createHiringModule(coreMigrationsDirectory),
58
61
  createPlansModule(coreMigrationsDirectory),
59
62
  createDecisionsModule(coreMigrationsDirectory),
63
+ createListsModule(coreMigrationsDirectory),
60
64
  createHandbookModule(coreMigrationsDirectory),
61
65
  // Neither of these takes a migrations directory: they own no tables and read
62
66
  // other modules'.
@@ -66,6 +70,13 @@ export const coreModules: readonly KelpieModule[] = [
66
70
  createImportExportModule(coreMigrationsDirectory),
67
71
  createAgentTasksModule(coreMigrationsDirectory),
68
72
  createWebhooksModule(coreMigrationsDirectory),
73
+ // Owns no tables. One-shot fixture install for a new workspace, called from
74
+ // the setup wizard's checkbox and the admin data page.
75
+ createSampleDataModule(),
76
+ // Owns no tables. Admin action that reruns the email-domain sweep across
77
+ // every workspace Company — backfills links for data that predates the
78
+ // auto-linker, or after a bulk import.
79
+ createEmailDomainLinkerModule(),
69
80
  // Owns no tables. Registers a `'smtp'` provider with core's email runtime;
70
81
  // the factory only runs if the assembly's `email.provider` picks it.
71
82
  createSmtpEmailModule(),
@@ -0,0 +1,40 @@
1
+ import type { KelpieModule } from '../../runtime/module.ts'
2
+ import { createActivityRecorder } from '../activities/index.ts'
3
+ import { mountEmailDomainLinkerRoutes } from './routes.ts'
4
+ import { createEmailDomainLinkerService } from './service.ts'
5
+
6
+ /**
7
+ * Rebuild every email-domain link across the workspace.
8
+ *
9
+ * Owns no tables. Requires the modules whose data the sweep reads and writes.
10
+ * The sweep is the same one the companies service runs on a domain change,
11
+ * applied to every workspace Company at once — used to backfill links for
12
+ * data that predates the auto-linker, or after a bulk import.
13
+ */
14
+ export function createEmailDomainLinkerModule(): KelpieModule {
15
+ return {
16
+ id: 'email-domain-linker',
17
+ requires: ['workspace', 'companies', 'people', 'positions', 'activities'],
18
+
19
+ register(context) {
20
+ const service = createEmailDomainLinkerService({
21
+ db: context.db,
22
+ transaction: context.transaction,
23
+ createId: context.createId,
24
+ now: context.now,
25
+ recordActivity: createActivityRecorder({
26
+ createId: context.createId,
27
+ now: context.now,
28
+ }),
29
+ })
30
+
31
+ context.routes((router) => {
32
+ mountEmailDomainLinkerRoutes(router, { db: context.db, now: context.now, service })
33
+ })
34
+
35
+ return Promise.resolve()
36
+ },
37
+ }
38
+ }
39
+
40
+ export type { EmailDomainLinkerService, RelinkCounts } from './service.ts'
@@ -0,0 +1,40 @@
1
+ import type { Context, Hono } from 'hono'
2
+
3
+ import type { Actor } from '../auth/actor.ts'
4
+ import { resolveActorFrom } from '../auth/credentials.ts'
5
+ import type { CredentialDependencies } from '../auth/credentials.ts'
6
+ import type { EmailDomainLinkerService, RelinkCounts } from './service.ts'
7
+
8
+ /**
9
+ * Wire shape for `POST /v1/workspaces/:id/relink-email-domains`.
10
+ *
11
+ * No request body. The response counts what the sweep did.
12
+ */
13
+
14
+ export interface EmailDomainLinkerRoutesDependencies extends CredentialDependencies {
15
+ readonly service: EmailDomainLinkerService
16
+ }
17
+
18
+ export function relinkResponse(counts: RelinkCounts): Record<string, unknown> {
19
+ return {
20
+ companies_scanned: counts.companiesScanned,
21
+ positions_created: counts.positionsCreated,
22
+ }
23
+ }
24
+
25
+ export function mountEmailDomainLinkerRoutes(
26
+ router: Hono,
27
+ dependencies: EmailDomainLinkerRoutesDependencies,
28
+ ): void {
29
+ const requireActor = (context: Context): Promise<Actor> =>
30
+ resolveActorFrom(dependencies, context)
31
+
32
+ router.post('/workspaces/:id/relink-email-domains', async (context) => {
33
+ const counts = await dependencies.service.relink(
34
+ await requireActor(context),
35
+ context.req.param('id'),
36
+ )
37
+
38
+ return context.json(relinkResponse(counts))
39
+ })
40
+ }
@@ -0,0 +1,122 @@
1
+ import { and, eq, isNotNull } from 'drizzle-orm'
2
+
3
+ import { toEventActor } from '../../lib/actor.ts'
4
+ import type { Database } from '../../lib/database.ts'
5
+ import { autoLinkCompanyByDomain } from '../../lib/emailDomainAutoLink.ts'
6
+ import { AppError } from '../../lib/errors.ts'
7
+ import type { IdFactory } from '../../lib/ids.ts'
8
+ import type { TransactionScope } from '../../runtime/transaction.ts'
9
+ import type { ActivityRecorder } from '../activities/recorder.ts'
10
+ import type { Actor } from '../auth/actor.ts'
11
+ import { requireWorkspaceId } from '../auth/actor.ts'
12
+ import * as authRepository from '../auth/repository.ts'
13
+ import { companies } from '../companies/schema.ts'
14
+ import { parseMemberRole, roleAllows } from '../workspace/roles.ts'
15
+
16
+ /**
17
+ * Rebuild every email-domain link across the workspace.
18
+ *
19
+ * Iterates each Company that carries a domain and runs the same sweep the
20
+ * companies service runs on a domain change — every workspace Person whose
21
+ * email is at that domain gets a titleless Position where none exists yet.
22
+ *
23
+ * Idempotent: a follow-up run adds nothing. Add-only, consumer-host-skipping,
24
+ * and workspace-scoped, same as the inline auto-linker.
25
+ *
26
+ * One transaction per Company, not one for the whole sweep. A workspace can
27
+ * carry thousands of Companies, and a single transaction that long would
28
+ * hold row-share locks against every write elsewhere for the whole run.
29
+ */
30
+
31
+ export interface EmailDomainLinkerDependencies {
32
+ readonly db: Database
33
+ readonly transaction: TransactionScope
34
+ readonly createId: IdFactory
35
+ readonly now: () => Date
36
+ readonly recordActivity: ActivityRecorder
37
+ }
38
+
39
+ export interface RelinkCounts {
40
+ /** Companies with a non-null domain that were considered. Consumer hosts are counted but skipped. */
41
+ readonly companiesScanned: number
42
+ /** New Positions the sweep inserted this run. */
43
+ readonly positionsCreated: number
44
+ }
45
+
46
+ export interface EmailDomainLinkerService {
47
+ relink(actor: Actor, workspaceId: string): Promise<RelinkCounts>
48
+ }
49
+
50
+ export function createEmailDomainLinkerService(
51
+ dependencies: EmailDomainLinkerDependencies,
52
+ ): EmailDomainLinkerService {
53
+ async function requireAdmin(actor: Actor, workspaceId: string): Promise<void> {
54
+ if (actor.workspaceId !== workspaceId) {
55
+ throw AppError.notFound('Workspace not found')
56
+ }
57
+
58
+ if (actor.kind === 'api_key' && actor.userId === null) {
59
+ if (!roleAllows(actor.role, 'admin')) {
60
+ throw new AppError('forbidden', 'This action needs the admin role')
61
+ }
62
+ return
63
+ }
64
+
65
+ const userId = actor.userId
66
+
67
+ if (userId === null) {
68
+ throw AppError.notFound('Workspace not found')
69
+ }
70
+
71
+ const membership = await authRepository.findMembership(dependencies.db, workspaceId, userId)
72
+
73
+ if (membership === undefined) {
74
+ throw AppError.notFound('Workspace not found')
75
+ }
76
+
77
+ const role = parseMemberRole(membership.role)
78
+
79
+ if (role === undefined) {
80
+ throw new Error(`workspace_members.role holds "${membership.role}", which its check forbids`)
81
+ }
82
+
83
+ if (!roleAllows(role, 'admin')) {
84
+ throw new AppError('forbidden', 'This action needs the admin role')
85
+ }
86
+ }
87
+
88
+ return {
89
+ async relink(actor, workspaceId) {
90
+ requireWorkspaceId(actor)
91
+ await requireAdmin(actor, workspaceId)
92
+
93
+ // Every workspace Company that carries a domain — the ones the sweep
94
+ // can match against. Pooled read outside the per-company transactions:
95
+ // the work-list is one query, each write opens its own scope.
96
+ const rows = await dependencies.db
97
+ .select()
98
+ .from(companies)
99
+ .where(and(eq(companies.workspaceId, workspaceId), isNotNull(companies.domain)))
100
+
101
+ let created = 0
102
+
103
+ // One transaction per Company, not one for the whole sweep. A workspace
104
+ // can carry thousands of Companies; a single transaction that long would
105
+ // hold row-share locks against every write elsewhere for the whole run.
106
+ for (const company of rows) {
107
+ const inserted = await dependencies.transaction(
108
+ async ({ tx, events }) =>
109
+ autoLinkCompanyByDomain(tx, events, workspaceId, company, {
110
+ createId: dependencies.createId,
111
+ now: dependencies.now,
112
+ recordActivity: dependencies.recordActivity,
113
+ }),
114
+ { workspaceId, actor: toEventActor(actor) },
115
+ )
116
+ created += inserted.length
117
+ }
118
+
119
+ return { companiesScanned: rows.length, positionsCreated: created }
120
+ },
121
+ }
122
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Loads every core module's `KelpieEventMap` augmentation at the package entry.
3
+ *
4
+ * Each module declares its event payload types in `<module>/events.ts` through
5
+ * `declare module '../../runtime/events.ts'`. Inside the monorepo those merge
6
+ * because the source graph reaches every file. A consumer that installs
7
+ * `@kelpie/server` from npm typechecks against `dist`, and declaration emit
8
+ * keeps only the imports a file actually references. Nothing in `dist/index.d.ts`
9
+ * referenced the per-module `events.d.ts` files, so `KelpieEventMap` stayed empty
10
+ * for consumers and every `subscribe` handler saw `event.data: unknown`.
11
+ *
12
+ * These side-effect imports give `index.ts` one reference into each catalog. The
13
+ * emitted `index.d.ts` carries them, so a consumer loads the augmentations and
14
+ * gets a typed `event.data` without a cast at the read site.
15
+ *
16
+ * List only modules whose `events.ts` augments `KelpieEventMap`. A module that
17
+ * publishes no events has no `events.ts` and does not belong here. Order mirrors
18
+ * `coreModules` in `core.ts` for readability; it has no runtime effect.
19
+ */
20
+
21
+ import './workspace/events.ts'
22
+ import './people/events.ts'
23
+ import './companies/events.ts'
24
+ import './positions/events.ts'
25
+ import './notes/events.ts'
26
+ import './lists/events.ts'
27
+ import './deals/events.ts'
28
+ import './opportunities/events.ts'
29
+ import './partnerships/events.ts'
30
+ import './raises/events.ts'
31
+ import './hiring/events.ts'
32
+ import './plans/events.ts'
33
+ import './decisions/events.ts'
34
+ import './handbook/events.ts'
35
+ import './forms/events.ts'
36
+ import './import-export/events.ts'