@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
@@ -148,6 +148,27 @@
148
148
  "when": 1787447911296,
149
149
  "tag": "0020_early_nemesis",
150
150
  "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "7",
155
+ "when": 1787527717966,
156
+ "tag": "0021_certain_maggott",
157
+ "breakpoints": true
158
+ },
159
+ {
160
+ "idx": 22,
161
+ "version": "7",
162
+ "when": 1787626585359,
163
+ "tag": "0022_stormy_manta",
164
+ "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "7",
169
+ "when": 1787651539093,
170
+ "tag": "0023_funny_maddog",
171
+ "breakpoints": true
151
172
  }
152
173
  ]
153
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelpie/server",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The Kelpie service as a library: module runtime, core CRM modules, REST API, MCP surface, and the shared migration pipeline.",
5
5
  "keywords": [
6
6
  "kelpie",
@@ -52,11 +52,12 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@hono/node-server": "^2.0.12",
55
- "@kelpie/schemas": "^0.7.0",
55
+ "@kelpie/schemas": "^0.8.0",
56
56
  "@node-rs/argon2": "^2.0.2",
57
57
  "drizzle-orm": "^0.45.2",
58
58
  "emittery": "^2.0.0",
59
59
  "hono": "^4.12.32",
60
+ "mailgen": "^2.0.36",
60
61
  "nodemailer": "^9.0.5",
61
62
  "postgres": "^3.4.9",
62
63
  "ulid": "^3.0.2",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Consumer email hosts, checked case-insensitively.
3
+ *
4
+ * The email-domain listener refuses to auto-link a Person to a Company on any
5
+ * domain in this list. A consumer address belongs to no one company, and a
6
+ * Company row entered by mistake against, say, `gmail.com` would otherwise
7
+ * auto-attach every consumer address after.
8
+ *
9
+ * A starter list of the common providers. Add locale variants and less common
10
+ * hosts as the need arrives.
11
+ */
12
+ const CONSUMER_EMAIL_DOMAINS: ReadonlySet<string> = new Set([
13
+ 'gmail.com',
14
+ 'googlemail.com',
15
+ 'yahoo.com',
16
+ 'yahoo.co.uk',
17
+ 'yahoo.co.jp',
18
+ 'yahoo.fr',
19
+ 'yahoo.de',
20
+ 'yahoo.ca',
21
+ 'yahoo.com.au',
22
+ 'ymail.com',
23
+ 'rocketmail.com',
24
+ 'hotmail.com',
25
+ 'hotmail.co.uk',
26
+ 'hotmail.fr',
27
+ 'hotmail.de',
28
+ 'hotmail.it',
29
+ 'hotmail.es',
30
+ 'outlook.com',
31
+ 'outlook.co.uk',
32
+ 'live.com',
33
+ 'live.co.uk',
34
+ 'msn.com',
35
+ 'aol.com',
36
+ 'aim.com',
37
+ 'icloud.com',
38
+ 'me.com',
39
+ 'mac.com',
40
+ 'protonmail.com',
41
+ 'proton.me',
42
+ 'pm.me',
43
+ 'gmx.com',
44
+ 'gmx.net',
45
+ 'gmx.de',
46
+ 'mail.com',
47
+ 'fastmail.com',
48
+ 'fastmail.fm',
49
+ 'zoho.com',
50
+ 'yandex.com',
51
+ 'yandex.ru',
52
+ 'tutanota.com',
53
+ 'tuta.io',
54
+ 'qq.com',
55
+ '163.com',
56
+ '126.com',
57
+ 'naver.com',
58
+ ])
59
+
60
+ export function isConsumerEmailDomain(domain: string): boolean {
61
+ return CONSUMER_EMAIL_DOMAINS.has(domain.trim().toLowerCase())
62
+ }
package/src/lib/email.ts CHANGED
@@ -21,7 +21,14 @@ import type { Logger } from './logger.ts'
21
21
  export interface EmailMessage {
22
22
  readonly to: string
23
23
  readonly subject: string
24
+ /** The plaintext part. Always present: text-only clients and logs read it. */
24
25
  readonly body: string
26
+ /**
27
+ * The HTML alternative, rendered by `lib/emailContent.ts`. Optional so
28
+ * provider modules written against the older port keep compiling and a
29
+ * plain-text message stays expressible.
30
+ */
31
+ readonly html?: string
25
32
  }
26
33
 
27
34
  export interface EmailSender {
@@ -0,0 +1,91 @@
1
+ import Mailgen from 'mailgen'
2
+
3
+ /**
4
+ * Renders transactional mail bodies: a plaintext part and an HTML part from
5
+ * one description of the message.
6
+ *
7
+ * Email clients need table-based, inline-styled HTML; mailgen owns that
8
+ * problem so Kelpie does not. The plaintext part is built here by hand rather
9
+ * than by mailgen's plaintext generator, because that generator pads lines
10
+ * with trailing whitespace and the plaintext body is the part tests and
11
+ * text-only clients read token links out of. The wording of the two parts
12
+ * comes from the same fields, so they cannot drift.
13
+ */
14
+
15
+ /** A link the message asks the reader to follow, rendered as a button in HTML. */
16
+ export interface EmailAction {
17
+ readonly instructions: string
18
+ readonly buttonText: string
19
+ readonly link: string
20
+ }
21
+
22
+ export interface EmailContent {
23
+ readonly intro?: string
24
+ readonly action?: EmailAction
25
+ readonly outro?: string
26
+ }
27
+
28
+ export interface RenderedEmail {
29
+ readonly text: string
30
+ readonly html: string
31
+ }
32
+
33
+ /**
34
+ * Fixed so rendering is deterministic. Mailgen's default footer stamps the
35
+ * current year, which the sender has no claim to: the workspace, not Kelpie,
36
+ * owns the relationship with the recipient.
37
+ */
38
+ const FOOTER_LINE = 'Sent by Kelpie.'
39
+
40
+ /**
41
+ * Renders one transactional message. `appBaseUrl` becomes the link on the
42
+ * product name in the header and footer, so every part of the mail points at
43
+ * the deployment that sent it.
44
+ */
45
+ export function renderEmail(content: EmailContent, appBaseUrl: string): RenderedEmail {
46
+ const generator = new Mailgen({
47
+ theme: 'default',
48
+ product: {
49
+ name: 'Kelpie',
50
+ link: appBaseUrl,
51
+ copyright: FOOTER_LINE,
52
+ },
53
+ })
54
+
55
+ const html: string = generator.generate({
56
+ body: {
57
+ greeting: false,
58
+ signature: false,
59
+ ...(content.intro === undefined ? {} : { intro: content.intro }),
60
+ ...(content.action === undefined
61
+ ? {}
62
+ : {
63
+ action: {
64
+ instructions: content.action.instructions,
65
+ button: {
66
+ text: content.action.buttonText,
67
+ link: content.action.link,
68
+ fallback: true,
69
+ },
70
+ },
71
+ }),
72
+ ...(content.outro === undefined ? {} : { outro: content.outro }),
73
+ },
74
+ })
75
+
76
+ const paragraphs: string[] = []
77
+
78
+ if (content.intro !== undefined) {
79
+ paragraphs.push(content.intro)
80
+ }
81
+
82
+ if (content.action !== undefined) {
83
+ paragraphs.push(`${content.action.instructions}\n\n${content.action.link}`)
84
+ }
85
+
86
+ if (content.outro !== undefined) {
87
+ paragraphs.push(content.outro)
88
+ }
89
+
90
+ return { text: paragraphs.join('\n\n'), html }
91
+ }
@@ -0,0 +1,165 @@
1
+ import { UNIQUE_VIOLATION, postgresErrorCode } from './database.ts'
2
+ import type { IdFactory } from './ids.ts'
3
+ import { isConsumerEmailDomain } from './consumerEmailDomains.ts'
4
+ import type { BufferedEvents, Transaction } from '../runtime/transaction.ts'
5
+ import type { ActivityRecorder, SystemActor } from '../modules/activities/recorder.ts'
6
+ import { describeLink } from '../modules/activities/wording.ts'
7
+ import * as companyRepository from '../modules/companies/repository.ts'
8
+ import type { CompanyRecord } from '../modules/companies/repository.ts'
9
+ import * as peopleRepository from '../modules/people/repository.ts'
10
+ import type { PersonRecord } from '../modules/people/repository.ts'
11
+ import '../modules/positions/events.ts'
12
+ import * as positionRepository from '../modules/positions/repository.ts'
13
+
14
+ /**
15
+ * Auto-link a Person to a workspace Company whose domain matches the person's
16
+ * email. Runs inside the caller's transaction, so the new Position is present
17
+ * in the response the API returns for the same request.
18
+ *
19
+ * Skips: no email, consumer email host (`isConsumerEmailDomain`), no Company
20
+ * on that domain in the workspace, or a Position already linking the two. A
21
+ * unique-key race with a concurrent linker is absorbed.
22
+ *
23
+ * The created Position carries an empty title. `positions.title` is `NOT NULL`,
24
+ * and empty reads honestly as "the domain matched; no title yet".
25
+ *
26
+ * Split from the people service so the layering (people below positions) is
27
+ * carried by imports only: this helper depends on both repositories, and the
28
+ * people service depends on this helper. The `positions` module still owns the
29
+ * event catalogue for the emitted event.
30
+ */
31
+
32
+ /** What the timeline calls this side effect. */
33
+ const LINKER_ACTOR: SystemActor = { kind: 'system', label: 'Email domain match' }
34
+
35
+ export interface AutoLinkDependencies {
36
+ readonly createId: IdFactory
37
+ readonly now: () => Date
38
+ readonly recordActivity: ActivityRecorder
39
+ }
40
+
41
+ /**
42
+ * Considers `person` for a matching Company in `workspaceId` and inserts a
43
+ * titleless Position if none links them yet.
44
+ *
45
+ * @returns The new Position's id, or `undefined` if nothing was written.
46
+ */
47
+ export async function autoLinkPersonByEmailDomain(
48
+ tx: Transaction,
49
+ events: BufferedEvents,
50
+ workspaceId: string,
51
+ person: PersonRecord,
52
+ dependencies: AutoLinkDependencies,
53
+ ): Promise<string | undefined> {
54
+ if (person.email === null) {
55
+ return undefined
56
+ }
57
+
58
+ const at = person.email.indexOf('@')
59
+ if (at < 0) {
60
+ return undefined
61
+ }
62
+
63
+ const domain = person.email.slice(at + 1)
64
+ if (domain.length === 0 || isConsumerEmailDomain(domain)) {
65
+ return undefined
66
+ }
67
+
68
+ const company = await companyRepository.findCompanyByDomain(tx, workspaceId, domain)
69
+ if (company === undefined) {
70
+ return undefined
71
+ }
72
+
73
+ const held = await positionRepository.listPositionsAt(tx, workspaceId, person.id, company.id)
74
+ if (held.length > 0) {
75
+ return undefined
76
+ }
77
+
78
+ return insertStubPosition(tx, events, workspaceId, person, company, dependencies)
79
+ }
80
+
81
+ /**
82
+ * Sweeps every Person in `workspaceId` whose email domain matches `company`'s
83
+ * domain and stubs a Position where none exists yet. Called when a Company is
84
+ * created or its domain is set.
85
+ *
86
+ * @returns The ids of every Position this call inserted.
87
+ */
88
+ export async function autoLinkCompanyByDomain(
89
+ tx: Transaction,
90
+ events: BufferedEvents,
91
+ workspaceId: string,
92
+ company: CompanyRecord,
93
+ dependencies: AutoLinkDependencies,
94
+ ): Promise<readonly string[]> {
95
+ if (company.domain === null || company.domain.length === 0) {
96
+ return []
97
+ }
98
+
99
+ if (isConsumerEmailDomain(company.domain)) {
100
+ return []
101
+ }
102
+
103
+ const matches = await peopleRepository.findPeopleByEmailDomain(tx, workspaceId, company.domain)
104
+ const created: string[] = []
105
+
106
+ for (const person of matches) {
107
+ const held = await positionRepository.listPositionsAt(tx, workspaceId, person.id, company.id)
108
+ if (held.length > 0) {
109
+ continue
110
+ }
111
+
112
+ const id = await insertStubPosition(tx, events, workspaceId, person, company, dependencies)
113
+ if (id !== undefined) {
114
+ created.push(id)
115
+ }
116
+ }
117
+
118
+ return created
119
+ }
120
+
121
+ /**
122
+ * The write half. Wraps the unique-constraint race the two entry points share:
123
+ * a concurrent linker that beat us to `(personId, companyId, '')` is treated
124
+ * as success (the link is there, either way).
125
+ */
126
+ async function insertStubPosition(
127
+ tx: Transaction,
128
+ events: BufferedEvents,
129
+ workspaceId: string,
130
+ person: PersonRecord,
131
+ company: CompanyRecord,
132
+ dependencies: AutoLinkDependencies,
133
+ ): Promise<string | undefined> {
134
+ try {
135
+ const created = await positionRepository.insertPosition(tx, {
136
+ id: dependencies.createId('position'),
137
+ workspaceId,
138
+ personId: person.id,
139
+ companyId: company.id,
140
+ title: '',
141
+ })
142
+
143
+ await dependencies.recordActivity(tx, workspaceId, LINKER_ACTOR, {
144
+ targetType: 'person',
145
+ targetId: person.id,
146
+ kind: 'linked',
147
+ ...describeLink('company', company.name),
148
+ })
149
+ await dependencies.recordActivity(tx, workspaceId, LINKER_ACTOR, {
150
+ targetType: 'company',
151
+ targetId: company.id,
152
+ kind: 'linked',
153
+ ...describeLink('person', person.name),
154
+ })
155
+
156
+ events.emit('positions.position.created', { type: 'position', id: created.id }, {})
157
+
158
+ return created.id
159
+ } catch (error: unknown) {
160
+ if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
161
+ return undefined
162
+ }
163
+ throw error
164
+ }
165
+ }
package/src/lib/ids.ts CHANGED
@@ -28,6 +28,8 @@ export const idPrefixes = {
28
28
  decision: 'dec',
29
29
  note: 'note',
30
30
  activity: 'act',
31
+ list: 'list',
32
+ listMember: 'lmem',
31
33
  handbookPage: 'hb',
32
34
  form: 'form',
33
35
  formField: 'ff',
@@ -6,6 +6,7 @@ import type { Queryable } from '../runtime/transaction.ts'
6
6
  import * as activityRepository from './activities/repository.ts'
7
7
  import * as decisionRepository from './decisions/repository.ts'
8
8
  import { candidates } from './hiring/schema.ts'
9
+ import * as listRepository from './lists/repository.ts'
9
10
  import * as noteRepository from './notes/repository.ts'
10
11
  import * as planRepository from './plans/repository.ts'
11
12
 
@@ -37,7 +38,7 @@ export async function deleteRecordsAttachedTo(
37
38
  targetType: AttachableTargetType,
38
39
  targetId: string,
39
40
  ): Promise<number> {
40
- // Sequential, not concurrent: a transaction is one connection, and four
41
+ // Sequential, not concurrent: a transaction is one connection, and five
41
42
  // statements racing down it is not something to rely on for a cheap delete.
42
43
  const notes = await noteRepository.deleteForTarget(db, workspaceId, targetType, targetId)
43
44
  const activities = await activityRepository.deleteForTarget(db, workspaceId, targetType, targetId)
@@ -45,8 +46,14 @@ export async function deleteRecordsAttachedTo(
45
46
  const plans = PLAN_TARGET_TYPES.has(targetType)
46
47
  ? await planRepository.deleteForTarget(db, workspaceId, targetType, targetId)
47
48
  : 0
49
+ const memberships = await listRepository.deleteMembershipsForTarget(
50
+ db,
51
+ workspaceId,
52
+ targetType,
53
+ targetId,
54
+ )
48
55
 
49
- return notes + activities + decisions + plans
56
+ return notes + activities + decisions + plans + memberships
50
57
  }
51
58
 
52
59
  /**
@@ -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