@kelpie/server 0.6.0 → 0.7.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/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/eventCatalogs.d.ts +35 -0
- package/dist/modules/eventCatalogs.d.ts.map +1 -0
- package/dist/modules/eventCatalogs.js +35 -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/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/meta/0019_snapshot.json +5138 -0
- package/migrations/meta/0020_snapshot.json +5138 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +2 -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/kelpieConfigFile.ts +8 -0
- package/src/modules/eventCatalogs.ts +35 -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/testing/app.ts +7 -1
|
@@ -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,
|
|
@@ -11,7 +11,7 @@ import * as dealRepository from '../deals/repository.ts'
|
|
|
11
11
|
import * as peopleRepository from '../people/repository.ts'
|
|
12
12
|
import * as positionRepository from '../positions/repository.ts'
|
|
13
13
|
import { IMPORT_DEAL_CURRENCY, NEW_COMPANY_DEFAULTS, NEW_PERSON_DEFAULTS } from './drafts.ts'
|
|
14
|
-
import type { ImportWrite, RowPlan } from './plan.ts'
|
|
14
|
+
import type { ImportWrite, PlannedAffiliation, RowPlan } from './plan.ts'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Applying one planned row: the insert or update, and the timeline entry that
|
|
@@ -34,6 +34,21 @@ export interface WriteDependencies {
|
|
|
34
34
|
readonly sourceName: string
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A record a row wrote besides its primary one.
|
|
39
|
+
*
|
|
40
|
+
* A People row with an affiliation makes a Position, and under
|
|
41
|
+
* `on_missing_company: create` a Company as well. Each needs its own module
|
|
42
|
+
* event, which the service emits from these.
|
|
43
|
+
*/
|
|
44
|
+
export interface SideRecord {
|
|
45
|
+
readonly objectType: RecordObjectType
|
|
46
|
+
readonly recordId: string
|
|
47
|
+
readonly created: boolean
|
|
48
|
+
/** The columns an update moved. Empty on a create. */
|
|
49
|
+
readonly changedFields: readonly string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
export interface WriteOutcome {
|
|
38
53
|
readonly recordId: string
|
|
39
54
|
readonly objectType: RecordObjectType
|
|
@@ -43,6 +58,8 @@ export interface WriteOutcome {
|
|
|
43
58
|
* nothing at all.
|
|
44
59
|
*/
|
|
45
60
|
readonly changedFields: readonly string[]
|
|
61
|
+
/** Records the row wrote besides its primary one: a People affiliation's company and position. */
|
|
62
|
+
readonly sideRecords?: readonly SideRecord[]
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
/** A create or update plan. `skip` and `error` never reach here. */
|
|
@@ -102,6 +119,92 @@ async function writeCompany(
|
|
|
102
119
|
return { recordId: id, objectType: 'company', changedFields }
|
|
103
120
|
}
|
|
104
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Writes the position a People row named, and the company it belongs to when the
|
|
124
|
+
* job was told to create one.
|
|
125
|
+
*
|
|
126
|
+
* The company is created before the position so the link has something to point
|
|
127
|
+
* at. The position is renamed in place when the person already holds one at that
|
|
128
|
+
* company, and created otherwise, which is the two-column-key behaviour the
|
|
129
|
+
* People affiliation chose. Files the `created Company` and `linked to company`
|
|
130
|
+
* activities as it goes, the same sentences the Companies and Positions imports
|
|
131
|
+
* write.
|
|
132
|
+
*/
|
|
133
|
+
async function applyAffiliation(
|
|
134
|
+
dependencies: WriteDependencies,
|
|
135
|
+
personId: string,
|
|
136
|
+
affiliation: PlannedAffiliation,
|
|
137
|
+
): Promise<readonly SideRecord[]> {
|
|
138
|
+
const { tx, workspaceId, actor, sourceName } = dependencies
|
|
139
|
+
const sideRecords: SideRecord[] = []
|
|
140
|
+
|
|
141
|
+
const companyId = await (async (): Promise<string> => {
|
|
142
|
+
if (affiliation.kind === 'link') {
|
|
143
|
+
return affiliation.companyId
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const created = await companyRepository.insertCompany(tx, {
|
|
147
|
+
...NEW_COMPANY_DEFAULTS,
|
|
148
|
+
...affiliation.company,
|
|
149
|
+
tags: affiliation.company.tags === undefined ? undefined : [...affiliation.company.tags],
|
|
150
|
+
id: dependencies.createId('company'),
|
|
151
|
+
workspaceId,
|
|
152
|
+
name: affiliation.company.name ?? '',
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
sideRecords.push({ objectType: 'company', recordId: created.id, created: true, changedFields: [] })
|
|
156
|
+
await dependencies.recordActivity(tx, workspaceId, actor, {
|
|
157
|
+
targetType: 'company',
|
|
158
|
+
targetId: created.id,
|
|
159
|
+
kind: 'created',
|
|
160
|
+
...describeCreationVia('Company', sourceName),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return created.id
|
|
164
|
+
})()
|
|
165
|
+
|
|
166
|
+
const held = await positionRepository.listPositionsAt(tx, workspaceId, personId, companyId)
|
|
167
|
+
const current = held[0]
|
|
168
|
+
|
|
169
|
+
if (current === undefined) {
|
|
170
|
+
const created = await positionRepository.insertPosition(tx, {
|
|
171
|
+
id: dependencies.createId('position'),
|
|
172
|
+
workspaceId,
|
|
173
|
+
personId,
|
|
174
|
+
companyId,
|
|
175
|
+
title: affiliation.title,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
sideRecords.push({ objectType: 'position', recordId: created.id, created: true, changedFields: [] })
|
|
179
|
+
|
|
180
|
+
for (const end of [
|
|
181
|
+
{ targetType: 'person', targetId: personId, related: 'company' },
|
|
182
|
+
{ targetType: 'company', targetId: companyId, related: 'person' },
|
|
183
|
+
] as const) {
|
|
184
|
+
await dependencies.recordActivity(tx, workspaceId, actor, {
|
|
185
|
+
targetType: end.targetType,
|
|
186
|
+
targetId: end.targetId,
|
|
187
|
+
kind: 'linked',
|
|
188
|
+
...describeLink(end.related, sourceName),
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return sideRecords
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const changedFields = changedKeys(current, { title: affiliation.title })
|
|
196
|
+
|
|
197
|
+
if (changedFields.length > 0) {
|
|
198
|
+
await positionRepository.updatePosition(tx, workspaceId, current.id, {
|
|
199
|
+
title: affiliation.title,
|
|
200
|
+
updatedAt: dependencies.now,
|
|
201
|
+
})
|
|
202
|
+
sideRecords.push({ objectType: 'position', recordId: current.id, created: false, changedFields })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return sideRecords
|
|
206
|
+
}
|
|
207
|
+
|
|
105
208
|
async function writePerson(
|
|
106
209
|
dependencies: WriteDependencies,
|
|
107
210
|
plan: WritablePlan,
|
|
@@ -120,7 +223,12 @@ async function writePerson(
|
|
|
120
223
|
name: write.draft.name ?? '',
|
|
121
224
|
})
|
|
122
225
|
|
|
123
|
-
|
|
226
|
+
const sideRecords =
|
|
227
|
+
write.affiliation === undefined
|
|
228
|
+
? []
|
|
229
|
+
: await applyAffiliation(dependencies, created.id, write.affiliation)
|
|
230
|
+
|
|
231
|
+
return { recordId: created.id, objectType: 'person', changedFields: [], sideRecords }
|
|
124
232
|
}
|
|
125
233
|
|
|
126
234
|
const id = requireTarget(plan)
|
|
@@ -144,7 +252,12 @@ async function writePerson(
|
|
|
144
252
|
})
|
|
145
253
|
}
|
|
146
254
|
|
|
147
|
-
|
|
255
|
+
const sideRecords =
|
|
256
|
+
write.affiliation === undefined
|
|
257
|
+
? []
|
|
258
|
+
: await applyAffiliation(dependencies, id, write.affiliation)
|
|
259
|
+
|
|
260
|
+
return { recordId: id, objectType: 'person', changedFields, sideRecords }
|
|
148
261
|
}
|
|
149
262
|
|
|
150
263
|
/**
|
package/src/testing/app.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Context, Hono } from 'hono'
|
|
|
3
3
|
import { createApp } from '../app.ts'
|
|
4
4
|
import type { AppBindings } from '../app.ts'
|
|
5
5
|
import type { Actor } from '../lib/actor.ts'
|
|
6
|
-
import type { Environment } from '../lib/config.ts'
|
|
6
|
+
import type { Environment, RuntimeMode } from '../lib/config.ts'
|
|
7
7
|
import type { DatabaseProbe } from '../lib/database.ts'
|
|
8
8
|
import { createCaptureTransport, createLogger } from '../lib/logger.ts'
|
|
9
9
|
import { rateLimitConfigFrom, rateLimitConfigSchema } from '../lib/rateLimit.ts'
|
|
@@ -57,6 +57,10 @@ export interface TestAppOptions {
|
|
|
57
57
|
readonly rateLimit?: RateLimitConfig
|
|
58
58
|
/** Defaults to reading `X-Forwarded-For`, so a test can simulate distinct callers. */
|
|
59
59
|
readonly resolveClientIp?: (context: Context) => string
|
|
60
|
+
/** Reported through `GET /v1/public/config`. Defaults to `'production'`. */
|
|
61
|
+
readonly runtimeMode?: RuntimeMode
|
|
62
|
+
/** Reported through `GET /v1/public/config`. Defaults to unset. */
|
|
63
|
+
readonly siteName?: string
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export interface TestApp {
|
|
@@ -102,6 +106,8 @@ export async function createTestApp(options: TestAppOptions = {}): Promise<TestA
|
|
|
102
106
|
createId: services.createId,
|
|
103
107
|
rateLimit: options.rateLimit ?? DEFAULT_TEST_RATE_LIMIT,
|
|
104
108
|
resolveClientIp: options.resolveClientIp ?? testClientIp,
|
|
109
|
+
...(options.runtimeMode === undefined ? {} : { runtimeMode: options.runtimeMode }),
|
|
110
|
+
...(options.siteName === undefined ? {} : { siteName: options.siteName }),
|
|
105
111
|
})
|
|
106
112
|
|
|
107
113
|
return { app, contributions, logLines, services }
|