@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
@@ -30,6 +30,7 @@ const companyShape = {
30
30
  tech_stack: z.array(z.string().min(1)),
31
31
  summary: z.string(),
32
32
  tags: z.array(z.string().min(1)),
33
+ is_own: z.boolean(),
33
34
  }
34
35
 
35
36
  /**
@@ -51,6 +52,7 @@ export const createBody = z.strictObject({
51
52
  tech_stack: companyShape.tech_stack.default([]),
52
53
  summary: companyShape.summary.default(''),
53
54
  tags: companyShape.tags.default([]),
55
+ is_own: companyShape.is_own.default(false),
54
56
  })
55
57
 
56
58
  export const updateBody = z.strictObject(companyShape).partial()
@@ -74,6 +76,7 @@ export function toCreateInput(body: z.infer<typeof createBody>): CreateCompanyIn
74
76
  techStack: body.tech_stack,
75
77
  summary: body.summary,
76
78
  tags: body.tags,
79
+ isOwn: body.is_own,
77
80
  }
78
81
  }
79
82
 
@@ -92,6 +95,7 @@ export function toUpdateInput(body: z.infer<typeof updateBody>): UpdateCompanyIn
92
95
  ...(body.tech_stack === undefined ? {} : { techStack: body.tech_stack }),
93
96
  ...(body.summary === undefined ? {} : { summary: body.summary }),
94
97
  ...(body.tags === undefined ? {} : { tags: body.tags }),
98
+ ...(body.is_own === undefined ? {} : { isOwn: body.is_own }),
95
99
  }
96
100
  }
97
101
 
@@ -111,6 +115,7 @@ export function companyResponse(company: CompanyView): Record<string, unknown> {
111
115
  tech_stack: company.techStack,
112
116
  summary: company.summary,
113
117
  tags: company.tags,
118
+ is_own: company.isOwn,
114
119
  created_at: company.createdAt.toISOString(),
115
120
  updated_at: company.updatedAt.toISOString(),
116
121
  }
@@ -120,11 +125,19 @@ export function mountCompaniesRoutes(router: Hono, dependencies: CompaniesRoutes
120
125
  const requireActor = (context: Context): Promise<Actor> => resolveActorFrom(dependencies, context)
121
126
 
122
127
  router.get('/companies', async (context) => {
128
+ // `?is_own=true|false` narrows to the workspace's own companies (or the
129
+ // rest). Anything else is treated as absent, so an unknown value never
130
+ // returns an accidental "everything".
131
+ const isOwnParam = context.req.query('is_own')
132
+ const isOwn =
133
+ isOwnParam === 'true' ? true : isOwnParam === 'false' ? false : undefined
134
+
123
135
  const page = await dependencies.service.list(
124
136
  await requireActor(context),
125
137
  {
126
138
  term: context.req.query('q'),
127
139
  personIds: readIdFilter(context, 'person_id'),
140
+ isOwn,
128
141
  },
129
142
  readListParameters(context),
130
143
  )
@@ -1,5 +1,5 @@
1
1
  import { ACCOUNT_TYPES, COMPANY_STAGES, ICP_FITS, SIZE_BANDS } from '@kelpie/schemas'
2
- import { index, pgTable, text, unique } from 'drizzle-orm/pg-core'
2
+ import { boolean, index, pgTable, text, unique } from 'drizzle-orm/pg-core'
3
3
 
4
4
  import {
5
5
  checkOneOf,
@@ -42,6 +42,10 @@ export const companies = pgTable(
42
42
  techStack: text('tech_stack').array().notNull().default([]),
43
43
  summary: text('summary').notNull().default(''),
44
44
  tags: text('tags').array().notNull().default([]),
45
+ // "This is us": a workspace marks its own company (or several, e.g. parent
46
+ // and subsidiary) so downstream views can distinguish self from prospects.
47
+ // Loose semantics on purpose — zero or many rows may carry it.
48
+ isOwn: boolean('is_own').notNull().default(false),
45
49
  createdAt: createdAt(),
46
50
  updatedAt: updatedAt(),
47
51
  searchVector: searchVector((): readonly SearchVectorPart[] => [
@@ -1,6 +1,7 @@
1
1
  import { changedKeys } from '../../lib/changes.ts'
2
2
  import { UNIQUE_VIOLATION, isReferenceViolation, postgresErrorCode } from '../../lib/database.ts'
3
3
  import type { Database } from '../../lib/database.ts'
4
+ import { autoLinkCompanyByDomain } from '../../lib/emailDomainAutoLink.ts'
4
5
  import { AppError } from '../../lib/errors.ts'
5
6
  import type { IdFactory } from '../../lib/ids.ts'
6
7
  import { normaliseDomain } from '../../lib/normalisation.ts'
@@ -51,6 +52,7 @@ const COMPANY_FIELD_LABELS: FieldLabels = {
51
52
  techStack: 'Tech stack',
52
53
  summary: 'Summary',
53
54
  tags: 'Tags',
55
+ isOwn: 'Own company',
54
56
  }
55
57
 
56
58
  /** A company as the API returns one: the stored row minus the tenancy column. */
