Files
pn-new-crm/src/lib/import/classify.ts

109 lines
3.4 KiB
TypeScript
Raw Normal View History

/**
* Row classification the heart of both the dry-run preview and the commit.
*
* For one mapped row: validate (required + per-field zod) resolve FKs
* dedup by natural key resolve to an outcome under the chosen conflict
* policy. Pure with respect to writes; the commit step acts on the outcome.
*/
import { applyMapping } from './mapping';
import type {
ClassifiedRow,
ConflictPolicy,
ImportAdapter,
ImportCtx,
MappedRow,
RawRow,
RowError,
} from './types';
/** Required-field + per-field zod validation. Never throws; returns all errors. */
export function validateRow(adapter: ImportAdapter, mapped: MappedRow): RowError[] {
const errors: RowError[] = [];
for (const field of adapter.targetFields) {
const value = mapped[field.key];
if (value === undefined) {
if (field.required) errors.push({ field: field.key, message: `${field.label} is required` });
continue;
}
const res = field.zod.safeParse(value);
if (!res.success) {
const first = res.error.issues[0];
errors.push({ field: field.key, message: first?.message ?? 'Invalid value' });
}
}
if (errors.length === 0 && adapter.extraValidate) errors.push(...adapter.extraValidate(mapped));
return errors;
}
export async function classifyRow(
adapter: ImportAdapter,
raw: RawRow,
mapping: Record<string, string>,
rowNumber: number,
policy: ConflictPolicy,
ctx: ImportCtx,
): Promise<ClassifiedRow> {
const mapped = applyMapping(raw, mapping);
const errors = validateRow(adapter, mapped);
if (errors.length > 0) return { rowNumber, outcome: 'error', errors };
// Foreign keys (by natural key) — resolution failures are row errors.
let resolved: Record<string, string> = {};
if (adapter.resolveForeignKeys) {
const fk = await adapter.resolveForeignKeys(mapped, ctx);
if (!fk.ok) return { rowNumber, outcome: 'error', errors: fk.errors };
resolved = fk.resolved;
}
// Dedup by natural key.
const key = adapter.matchKey(mapped);
const existing = key ? await adapter.findExisting(ctx.portId, key) : null;
if (existing) {
if (policy === 'error-on-match') {
return {
rowNumber,
outcome: 'error',
existingId: existing.id,
errors: [{ field: '*', message: 'Matches an existing record' }],
};
}
if (policy === 'update-matches' && adapter.update) {
return { rowNumber, outcome: 'update', existingId: existing.id, mapped, resolved };
}
// skip-matches, or update requested on an insert-only adapter.
return { rowNumber, outcome: 'skip', existingId: existing.id, mapped, resolved };
}
return { rowNumber, outcome: 'insert', mapped, resolved };
}
export interface DryRunSummary {
total: number;
insert: number;
update: number;
skip: number;
error: number;
rows: ClassifiedRow[];
}
/** Classify a whole file. Sequential to keep FK lookups + dedup ordering
* deterministic (migration files are small). */
export async function classifyRows(
adapter: ImportAdapter,
rawRows: RawRow[],
mapping: Record<string, string>,
policy: ConflictPolicy,
ctx: ImportCtx,
): Promise<DryRunSummary> {
const rows: ClassifiedRow[] = [];
const summary = { total: rawRows.length, insert: 0, update: 0, skip: 0, error: 0 };
for (let i = 0; i < rawRows.length; i++) {
const c = await classifyRow(adapter, rawRows[i]!, mapping, i + 1, policy, ctx);
rows.push(c);
summary[c.outcome] += 1;
}
return { ...summary, rows };
}