From 5b224677dc619400c5472e380a0bb67e8b71fa1b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 13:32:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(postcode-search):=20pure=20domain=20core?= =?UTF-8?q?=20=E2=80=94=20normalisation,=20OS-code=20mapping,=20write=20pl?= =?UTF-8?q?an?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD'd (9 tests): postcode normalisation to canonical form; the ADR-0007 deterministic OS classification mapping (RD02/03/04/06/07; other residential codes selectable with no facts, Unknown never written; RD10 + non-RD unselectable); 30-day cache freshness for postcode_search; DPA-preferred UPRN-deduped address candidates with postcode-stripped title-case addresses; buildWritePlan producing the property row (incl. local_authority/constituency from postcodes.io — closing the no-source gap) and 0-2 property_overrides facts keyed to the OS code. Adds 'os_places' to OverrideSourceValues — enum migration still needs generating via generate_migration.sh before deploy. Co-Authored-By: Claude Fable 5 --- src/app/db/schema/landlord_overrides.ts | 2 + src/lib/postcodeSearch/model.test.ts | 106 +++++++++++++++++ src/lib/postcodeSearch/model.ts | 150 ++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 src/lib/postcodeSearch/model.test.ts create mode 100644 src/lib/postcodeSearch/model.ts diff --git a/src/app/db/schema/landlord_overrides.ts b/src/app/db/schema/landlord_overrides.ts index 7a65814b..7d095735 100644 --- a/src/app/db/schema/landlord_overrides.ts +++ b/src/app/db/schema/landlord_overrides.ts @@ -262,6 +262,8 @@ export const MainHeatingSystemValues: [string, ...string[]] = [ export const OverrideSourceValues: [string, ...string[]] = [ "classifier", "user", + // Deterministic OS Places classification-code mapping (postcode search, ADR-0007) + "os_places", ]; export const propertyTypeEnum = pgEnum("property_type", PropertyTypeValues); diff --git a/src/lib/postcodeSearch/model.test.ts b/src/lib/postcodeSearch/model.test.ts new file mode 100644 index 00000000..335d7d96 --- /dev/null +++ b/src/lib/postcodeSearch/model.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + buildWritePlan, + classifyOsCode, + isCacheFresh, + normalisePostcode, + toAddressCandidates, +} from "./model"; + +describe("classifyOsCode", () => { + it("maps the five confident residential codes per ADR-0007", () => { + expect(classifyOsCode("RD02")).toEqual({ selectable: true, propertyType: "House", builtForm: "Detached" }); + expect(classifyOsCode("RD03")).toEqual({ selectable: true, propertyType: "House", builtForm: "Semi-Detached" }); + // Terraced: mid/end unknowable — type only + expect(classifyOsCode("RD04")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + expect(classifyOsCode("RD06")).toEqual({ selectable: true, propertyType: "Flat", builtForm: null }); + expect(classifyOsCode("RD07")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + }); + + it("keeps other residential codes selectable but writes no facts — never Unknown", () => { + for (const code of ["RD", "RD01", "RD08"]) { + expect(classifyOsCode(code)).toEqual({ selectable: true, propertyType: null, builtForm: null }); + } + }); + + it("marks non-residential and institutional codes unselectable", () => { + expect(classifyOsCode("CR08").selectable).toBe(false); // commercial retail + expect(classifyOsCode("RD10").selectable).toBe(false); // residential institution + expect(classifyOsCode(null).selectable).toBe(false); + }); +}); + +describe("isCacheFresh", () => { + it("serves cache younger than 30 days, refreshes older", () => { + const now = new Date("2026-07-06T12:00:00Z"); + expect(isCacheFresh(new Date("2026-06-10T12:00:00Z"), now)).toBe(true); + expect(isCacheFresh(new Date("2026-06-06T11:00:00Z"), now)).toBe(false); + expect(isCacheFresh(null, now)).toBe(false); + }); +}); + +describe("toAddressCandidates", () => { + const items = [ + { DPA: { UPRN: "100", ADDRESS: "16 ALDER GROVE, MANCHESTER, M20 4TF", POSTCODE: "M20 4TF", + CLASSIFICATION_CODE: "RD02", LAT: 53.43, LNG: -2.23 } }, + // LPI-only record — still usable + { LPI: { UPRN: "101", ADDRESS: "18 ALDER GROVE, MANCHESTER, M20 4TF", POSTCODE_LOCATOR: "M20 4TF", + CLASSIFICATION_CODE: "CR08", LAT: 53.44, LNG: -2.24 } }, + // duplicate UPRN (DPA + LPI for same address) — deduped, DPA wins + { LPI: { UPRN: "100", ADDRESS: "16 ALDER GROVE ALT, M20 4TF", CLASSIFICATION_CODE: "RD02" } }, + ]; + it("prefers DPA, dedupes by UPRN, strips the postcode from the address", () => { + const out = toAddressCandidates(items as never, "M20 4TF"); + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ + uprn: "100", address: "16 Alder Grove, Manchester", postcode: "M20 4TF", + classificationCode: "RD02", lat: 53.43, lng: -2.23, selectable: true, + propertyType: "House", builtForm: "Detached", + }); + expect(out[1]).toMatchObject({ uprn: "101", selectable: false }); + }); +}); + +describe("buildWritePlan", () => { + const geo = { localAuthority: "Manchester", constituency: "Manchester Withington" }; + it("builds the property row and both override layers for a mapped candidate", () => { + const plan = buildWritePlan( + { uprn: "100", address: "16 Alder Grove, Manchester", postcode: "M20 4TF", + classificationCode: "RD02", lat: 53.43, lng: -2.23, selectable: true, + propertyType: "House", builtForm: "Detached" }, + geo, + ); + expect(plan.property).toMatchObject({ + address: "16 Alder Grove, Manchester", postcode: "M20 4TF", uprn: 100n, + latitude: 53.43, longitude: -2.23, + localAuthority: "Manchester", constituency: "Manchester Withington", + }); + // both facts, snapshot keyed to the OS code, main building part + expect(plan.overrides).toEqual([ + { component: "property_type", value: "House", originalDescription: "RD02", buildingPart: 0 }, + { component: "built_form_type", value: "Detached", originalDescription: "RD02", buildingPart: 0 }, + ]); + }); + it("writes no overrides when the code maps nothing — never Unknown", () => { + const plan = buildWritePlan( + { uprn: "200", address: "Birch Court", postcode: "M20 4UD", classificationCode: "RD", + lat: null, lng: null, selectable: true, propertyType: null, builtForm: null }, + geo, + ); + expect(plan.overrides).toEqual([]); + }); +}); + +describe("normalisePostcode", () => { + it("uppercases and collapses to the canonical single-space form", () => { + expect(normalisePostcode("m20 4tf")).toBe("M20 4TF"); + expect(normalisePostcode(" sw1a1aa ")).toBe("SW1A 1AA"); + expect(normalisePostcode("M20 4TF")).toBe("M20 4TF"); + }); + + it("returns null for strings that are not plausible postcodes", () => { + expect(normalisePostcode("")).toBeNull(); + expect(normalisePostcode("not a postcode")).toBeNull(); + expect(normalisePostcode("12345")).toBeNull(); + }); +}); diff --git a/src/lib/postcodeSearch/model.ts b/src/lib/postcodeSearch/model.ts new file mode 100644 index 00000000..364b2796 --- /dev/null +++ b/src/lib/postcodeSearch/model.ts @@ -0,0 +1,150 @@ +/** + * 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). + */ +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, + }; + }); +} + +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, + }; +} + +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, + }; +}