@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
@@ -0,0 +1,49 @@
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 { SampleDataCounts, SampleDataService } from './service.ts'
7
+
8
+ /**
9
+ * Wire shape for `POST /v1/workspaces/:id/sample-data`.
10
+ *
11
+ * No request body: the installer takes no options today. Any option we add
12
+ * later goes on the body, not the URL.
13
+ */
14
+
15
+ export interface SampleDataRoutesDependencies extends CredentialDependencies {
16
+ readonly service: SampleDataService
17
+ }
18
+
19
+ export function sampleDataResponse(counts: SampleDataCounts): Record<string, unknown> {
20
+ return {
21
+ companies: counts.companies,
22
+ people: counts.people,
23
+ positions: counts.positions,
24
+ deals: counts.deals,
25
+ plan_items: counts.planItems,
26
+ notes: counts.notes,
27
+ opportunities: counts.opportunities,
28
+ raises: counts.raises,
29
+ partnerships: counts.partnerships,
30
+ roles: counts.roles,
31
+ candidates: counts.candidates,
32
+ }
33
+ }
34
+
35
+ export function mountSampleDataRoutes(
36
+ router: Hono,
37
+ dependencies: SampleDataRoutesDependencies,
38
+ ): void {
39
+ const requireActor = (context: Context): Promise<Actor> => resolveActorFrom(dependencies, context)
40
+
41
+ router.post('/workspaces/:id/sample-data', async (context) => {
42
+ const counts = await dependencies.service.install(
43
+ await requireActor(context),
44
+ context.req.param('id'),
45
+ )
46
+
47
+ return context.json(sampleDataResponse(counts), 201)
48
+ })
49
+ }
@@ -0,0 +1,590 @@
1
+ import { and, count, eq, inArray } from 'drizzle-orm'
2
+
3
+ import { toEventActor } from '../../lib/actor.ts'
4
+ import type { Database } from '../../lib/database.ts'
5
+ import { AppError } from '../../lib/errors.ts'
6
+ import type { IdFactory } from '../../lib/ids.ts'
7
+ import type { TransactionScope } from '../../runtime/transaction.ts'
8
+ import type { Actor } from '../auth/actor.ts'
9
+ import { requireWorkspaceId } from '../auth/actor.ts'
10
+ import * as authRepository from '../auth/repository.ts'
11
+ import { companies } from '../companies/schema.ts'
12
+ import { dealPeople, deals } from '../deals/schema.ts'
13
+ import { candidates, roles } from '../hiring/schema.ts'
14
+ import { notes } from '../notes/schema.ts'
15
+ import { opportunities } from '../opportunities/schema.ts'
16
+ import { partnershipPeople, partnerships } from '../partnerships/schema.ts'
17
+ import { people } from '../people/schema.ts'
18
+ import { pipelineStages } from '../pipelines/schema.ts'
19
+ import { planItems } from '../plans/schema.ts'
20
+ import { positions } from '../positions/schema.ts'
21
+ import { raisePeople, raises } from '../raises/schema.ts'
22
+ import { parseMemberRole, roleAllows } from '../workspace/roles.ts'
23
+ import { SAMPLE_DATA_FIXTURE } from './fixture.ts'
24
+ import type { Fixture } from './fixture.ts'
25
+
26
+ /**
27
+ * Installs the sample workspace, in one transaction.
28
+ *
29
+ * The whole seed is one write. A failure part-way through rolls the whole
30
+ * thing back, so a workspace never ends up with half of it.
31
+ *
32
+ * Idempotent by refusal: a workspace that already holds any companies or
33
+ * people gets a 409. Installing twice would double the fixture, which is not
34
+ * what an "install once" button says.
35
+ */
36
+
37
+ export interface SampleDataDependencies {
38
+ readonly db: Database
39
+ readonly transaction: TransactionScope
40
+ readonly createId: IdFactory
41
+ readonly now: () => Date
42
+ }
43
+
44
+ export interface SampleDataCounts {
45
+ readonly companies: number
46
+ readonly people: number
47
+ readonly positions: number
48
+ readonly deals: number
49
+ readonly planItems: number
50
+ readonly notes: number
51
+ readonly opportunities: number
52
+ readonly raises: number
53
+ readonly partnerships: number
54
+ readonly roles: number
55
+ readonly candidates: number
56
+ }
57
+
58
+ export interface SampleDataService {
59
+ install(actor: Actor, workspaceId: string): Promise<SampleDataCounts>
60
+ }
61
+
62
+ export function createSampleDataService(dependencies: SampleDataDependencies): SampleDataService {
63
+ async function requireAdmin(actor: Actor, workspaceId: string): Promise<void> {
64
+ if (actor.workspaceId !== workspaceId) {
65
+ throw AppError.notFound('Workspace not found')
66
+ }
67
+
68
+ if (actor.kind === 'api_key' && actor.userId === null) {
69
+ if (!roleAllows(actor.role, 'admin')) {
70
+ throw new AppError('forbidden', 'This action needs the admin role')
71
+ }
72
+ return
73
+ }
74
+
75
+ const userId = actor.userId
76
+
77
+ if (userId === null) {
78
+ throw AppError.notFound('Workspace not found')
79
+ }
80
+
81
+ const membership = await authRepository.findMembership(dependencies.db, workspaceId, userId)
82
+
83
+ if (membership === undefined) {
84
+ throw AppError.notFound('Workspace not found')
85
+ }
86
+
87
+ const role = parseMemberRole(membership.role)
88
+
89
+ if (role === undefined) {
90
+ throw new Error(`workspace_members.role holds "${membership.role}", which its check forbids`)
91
+ }
92
+
93
+ if (!roleAllows(role, 'admin')) {
94
+ throw new AppError('forbidden', 'This action needs the admin role')
95
+ }
96
+ }
97
+
98
+ return {
99
+ async install(actor, workspaceId) {
100
+ // The path parameter must match the actor's workspace, and it must be an
101
+ // admin. `requireWorkspaceId` is what CRM endpoints call to reject an
102
+ // actor with no workspace at all.
103
+ requireWorkspaceId(actor)
104
+ await requireAdmin(actor, workspaceId)
105
+
106
+ const fixture = SAMPLE_DATA_FIXTURE
107
+
108
+ return dependencies.transaction(
109
+ async ({ tx }) => {
110
+ const [companiesTally] = await tx
111
+ .select({ value: count() })
112
+ .from(companies)
113
+ .where(eq(companies.workspaceId, workspaceId))
114
+
115
+ const [peopleTally] = await tx
116
+ .select({ value: count() })
117
+ .from(people)
118
+ .where(eq(people.workspaceId, workspaceId))
119
+
120
+ const alreadyHasData =
121
+ (companiesTally?.value ?? 0) > 0 || (peopleTally?.value ?? 0) > 0
122
+
123
+ if (alreadyHasData) {
124
+ throw AppError.conflict('This workspace already has CRM data')
125
+ }
126
+
127
+ // Every stage this workspace carries, grouped by kind so a deal, an
128
+ // opportunity, a raise and a partnership all resolve their fixture
129
+ // stage slugs against the same read.
130
+ const allStages = await tx
131
+ .select({
132
+ id: pipelineStages.id,
133
+ slug: pipelineStages.slug,
134
+ kind: pipelineStages.kind,
135
+ })
136
+ .from(pipelineStages)
137
+ .where(
138
+ and(
139
+ eq(pipelineStages.workspaceId, workspaceId),
140
+ inArray(pipelineStages.kind, ['deal', 'opportunity', 'raise', 'partnership']),
141
+ ),
142
+ )
143
+
144
+ const stageIdByKindAndSlug = new Map<string, string>()
145
+
146
+ for (const row of allStages) {
147
+ stageIdByKindAndSlug.set(stageLookupKey(row.kind, row.slug), row.id)
148
+ }
149
+
150
+ const now = dependencies.now()
151
+ const companyIds = new Map<string, string>()
152
+ const personIds = new Map<string, string>()
153
+ const dealIds = new Map<string, string>()
154
+ const opportunityIds = new Map<string, string>()
155
+ const raiseIds = new Map<string, string>()
156
+ const partnershipIds = new Map<string, string>()
157
+ const roleIds = new Map<string, string>()
158
+ const candidateIds = new Map<string, string>()
159
+
160
+ for (const record of fixture.companies) {
161
+ const id = dependencies.createId('company')
162
+ companyIds.set(record.key, id)
163
+
164
+ await tx.insert(companies).values({
165
+ id,
166
+ workspaceId,
167
+ name: record.name,
168
+ domain: record.domain,
169
+ industry: record.industry,
170
+ description: record.description,
171
+ stage: record.stage,
172
+ sizeBand: record.sizeBand,
173
+ hq: record.hq,
174
+ website: record.website,
175
+ accountType: record.accountType,
176
+ icpFit: record.icpFit,
177
+ techStack: [...record.techStack],
178
+ summary: record.summary,
179
+ tags: [...record.tags],
180
+ createdAt: now,
181
+ updatedAt: now,
182
+ })
183
+ }
184
+
185
+ for (const record of fixture.people) {
186
+ const id = dependencies.createId('person')
187
+ personIds.set(record.key, id)
188
+
189
+ await tx.insert(people).values({
190
+ id,
191
+ workspaceId,
192
+ name: record.name,
193
+ email: record.email,
194
+ location: record.location,
195
+ preferredChannel: record.preferredChannel,
196
+ influence: record.influence,
197
+ relationship: record.relationship,
198
+ summary: record.summary,
199
+ tags: [...record.tags],
200
+ createdAt: now,
201
+ updatedAt: now,
202
+ })
203
+ }
204
+
205
+ for (const record of fixture.positions) {
206
+ const personId = personIds.get(record.personKey)
207
+ const companyId = companyIds.get(record.companyKey)
208
+
209
+ if (personId === undefined || companyId === undefined) {
210
+ throw new Error(
211
+ `Sample position links unknown keys: person=${record.personKey}, company=${record.companyKey}`,
212
+ )
213
+ }
214
+
215
+ await tx.insert(positions).values({
216
+ id: dependencies.createId('position'),
217
+ workspaceId,
218
+ personId,
219
+ companyId,
220
+ title: record.title,
221
+ createdAt: now,
222
+ updatedAt: now,
223
+ })
224
+ }
225
+
226
+ for (const record of fixture.deals) {
227
+ const stageId = stageIdByKindAndSlug.get(stageLookupKey('deal', record.stageSlug))
228
+ const companyId = companyIds.get(record.companyKey)
229
+
230
+ if (stageId === undefined) {
231
+ throw new Error(
232
+ `Sample deal "${record.name}" names stage "${record.stageSlug}", which this workspace's deal pipeline does not carry`,
233
+ )
234
+ }
235
+
236
+ if (companyId === undefined) {
237
+ throw new Error(
238
+ `Sample deal "${record.name}" names unknown company "${record.companyKey}"`,
239
+ )
240
+ }
241
+
242
+ const id = dependencies.createId('deal')
243
+ dealIds.set(record.key, id)
244
+
245
+ await tx.insert(deals).values({
246
+ id,
247
+ workspaceId,
248
+ name: record.name,
249
+ companyId,
250
+ stageId,
251
+ valueCents: record.valueCents,
252
+ currency: record.currency,
253
+ expectedClose: record.expectedClose,
254
+ competitors: [],
255
+ risks: record.risks,
256
+ whyWin: record.whyWin,
257
+ summary: record.summary,
258
+ tags: [...record.tags],
259
+ createdAt: now,
260
+ updatedAt: now,
261
+ })
262
+
263
+ for (const personKey of record.peopleKeys) {
264
+ const personId = personIds.get(personKey)
265
+
266
+ if (personId === undefined) {
267
+ throw new Error(
268
+ `Sample deal "${record.name}" names unknown person "${personKey}"`,
269
+ )
270
+ }
271
+
272
+ await tx.insert(dealPeople).values({ dealId: id, personId })
273
+ }
274
+ }
275
+
276
+ for (const record of fixture.opportunities) {
277
+ const stageId = stageIdByKindAndSlug.get(
278
+ stageLookupKey('opportunity', record.stageSlug),
279
+ )
280
+
281
+ if (stageId === undefined) {
282
+ throw new Error(
283
+ `Sample opportunity "${record.name}" names stage "${record.stageSlug}", which this workspace's opportunity pipeline does not carry`,
284
+ )
285
+ }
286
+
287
+ const companyId =
288
+ record.companyKey === null ? null : (companyIds.get(record.companyKey) ?? undefined)
289
+
290
+ if (companyId === undefined) {
291
+ throw new Error(
292
+ `Sample opportunity "${record.name}" names unknown company "${String(record.companyKey)}"`,
293
+ )
294
+ }
295
+
296
+ const id = dependencies.createId('opportunity')
297
+ opportunityIds.set(record.key, id)
298
+
299
+ await tx.insert(opportunities).values({
300
+ id,
301
+ workspaceId,
302
+ name: record.name,
303
+ kind: record.kind,
304
+ stageId,
305
+ companyId,
306
+ expectedClose: record.expectedClose,
307
+ summary: record.summary,
308
+ tags: [...record.tags],
309
+ createdAt: now,
310
+ updatedAt: now,
311
+ })
312
+ }
313
+
314
+ for (const record of fixture.raises) {
315
+ const stageId = stageIdByKindAndSlug.get(stageLookupKey('raise', record.stageSlug))
316
+ const companyId = companyIds.get(record.companyKey)
317
+
318
+ if (stageId === undefined) {
319
+ throw new Error(
320
+ `Sample raise "${record.name}" names stage "${record.stageSlug}", which this workspace's raise pipeline does not carry`,
321
+ )
322
+ }
323
+
324
+ if (companyId === undefined) {
325
+ throw new Error(
326
+ `Sample raise "${record.name}" names unknown company "${record.companyKey}"`,
327
+ )
328
+ }
329
+
330
+ const id = dependencies.createId('raise')
331
+ raiseIds.set(record.key, id)
332
+
333
+ await tx.insert(raises).values({
334
+ id,
335
+ workspaceId,
336
+ name: record.name,
337
+ companyId,
338
+ stageId,
339
+ checkSizeCents: record.checkSizeCents,
340
+ currency: record.currency,
341
+ thesisFit: record.thesisFit,
342
+ expectedClose: record.expectedClose,
343
+ summary: record.summary,
344
+ tags: [...record.tags],
345
+ createdAt: now,
346
+ updatedAt: now,
347
+ })
348
+
349
+ for (const personKey of record.peopleKeys) {
350
+ const personId = personIds.get(personKey)
351
+
352
+ if (personId === undefined) {
353
+ throw new Error(
354
+ `Sample raise "${record.name}" names unknown person "${personKey}"`,
355
+ )
356
+ }
357
+
358
+ await tx.insert(raisePeople).values({ raiseId: id, personId })
359
+ }
360
+ }
361
+
362
+ for (const record of fixture.partnerships) {
363
+ const stageId = stageIdByKindAndSlug.get(
364
+ stageLookupKey('partnership', record.stageSlug),
365
+ )
366
+ const companyId = companyIds.get(record.companyKey)
367
+
368
+ if (stageId === undefined) {
369
+ throw new Error(
370
+ `Sample partnership "${record.name}" names stage "${record.stageSlug}", which this workspace's partnership pipeline does not carry`,
371
+ )
372
+ }
373
+
374
+ if (companyId === undefined) {
375
+ throw new Error(
376
+ `Sample partnership "${record.name}" names unknown company "${record.companyKey}"`,
377
+ )
378
+ }
379
+
380
+ const id = dependencies.createId('partnership')
381
+ partnershipIds.set(record.key, id)
382
+
383
+ await tx.insert(partnerships).values({
384
+ id,
385
+ workspaceId,
386
+ name: record.name,
387
+ companyId,
388
+ stageId,
389
+ kind: record.kind,
390
+ nextTouchpoint: record.nextTouchpoint,
391
+ goals: record.goals,
392
+ successLooksLike: record.successLooksLike,
393
+ summary: record.summary,
394
+ tags: [...record.tags],
395
+ createdAt: now,
396
+ updatedAt: now,
397
+ })
398
+
399
+ for (const personKey of record.peopleKeys) {
400
+ const personId = personIds.get(personKey)
401
+
402
+ if (personId === undefined) {
403
+ throw new Error(
404
+ `Sample partnership "${record.name}" names unknown person "${personKey}"`,
405
+ )
406
+ }
407
+
408
+ await tx.insert(partnershipPeople).values({ partnershipId: id, personId })
409
+ }
410
+ }
411
+
412
+ for (const record of fixture.roles) {
413
+ const id = dependencies.createId('role')
414
+ roleIds.set(record.key, id)
415
+
416
+ await tx.insert(roles).values({
417
+ id,
418
+ workspaceId,
419
+ title: record.title,
420
+ status: record.status,
421
+ createdAt: now,
422
+ updatedAt: now,
423
+ })
424
+ }
425
+
426
+ for (const record of fixture.candidates) {
427
+ const roleId = roleIds.get(record.roleKey)
428
+ const personId = personIds.get(record.personKey)
429
+ const referrerPersonId =
430
+ record.referrerPersonKey === null
431
+ ? null
432
+ : (personIds.get(record.referrerPersonKey) ?? undefined)
433
+
434
+ if (roleId === undefined) {
435
+ throw new Error(
436
+ `Sample candidate "${record.key}" names unknown role "${record.roleKey}"`,
437
+ )
438
+ }
439
+
440
+ if (personId === undefined) {
441
+ throw new Error(
442
+ `Sample candidate "${record.key}" names unknown person "${record.personKey}"`,
443
+ )
444
+ }
445
+
446
+ if (referrerPersonId === undefined) {
447
+ throw new Error(
448
+ `Sample candidate "${record.key}" names unknown referrer "${String(record.referrerPersonKey)}"`,
449
+ )
450
+ }
451
+
452
+ const id = dependencies.createId('candidate')
453
+ candidateIds.set(record.key, id)
454
+
455
+ await tx.insert(candidates).values({
456
+ id,
457
+ workspaceId,
458
+ roleId,
459
+ personId,
460
+ status: record.status,
461
+ // The interview_stage column carries a value only while the
462
+ // candidate is in process. Every other status clears it, which
463
+ // the service does the same way `hiring` does for a PATCH.
464
+ interviewStage: record.status === 'in_process' ? record.interviewStage : null,
465
+ referrerPersonId,
466
+ createdAt: now,
467
+ updatedAt: now,
468
+ })
469
+ }
470
+
471
+ for (const record of fixture.plans) {
472
+ const dealId = dealIds.get(record.targetDealKey)
473
+
474
+ if (dealId === undefined) {
475
+ throw new Error(
476
+ `Sample plan item "${record.title}" names unknown deal "${record.targetDealKey}"`,
477
+ )
478
+ }
479
+
480
+ await tx.insert(planItems).values({
481
+ id: dependencies.createId('planItem'),
482
+ workspaceId,
483
+ targetType: record.targetType,
484
+ targetId: dealId,
485
+ date: record.date,
486
+ title: record.title,
487
+ status: record.status,
488
+ createdAt: now,
489
+ updatedAt: now,
490
+ })
491
+ }
492
+
493
+ for (const record of fixture.notes) {
494
+ const targetId = resolveNoteTargetId(record.targetType, record.targetKey, {
495
+ companyIds,
496
+ personIds,
497
+ dealIds,
498
+ opportunityIds,
499
+ raiseIds,
500
+ partnershipIds,
501
+ candidateIds,
502
+ })
503
+
504
+ if (targetId === undefined) {
505
+ throw new Error(
506
+ `Sample note names unknown ${record.targetType} "${record.targetKey}"`,
507
+ )
508
+ }
509
+
510
+ await tx.insert(notes).values({
511
+ id: dependencies.createId('note'),
512
+ workspaceId,
513
+ targetType: record.targetType,
514
+ targetId,
515
+ body: record.body,
516
+ pinned: record.pinned,
517
+ createdAt: now,
518
+ updatedAt: now,
519
+ })
520
+ }
521
+
522
+ return countsFor(fixture)
523
+ },
524
+ { workspaceId, actor: toEventActor(actor) },
525
+ )
526
+ },
527
+ }
528
+ }
529
+
530
+ interface KeyMaps {
531
+ readonly companyIds: ReadonlyMap<string, string>
532
+ readonly personIds: ReadonlyMap<string, string>
533
+ readonly dealIds: ReadonlyMap<string, string>
534
+ readonly opportunityIds: ReadonlyMap<string, string>
535
+ readonly raiseIds: ReadonlyMap<string, string>
536
+ readonly partnershipIds: ReadonlyMap<string, string>
537
+ readonly candidateIds: ReadonlyMap<string, string>
538
+ }
539
+
540
+ function resolveNoteTargetId(
541
+ targetType: string,
542
+ targetKey: string,
543
+ maps: KeyMaps,
544
+ ): string | undefined {
545
+ switch (targetType) {
546
+ case 'company':
547
+ return maps.companyIds.get(targetKey)
548
+ case 'person':
549
+ return maps.personIds.get(targetKey)
550
+ case 'deal':
551
+ return maps.dealIds.get(targetKey)
552
+ case 'opportunity':
553
+ return maps.opportunityIds.get(targetKey)
554
+ case 'raise':
555
+ return maps.raiseIds.get(targetKey)
556
+ case 'partnership':
557
+ return maps.partnershipIds.get(targetKey)
558
+ case 'candidate':
559
+ return maps.candidateIds.get(targetKey)
560
+ default:
561
+ // A new RECORD_TARGET_TYPES value in the fixture requires a case here;
562
+ // falling through silently would drop a note on the floor.
563
+ return undefined
564
+ }
565
+ }
566
+
567
+ /**
568
+ * The lookup key `stageIdByKindAndSlug` uses. A slug is only unique per kind
569
+ * (`won` is a stage in both `deal` and `opportunity`), so the map is keyed by
570
+ * both fields joined.
571
+ */
572
+ function stageLookupKey(kind: string, slug: string): string {
573
+ return `${kind}::${slug}`
574
+ }
575
+
576
+ function countsFor(fixture: Fixture): SampleDataCounts {
577
+ return {
578
+ companies: fixture.companies.length,
579
+ people: fixture.people.length,
580
+ positions: fixture.positions.length,
581
+ deals: fixture.deals.length,
582
+ planItems: fixture.plans.length,
583
+ notes: fixture.notes.length,
584
+ opportunities: fixture.opportunities.length,
585
+ raises: fixture.raises.length,
586
+ partnerships: fixture.partnerships.length,
587
+ roles: fixture.roles.length,
588
+ candidates: fixture.candidates.length,
589
+ }
590
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod'
2
+
3
+ import { requireWorkspaceId } from '../auth/actor.ts'
4
+ import type { McpToolRegistry } from '../../runtime/module.ts'
5
+ import { sampleDataResponse } from './routes.ts'
6
+ import type { SampleDataService } from './service.ts'
7
+
8
+ /**
9
+ * `sample_data_install`: the MCP mirror of `POST /v1/workspaces/:id/sample-data`.
10
+ *
11
+ * Same admin check, same conflict on a workspace that already has data. The
12
+ * tool takes no arguments: it installs into the actor's own workspace.
13
+ */
14
+
15
+ const installArgs = z.strictObject({})
16
+
17
+ export function registerSampleDataTools(
18
+ mcp: McpToolRegistry,
19
+ service: SampleDataService,
20
+ ): void {
21
+ mcp.tool({
22
+ name: 'sample_data_install',
23
+ description:
24
+ 'Populate this workspace with a small sample of companies, people, positions, deals, plans and notes. Refuses if the workspace already has data.',
25
+ inputSchema: installArgs,
26
+ async invoke(_args, actor) {
27
+ const counts = await service.install(actor, requireWorkspaceId(actor))
28
+
29
+ return sampleDataResponse(counts)
30
+ },
31
+ })
32
+ }
@@ -44,7 +44,13 @@ export type SmtpEmailConfig = z.infer<typeof smtpEmailConfigSchema>
44
44
  * sender is verifiable without a container running a real SMTP conversation.
45
45
  */
46
46
  export interface SmtpTransport {
47
- sendMail(message: { from: string; to: string; subject: string; text: string }): Promise<unknown>
47
+ sendMail(message: {
48
+ from: string
49
+ to: string
50
+ subject: string
51
+ text: string
52
+ html?: string
53
+ }): Promise<unknown>
48
54
  }
49
55
 
50
56
  function createNodemailerTransport(config: SmtpEmailConfig): SmtpTransport {
@@ -70,6 +76,7 @@ export function createSmtpEmailSender(
70
76
  to: message.to,
71
77
  subject: message.subject,
72
78
  text: message.body,
79
+ ...(message.html === undefined ? {} : { html: message.html }),
73
80
  })
74
81
  } catch (cause) {
75
82
  const reason = describeThrown(cause)