feat(postcode-search): pure domain core — normalisation, OS-code mapping, write plan

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 13:32:18 +00:00
parent b86efddfdf
commit 5b224677dc
3 changed files with 258 additions and 0 deletions

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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<string, { propertyType: string; builtForm: string | null }> = {
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<string, unknown>; LPI?: Record<string, unknown> };
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<string, { rec: Record<string, unknown>; 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 + 02 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,
};
}