@kelpie/server 0.6.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 (266) hide show
  1. package/dist/app.d.ts +13 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +9 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/boot.d.ts +42 -0
  6. package/dist/boot.d.ts.map +1 -0
  7. package/dist/boot.js +57 -0
  8. package/dist/boot.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +5 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/config.d.ts +7 -0
  14. package/dist/lib/config.d.ts.map +1 -1
  15. package/dist/lib/config.js +2 -0
  16. package/dist/lib/config.js.map +1 -1
  17. package/dist/lib/consumerEmailDomains.d.ts +2 -0
  18. package/dist/lib/consumerEmailDomains.d.ts.map +1 -0
  19. package/dist/lib/consumerEmailDomains.js +62 -0
  20. package/dist/lib/consumerEmailDomains.js.map +1 -0
  21. package/dist/lib/email.d.ts +7 -0
  22. package/dist/lib/email.d.ts.map +1 -1
  23. package/dist/lib/email.js.map +1 -1
  24. package/dist/lib/emailContent.d.ts +33 -0
  25. package/dist/lib/emailContent.d.ts.map +1 -0
  26. package/dist/lib/emailContent.js +54 -0
  27. package/dist/lib/emailContent.js.map +1 -0
  28. package/dist/lib/emailDomainAutoLink.d.ts +27 -0
  29. package/dist/lib/emailDomainAutoLink.d.ts.map +1 -0
  30. package/dist/lib/emailDomainAutoLink.js +119 -0
  31. package/dist/lib/emailDomainAutoLink.js.map +1 -0
  32. package/dist/lib/ids.d.ts +2 -0
  33. package/dist/lib/ids.d.ts.map +1 -1
  34. package/dist/lib/ids.js +2 -0
  35. package/dist/lib/ids.js.map +1 -1
  36. package/dist/lib/kelpieConfigFile.d.ts +6 -0
  37. package/dist/lib/kelpieConfigFile.d.ts.map +1 -1
  38. package/dist/lib/kelpieConfigFile.js +1 -0
  39. package/dist/lib/kelpieConfigFile.js.map +1 -1
  40. package/dist/modules/attachedRecords.d.ts.map +1 -1
  41. package/dist/modules/attachedRecords.js +4 -2
  42. package/dist/modules/attachedRecords.js.map +1 -1
  43. package/dist/modules/auth/preferences.d.ts +11 -1
  44. package/dist/modules/auth/preferences.d.ts.map +1 -1
  45. package/dist/modules/auth/preferences.js +11 -0
  46. package/dist/modules/auth/preferences.js.map +1 -1
  47. package/dist/modules/auth/routes.d.ts.map +1 -1
  48. package/dist/modules/auth/routes.js +18 -0
  49. package/dist/modules/auth/routes.js.map +1 -1
  50. package/dist/modules/auth/schema.d.ts +20 -0
  51. package/dist/modules/auth/schema.d.ts.map +1 -1
  52. package/dist/modules/auth/schema.js +11 -1
  53. package/dist/modules/auth/schema.js.map +1 -1
  54. package/dist/modules/auth/service.d.ts.map +1 -1
  55. package/dist/modules/auth/service.js +27 -3
  56. package/dist/modules/auth/service.js.map +1 -1
  57. package/dist/modules/companies/repository.d.ts +2 -0
  58. package/dist/modules/companies/repository.d.ts.map +1 -1
  59. package/dist/modules/companies/repository.js +1 -0
  60. package/dist/modules/companies/repository.js.map +1 -1
  61. package/dist/modules/companies/routes.d.ts +2 -0
  62. package/dist/modules/companies/routes.d.ts.map +1 -1
  63. package/dist/modules/companies/routes.js +11 -0
  64. package/dist/modules/companies/routes.js.map +1 -1
  65. package/dist/modules/companies/schema.d.ts +17 -0
  66. package/dist/modules/companies/schema.d.ts.map +1 -1
  67. package/dist/modules/companies/schema.js +5 -1
  68. package/dist/modules/companies/schema.js.map +1 -1
  69. package/dist/modules/companies/service.d.ts +1 -0
  70. package/dist/modules/companies/service.d.ts.map +1 -1
  71. package/dist/modules/companies/service.js +22 -0
  72. package/dist/modules/companies/service.js.map +1 -1
  73. package/dist/modules/core.d.ts.map +1 -1
  74. package/dist/modules/core.js +11 -0
  75. package/dist/modules/core.js.map +1 -1
  76. package/dist/modules/email-domain-linker/index.d.ts +12 -0
  77. package/dist/modules/email-domain-linker/index.d.ts.map +1 -0
  78. package/dist/modules/email-domain-linker/index.js +34 -0
  79. package/dist/modules/email-domain-linker/index.js.map +1 -0
  80. package/dist/modules/email-domain-linker/routes.d.ts +14 -0
  81. package/dist/modules/email-domain-linker/routes.d.ts.map +1 -0
  82. package/dist/modules/email-domain-linker/routes.js +15 -0
  83. package/dist/modules/email-domain-linker/routes.js.map +1 -0
  84. package/dist/modules/email-domain-linker/service.d.ts +37 -0
  85. package/dist/modules/email-domain-linker/service.d.ts.map +1 -0
  86. package/dist/modules/email-domain-linker/service.js +63 -0
  87. package/dist/modules/email-domain-linker/service.js.map +1 -0
  88. package/dist/modules/eventCatalogs.d.ts +36 -0
  89. package/dist/modules/eventCatalogs.d.ts.map +1 -0
  90. package/dist/modules/eventCatalogs.js +36 -0
  91. package/dist/modules/eventCatalogs.js.map +1 -0
  92. package/dist/modules/import-export/drafts.d.ts +9 -0
  93. package/dist/modules/import-export/drafts.d.ts.map +1 -1
  94. package/dist/modules/import-export/drafts.js +25 -0
  95. package/dist/modules/import-export/drafts.js.map +1 -1
  96. package/dist/modules/import-export/exportRows.d.ts +9 -0
  97. package/dist/modules/import-export/exportRows.d.ts.map +1 -1
  98. package/dist/modules/import-export/exportRows.js +10 -1
  99. package/dist/modules/import-export/exportRows.js.map +1 -1
  100. package/dist/modules/import-export/plan.d.ts +23 -1
  101. package/dist/modules/import-export/plan.d.ts.map +1 -1
  102. package/dist/modules/import-export/plan.js +62 -3
  103. package/dist/modules/import-export/plan.js.map +1 -1
  104. package/dist/modules/import-export/presets.d.ts.map +1 -1
  105. package/dist/modules/import-export/presets.js +25 -0
  106. package/dist/modules/import-export/presets.js.map +1 -1
  107. package/dist/modules/import-export/repository.d.ts +17 -0
  108. package/dist/modules/import-export/repository.d.ts.map +1 -1
  109. package/dist/modules/import-export/repository.js +21 -1
  110. package/dist/modules/import-export/repository.js.map +1 -1
  111. package/dist/modules/import-export/routes.d.ts.map +1 -1
  112. package/dist/modules/import-export/routes.js +5 -1
  113. package/dist/modules/import-export/routes.js.map +1 -1
  114. package/dist/modules/import-export/schema.d.ts +55 -0
  115. package/dist/modules/import-export/schema.d.ts.map +1 -1
  116. package/dist/modules/import-export/schema.js +13 -1
  117. package/dist/modules/import-export/schema.js.map +1 -1
  118. package/dist/modules/import-export/service.d.ts +4 -1
  119. package/dist/modules/import-export/service.d.ts.map +1 -1
  120. package/dist/modules/import-export/service.js +51 -3
  121. package/dist/modules/import-export/service.js.map +1 -1
  122. package/dist/modules/import-export/tools.d.ts.map +1 -1
  123. package/dist/modules/import-export/tools.js +7 -1
  124. package/dist/modules/import-export/tools.js.map +1 -1
  125. package/dist/modules/import-export/writes.d.ts +16 -0
  126. package/dist/modules/import-export/writes.d.ts.map +1 -1
  127. package/dist/modules/import-export/writes.js +77 -2
  128. package/dist/modules/import-export/writes.js.map +1 -1
  129. package/dist/modules/lists/events.d.ts +42 -0
  130. package/dist/modules/lists/events.d.ts.map +1 -0
  131. package/dist/modules/lists/events.js +16 -0
  132. package/dist/modules/lists/events.js.map +1 -0
  133. package/dist/modules/lists/index.d.ts +10 -0
  134. package/dist/modules/lists/index.d.ts.map +1 -0
  135. package/dist/modules/lists/index.js +35 -0
  136. package/dist/modules/lists/index.js.map +1 -0
  137. package/dist/modules/lists/repository.d.ts +69 -0
  138. package/dist/modules/lists/repository.d.ts.map +1 -0
  139. package/dist/modules/lists/repository.js +165 -0
  140. package/dist/modules/lists/repository.js.map +1 -0
  141. package/dist/modules/lists/routes.d.ts +51 -0
  142. package/dist/modules/lists/routes.d.ts.map +1 -0
  143. package/dist/modules/lists/routes.js +160 -0
  144. package/dist/modules/lists/routes.js.map +1 -0
  145. package/dist/modules/lists/schema.d.ts +264 -0
  146. package/dist/modules/lists/schema.d.ts.map +1 -0
  147. package/dist/modules/lists/schema.js +73 -0
  148. package/dist/modules/lists/schema.js.map +1 -0
  149. package/dist/modules/lists/service.d.ts +70 -0
  150. package/dist/modules/lists/service.d.ts.map +1 -0
  151. package/dist/modules/lists/service.js +214 -0
  152. package/dist/modules/lists/service.js.map +1 -0
  153. package/dist/modules/lists/tools.d.ts +4 -0
  154. package/dist/modules/lists/tools.d.ts.map +1 -0
  155. package/dist/modules/lists/tools.js +86 -0
  156. package/dist/modules/lists/tools.js.map +1 -0
  157. package/dist/modules/people/repository.d.ts +11 -0
  158. package/dist/modules/people/repository.d.ts.map +1 -1
  159. package/dist/modules/people/repository.js +16 -0
  160. package/dist/modules/people/repository.js.map +1 -1
  161. package/dist/modules/people/service.d.ts.map +1 -1
  162. package/dist/modules/people/service.js +18 -0
  163. package/dist/modules/people/service.js.map +1 -1
  164. package/dist/modules/positions/routes.d.ts.map +1 -1
  165. package/dist/modules/positions/routes.js +4 -2
  166. package/dist/modules/positions/routes.js.map +1 -1
  167. package/dist/modules/sample-data/fixture.d.ts +139 -0
  168. package/dist/modules/sample-data/fixture.d.ts.map +1 -0
  169. package/dist/modules/sample-data/fixture.js +461 -0
  170. package/dist/modules/sample-data/fixture.js.map +1 -0
  171. package/dist/modules/sample-data/index.d.ts +11 -0
  172. package/dist/modules/sample-data/index.d.ts.map +1 -0
  173. package/dist/modules/sample-data/index.js +43 -0
  174. package/dist/modules/sample-data/index.js.map +1 -0
  175. package/dist/modules/sample-data/routes.d.ts +15 -0
  176. package/dist/modules/sample-data/routes.d.ts.map +1 -0
  177. package/dist/modules/sample-data/routes.js +24 -0
  178. package/dist/modules/sample-data/routes.js.map +1 -0
  179. package/dist/modules/sample-data/service.d.ts +38 -0
  180. package/dist/modules/sample-data/service.d.ts.map +1 -0
  181. package/dist/modules/sample-data/service.js +412 -0
  182. package/dist/modules/sample-data/service.js.map +1 -0
  183. package/dist/modules/sample-data/tools.d.ts +4 -0
  184. package/dist/modules/sample-data/tools.d.ts.map +1 -0
  185. package/dist/modules/sample-data/tools.js +22 -0
  186. package/dist/modules/sample-data/tools.js.map +1 -0
  187. package/dist/modules/smtp-email/index.d.ts +1 -0
  188. package/dist/modules/smtp-email/index.d.ts.map +1 -1
  189. package/dist/modules/smtp-email/index.js +1 -0
  190. package/dist/modules/smtp-email/index.js.map +1 -1
  191. package/dist/modules/workspace/service.d.ts.map +1 -1
  192. package/dist/modules/workspace/service.js +10 -1
  193. package/dist/modules/workspace/service.js.map +1 -1
  194. package/dist/schema/index.d.ts +1 -0
  195. package/dist/schema/index.d.ts.map +1 -1
  196. package/dist/schema/index.js +1 -0
  197. package/dist/schema/index.js.map +1 -1
  198. package/dist/testing/app.d.ts +5 -1
  199. package/dist/testing/app.d.ts.map +1 -1
  200. package/dist/testing/app.js +2 -0
  201. package/dist/testing/app.js.map +1 -1
  202. package/migrations/0019_sharp_red_skull.sql +4 -0
  203. package/migrations/0020_early_nemesis.sql +2 -0
  204. package/migrations/0021_certain_maggott.sql +30 -0
  205. package/migrations/0022_stormy_manta.sql +1 -0
  206. package/migrations/0023_funny_maddog.sql +1 -0
  207. package/migrations/meta/0019_snapshot.json +5138 -0
  208. package/migrations/meta/0020_snapshot.json +5138 -0
  209. package/migrations/meta/0021_snapshot.json +5389 -0
  210. package/migrations/meta/0022_snapshot.json +5396 -0
  211. package/migrations/meta/0023_snapshot.json +5403 -0
  212. package/migrations/meta/_journal.json +35 -0
  213. package/package.json +3 -2
  214. package/src/app.ts +26 -0
  215. package/src/boot.ts +89 -0
  216. package/src/index.ts +8 -0
  217. package/src/lib/config.ts +9 -0
  218. package/src/lib/consumerEmailDomains.ts +62 -0
  219. package/src/lib/email.ts +7 -0
  220. package/src/lib/emailContent.ts +91 -0
  221. package/src/lib/emailDomainAutoLink.ts +165 -0
  222. package/src/lib/ids.ts +2 -0
  223. package/src/lib/kelpieConfigFile.ts +8 -0
  224. package/src/modules/attachedRecords.ts +9 -2
  225. package/src/modules/auth/preferences.ts +13 -1
  226. package/src/modules/auth/routes.ts +22 -0
  227. package/src/modules/auth/schema.ts +12 -2
  228. package/src/modules/auth/service.ts +37 -3
  229. package/src/modules/companies/repository.ts +3 -0
  230. package/src/modules/companies/routes.ts +13 -0
  231. package/src/modules/companies/schema.ts +5 -1
  232. package/src/modules/companies/service.ts +25 -0
  233. package/src/modules/core.ts +11 -0
  234. package/src/modules/email-domain-linker/index.ts +40 -0
  235. package/src/modules/email-domain-linker/routes.ts +40 -0
  236. package/src/modules/email-domain-linker/service.ts +122 -0
  237. package/src/modules/eventCatalogs.ts +36 -0
  238. package/src/modules/import-export/drafts.ts +27 -0
  239. package/src/modules/import-export/exportRows.ts +10 -1
  240. package/src/modules/import-export/plan.ts +107 -5
  241. package/src/modules/import-export/presets.ts +25 -0
  242. package/src/modules/import-export/repository.ts +35 -1
  243. package/src/modules/import-export/routes.ts +5 -0
  244. package/src/modules/import-export/schema.ts +13 -0
  245. package/src/modules/import-export/service.ts +70 -4
  246. package/src/modules/import-export/tools.ts +9 -0
  247. package/src/modules/import-export/writes.ts +116 -3
  248. package/src/modules/lists/events.ts +41 -0
  249. package/src/modules/lists/index.ts +41 -0
  250. package/src/modules/lists/repository.ts +313 -0
  251. package/src/modules/lists/routes.ts +247 -0
  252. package/src/modules/lists/schema.ts +85 -0
  253. package/src/modules/lists/service.ts +404 -0
  254. package/src/modules/lists/tools.ts +128 -0
  255. package/src/modules/people/repository.ts +26 -0
  256. package/src/modules/people/service.ts +20 -0
  257. package/src/modules/positions/routes.ts +4 -2
  258. package/src/modules/sample-data/fixture.ts +627 -0
  259. package/src/modules/sample-data/index.ts +50 -0
  260. package/src/modules/sample-data/routes.ts +49 -0
  261. package/src/modules/sample-data/service.ts +590 -0
  262. package/src/modules/sample-data/tools.ts +32 -0
  263. package/src/modules/smtp-email/index.ts +8 -1
  264. package/src/modules/workspace/service.ts +13 -1
  265. package/src/schema/index.ts +1 -0
  266. package/src/testing/app.ts +7 -1
