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