// 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 }, { value: "main_fuel", label: "Main Fuel", kind: "classifier", required: false }, { value: "glazing", label: "Glazing", kind: "classifier", required: false }, { value: "construction_age_band", label: "Construction Age Band", kind: "classifier", required: false }, { value: "water_heating", label: "Hot Water", kind: "classifier", required: false }, { value: "main_heating_system", label: "Main Heating", 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, ): Record { const mapping: Record = { ...(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 | 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(); 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): Record { const out: Record = {}; for (const field of CLASSIFIER_FIELD_VALUES) { if (mapping[field]) out[field] = mapping[field]; } return out; }