@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.
- package/dist/lib/consumerEmailDomains.d.ts +2 -0
- package/dist/lib/consumerEmailDomains.d.ts.map +1 -0
- package/dist/lib/consumerEmailDomains.js +62 -0
- package/dist/lib/consumerEmailDomains.js.map +1 -0
- package/dist/lib/email.d.ts +7 -0
- package/dist/lib/email.d.ts.map +1 -1
- package/dist/lib/email.js.map +1 -1
- package/dist/lib/emailContent.d.ts +33 -0
- package/dist/lib/emailContent.d.ts.map +1 -0
- package/dist/lib/emailContent.js +54 -0
- package/dist/lib/emailContent.js.map +1 -0
- package/dist/lib/emailDomainAutoLink.d.ts +27 -0
- package/dist/lib/emailDomainAutoLink.d.ts.map +1 -0
- package/dist/lib/emailDomainAutoLink.js +119 -0
- package/dist/lib/emailDomainAutoLink.js.map +1 -0
- package/dist/lib/ids.d.ts +2 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +2 -0
- package/dist/lib/ids.js.map +1 -1
- package/dist/modules/attachedRecords.d.ts.map +1 -1
- package/dist/modules/attachedRecords.js +4 -2
- package/dist/modules/attachedRecords.js.map +1 -1
- package/dist/modules/auth/preferences.d.ts +11 -1
- package/dist/modules/auth/preferences.d.ts.map +1 -1
- package/dist/modules/auth/preferences.js +11 -0
- package/dist/modules/auth/preferences.js.map +1 -1
- package/dist/modules/auth/routes.d.ts.map +1 -1
- package/dist/modules/auth/routes.js +18 -0
- package/dist/modules/auth/routes.js.map +1 -1
- package/dist/modules/auth/schema.d.ts +20 -0
- package/dist/modules/auth/schema.d.ts.map +1 -1
- package/dist/modules/auth/schema.js +11 -1
- package/dist/modules/auth/schema.js.map +1 -1
- package/dist/modules/auth/service.d.ts.map +1 -1
- package/dist/modules/auth/service.js +27 -3
- package/dist/modules/auth/service.js.map +1 -1
- package/dist/modules/companies/repository.d.ts +2 -0
- package/dist/modules/companies/repository.d.ts.map +1 -1
- package/dist/modules/companies/repository.js +1 -0
- package/dist/modules/companies/repository.js.map +1 -1
- package/dist/modules/companies/routes.d.ts +2 -0
- package/dist/modules/companies/routes.d.ts.map +1 -1
- package/dist/modules/companies/routes.js +11 -0
- package/dist/modules/companies/routes.js.map +1 -1
- package/dist/modules/companies/schema.d.ts +17 -0
- package/dist/modules/companies/schema.d.ts.map +1 -1
- package/dist/modules/companies/schema.js +5 -1
- package/dist/modules/companies/schema.js.map +1 -1
- package/dist/modules/companies/service.d.ts +1 -0
- package/dist/modules/companies/service.d.ts.map +1 -1
- package/dist/modules/companies/service.js +22 -0
- package/dist/modules/companies/service.js.map +1 -1
- package/dist/modules/core.d.ts.map +1 -1
- package/dist/modules/core.js +11 -0
- package/dist/modules/core.js.map +1 -1
- package/dist/modules/email-domain-linker/index.d.ts +12 -0
- package/dist/modules/email-domain-linker/index.d.ts.map +1 -0
- package/dist/modules/email-domain-linker/index.js +34 -0
- package/dist/modules/email-domain-linker/index.js.map +1 -0
- package/dist/modules/email-domain-linker/routes.d.ts +14 -0
- package/dist/modules/email-domain-linker/routes.d.ts.map +1 -0
- package/dist/modules/email-domain-linker/routes.js +15 -0
- package/dist/modules/email-domain-linker/routes.js.map +1 -0
- package/dist/modules/email-domain-linker/service.d.ts +37 -0
- package/dist/modules/email-domain-linker/service.d.ts.map +1 -0
- package/dist/modules/email-domain-linker/service.js +63 -0
- package/dist/modules/email-domain-linker/service.js.map +1 -0
- package/dist/modules/eventCatalogs.d.ts +1 -0
- package/dist/modules/eventCatalogs.d.ts.map +1 -1
- package/dist/modules/eventCatalogs.js +1 -0
- package/dist/modules/eventCatalogs.js.map +1 -1
- package/dist/modules/lists/events.d.ts +42 -0
- package/dist/modules/lists/events.d.ts.map +1 -0
- package/dist/modules/lists/events.js +16 -0
- package/dist/modules/lists/events.js.map +1 -0
- package/dist/modules/lists/index.d.ts +10 -0
- package/dist/modules/lists/index.d.ts.map +1 -0
- package/dist/modules/lists/index.js +35 -0
- package/dist/modules/lists/index.js.map +1 -0
- package/dist/modules/lists/repository.d.ts +69 -0
- package/dist/modules/lists/repository.d.ts.map +1 -0
- package/dist/modules/lists/repository.js +165 -0
- package/dist/modules/lists/repository.js.map +1 -0
- package/dist/modules/lists/routes.d.ts +51 -0
- package/dist/modules/lists/routes.d.ts.map +1 -0
- package/dist/modules/lists/routes.js +160 -0
- package/dist/modules/lists/routes.js.map +1 -0
- package/dist/modules/lists/schema.d.ts +264 -0
- package/dist/modules/lists/schema.d.ts.map +1 -0
- package/dist/modules/lists/schema.js +73 -0
- package/dist/modules/lists/schema.js.map +1 -0
- package/dist/modules/lists/service.d.ts +70 -0
- package/dist/modules/lists/service.d.ts.map +1 -0
- package/dist/modules/lists/service.js +214 -0
- package/dist/modules/lists/service.js.map +1 -0
- package/dist/modules/lists/tools.d.ts +4 -0
- package/dist/modules/lists/tools.d.ts.map +1 -0
- package/dist/modules/lists/tools.js +86 -0
- package/dist/modules/lists/tools.js.map +1 -0
- package/dist/modules/people/repository.d.ts +11 -0
- package/dist/modules/people/repository.d.ts.map +1 -1
- package/dist/modules/people/repository.js +16 -0
- package/dist/modules/people/repository.js.map +1 -1
- package/dist/modules/people/service.d.ts.map +1 -1
- package/dist/modules/people/service.js +18 -0
- package/dist/modules/people/service.js.map +1 -1
- package/dist/modules/positions/routes.d.ts.map +1 -1
- package/dist/modules/positions/routes.js +4 -2
- package/dist/modules/positions/routes.js.map +1 -1
- package/dist/modules/sample-data/fixture.d.ts +139 -0
- package/dist/modules/sample-data/fixture.d.ts.map +1 -0
- package/dist/modules/sample-data/fixture.js +461 -0
- package/dist/modules/sample-data/fixture.js.map +1 -0
- package/dist/modules/sample-data/index.d.ts +11 -0
- package/dist/modules/sample-data/index.d.ts.map +1 -0
- package/dist/modules/sample-data/index.js +43 -0
- package/dist/modules/sample-data/index.js.map +1 -0
- package/dist/modules/sample-data/routes.d.ts +15 -0
- package/dist/modules/sample-data/routes.d.ts.map +1 -0
- package/dist/modules/sample-data/routes.js +24 -0
- package/dist/modules/sample-data/routes.js.map +1 -0
- package/dist/modules/sample-data/service.d.ts +38 -0
- package/dist/modules/sample-data/service.d.ts.map +1 -0
- package/dist/modules/sample-data/service.js +412 -0
- package/dist/modules/sample-data/service.js.map +1 -0
- package/dist/modules/sample-data/tools.d.ts +4 -0
- package/dist/modules/sample-data/tools.d.ts.map +1 -0
- package/dist/modules/sample-data/tools.js +22 -0
- package/dist/modules/sample-data/tools.js.map +1 -0
- package/dist/modules/smtp-email/index.d.ts +1 -0
- package/dist/modules/smtp-email/index.d.ts.map +1 -1
- package/dist/modules/smtp-email/index.js +1 -0
- package/dist/modules/smtp-email/index.js.map +1 -1
- package/dist/modules/workspace/service.d.ts.map +1 -1
- package/dist/modules/workspace/service.js +10 -1
- package/dist/modules/workspace/service.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/migrations/0021_certain_maggott.sql +30 -0
- package/migrations/0022_stormy_manta.sql +1 -0
- package/migrations/0023_funny_maddog.sql +1 -0
- package/migrations/meta/0021_snapshot.json +5389 -0
- package/migrations/meta/0022_snapshot.json +5396 -0
- package/migrations/meta/0023_snapshot.json +5403 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +3 -2
- package/src/lib/consumerEmailDomains.ts +62 -0
- package/src/lib/email.ts +7 -0
- package/src/lib/emailContent.ts +91 -0
- package/src/lib/emailDomainAutoLink.ts +165 -0
- package/src/lib/ids.ts +2 -0
- package/src/modules/attachedRecords.ts +9 -2
- package/src/modules/auth/preferences.ts +13 -1
- package/src/modules/auth/routes.ts +22 -0
- package/src/modules/auth/schema.ts +12 -2
- package/src/modules/auth/service.ts +37 -3
- package/src/modules/companies/repository.ts +3 -0
- package/src/modules/companies/routes.ts +13 -0
- package/src/modules/companies/schema.ts +5 -1
- package/src/modules/companies/service.ts +25 -0
- package/src/modules/core.ts +11 -0
- package/src/modules/email-domain-linker/index.ts +40 -0
- package/src/modules/email-domain-linker/routes.ts +40 -0
- package/src/modules/email-domain-linker/service.ts +122 -0
- package/src/modules/eventCatalogs.ts +1 -0
- package/src/modules/lists/events.ts +41 -0
- package/src/modules/lists/index.ts +41 -0
- package/src/modules/lists/repository.ts +313 -0
- package/src/modules/lists/routes.ts +247 -0
- package/src/modules/lists/schema.ts +85 -0
- package/src/modules/lists/service.ts +404 -0
- package/src/modules/lists/tools.ts +128 -0
- package/src/modules/people/repository.ts +26 -0
- package/src/modules/people/service.ts +20 -0
- package/src/modules/positions/routes.ts +4 -2
- package/src/modules/sample-data/fixture.ts +627 -0
- package/src/modules/sample-data/index.ts +50 -0
- package/src/modules/sample-data/routes.ts +49 -0
- package/src/modules/sample-data/service.ts +590 -0
- package/src/modules/sample-data/tools.ts +32 -0
- package/src/modules/smtp-email/index.ts +8 -1
- package/src/modules/workspace/service.ts +13 -1
- 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.
|
|
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.
|
|
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
|
@@ -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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|