@@ -70,6 +72,7 @@ export interface CreateCompanyInput {
70
72
  readonly techStack: readonly string[]
71
73
  readonly summary: string
72
74
  readonly tags: readonly string[]
75
+ readonly isOwn: boolean
73
76
  }
74
77
 
75
78
  /** PATCH semantics: an absent field is left alone, and null clears a nullable one. */
@@ -112,6 +115,7 @@ function toStoredColumns(input: UpdateCompanyInput): Partial<repository.CompanyC
112
115
  ...(input.techStack === undefined ? {} : { techStack: [...input.techStack] }),
113
116
  ...(input.summary === undefined ? {} : { summary: input.summary }),
114
117
  ...(input.tags === undefined ? {} : { tags: [...input.tags] }),
118
+ ...(input.isOwn === undefined ? {} : { isOwn: input.isOwn }),
115
119
  }
116
120
  }
117
121
 
@@ -171,6 +175,7 @@ export function createCompaniesService(dependencies: CompaniesDependencies): Com
171
175
  techStack: [...input.techStack],
172
176
  summary: input.summary,
173
177
  tags: [...input.tags],
178
+ isOwn: input.isOwn,
174
179
  })
175
180
  } catch (error: unknown) {
176
181
  if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
@@ -189,6 +194,15 @@ export function createCompaniesService(dependencies: CompaniesDependencies): Com
189
194
 
190
195
  events.emit('companies.company.created', { type: 'company', id: created.id }, {})
191
196
 
197
+ // Sync sweep of the workspace's people: everyone whose email domain
198
+ // matches this Company gets a titleless Position where none exists yet.
199
+ // Inside the same transaction so a follow-up read sees the new links.
200
+ await autoLinkCompanyByDomain(tx, events, workspaceId, created, {
201
+ createId: dependencies.createId,
202
+ now: dependencies.now,
203
+ recordActivity: dependencies.recordActivity,
204
+ })
205
+
192
206
  return toView(created)
193
207
  }, { workspaceId, actor: toEventActor(actor) })
194
208
  },
@@ -232,6 +246,17 @@ export function createCompaniesService(dependencies: CompaniesDependencies): Com
232
246
 
233
247
  events.emit('companies.company.updated', { type: 'company', id }, { changed })
234
248
 
249
+ // Sync sweep when the domain moved. A newly-set domain gains matches;
250
+ // a changed domain gains new matches without touching the old links
251
+ // (never-remove — a person may still work at the previous domain).
252
+ if (changed.includes('domain')) {
253
+ await autoLinkCompanyByDomain(tx, events, workspaceId, updated, {
254
+ createId: dependencies.createId,
255
+ now: dependencies.now,
256
+ recordActivity: dependencies.recordActivity,
257
+ })
258
+ }
259
+
235
260
  return toView(updated)
