@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
|
@@ -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
|
},
|
package/src/modules/core.ts
CHANGED
|
@@ -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,41 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import type { ModuleEventCatalog } from '../../runtime/module.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Domain events published by the lists module.
|
|
7
|
+
*
|
|
8
|
+
* `target` on a `lists.list.*` event is the list itself. `lists.member.*` events
|
|
9
|
+
* name the record that changed membership, so a consumer watching records hears
|
|
10
|
+
* about a list touching it without a second lookup.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const listsEvents = {
|
|
14
|
+
'lists.list.created': z.object({}).strict(),
|
|
15
|
+
'lists.list.updated': z.object({ changed: z.array(z.string()).readonly() }),
|
|
16
|
+
'lists.list.deleted': z.object({}).strict(),
|
|
17
|
+
'lists.member.added': z.object({ listId: z.string() }),
|
|
18
|
+
'lists.member.removed': z.object({ listId: z.string() }),
|
|
19
|
+
} satisfies ModuleEventCatalog
|
|
20
|
+
|
|
21
|
+
export type ListCreatedData = Record<string, never>
|
|
22
|
+
export interface ListUpdatedData {
|
|
23
|
+
readonly changed: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
export type ListDeletedData = Record<string, never>
|
|
26
|
+
export interface ListMemberAddedData {
|
|
27
|
+
readonly listId: string
|
|
28
|
+
}
|
|
29
|
+
export interface ListMemberRemovedData {
|
|
30
|
+
readonly listId: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare module '../../runtime/events.ts' {
|
|
34
|
+
interface KelpieEventMap {
|
|
35
|
+
'lists.list.created': ListCreatedData
|
|
36
|
+
'lists.list.updated': ListUpdatedData
|
|
37
|
+
'lists.list.deleted': ListDeletedData
|
|
38
|
+
'lists.member.added': ListMemberAddedData
|
|
39
|
+
'lists.member.removed': ListMemberRemovedData
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { KelpieModule } from '../../runtime/module.ts'
|
|
2
|
+
import { listsEvents } from './events.ts'
|
|
3
|
+
import { mountListsRoutes } from './routes.ts'
|
|
4
|
+
import * as schema from './schema.ts'
|
|
5
|
+
import { createListsService } from './service.ts'
|
|
6
|
+
import { registerListsTools } from './tools.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lists: named collections of records of one type.
|
|
10
|
+
*
|
|
11
|
+
* Requires `workspace` for the tenancy column. Every other record type is
|
|
12
|
+
* addressed polymorphically through `target_type` + `target_id`, so this module
|
|
13
|
+
* does not depend on the modules whose records it references.
|
|
14
|
+
*/
|
|
15
|
+
export function createListsModule(migrationsDirectory: string): KelpieModule {
|
|
16
|
+
return {
|
|
17
|
+
id: 'lists',
|
|
18
|
+
requires: ['workspace'],
|
|
19
|
+
structural: true,
|
|
20
|
+
events: listsEvents,
|
|
21
|
+
|
|
22
|
+
register(context) {
|
|
23
|
+
const service = createListsService({
|
|
24
|
+
db: context.db,
|
|
25
|
+
transaction: context.transaction,
|
|
26
|
+
createId: context.createId,
|
|
27
|
+
now: context.now,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
context.schema(schema, migrationsDirectory)
|
|
31
|
+
|
|
32
|
+
context.routes((router) => {
|
|
33
|
+
mountListsRoutes(router, { db: context.db, now: context.now, service })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
registerListsTools(context.mcp, service)
|
|
37
|
+
|
|
38
|
+
return Promise.resolve()
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|