@@ -174,6 +174,33 @@ export function personDraft(mapped: Readonly<Record<string, string>>): PersonDra
174
174
  })
175
175
  }
176
176
 
177
+ /**
178
+ * The company a People row's affiliation would create.
179
+ *
180
+ * Built from the row's `company_domain` and `company_name` cells, not the
181
+ * `name`/`domain` a Companies file uses. The name falls back to the raw domain
182
+ * so a create from a domain-only row is not a nameless company. The write lays
183
+ * `NEW_COMPANY_DEFAULTS` over this, the same as a Companies import.
184
+ */
185
+ export function affiliationCompanyDraft(mapped: Readonly<Record<string, string>>): CompanyDraft {
186
+ const domainRaw = text(mapped, 'company_domain')
187
+
188
+ return present<CompanyDraft>({
189
+ name: text(mapped, 'company_name') ?? domainRaw,
190
+ domain: domainRaw === undefined ? undefined : (normaliseDomain(domainRaw) ?? undefined),
191
+ industry: undefined,
192
+ description: undefined,
193
+ stage: undefined,
194
+ sizeBand: undefined,
195
+ hq: undefined,
196
+ website: undefined,
197
+ accountType: undefined,
198
+ icpFit: undefined,
199
+ summary: undefined,
200
+ tags: undefined,
201
+ })
202
+ }
203
+
177
204
  /**
178
205
  * `value` is required, so `moneyToCents` has already been checked by
179
206
  * `validateRow` and cannot be undefined here.
@@ -26,8 +26,17 @@ function joinList(values: readonly string[]): string {
26
26
  return values.join('|')
27
27
  }
28
28
 
29
+ /**
30
+ * The header row of an export and of a template.
31
+ *
32
+ * `importOnly` columns are left out: a People import may map `company_domain`,
33
+ * `company_name` and `title` to drive a Position, but a person holds many
34
+ * positions and none of them is a field on the person. Writing one on the export
35
+ * would be lossy, so the export and the template carry the round-trippable
36
+ * columns only, and each cell function matches this order one for one.
37
+ */
29
38
  export function headersFor(object: ImportObject): readonly string[] {
30
- return OBJECT_COLUMNS[object].map((column) => column.key)
39
+ return OBJECT_COLUMNS[object].filter((column) => column.importOnly !== true).map((column) => column.key)
31
40
  }