236
261
  }, { workspaceId, actor: toEventActor(actor) })
237
262
  },
@@ -9,10 +9,12 @@ import { createCompaniesModule } from './companies/index.ts'
9
9
  import { createDashboardModule } from './dashboard/index.ts'
10
10
  import { createDealsModule } from './deals/index.ts'
11
11
  import { createDecisionsModule } from './decisions/index.ts'
12
+ import { createEmailDomainLinkerModule } from './email-domain-linker/index.ts'
12
13
  import { createFormsModule } from './forms/index.ts'
13
14
  import { createHandbookModule } from './handbook/index.ts'
14
15
  import { createHiringModule } from './hiring/index.ts'
15
16
  import { createImportExportModule } from './import-export/index.ts'
17
+ import { createListsModule } from './lists/index.ts'
16
18
  import { createNotesModule } from './notes/index.ts'
17
19
  import { createOpportunitiesModule } from './opportunities/index.ts'
18
20
  import { createPartnershipsModule } from './partnerships/index.ts'
@@ -21,6 +23,7 @@ import { createPipelinesModule } from './pipelines/index.ts'
21
23
  import { createPlansModule } from './plans/index.ts'
22
24
  import { createPositionsModule } from './positions/index.ts'
23
25
  import { createRaisesModule } from './raises/index.ts'
26
+ import { createSampleDataModule } from './sample-data/index.ts'
24
27
  import { createSearchModule } from './search/index.ts'
25
28
  import { createSmtpEmailModule } from './smtp-email/index.ts'
26
29
  import { createWebhooksModule } from './webhooks/index.ts'
