assessment-model/src/lib/bulkUpload/columnFields.ts
2026-06-08 09:36:26 +00:00

100 lines
4.7 KiB
TypeScript

// Single source of truth for BulkUpload column mapping.
//
// The mapping is stored as `field → source CSV header` (one entry per mapped
// internal field). One source header may feed several CLASSIFIER fields (e.g.
// "Property Type" → both property_type and built_form_type) but at most one
// ADDRESS field — see docs/adr/0003-classifier-triggers-as-address-subtask.md
// and docs/wip/landlord-override-frontend-plan.md (Q2.2, Q3).
//
// Classifier field `value`s mirror the Model service's ClassifiableColumn names
// (property_type / built_form_type / wall_type / roof_type) so the mapping can
// be forwarded to the lambda trigger verbatim.
export type InternalFieldKind = "address" | "classifier";
export interface InternalField {
value: string;
label: string;
kind: InternalFieldKind;
required: boolean;
// Canonical header written into the address-matching CSV (address fields only).
outputHeader?: string;
}
export const INTERNAL_FIELDS: InternalField[] = [
{ value: "address_1", label: "Address 1", kind: "address", required: true, outputHeader: "Address 1" },
{ value: "address_2", label: "Address 2", kind: "address", required: false, outputHeader: "Address 2" },
{ value: "address_3", label: "Address 3", kind: "address", required: false, outputHeader: "Address 3" },
{ value: "postcode", label: "Postcode", kind: "address", required: true, outputHeader: "postcode" },
{ value: "internal_reference", label: "Internal Reference", kind: "address", required: false, outputHeader: "Internal Reference" },
{ value: "property_type", label: "Property Type", kind: "classifier", required: false },
{ value: "built_form_type", label: "Built Form", kind: "classifier", required: false },
{ value: "wall_type", label: "Wall Type", kind: "classifier", required: false },
{ value: "roof_type", label: "Roof Type", kind: "classifier", required: false },
];
export const ADDRESS_FIELDS = INTERNAL_FIELDS.filter((f) => f.kind === "address");
export const CLASSIFIER_FIELDS = INTERNAL_FIELDS.filter((f) => f.kind === "classifier");
export const CLASSIFIER_FIELD_VALUES = CLASSIFIER_FIELDS.map((f) => f.value);
export const REQUIRED_FIELD_VALUES = INTERNAL_FIELDS.filter((f) => f.required).map((f) => f.value);
// Sentinel for an unmapped field in the UI dropdown ("Not provided").
export const NOT_PROVIDED = "";
// header → address field detection. Classifier fields are never auto-detected
// (Q2.1): mapping them is always an explicit user choice.
export function autoDetectField(header: string): string | null {
const h = header.toLowerCase().replace(/[\s_\-]/g, "");
if (/^(address|addr)(line)?(1|one)?$/.test(h)) return "address_1";
if (/^(address|addr)(line)?(2|two)|^street$/.test(h)) return "address_2";
if (/^(address|addr)(line)?(3|three)|^locality$|^town$|^city$/.test(h)) return "address_3";
if (/^post(al)?code$|^postcode$|^pcode$/.test(h)) return "postcode";
if (/^(internal)?ref(erence)?$|^id$/.test(h)) return "internal_reference";
return null;
}
// Build the initial field→header mapping: keep any existing choices, then
// auto-fill address fields from the headers (first matching header wins).
export function buildInitialMapping(
sourceHeaders: string[],
existing?: Record<string, string>,
): Record<string, string> {
const mapping: Record<string, string> = { ...(existing ?? {}) };
for (const header of sourceHeaders) {
const field = autoDetectField(header);
if (!field) continue;
if (mapping[field] === undefined) mapping[field] = header;
}
return mapping;
}
// Validation shared by the client (live) and the server (authoritative).
// Returns the first problem as a message, or null when the mapping is valid.
export function validateColumnMapping(mapping: Record<string, string>): string | null {
for (const field of REQUIRED_FIELD_VALUES) {
if (!mapping[field]) {
const label = INTERNAL_FIELDS.find((f) => f.value === field)?.label ?? field;
return `${label} must be mapped to a column.`;
}
}
const usedAddressHeaders = new Set<string>();
for (const field of ADDRESS_FIELDS) {
const header = mapping[field.value];
if (!header) continue;
if (usedAddressHeaders.has(header)) {
return `Column "${header}" is mapped to more than one address field.`;
}
usedAddressHeaders.add(header);
}
return null;
}
// The classifier subset of the mapping (category → source header) that gets
// forwarded to the lambda trigger. Address fields are intentionally excluded.
export function classifierMapping(mapping: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const field of CLASSIFIER_FIELD_VALUES) {
if (mapping[field]) out[field] = mapping[field];
}
return out;
}