32
41
 
33
42
  export function companyCells(row: ExportCompanyRow): readonly string[] {
@@ -3,10 +3,11 @@ import type {
3
3
  ImportCounts,
4
4
  ImportObject,
5
5
  MatchKeyOption,
6
+ OnMissingCompany,
6
7
  } from '@kelpie/schemas'
7
8
 
8
9
  import { normaliseDomain, normaliseEmail } from '../../lib/normalisation.ts'
9
- import { companyDraft, dealFieldsDraft, personDraft } from './drafts.ts'
10
+ import { affiliationCompanyDraft, companyDraft, dealFieldsDraft, personDraft } from './drafts.ts'
10
11
  import type { CompanyDraft, DealFieldsDraft, PersonDraft } from './drafts.ts'
11
12
  import { buildMatchKey, splitList } from './mapping.ts'
12
13
  import { aliasedStageSlug } from './presets.ts'
@@ -29,6 +30,8 @@ export interface ImportLookups {
29
30
  readonly existing: ReadonlyMap<string, string>
30
31
  readonly personIdByEmail: ReadonlyMap<string, string>
31
32
  readonly companyIdByDomain: ReadonlyMap<string, string>
33
+ /** Company id by its folded name, for a People affiliation matched by name. */
34
+ readonly companyIdByName: ReadonlyMap<string, string>
32
35
  /** Workspace member id by the address of the user behind it. */
33
36
  readonly memberIdByEmail: ReadonlyMap<string, string>
34
37
  /** Deal stage id, keyed by both its slug and its folded label. */
@@ -39,13 +42,28 @@ export interface PlanContext {
39
42
  readonly object: ImportObject
40
43
  readonly matchKey: MatchKeyOption
41
44
  readonly conflictMode: ImportConflictMode
45
+ /** What a People row does with an absent company. Ignored by the other objects. */
46
+ readonly onMissingCompany: OnMissingCompany
42
47
  readonly lookups: ImportLookups
43
48
  }
44
49
 
50
+ /**
51
+ * A company affiliation a People row asks for: the position to write, and
52
+ * whether the company must be created before it can be linked.
53
+ */
54
+ export type PlannedAffiliation =
55
+ | { readonly kind: 'link'; readonly companyId: string; readonly title: string }
56
+ | { readonly kind: 'create'; readonly company: CompanyDraft; readonly title: string }
57
+
45
58
  /** The values a write applies, once every reference in the row has been resolved. */
46
59
  export type ImportWrite =
47
60
  | { readonly object: 'companies'; readonly draft: CompanyDraft }
48
- | { readonly object: 'people'; readonly draft: PersonDraft }
61
+ | {
62
+ readonly object: 'people'
63
+ readonly draft: PersonDraft
64
+ /** A position to upsert alongside the person, when the row named a company and a title. */
65
+ readonly affiliation?: PlannedAffiliation
66
+ }
49
67
  | {
50
68
  readonly object: 'positions'
51
69
  readonly personId: string
@@ -78,12 +96,19 @@ export type RowPlan =
78
96
  * reaches the row the earlier one has been written, so it resolves to an id.
79
97
  */
80
98
  | { readonly action: 'skip'; readonly key: string; readonly targetId: string | null }
81
- | { readonly action: 'create'; readonly key: string; readonly write: ImportWrite }
99
+ | {
100
+ readonly action: 'create'
101
+ readonly key: string
102
+ readonly write: ImportWrite
103
+ /** Non-fatal notes about the applied row, e.g. a People affiliation left unlinked. */
104
+ readonly warnings?: readonly StoredRowError[]
105
+ }
82
106
  | {
83
107
  readonly action: 'update'
84
108
  readonly key: string
85
109
  readonly targetId: string | null
86
110
  readonly write: ImportWrite
111
+ readonly warnings?: readonly StoredRowError[]
87
112
  }
88
113
 
89
114
  export interface PlannedRow {
@@ -233,6 +258,80 @@ function resolveWrite(context: PlanContext, mapped: Readonly<Record<string, stri
233
258
  }
234
259
  }
235
260
 
261
+ /**
262
+ * The company affiliation a People row asks for, if any, and any note about it.
263
+ *
264
+ * A row states an affiliation only when it carries both a title and a company
265
+ * identity, so a blank optional cell still says nothing, the additive rule every
266
+ * column follows. The company is matched by domain when the row has one and by
267
+ * name otherwise. A named company that is not here follows `on_missing_company`:
268
+ * `create` invents it from the row, `skip` imports the person alone and returns
269
+ * a warning.
270
+ */
271
+ function planAffiliation(
272
+ context: PlanContext,
273
+ mapped: Readonly<Record<string, string>>,
274
+ ): { readonly affiliation?: PlannedAffiliation; readonly warnings: readonly StoredRowError[] } {
275
+ const title = (mapped.title ?? '').trim()
276
+ const domainRaw = (mapped.company_domain ?? '').trim()
277
+ const nameRaw = (mapped.company_name ?? '').trim()
278
+
279
+ if (title.length === 0 || (domainRaw.length === 0 && nameRaw.length === 0)) {
280
+ return { warnings: [] }
281
+ }
282
+
283
+ const domain = normaliseDomain(domainRaw)
284
+ const companyId =
285
+ domainRaw.length > 0
286
+ ? domain === null
287
+ ? undefined
288
+ : context.lookups.companyIdByDomain.get(domain)
289
+ : context.lookups.companyIdByName.get(nameRaw.toLowerCase())
290
+
291
+ if (companyId !== undefined) {
292
+ return { affiliation: { kind: 'link', companyId, title }, warnings: [] }
293
+ }
294
+
295
+ if (context.onMissingCompany === 'create') {
296
+ return {
297
+ affiliation: { kind: 'create', company: affiliationCompanyDraft(mapped), title },
298
+ warnings: [],
299
+ }
300
+ }
301
+
302
+ const named = domainRaw.length > 0 ? domainRaw : nameRaw
303
+
304
+ return {
305
+ warnings: [
306
+ {
307
+ field: domainRaw.length > 0 ? 'company_domain' : 'company_name',
308
+ message: `No company here matches "${named}", so the person imported without a position`,
309
+ },
310
+ ],
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Folds a People row's affiliation into its create or update plan.
316
+ *
317
+ * A no-op for every other object, and for a person the row said nothing about a
318
+ * company for. The affiliation rides on the write; any note rides on the plan.
319
+ */
320
+ function withAffiliation(
321
+ context: PlanContext,
322
+ mapped: Readonly<Record<string, string>>,
323
+ plan: Extract<RowPlan, { action: 'create' } | { action: 'update' }>,
324
+ ): RowPlan {
325
+ if (context.object !== 'people' || plan.write.object !== 'people') {
326
+ return plan
327
+ }
328
+
329
+ const { affiliation, warnings } = planAffiliation(context, mapped)
330
+ const write = affiliation === undefined ? plan.write : { ...plan.write, affiliation }
331
+
332
+ return { ...plan, write, ...(warnings.length > 0 ? { warnings } : {}) }
333
+ }
334
+
236
335
  /**
237
336
  * @param mapped The row's cells by Kelpie column, from `mapRow`.
238
337
  * @returns What this row would do. `create` when its key matches nothing;
@@ -265,15 +364,18 @@ export function planRow(context: PlanContext, mapped: Readonly<Record<string, st
265
364
  const targetId = context.lookups.existing.get(key)
266
365
 
267
366
  if (targetId === undefined) {
268
- return { action: 'create', key, write }
367
+ return withAffiliation(context, mapped, { action: 'create', key, write })
269
368
  }
270
369
 
271
370
  // An empty string is the placeholder an in-file match carries: the record it
272
371
  // matched has not been written yet, so there is no id to report.
273
372
  const resolved = targetId.length === 0 ? null : targetId
274
373
 
374
+ // A skipped row is left entirely alone, affiliation included. A caller who
375
+ // wants an existing person's position updated runs the job in `update` mode,
376
+ // which is where the rename-in-place lives.
275
377
  return context.conflictMode === 'update'
276
- ? { action: 'update', key, targetId: resolved, write }
378
+ ? withAffiliation(context, mapped, { action: 'update', key, targetId: resolved, write })
277
379
  : { action: 'skip', key, targetId: resolved }
278
380
  }
279
381
 
@@ -31,6 +31,8 @@ export const SOURCE_PRESETS: Readonly<Record<ImportSource, SourcePreset>> = {
31
31
  email: 'Email',
32
32
  phones: 'Phone Number',
33
33
  location: 'City',
34
+ company_domain: 'Company Domain Name',
35
+ title: 'Job Title',
34
36
  },
35
37
  positions: {
36
38
  person_email: 'Email',
@@ -60,6 +62,8 @@ export const SOURCE_PRESETS: Readonly<Record<ImportSource, SourcePreset>> = {
60
62
  email: 'Email',
61
63
  phones: 'Phone',
62
64
  location: 'Mailing City',
65
+ company_name: 'Account Name',
66
+ title: 'Title',
63
67
  },
64
68
  positions: {
65
69
  person_email: 'Email',
@@ -76,6 +80,27 @@ export const SOURCE_PRESETS: Readonly<Record<ImportSource, SourcePreset>> = {
76
80
  external_id: 'Opportunity ID',
77
81
  },
78
82
  },
83
+ attio: {
84
+ // Attio's export writes one column per attribute, and a linked or nested
85
+ // attribute as `Parent > Child`. Only Companies and People are mapped: the
86
+ // sample export carries no Deals or Positions file, and an Attio People row
87
+ // names its company but not the company's domain, so a Position could not be
88
+ // keyed from it. Those objects fall back to `custom`.
89
+ companies: {
90
+ name: 'Record',
91
+ domain: 'Domains',
92
+ industry: 'Categories',
93
+ description: 'Description',
94
+ hq: 'Primary location > Country',
95
+ },
96
+ people: {
97
+ name: 'Record',
98
+ email: 'Email addresses',
99
+ location: 'Primary location > Country',
100
+ company_name: 'Company > Name',
101
+ title: 'Job title',
102
+ },
103
+ },
79
104
  }
80
105
 
81
106
  /**
@@ -175,6 +175,8 @@ export interface RowOutcome {
175
175
  readonly rowNumber: number
176
176
  readonly action: SettledRowAction
177
177
  readonly errors: readonly { readonly field: string; readonly message: string }[]
178
+ /** Non-fatal notes about a row that was applied anyway, e.g. an unlinked position. */
179
+ readonly warnings: readonly { readonly field: string; readonly message: string }[]
178
180
  }
179
181
 
180
182
  /**
@@ -202,12 +204,18 @@ export async function recordRowOutcome(
202
204
  values,
203
205
  action: outcome.action,
204
206
  errors: outcome.errors,
207
+ warnings: outcome.warnings,
205
208
  createdAt: now,
206
209
  updatedAt: now,
207
210
  })
208
211
  .onConflictDoUpdate({
209
212
  target: [importJobRows.jobId, importJobRows.rowNumber],
210
- set: { action: outcome.action, errors: outcome.errors, updatedAt: now },
213
+ set: {
214
+ action: outcome.action,
215
+ errors: outcome.errors,
216
+ warnings: outcome.warnings,
217
+ updatedAt: now,
218
+ },
211
219
  })
212
220
  }
213
221
 
@@ -381,6 +389,32 @@ export function findCompanyIdsByDomain(
381
389
  )
382
390
  }
383
391
 
392
+ /**
393
+ * Company ids by folded name, for a People affiliation matched on a company
394
+ * name rather than a domain.
395
+ *
396
+ * `lower(name) in (…)` rather than `ilike`, so a `%` inside a caller-supplied
397
+ * name is a literal, not a wildcard. The same rule `findCompanyKeys` follows.
398
+ * The caller passes names already lowercased.
399
+ */
400
+ export function findCompanyIdsByName(
401
+ db: Queryable,
402
+ workspaceId: string,
403
+ names: readonly string[],
404
+ ): Promise<{ name: string; id: string }[]> {
405
+ return overChunks(names, (chunk) =>
406
+ db
407
+ .select({ id: companies.id, name: companies.name })
408
+ .from(companies)
409
+ .where(
410
+ and(
411
+ eq(companies.workspaceId, workspaceId),
412
+ inArray(sql<string>`lower(${companies.name})`, [...chunk]),
413
+ ),
414
+ ),
415
+ )
416
+ }
417
+
384
418
  /** Every member of the workspace, by the address of the user behind them. */
385
419
  export function listMemberEmails(
386
420
  db: Queryable,
@@ -3,6 +3,7 @@ import {
3
3
  IMPORT_OBJECTS,
4
4
  IMPORT_SOURCES,
5
5
  MAX_IMPORT_FILE_BYTES,
6
+ ON_MISSING_COMPANY,
6
7
  defaultMatchKeyId,
7
8
  } from '@kelpie/schemas'
8
9
  import type { ImportColumnMap, ImportObject } from '@kelpie/schemas'
@@ -42,6 +43,7 @@ const createJobForm = z.object({
42
43
  source: z.enum(IMPORT_SOURCES),
43
44
  object: z.enum(IMPORT_OBJECTS),
44
45
  conflict_mode: z.enum(IMPORT_CONFLICT_MODES).default('skip'),
46
+ on_missing_company: z.enum(ON_MISSING_COMPANY).default('skip'),
45
47
  match_key: z.string().min(1).optional(),
46
48
  column_map: z.string().optional(),
47
49
  dry_run: z.enum(['true', 'false']).default('true'),
@@ -58,12 +60,14 @@ export function importJobResponse(job: ImportJobView): Record<string, unknown> {
58
60
  object: job.object,
59
61
  status: job.status,
60
62
  conflict_mode: job.conflictMode,
63
+ on_missing_company: job.onMissingCompany,
61
64
  match_key: job.matchKey,
62
65
  column_map: job.columnMap,
63
66
  source_headers: job.sourceHeaders,
64
67
  file_name: job.fileName,
65
68
  counts: job.counts,
66
69
  errors: job.errors,
70
+ warnings: job.warnings,
67
71
  preview: job.preview,
68
72
  created_at: job.createdAt.toISOString(),
69
73
  updated_at: job.updatedAt.toISOString(),
@@ -230,6 +234,7 @@ export function mountImportExportRoutes(
230
234
  source: parsed.data.source,
231
235
  object: parsed.data.object,
232
236
  conflictMode: parsed.data.conflict_mode,
237
+ onMissingCompany: parsed.data.on_missing_company,
233
238
  matchKeyId: parsed.data.match_key ?? defaultMatchKeyId(parsed.data.object),
234
239
  columnMap: readColumnMap(parsed.data.column_map),
235
240
  fileName: file.name.length === 0 ? null : file.name,
@@ -4,6 +4,7 @@ import {
4
4
  IMPORT_OBJECTS,
5
5
  IMPORT_ROW_ACTIONS,
6
6
  IMPORT_SOURCES,
7
+ ON_MISSING_COMPANY,
7
8
  } from '@kelpie/schemas'
8
9
  import type {
9
10
  ImportColumnMap,
@@ -68,12 +69,21 @@ export const importJobs = pgTable(
68
69
  object: text('object').notNull(),
69
70
  status: text('status').notNull(),
70
71
  conflictMode: text('conflict_mode').notNull(),
72
+ /**
73
+ * What a People import does with a row naming a company that is not here
74
+ * yet: `skip` leaves the affiliation unlinked and warns, `create` invents
75
+ * the company. Defaulted so a job predating this reads as `skip`, which is
76
+ * the behaviour every other object already has.
77
+ */
78
+ onMissingCompany: text('on_missing_company').notNull().default('skip'),
71
79
  matchKey: text('match_key').notNull(),
72
80
  columnMap: jsonb('column_map').$type<ImportColumnMap>().notNull().default({}),
73
81
  sourceHeaders: jsonb('source_headers').$type<readonly string[]>().notNull().default([]),
74
82
  counts: jsonb('counts').$type<ImportCounts>().notNull(),
75
83
  /** The first `IMPORT_REPORTED_ERRORS` failing rows. `counts.error` is the true number. */
76
84
  errors: jsonb('errors').$type<readonly ImportRowError[]>().notNull().default([]),
85
+ /** The first `IMPORT_REPORTED_ERRORS` non-fatal notes, e.g. a person imported with the position skipped. */
86
+ warnings: jsonb('warnings').$type<readonly ImportRowError[]>().notNull().default([]),
77
87
  /** The first `IMPORT_PREVIEW_ROWS` rows, mapped as Kelpie read them. */
78
88
  preview: jsonb('preview').$type<readonly ImportPreviewRow[]>().notNull().default([]),
79
89
  /**
@@ -97,6 +107,7 @@ export const importJobs = pgTable(
97
107
  checkOneOf('import_jobs_source_check', table.source, IMPORT_SOURCES),
98
108
  checkOneOf('import_jobs_object_check', table.object, IMPORT_OBJECTS),
99
109
  checkOneOf('import_jobs_conflict_mode_check', table.conflictMode, IMPORT_CONFLICT_MODES),
110
+ checkOneOf('import_jobs_on_missing_company_check', table.onMissingCompany, ON_MISSING_COMPANY),
100
111
  checkOneOf('import_jobs_status_check', table.status, IMPORT_JOB_STATUSES),
101
112
  ],
102
113
  )
@@ -128,6 +139,8 @@ export const importJobRows = pgTable(
128
139
  values: jsonb('values').$type<Readonly<Record<string, string>>>().notNull().default({}),
129
140
  action: text('action').notNull(),
130
141
  errors: jsonb('errors').$type<readonly StoredRowError[]>().notNull().default([]),
142
+ /** Non-fatal notes about a row that was applied anyway, e.g. an unlinked position. */
143
+ warnings: jsonb('warnings').$type<readonly StoredRowError[]>().notNull().default([]),
131
144
  createdAt: createdAt(),
132
145
  updatedAt: updatedAt(),
133
146
  },
@@ -17,6 +17,7 @@ import type {
17
17
  ImportRowError,
18
18
  ImportSource,
19
19
  MatchKeyOption,
20
+ OnMissingCompany,
20
21
  } from '@kelpie/schemas'
21
22
 
22
23
  import type { Database } from '../../lib/database.ts'
@@ -142,12 +143,14 @@ export interface ImportJobView {
142
143
  readonly object: ImportObject
143
144
  readonly status: string
144
145
  readonly conflictMode: ImportConflictMode
146
+ readonly onMissingCompany: OnMissingCompany
145
147
  readonly matchKey: string
146
148
  readonly columnMap: ImportColumnMap
147
149
  readonly sourceHeaders: readonly string[]
148
150
  readonly fileName: string | null
149
151
  readonly counts: ImportCounts
150
152
  readonly errors: readonly ImportRowError[]
153
+ readonly warnings: readonly ImportRowError[]
151
154
  readonly preview: readonly ImportPreviewRow[]
152
155
  readonly createdAt: Date
153
156
  readonly updatedAt: Date
@@ -157,6 +160,7 @@ export interface CreateImportJobInput {
157
160
  readonly source: ImportSource
158
161
  readonly object: ImportObject
159
162
  readonly conflictMode: ImportConflictMode
163
+ readonly onMissingCompany: OnMissingCompany
160
164
  readonly matchKeyId: string
161
165
  /** Absent means "derive one from the source preset and the file's own headers". */
162
166
  readonly columnMap: ImportColumnMap | undefined
@@ -213,6 +217,7 @@ function toView(job: ImportJobRecord): ImportJobView {
213
217
  source: rest.source as ImportSource,
214
218
  object: rest.object as ImportObject,
215
219
  conflictMode: rest.conflictMode as ImportConflictMode,
220
+ onMissingCompany: rest.onMissingCompany as OnMissingCompany,
216
221
  }
217
222
  }
218
223
 
@@ -358,11 +363,37 @@ export function createImportExportService(
358
363
  ): Promise<ImportLookups> {
359
364
  const existing = await findExistingKeys(db, workspaceId, object, matchKey, rows)
360
365
 
361
- if (object === 'companies' || object === 'people') {
366
+ if (object === 'companies') {
362
367
  return {
363
368
  existing,
364
369
  personIdByEmail: new Map(),
365
370
  companyIdByDomain: new Map(),
371
+ companyIdByName: new Map(),
372
+ memberIdByEmail: new Map(),
373
+ dealStageIdByName: new Map(),
374
+ }
375
+ }
376
+
377
+ // People only need companies, and only when a row carries an affiliation.
378
+ // Both a domain map and a name map, because a row may identify its company
379
+ // either way.
380
+ if (object === 'people') {
381
+ const domains = distinct(rows.map((row) => normaliseDomain(row.mapped.company_domain ?? '')))
382
+ const names = distinct(
383
+ rows.map((row) => (row.mapped.company_name ?? '').trim().toLowerCase() || null),
384
+ )
385
+ const [byDomain, byName] = await Promise.all([
386
+ repository.findCompanyIdsByDomain(db, workspaceId, domains),
387
+ repository.findCompanyIdsByName(db, workspaceId, names),
388
+ ])
389
+
390
+ return {
391
+ existing,
392
+ personIdByEmail: new Map(),
393
+ companyIdByDomain: new Map(
394
+ byDomain.flatMap((row) => (row.domain === null ? [] : [[row.domain, row.id] as const])),
395
+ ),
396
+ companyIdByName: new Map(byName.map((row) => [row.name.toLowerCase(), row.id] as const)),
366
397
  memberIdByEmail: new Map(),
367
398
  dealStageIdByName: new Map(),
368
399
  }
@@ -389,6 +420,7 @@ export function createImportExportService(
389
420
  companyIdByDomain: new Map(
390
421
  companyRows.flatMap((row) => (row.domain === null ? [] : [[row.domain, row.id] as const])),
391
422
  ),
423
+ companyIdByName: new Map(),
392
424
  memberIdByEmail: new Map(),
393
425
  dealStageIdByName: new Map(),
394
426
  }
@@ -416,6 +448,7 @@ export function createImportExportService(
416
448
  companyIdByDomain: new Map(
417
449
  companyRows.flatMap((row) => (row.domain === null ? [] : [[row.domain, row.id] as const])),
418
450
  ),
451
+ companyIdByName: new Map(),
419
452
  memberIdByEmail: new Map(members.map((member) => [member.email.toLowerCase(), member.id])),
420
453
  dealStageIdByName: stagesByName,
421
454
  }
@@ -426,6 +459,7 @@ export function createImportExportService(
426
459
  object: job.object as ImportObject,
427
460
  matchKey: requireMatchKey(job.object as ImportObject, job.matchKey),
428
461
  conflictMode: job.conflictMode as ImportConflictMode,
462
+ onMissingCompany: job.onMissingCompany as OnMissingCompany,
429
463
  lookups,
430
464
  }
431
465
  }
@@ -469,10 +503,13 @@ export function createImportExportService(
469
503
  }
470
504
 
471
505
  function toOutcome(planned: PlannedRow): repository.RowOutcome {
506
+ const { plan } = planned
507
+
472
508
  return {
473
509
  rowNumber: planned.row,
474
- action: planned.plan.action,
475
- errors: planned.plan.action === 'error' ? planned.plan.errors : [],
510
+ action: plan.action,
511
+ errors: plan.action === 'error' ? plan.errors : [],
512
+ warnings: plan.action === 'create' || plan.action === 'update' ? (plan.warnings ?? []) : [],
476
513
  }
477
514
  }
478
515
 
@@ -486,7 +523,11 @@ export function createImportExportService(
486
523
  function reportOf(
487
524
  outcomes: readonly repository.RowOutcome[],
488
525
  mapped: readonly MappedRow[],
489
- ): { errors: readonly ImportRowError[]; preview: readonly ImportPreviewRow[] } {
526
+ ): {
527
+ errors: readonly ImportRowError[]
528
+ warnings: readonly ImportRowError[]
529
+ preview: readonly ImportPreviewRow[]
530
+ } {
490
531
  const byRow = new Map(mapped.map((row) => [row.row, row.mapped]))
491
532
 
492
533
  return {
@@ -500,6 +541,16 @@ export function createImportExportService(
500
541
  message: problem.message,
501
542
  })),
502
543
  ),
544
+ warnings: outcomes
545
+ .filter((outcome) => outcome.warnings.length > 0)
546
+ .slice(0, IMPORT_REPORTED_ERRORS)
547
+ .flatMap((outcome) =>
548
+ outcome.warnings.map((problem) => ({
549
+ row: outcome.rowNumber,
550
+ field: problem.field,
551
+ message: problem.message,
552
+ })),
553
+ ),
503
554
  preview: outcomes.slice(0, IMPORT_PREVIEW_ROWS).map((outcome) => ({
504
555
  row: outcome.rowNumber,
505
556
  action: outcome.action,
@@ -601,6 +652,8 @@ export function createImportExportService(
601
652
  rowNumber: line.number,
602
653
  action: plan.action,
603
654
  errors: plan.action === 'error' ? plan.errors : [],
655
+ warnings:
656
+ plan.action === 'create' || plan.action === 'update' ? (plan.warnings ?? []) : [],
604
657
  }
605
658
 
606
659
  if (plan.action !== 'error' && plan.action !== 'skip') {
@@ -629,6 +682,16 @@ export function createImportExportService(
629
682
  written.changedFields,
630
683
  )
631
684
  }
685
+
686
+ // A People affiliation makes a Position, and maybe a Company. Each is its
687
+ // own record with its own module event, whatever the person row did.
688
+ for (const side of written.sideRecords ?? []) {
689
+ if (side.created) {
690
+ emitImportedRecordCreated(events, side.objectType, side.recordId)
691
+ } else if (side.changedFields.length > 0) {
692
+ emitImportedRecordUpdated(events, side.objectType, side.recordId, side.changedFields)
693
+ }
694
+ }
632
695
  }
633
696
 
634
697
  // In the same transaction as the record it wrote, so the account of the
@@ -682,6 +745,7 @@ export function createImportExportService(
682
745
  rowNumber: row.row,
683
746
  action: 'error' as const,
684
747
  errors: [{ field: '', message: describeThrown(error) }],
748
+ warnings: [],
685
749
  }
686
750
 
687
751
  // Its own statement: the transaction that would have carried this one
@@ -786,11 +850,13 @@ export function createImportExportService(
786
850
  object: input.object,
787
851
  status: 'pending',
788
852
  conflictMode: input.conflictMode,
853
+ onMissingCompany: input.onMissingCompany,
789
854
  matchKey: matchKey.id,
790
855
  columnMap,
791
856
  sourceHeaders: parsed.headers,
792
857
  counts: { ...EMPTY_COUNTS, total: parsed.rows.length },
793
858
  errors: [],
859
+ warnings: [],
794
860
  preview: [],
795
861
  fileSha256: fileDigest(input.csv),
796
862
  fileName: input.fileName,
@@ -3,6 +3,7 @@ import {
3
3
  IMPORT_OBJECTS,
4
4
  IMPORT_SOURCES,
5
5
  MAX_IMPORT_FILE_BYTES,
6
+ ON_MISSING_COMPANY,
6
7
  defaultMatchKeyId,
7
8
  } from '@kelpie/schemas'
8
9
  import { z } from 'zod'
@@ -59,6 +60,13 @@ const previewArgs = z.strictObject({
59
60
  .enum(IMPORT_CONFLICT_MODES)
60
61
  .default('skip')
61
62
  .describe('What to do with a row that matches an existing record.'),
63
+ on_missing_company: z
64
+ .enum(ON_MISSING_COMPANY)
65
+ .default('skip')
66
+ .describe(
67
+ 'People import only: what to do when a row names a company that is not here yet. ' +
68
+ "'skip' imports the person and leaves the position unlinked; 'create' invents the company.",
69
+ ),
62
70
  match_key: z.string().min(1).optional().describe('Which field decides that match.'),
63
71
  column_map: columnMapArg.optional(),
64
72
  file_name: z.string().min(1).nullable().default(null).describe('Recorded on the job, for the log.'),
@@ -124,6 +132,7 @@ export function registerImportExportTools(mcp: McpToolRegistry, service: ImportE
124
132
  source: args.source,
125
133
  object: args.object,
126
134
  conflictMode: args.conflict_mode,
135
+ onMissingCompany: args.on_missing_company,
127
136
  matchKeyId: args.match_key ?? defaultMatchKeyId(args.object),
128
137
  columnMap: args.column_map,
129
138
  fileName: args.file_name,