/** * Postcode search domain model — pure logic for the add-properties-by-postcode * journey. See CONTEXT.md (Postcode search) and ADR-0007. */ /** Canonical form: uppercase, single space before the inward code. Null if implausible. */ export function normalisePostcode(input: string): string | null { const m = input.trim().toUpperCase().replace(/\s+/g, "").match( /^([A-Z]{1,2}\d[A-Z\d]?)(\d[A-Z]{2})$/, ); return m ? `${m[1]} ${m[2]}` : null; } export interface OsClassification { selectable: boolean; propertyType: string | null; builtForm: string | null; } /** * Deterministic OS classification-code mapping (ADR-0007). Only codes with a * confident enum equivalent write facts; "Unknown" is never produced — an * unmappable residential code stays selectable with no facts. RD10 * (residential institutions) and all non-RD codes are not addable. */ const OS_CODE_FACTS: Record = { RD02: { propertyType: "House", builtForm: "Detached" }, RD03: { propertyType: "House", builtForm: "Semi-Detached" }, RD04: { propertyType: "House", builtForm: null }, // terraced — mid/end unknowable RD06: { propertyType: "Flat", builtForm: null }, RD07: { propertyType: "House", builtForm: null }, // HMO }; const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; /** Read-through cache rule: serve postcode_search rows younger than 30 days. */ export function isCacheFresh(fetchedAt: Date | null, now: Date): boolean { return !!fetchedAt && now.getTime() - fetchedAt.getTime() < CACHE_MAX_AGE_MS; } type OsItem = { DPA?: Record; LPI?: Record }; export interface AddressCandidate { uprn: string; address: string; postcode: string; classificationCode: string | null; lat: number | null; lng: number | null; selectable: boolean; propertyType: string | null; builtForm: string | null; } const titleCase = (s: string) => s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase()); /** * Flatten OS Places items into unique address candidates: DPA preferred over * LPI for the same UPRN; address rendered in title case with the postcode * suffix stripped (it lives in its own column); ordered naturally so * "Flat 2" sorts before "Flat 10". */ export function toAddressCandidates( items: OsItem[], postcode: string, ): AddressCandidate[] { const byUprn = new Map; isDpa: boolean }>(); for (const item of items) { const rec = item.DPA ?? item.LPI; if (!rec || rec.UPRN == null) continue; const uprn = String(rec.UPRN); const existing = byUprn.get(uprn); if (!existing || (item.DPA && !existing.isDpa)) { byUprn.set(uprn, { rec, isDpa: !!item.DPA }); } } return [...byUprn.values()].map(({ rec }) => { const code = (rec.CLASSIFICATION_CODE as string) ?? null; const cls = classifyOsCode(code); const raw = String(rec.ADDRESS ?? ""); const address = titleCase( raw.replace(new RegExp(`,?\\s*${postcode.replace(" ", "\\s*")}\\s*$`, "i"), ""), ); return { uprn: String(rec.UPRN), address, postcode, classificationCode: code, lat: typeof rec.LAT === "number" ? rec.LAT : null, lng: typeof rec.LNG === "number" ? rec.LNG : null, ...cls, }; }).sort((a, b) => a.address.localeCompare(b.address, "en", { numeric: true, sensitivity: "base" }), ); } export interface PostcodeGeo { localAuthority: string | null; constituency: string | null; } /** The rows a selected candidate produces: one property + 0–2 override facts (ADR-0007). */ export function buildWritePlan(c: AddressCandidate, geo: PostcodeGeo) { const overrides: { component: "property_type" | "built_form_type"; value: string; originalDescription: string; buildingPart: number; }[] = []; if (c.propertyType && c.classificationCode) { overrides.push({ component: "property_type", value: c.propertyType, originalDescription: c.classificationCode, buildingPart: 0, }); } if (c.builtForm && c.classificationCode) { overrides.push({ component: "built_form_type", value: c.builtForm, originalDescription: c.classificationCode, buildingPart: 0, }); } return { property: { address: c.address, postcode: c.postcode, uprn: BigInt(c.uprn), latitude: c.lat, longitude: c.lng, localAuthority: geo.localAuthority, constituency: geo.constituency, }, overrides, }; } /** * Presentation of a candidate's classification: the type chip label plus an * optional explanatory note (why terraced has no built form; why an unmapped * residential code still adds cleanly). */ export function describeCandidate(c: { selectable: boolean; propertyType?: string | null; builtForm?: string | null; classificationCode?: string | null; }): { label: string; note: string | null } { if (!c.selectable) return { label: "Non-residential", note: null }; if (c.propertyType && c.builtForm) { return { label: `${c.propertyType} · ${c.builtForm}`, note: null }; } if (c.propertyType) { return { label: c.propertyType, note: c.classificationCode?.toUpperCase() === "RD04" ? "Terraced — mid or end unknown" : null, }; } return { label: "Residential", note: "No classification — type comes with modelling" }; } /** * postcode_search cache keys to read, canonical spaced form first. Legacy rows * were keyed without the space; new rows are written under the canonical form. */ export function postcodeCacheKeys(postcode: string): string[] { return [postcode, postcode.replace(" ", "")]; } /** Whole days since the cache row was refreshed (for the "N days old" line). */ export function cacheAgeDays(fetchedAt: Date, now: Date): number { return Math.max(0, Math.floor((now.getTime() - fetchedAt.getTime()) / (24 * 60 * 60 * 1000))); } export function classifyOsCode(code: string | null): OsClassification { if (!code) return { selectable: false, propertyType: null, builtForm: null }; const c = code.trim().toUpperCase(); const residential = c === "RD" || (c.startsWith("RD") && c !== "RD10"); if (!residential) return { selectable: false, propertyType: null, builtForm: null }; const facts = OS_CODE_FACTS[c]; return { selectable: true, propertyType: facts?.propertyType ?? null, builtForm: facts?.builtForm ?? null, }; }