mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
100 lines
4.7 KiB
TypeScript
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;
|
|
}
|