@@ -57,6 +60,7 @@ export const coreModules: readonly KelpieModule[] = [
57
60
  createHiringModule(coreMigrationsDirectory),
58
61
  createPlansModule(coreMigrationsDirectory),
59
62
  createDecisionsModule(coreMigrationsDirectory),
63
+ createListsModule(coreMigrationsDirectory),
60
64
  createHandbookModule(coreMigrationsDirectory),
61
65
  // Neither of these takes a migrations directory: they own no tables and read
62
66
  // other modules'.
@@ -66,6 +70,13 @@ export const coreModules: readonly KelpieModule[] = [
66
70
  createImportExportModule(coreMigrationsDirectory),
67
71
  createAgentTasksModule(coreMigrationsDirectory),
68
72
  createWebhooksModule(coreMigrationsDirectory),
73
+ // Owns no tables. One-shot fixture install for a new workspace, called from
74
+ // the setup wizard's checkbox and the admin data page.
75
+ createSampleDataModule(),
76
+ // Owns no tables. Admin action that reruns the email-domain sweep across
77
+ // every workspace Company — backfills links for data that predates the
78
+ // auto-linker, or after a bulk import.
79
+ createEmailDomainLinkerModule(),
69
80
  // Owns no tables. Registers a `'smtp'` provider with core's email runtime;
70
81
  // the factory only runs if the assembly's `email.provider` picks it.
71
82
  createSmtpEmailModule(),
@@ -0,0 +1,40 @@
1
+ import type { KelpieModule } from '../../runtime/module.ts'
2
+ import { createActivityRecorder } from '../activities/index.ts'
3
+ import { mountEmailDomainLinkerRoutes } from './routes.ts'
4
+ import { createEmailDomainLinkerService } from './service.ts'
5
+
6
+ /**
7
+ * Rebuild every email-domain link across the workspace.
8
+ *
9
+ * Owns no tables. Requires the modules whose data the sweep reads and writes.
10
+ * The sweep is the same one the companies service runs on a domain change,
11
+ * applied to every workspace Company at once — used to backfill links for
12
+ * data that predates the auto-linker, or after a bulk import.
13
+ */
14
+ export function createEmailDomainLinkerModule(): KelpieModule {
15
+ return {
16
+ id: 'email-domain-linker',
17
+ requires: ['workspace', 'companies', 'people', 'positions', 'activities'],
18
+
19
+ register(context) {
20
+ const service = createEmailDomainLinkerService({
21
+ db: context.db,
22
+ transaction: context.transaction,
23
+ createId: context.createId,
24
+ now: context.now,
25
+ recordActivity: createActivityRecorder({
26
+ createId: context.createId,
27
+ now: context.now,
28
+ }),
29
+ })
30
+
31
+ context.routes((router) => {
32
+ mountEmailDomainLinkerRoutes(router, { db: context.db, now: context.now, service })
33
+ })
34
+
35
+ return Promise.resolve()
36
+ },
37
+ }
38
+ }
39
+
40
+ export type { EmailDomainLinkerService, RelinkCounts } from './service.ts'
@@ -0,0 +1,40 @@
1
+ import type { Context, Hono } from 'hono'
2
+
3
+ import type { Actor } from '../auth/actor.ts'
4
+ import { resolveActorFrom } from '../auth/credentials.ts'
5
+ import type { CredentialDependencies } from '../auth/credentials.ts'
6
+ import type { EmailDomainLinkerService, RelinkCounts } from './service.ts'
7
+
8
+ /**
9
+ * Wire shape for `POST /v1/workspaces/:id/relink-email-domains`.
10
+ *
11
+ * No request body. The response counts what the sweep did.
12
+ */
13
+
14
+ export interface EmailDomainLinkerRoutesDependencies extends CredentialDependencies {
15
+ readonly service: EmailDomainLinkerService
16
+ }
17
+
18
+ export function relinkResponse(counts: RelinkCounts): Record<string, unknown> {
19
+ return {
20
+ companies_scanned: counts.companiesScanned,
21
+ positions_created: counts.positionsCreated,
22
+ }
23
+ }
24
+
25
+ export function mountEmailDomainLinkerRoutes(
26
+ router: Hono,
27
+ dependencies: EmailDomainLinkerRoutesDependencies,
28
+ ): void {
29
+ const requireActor = (context: Context): Promise<Actor> =>
30
+ resolveActorFrom(dependencies, context)
31
+
32
+ router.post('/workspaces/:id/relink-email-domains', async (context) => {
33
+ const counts = await dependencies.service.relink(
34
+ await requireActor(context),
35
+ context.req.param('id'),
36
+ )
37
+
38
+ return context.json(relinkResponse(counts))
39
+ })
40
+ }
@@ -0,0 +1,122 @@
1
+ import { and, eq, isNotNull } from 'drizzle-orm'
2
+
3
+ import { toEventActor } from '../../lib/actor.ts'
4
+ import type { Database } from '../../lib/database.ts'
5
+ import { autoLinkCompanyByDomain } from '../../lib/emailDomainAutoLink.ts'
6
+ import { AppError } from '../../lib/errors.ts'
7
+ import type { IdFactory } from '../../lib/ids.ts'
8
+ import type { TransactionScope } from '../../runtime/transaction.ts'
9
+ import type { ActivityRecorder } from '../activities/recorder.ts'
10
+ import type { Actor } from '../auth/actor.ts'
11
+ import { requireWorkspaceId } from '../auth/actor.ts'
12
+ import * as authRepository from '../auth/repository.ts'
13
+ import { companies } from '../companies/schema.ts'
14
+ import { parseMemberRole, roleAllows } from '../workspace/roles.ts'
15
+
16
+ /**
17
+ * Rebuild every email-domain link across the workspace.
18
+ *
19
+ * Iterates each Company that carries a domain and runs the same sweep the
20
+ * companies service runs on a domain change — every workspace Person whose
21
+ * email is at that domain gets a titleless Position where none exists yet.
22
+ *
23
+ * Idempotent: a follow-up run adds nothing. Add-only, consumer-host-skipping,
24
+ * and workspace-scoped, same as the inline auto-linker.
25
+ *
26
+ * One transaction per Company, not one for the whole sweep. A workspace can
27
+ * carry thousands of Companies, and a single transaction that long would
28
+ * hold row-share locks against every write elsewhere for the whole run.
29
+ */
30
+
31
+ export interface EmailDomainLinkerDependencies {
32
+ readonly db: Database
33
+ readonly transaction: TransactionScope
34
+ readonly createId: IdFactory
35
+ readonly now: () => Date
36
+ readonly recordActivity: ActivityRecorder
37
+ }
38
+
39
+ export interface RelinkCounts {
40
+ /** Companies with a non-null domain that were considered. Consumer hosts are counted but skipped. */
41
+ readonly companiesScanned: number
42
+ /** New Positions the sweep inserted this run. */
43
+ readonly positionsCreated: number
44
+ }
45
+
46
+ export interface EmailDomainLinkerService {
47
+ relink(actor: Actor, workspaceId: string): Promise<RelinkCounts>
48
+ }
49
+
50
+ export function createEmailDomainLinkerService(
51
+ dependencies: EmailDomainLinkerDependencies,
52
+ ): EmailDomainLinkerService {
53
+ async function requireAdmin(actor: Actor, workspaceId: string): Promise<void> {
54
+ if (actor.workspaceId !== workspaceId) {
55
+ throw AppError.notFound('Workspace not found')
56
+ }
57
+
58
+ if (actor.kind === 'api_key' && actor.userId === null) {
59
+ if (!roleAllows(actor.role, 'admin')) {
60
+ throw new AppError('forbidden', 'This action needs the admin role')
61
+ }
62
+ return
63
+ }
64
+
65
+ const userId = actor.userId
66
+
67
+ if (userId === null) {
68
+ throw AppError.notFound('Workspace not found')
69
+ }
70
+
71
+ const membership = await authRepository.findMembership(dependencies.db, workspaceId, userId)
72
+
73
+ if (membership === undefined) {
74
+ throw AppError.notFound('Workspace not found')
75
+ }
76
+
77
+ const role = parseMemberRole(membership.role)
78
+
79
+ if (role === undefined) {
80
+ throw new Error(`workspace_members.role holds "${membership.role}", which its check forbids`)
81
+ }
82
+
83
+ if (!roleAllows(role, 'admin')) {
84
+ throw new AppError('forbidden', 'This action needs the admin role')
85
+ }
86
+ }
87
+
88
+ return {
89
+ async relink(actor, workspaceId) {
90
+ requireWorkspaceId(actor)
91
+ await requireAdmin(actor, workspaceId)
92
+
93
+ // Every workspace Company that carries a domain — the ones the sweep
94
+ // can match against. Pooled read outside the per-company transactions:
95
+ // the work-list is one query, each write opens its own scope.
96
+ const rows = await dependencies.db
97
+ .select()
98
+ .from(companies)
99
+ .where(and(eq(companies.workspaceId, workspaceId), isNotNull(companies.domain)))
100
+
101
+ let created = 0
102
+
103
+ // One transaction per Company, not one for the whole sweep. A workspace
104
+ // can carry thousands of Companies; a single transaction that long would
105
+ // hold row-share locks against every write elsewhere for the whole run.
106
+ for (const company of rows) {
107
+ const inserted = await dependencies.transaction(
108
+ async ({ tx, events }) =>
109
+ autoLinkCompanyByDomain(tx, events, workspaceId, company, {
110
+ createId: dependencies.createId,
111
+ now: dependencies.now,
112
+ recordActivity: dependencies.recordActivity,
113
+ }),
114
+ { workspaceId, actor: toEventActor(actor) },
115
+ )
116
+ created += inserted.length
117
+ }
118
+
119
+ return { companiesScanned: rows.length, positionsCreated: created }
120
+ },
121
+ }
122
+ }
@@ -23,6 +23,7 @@ import './people/events.ts'
23
23
  import './companies/events.ts'
24
24
  import './positions/events.ts'
25
25
  import './notes/events.ts'
26
+ import './lists/events.ts'
26
27
  import './deals/events.ts'
27
28
  import './opportunities/events.ts'
28
29
  import './partnerships/events.ts'
@@ -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
+ }