Merge pull request #355 from Hestia-Homes/feature/add-properties-by-postcode

feat: add properties by postcode search
This commit is contained in:
KhalimCK 2026-07-06 16:21:07 +01:00 committed by GitHub
commit 08fcb77734
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1235 additions and 184 deletions

4
.gitignore vendored
View file

@ -40,7 +40,9 @@ next-env.d.ts
backlog/**
docs/adr/**
# Session working notes / handovers — not for review (PR #355 feedback)
docs/wip/**
# Personal Claude Code settings (per-developer, not shared)
.claude/settings.local.json

View file

@ -45,14 +45,22 @@ _Avoid_: customer data, manual override, landlord data
The per-Property fact layer — one resolved fact per `(Property, Building part, component)`, where component is one of `wall_type`/`roof_type`/`property_type`/`built_form_type`. Holds a **snapshot** of the resolved enum value (a denormalised copy of the VocabularyMapping outcome at finalise time, so two Properties sharing a description can later diverge), plus the original spreadsheet text it resolved from. Materialised by the finaliser **for UPRN-matched Properties only** (v2); the resolved value is never `UNKNOWN` — the Verify step forces every `UNKNOWN` to be mapped before Finalise, and an unresolved description fails the run. See [ADR-0005](./docs/adr/0005-async-bulk-upload-finaliser.md) (table) and [ADR-0006](./docs/adr/0006-property-overrides-join-and-no-uprn-defer.md) (population).
_Avoid_: per-property mapping, property fact, override row
The Model backend consumes Property overrides at modelling time; property type (and built form when known) lets the **predict-EPC service** estimate a never-EPC'd property from surrounding homes of similar archetype.
**Source row id**:
A synthetic UUID minted per source-file row at `start-address-matching` and written into **both** the address CSV and the classifier CSV. It is the stable join key that lets the finaliser tie a row's identity (combiner output → `property_id`) to that row's raw descriptions (classifier CSV), since neither file preserves row order and `Internal Reference` is absent from the classifier CSV. See [ADR-0006](./docs/adr/0006-property-overrides-join-and-no-uprn-defer.md).
_Avoid_: row index, internal reference (a separate, optional landlord field)
**VocabularyMapping**:
The translation from a Landlord's free-text description in a BulkUpload column (e.g. `"cavity: filledcavity"`) to a canonical domain enum value (e.g. `WallType.CAVITY`). Produced by a `ColumnClassifier` (today an LLM, tomorrow possibly a lookup table or rules engine) in the Model service. Stored per-Portfolio, one row per `(category, description)`. A row carries provenance (`classifier` or `user`) so user overrides survive re-classification.
The translation from a free-text description to a canonical domain enum value (e.g. `"cavity: filledcavity"``WallType.CAVITY`). Two producers: the `ColumnClassifier` (an LLM in the Model service — needed because BulkUpload spreadsheets contain arbitrary landlord text that must be sanitised) and the **deterministic OS-code mapping** used by the postcode-search journey (a fixed OS classification code → enum table; no interpretation involved). Stored per-Portfolio, one row per `(category, description)`. A row carries provenance (`classifier`, `user`, or `os_places`) so user overrides survive re-classification and OS-derived facts stay distinguishable.
_Avoid_: column mapping (that's a separate concept — see `ColumnMapping` above), classification, dictionary
### Postcode search
**Postcode search**:
The journey that adds Properties to a Portfolio by searching a postcode: postcodes.io validates/normalises and supplies geography (local authority, constituency), OS Places supplies the addresses (UPRN, coordinates, classification code), and the user selects which addresses to add — across multiple postcode searches in one basket, submitted once. Raw OS responses are cached per-postcode (`postcode_search`) and re-served while fresh. Creates real Properties whose energy data is absent-until-modelled (derived state, no marker column); writes property type / built form facts through the Landlord-override layers via the deterministic OS-code mapping.
_Avoid_: address import, quick add, single add
### Building parts
**Building part**:

View file

@ -0,0 +1,60 @@
# 7. Postcode search creates Properties directly, with OS-derived overrides
Date: 2026-07-06
## Status
Accepted
Numbering note: 0003 is taken by the app-authored-scenarios ADR (PR #353);
00040006 are referenced by CONTEXT.md but were lost to a `docs/adr/**`
.gitignore rule (removed in this commit). 0007 avoids all collisions.
## Context
Properties previously came into existence only via the BulkUpload finaliser or
the backend pipeline. The "Postcode search" journey (CONTEXT.md) lets a user
search a postcode (postcodes.io for validation + geography, OS Places for
addresses) and add selected addresses to a Portfolio directly — before any
modelling exists.
## Decision
- **Next.js inserts `property` rows directly**: address, postcode, uprn,
per-property lat/lng, local_authority (postcodes.io `admin_district`),
constituency (postcodes.io `parliamentary_constituency`). Nothing inferred —
no type/age/tenure guesses on the row. Energy state is derived: no EPC-graph
rows ⇒ "awaiting modelling" (mirrors ADR on app-authored scenarios).
- Dedup rides the existing partial unique index `uq_property_portfolio_uprn`
via `ON CONFLICT DO NOTHING`; the submit reports added vs already-existed.
- **Both override layers are written**, mimicking the finaliser: a
VocabularyMapping upsert per distinct OS classification code and a
`property_overrides` snapshot per property (original description = the OS
code). New `override_source` enum value **`os_places`** — the deterministic
OS-code mapping is not the LLM "classifier" and provenance must stay
distinguishable.
- **Deterministic mapping** (fixed table, tested): RD02→House+Detached,
RD03→House+Semi-Detached, RD04→House (Mid/End unknowable — no built form),
RD06→Flat, RD07→House. Every other code maps nothing; `Unknown` is never
written (absence, not noise) — preserving the finaliser's invariant. The
backend's predict-EPC service consumes these for archetype matching.
- **Read-through cache** on the existing `postcode_search` jsonb table:
serve when fresher than 30 days, else refresh from OS Places and upsert.
## Alternatives considered
- Routing through the BulkUpload pipeline (synthesise a one-postcode "upload"):
full provenance for free, but drags an interactive journey through an async
multi-stage state machine built for files.
- Property row carries property_type directly: bypasses the override layers the
Model backend actually consumes, and creates a second source of truth.
## Consequences
- A third Property creator exists; anything assuming "property ⇒ came from a
BulkUpload or the backend" is now wrong.
- `property_overrides` gains a second writer with `os_places` provenance;
re-classification tooling must not clobber user rows (existing rule) nor
treat OS rows as user-authored.
- Postcode-search-created properties render blank/awaiting-modelling in the
portfolio table and reporting until a scenario is modelled against them.

View file

@ -0,0 +1,197 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { db } from "@/app/db/db";
import { property, propertyDetailsSpatial } from "@/app/db/schema/property";
import {
landlordBuiltFormTypeOverrides,
landlordPropertyTypeOverrides,
} from "@/app/db/schema/landlord_overrides";
import { propertyOverrides } from "@/app/db/schema/property_overrides";
import { and, eq, inArray } from "drizzle-orm";
import {
buildWritePlan,
classifyOsCode,
normalisePostcode,
} from "@/lib/postcodeSearch/model";
const bodySchema = z.object({
geo: z.object({
localAuthority: z.string().nullable(),
constituency: z.string().nullable(),
}),
candidates: z
.array(
z.object({
uprn: z.string().regex(/^\d+$/),
address: z.string().trim().min(1),
postcode: z.string(),
classificationCode: z.string().nullable(),
lat: z.number().nullable(),
lng: z.number().nullable(),
}),
)
.min(1)
.max(500),
});
// POST — create the selected address candidates as Properties (ADR-0007):
// property rows via the uq_property_portfolio_uprn dedup, plus both override
// layers (vocabulary upsert + per-property snapshot) with os_places provenance.
// Facts are re-derived server-side from the classification code — the client's
// propertyType/builtForm are never trusted.
export async function POST(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
const pid = BigInt(portfolioId);
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const seen = new Set<string>();
const plans: ReturnType<typeof buildWritePlan>[] = [];
for (const c of body.candidates) {
const postcode = normalisePostcode(c.postcode);
if (!postcode) {
return NextResponse.json(
{ error: `Invalid postcode '${c.postcode}'` },
{ status: 400 },
);
}
const classificationCode = c.classificationCode?.trim().toUpperCase() ?? null;
const cls = classifyOsCode(classificationCode);
if (!cls.selectable || seen.has(c.uprn)) continue;
seen.add(c.uprn);
plans.push(
buildWritePlan(
{ ...c, postcode, classificationCode, ...cls },
body.geo,
),
);
}
if (plans.length === 0) {
return NextResponse.json(
{ error: "No addable addresses in the selection" },
{ status: 400 },
);
}
try {
const result = await db.transaction(async (tx) => {
// Vocabulary layer: one upsert per distinct OS code, then read back the
// resolved value so snapshots mirror it — DO NOTHING preserves any
// existing user/classifier row for the same description (existing rule).
const factsFor = (component: string) => {
const byDescription = new Map<string, string>();
for (const p of plans)
for (const o of p.overrides)
if (o.component === component)
byDescription.set(o.originalDescription, o.value);
return byDescription;
};
const resolved = new Map<string, string>();
for (const [component, table] of [
["property_type", landlordPropertyTypeOverrides],
["built_form_type", landlordBuiltFormTypeOverrides],
] as const) {
const facts = factsFor(component);
if (facts.size === 0) continue;
await tx
.insert(table)
.values(
[...facts].map(([description, value]) => ({
portfolioId: pid,
description,
value,
source: "os_places",
})),
)
.onConflictDoNothing();
const rows = await tx
.select({ description: table.description, value: table.value })
.from(table)
.where(
and(
eq(table.portfolioId, pid),
inArray(table.description, [...facts.keys()]),
),
);
for (const r of rows) resolved.set(`${component}|${r.description}`, r.value);
}
const inserted = await tx
.insert(property)
.values(
plans.map((p) => ({
portfolioId: pid,
creationStatus: "READY",
uprn: p.property.uprn,
address: p.property.address,
postcode: p.property.postcode,
localAuthority: p.property.localAuthority,
constituency: p.property.constituency,
})),
)
.onConflictDoNothing()
.returning({ id: property.id, uprn: property.uprn });
const idByUprn = new Map(
inserted.map((r) => [r.uprn!.toString(), r.id]),
);
const spatialRows = plans
.filter(
(p) =>
idByUprn.has(p.property.uprn.toString()) &&
p.property.latitude != null &&
p.property.longitude != null,
)
.map((p) => ({
uprn: p.property.uprn,
latitude: p.property.latitude,
longitude: p.property.longitude,
}));
if (spatialRows.length > 0) {
await tx.insert(propertyDetailsSpatial).values(spatialRows).onConflictDoNothing();
}
const snapshotRows = plans.flatMap((p) => {
const propertyId = idByUprn.get(p.property.uprn.toString());
if (!propertyId) return [];
return p.overrides.map((o) => ({
propertyId,
portfolioId: pid,
buildingPart: o.buildingPart,
overrideComponent: o.component,
overrideValue:
resolved.get(`${o.component}|${o.originalDescription}`) ?? o.value,
originalSpreadsheetDescription: o.originalDescription,
}));
});
if (snapshotRows.length > 0) {
await tx.insert(propertyOverrides).values(snapshotRows).onConflictDoNothing();
}
return { added: inserted.length, skipped: plans.length - inserted.length };
});
return NextResponse.json(result);
} catch (err) {
console.error("POST /properties error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { postcodeSearch, OSPlacesItem } from "@/app/db/schema/addresses";
import { property } from "@/app/db/schema/property";
import { and, desc, eq, inArray } from "drizzle-orm";
import { lookupPostcode } from "@/app/api/postcode/LookupPostcode";
import { lookupOsPlaces } from "@/app/api/postcode/LookupOsPlaces";
import {
cacheAgeDays,
isCacheFresh,
normalisePostcode,
postcodeCacheKeys,
toAddressCandidates,
} from "@/lib/postcodeSearch/model";
// GET ?postcode= — address candidates for the add-properties journey
// (ADR-0007): postcodes.io for validation + geography every time, OS Places
// through the 30-day postcode_search read-through cache.
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
const postcode = normalisePostcode(
request.nextUrl.searchParams.get("postcode") ?? "",
);
if (!postcode) {
return NextResponse.json(
{ error: "That doesn't look like a full UK postcode" },
{ status: 400 },
);
}
try {
const geoLookup = await lookupPostcode(postcode);
if (!geoLookup.result) {
const notFound = geoLookup.status === 404;
return NextResponse.json(
{ error: notFound ? "Postcode not found" : (geoLookup.error ?? "Postcode lookup failed") },
{ status: notFound ? 404 : 502 },
);
}
const geo = {
localAuthority: geoLookup.result.admin_district ?? null,
constituency: geoLookup.result.parliamentary_constituency ?? null,
};
const now = new Date();
// refresh=1 bypasses the cache ("Refresh from source") — the upsert below
// still rewrites the row, so the next reader gets the fresh copy.
const refresh = request.nextUrl.searchParams.get("refresh") === "1";
const cached = await db
.select()
.from(postcodeSearch)
.where(inArray(postcodeSearch.postcode, postcodeCacheKeys(postcode)))
.orderBy(desc(postcodeSearch.lastUpdatedAt))
.limit(1);
let items: OSPlacesItem[];
let source: "cache" | "live";
let fetchedAt: Date;
if (!refresh && cached.length > 0 && isCacheFresh(cached[0].lastUpdatedAt, now)) {
items = cached[0].resultData?.results ?? [];
source = "cache";
fetchedAt = cached[0].lastUpdatedAt;
} else {
const os = await lookupOsPlaces(postcode);
if (os.error || !os.data) {
return NextResponse.json(
{ error: os.error ?? "Failed to fetch address data" },
{ status: os.status >= 400 ? os.status : 502 },
);
}
items = os.results ?? [];
source = "live";
fetchedAt = now;
await db
.insert(postcodeSearch)
.values({ postcode, resultData: os.data, lastUpdatedAt: now })
.onConflictDoUpdate({
target: postcodeSearch.postcode,
set: { resultData: os.data, lastUpdatedAt: now },
});
}
const candidates = toAddressCandidates(items, postcode);
const uprns = candidates.map((c) => BigInt(c.uprn));
const existing = uprns.length
? await db
.select({ uprn: property.uprn })
.from(property)
.where(
and(
eq(property.portfolioId, BigInt(portfolioId)),
inArray(property.uprn, uprns),
),
)
: [];
const inPortfolio = new Set(existing.map((r) => r.uprn?.toString()));
return NextResponse.json({
postcode,
geo,
source,
cacheAgeDays: cacheAgeDays(fetchedAt, now),
candidates: candidates.map((c) => ({
...c,
alreadyInPortfolio: inPortfolio.has(c.uprn),
})),
});
} catch (err) {
console.error("GET /properties/search error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -24,9 +24,12 @@ async function fetchOsPlacesPage(
retries: number,
delay: number
): Promise<{ status: number; data?: OSPlacesResponse; error?: string }> {
// output_srs=EPSG:4326 makes each DPA/LPI record carry LAT/LNG (WGS84)
// instead of BNG eastings/northings — the postcode-search journey stores
// per-property coordinates. No mapper reads X/Y_COORDINATE.
const url = `https://api.os.uk/search/places/v1/postcode?postcode=${encodeURIComponent(
postcode
)}&dataset=DPA,LPI&maxresults=100&offset=${offset}&key=${process.env.OS_API_KEY}`;
)}&dataset=DPA,LPI&maxresults=100&offset=${offset}&output_srs=EPSG%3A4326&key=${process.env.OS_API_KEY}`;
try {
const res = await fetch(url, { method: "GET" });

View file

@ -7,6 +7,7 @@ export interface PostcodeLookupResult {
country: string;
region: string | null;
admin_district: string | null;
parliamentary_constituency: string | null;
latitude: number;
longitude: number;
};

View file

@ -1,158 +0,0 @@
"use client";
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react";
import {
TableCellsIcon,
DocumentMagnifyingGlassIcon,
ChevronDownIcon,
DocumentPlusIcon,
RectangleStackIcon,
} from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { Dispatch, SetStateAction, useState } from "react";
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
interface AddNewProps {
portfolioId: string;
isUploadCsvOpen: boolean;
setIsUploadCsvOpen: Dispatch<SetStateAction<boolean>>;
}
export default function AddNew({
portfolioId,
isUploadCsvOpen,
setIsUploadCsvOpen,
}: AddNewProps) {
const router = useRouter();
const [loadingRemote, setLoadingRemote] = useState(false);
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false);
function handleRemoteAssessment() {
setLoadingRemote(true);
router.push(`/portfolio/${portfolioId}/remote-assessment`);
}
function handleBulkUploadClick() {
const pw = window.prompt("Enter password to access bulk upload");
if (pw === null) return;
if (pw === "domnatechteamonly") {
setIsBulkUploadOpen(true);
} else {
window.alert("Incorrect password");
}
}
return (
<>
<BulkUploadComingSoonModal
isOpen={isBulkUploadOpen}
onClose={() => setIsBulkUploadOpen(false)}
portfolioId={portfolioId}
/>
<Menu as="div" className="relative inline-block text-left">
<MenuButton
className="
inline-flex items-center gap-1 px-4 py-2 rounded-md
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
transition-colors text-xs font-medium
"
>
<DocumentPlusIcon className="h-4 w-4 mr-2" />
New Property
<ChevronDownIcon className="h-4 w-4 opacity-70" />
</MenuButton>
<MenuItems
className="
absolute right-0 mt-3 w-72 origin-top-right rounded-md
bg-white shadow-lg ring-1 ring-black/5 focus:outline-none
z-[9999] py-3
"
>
<div className="flex flex-col gap-2 px-3">
{/* Remote Assessment */}
<MenuItem>
{({ active }) => (
<button
onClick={handleRemoteAssessment}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"
)}
>
<DocumentMagnifyingGlassIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
Remote Assessment
{loadingRemote && (
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
)}
</span>
<span className="text-xs text-gray-500 leading-snug">
Run a remote assessment for a single property.
</span>
</div>
</button>
)}
</MenuItem>
{/* CSV Upload */}
<MenuItem>
{({ active }) => (
<button
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"
)}
>
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
File Import
</span>
<span className="text-xs text-gray-500 leading-snug">
For bulk uploads, please contact a Domna user.
</span>
</div>
</button>
)}
</MenuItem>
{/* Bulk Upload (Coming Soon) */}
<MenuItem>
{({ active }) => (
<button
onClick={handleBulkUploadClick}
className={cn(
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
active && "bg-gray-100"
)}
>
<RectangleStackIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
new: Bulk upload
<span className="text-[10px] font-semibold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-full leading-none">
coming soon
</span>
</span>
<span className="text-xs text-gray-500 leading-snug">
Upload multiple addresses in one go.
</span>
</div>
</button>
)}
</MenuItem>
</div>
</MenuItems>
</Menu>
</>
);
}

View file

@ -13,23 +13,18 @@ import {
NavigationMenuList,
NavigationMenuViewport,
} from "@/app/shadcn_components/ui/navigation-menu";
import AddNewDropDown from "./AddNew";
import YourProjectsDropdown from "./YourProjectsDropdown";
import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal";
import { ScenarioSelect } from "@/app/db/schema/recommendations";
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface ToolbarProps {
portfolioId: string;
scenarios: ScenarioSelect[];
}
export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
export function Toolbar({ portfolioId }: ToolbarProps) {
const router = useRouter();
const pathname = usePathname();
const [modalIsOpen, setModalIsOpen] = useState(false);
const [loadingHref, setLoadingHref] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
@ -110,11 +105,6 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
);
})}
<YourProjectsDropdown portfolioId={portfolioId} />
<AddNewDropDown
portfolioId={portfolioId}
isUploadCsvOpen={modalIsOpen}
setIsUploadCsvOpen={setModalIsOpen}
/>
{SettingsItems.map(({ label, icon: Icon, href, match }) => {
const isActive = match(pathname);
const isLoading = loadingHref === href && isPending;
@ -152,13 +142,6 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
<NavigationMenuViewport />
</NavigationMenu>
<UploadCsvModal
isOpen={modalIsOpen}
setIsOpen={setModalIsOpen}
portfolioId={portfolioId}
scenarios={scenarios}
/>
</>
);
}

View file

@ -1,5 +1,5 @@
import { Toolbar } from "@/app/components/portfolio/Toolbar";
import { getPortfolio, getPortfolioScenarios } from "../utils";
import { getPortfolio } from "../utils";
import * as React from "react";
@ -16,8 +16,6 @@ export default async function PortfolioLayout(props: {
const portfolioId = params.slug;
const { name: portfolioName } = await getPortfolio(portfolioId);
// We retrieve the scenarios associated with the portfolio
const scenarios = await getPortfolioScenarios(portfolioId);
return (
<section>
@ -29,7 +27,7 @@ export default async function PortfolioLayout(props: {
<div className="flex justify-center">
<div className="grid grid-cols-8 w-full max-w-8xl">
<div className="col-span-12 justify-center bg-gray-50 py-1 px-4 relative">
<Toolbar portfolioId={portfolioId} scenarios={scenarios} />
<Toolbar portfolioId={portfolioId} />
</div>
<div className="col-span-12">
{children}

View file

@ -11,7 +11,11 @@ import {
XMarkIcon,
ArrowDownTrayIcon,
ViewColumnsIcon,
PlusIcon,
MagnifyingGlassIcon,
DocumentArrowUpIcon,
} from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { HomeIcon } from "@heroicons/react/24/outline";
import { sapToEpc } from "@/app/utils";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
@ -146,7 +150,7 @@ function EmptyPropertyState() {
<div className="text-center text-gray-400">
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
<p>
Hover over <strong>&ldquo;New Property&rdquo;</strong> to start adding
Use <strong>&ldquo;Add properties&rdquo;</strong> to start adding
properties to your portfolio.
</p>
</div>
@ -301,6 +305,7 @@ export default function PropertyTable({
}: {
portfolioId: string;
}) {
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [committedAddress, setCommittedAddress] = useState("");
@ -621,6 +626,52 @@ export default function PropertyTable({
Export
</button>
)}
{/* Add properties */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition">
<PlusIcon className="h-3.5 w-3.5" />
Add properties
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 p-1.5">
<DropdownMenuItem
className="flex items-start gap-3 cursor-pointer rounded-lg p-3"
onSelect={() =>
router.push(`/portfolio/${portfolioId}/properties/add`)
}
>
<MagnifyingGlassIcon className="h-[18px] w-[18px] mt-0.5 text-gray-700 shrink-0" />
<span>
<span className="block text-sm font-semibold text-gray-900">
Search by postcode
</span>
<span className="block text-xs text-slate-400">
Find addresses and add them in a few clicks
</span>
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled
className="flex items-start gap-3 rounded-lg p-3"
>
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-400 shrink-0" />
<span className="flex-1">
<span className="block text-sm font-semibold text-gray-500">
Bulk excel import
</span>
<span className="block text-xs text-slate-400">
Upload a spreadsheet of addresses
</span>
</span>
<span className="text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-800 rounded-full px-2 py-0.5 shrink-0">
Soon
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

View file

@ -0,0 +1,411 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import {
AddressCandidate,
PostcodeGeo,
describeCandidate,
normalisePostcode,
} from "@/lib/postcodeSearch/model";
type SearchCandidate = AddressCandidate & { alreadyInPortfolio: boolean };
interface SearchResponse {
postcode: string;
geo: PostcodeGeo;
source: "cache" | "live";
cacheAgeDays: number;
candidates: SearchCandidate[];
}
// Selections survive across searches so a user can gather several postcodes
// before submitting once; each group keeps its own geography for the POST.
type Basket = Record<
string,
{ geo: PostcodeGeo; byUprn: Record<string, SearchCandidate> }
>;
const countSelected = (basket: Basket) =>
Object.values(basket).reduce((n, g) => n + Object.keys(g.byUprn).length, 0);
export default function AddPropertiesClient({
portfolioId,
portfolioName,
}: {
portfolioId: string;
portfolioName: string;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const [input, setInput] = useState("");
const [inputError, setInputError] = useState<string | null>(null);
const [search, setSearch] = useState<{ pc: string; refresh: number } | null>(null);
const [basket, setBasket] = useState<Basket>({});
const [done, setDone] = useState<{ added: number; skipped: number } | null>(null);
const query = useQuery<SearchResponse, Error>({
queryKey: ["postcode-search", portfolioId, search?.pc, search?.refresh],
enabled: !!search,
staleTime: 5 * 60 * 1000,
retry: false,
queryFn: async () => {
const params = new URLSearchParams({ postcode: search!.pc });
if (search!.refresh > 0) params.set("refresh", "1");
const res = await fetch(
`/api/portfolio/${portfolioId}/properties/search?${params}`,
);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Search failed");
return body as SearchResponse;
},
});
const submit = useMutation<
{ added: number; skipped: number },
Error,
Basket
>({
mutationFn: async (toSubmit) => {
let added = 0;
let skipped = 0;
for (const [, group] of Object.entries(toSubmit)) {
const res = await fetch(`/api/portfolio/${portfolioId}/properties`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
geo: group.geo,
candidates: Object.values(group.byUprn),
}),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Adding properties failed");
added += body.added;
skipped += body.skipped;
}
return { added, skipped };
},
onSuccess: (totals) => {
setBasket({});
setDone(totals);
// Added properties change the search's alreadyInPortfolio flags
queryClient.invalidateQueries(["postcode-search", portfolioId]);
},
});
const doSearch = () => {
const pc = normalisePostcode(input);
if (!pc) {
setInputError("That doesn't look like a full UK postcode");
return;
}
setInputError(null);
setDone(null);
setSearch({ pc, refresh: 0 });
};
const toggle = (postcode: string, geo: PostcodeGeo, c: SearchCandidate) => {
setBasket((prev) => {
const group = prev[postcode] ?? { geo, byUprn: {} };
const byUprn = { ...group.byUprn };
if (byUprn[c.uprn]) delete byUprn[c.uprn];
else byUprn[c.uprn] = c;
const next = { ...prev, [postcode]: { geo, byUprn } };
if (Object.keys(byUprn).length === 0) delete next[postcode];
return next;
});
};
const data = done ? undefined : query.data;
const selected = data ? (basket[data.postcode]?.byUprn ?? {}) : {};
const addable = data
? data.candidates.filter((c) => c.selectable && !c.alreadyInPortfolio)
: [];
const totalSelected = countSelected(basket);
const postcodes = Object.keys(basket);
return (
<div className="max-w-3xl mx-auto px-6 pt-10 pb-40">
{/* Breadcrumb */}
<div className="text-xs text-slate-400 mb-4">
<Link href={`/portfolio/${portfolioId}`} className="hover:text-primary">
{portfolioName} / Portfolio
</Link>{" "}
/ Add properties
</div>
<h1 className="text-3xl font-bold text-gray-800 tracking-tight mb-3">
Bring your properties in.
</h1>
<p className="text-sm text-slate-500 max-w-xl leading-relaxed">
Search a postcode and pick the homes that belong to this portfolio. We
identify each address against the national register type and built
form come with it, ready for modelling.
</p>
{/* Search card */}
<div className="mt-8 bg-white border border-slate-200 rounded-2xl p-5 shadow-sm">
<span className="block text-[11px] font-black uppercase tracking-wider text-primary mb-3">
Search by postcode
</span>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
}}
placeholder='e.g. "M20 4TF"'
autoFocus
spellCheck={false}
autoComplete="off"
autoCorrect="off"
className="flex-1 border border-slate-200 rounded-xl px-4 py-2.5 text-base font-semibold uppercase placeholder:normal-case placeholder:font-normal placeholder:text-sm placeholder:text-slate-400 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10"
/>
<button
onClick={doSearch}
disabled={query.isFetching}
className="flex items-center gap-1.5 px-5 rounded-xl bg-primary text-white text-sm font-semibold hover:opacity-90 transition disabled:opacity-50"
>
<MagnifyingGlassIcon className="h-4 w-4" />
{query.isFetching ? "Searching…" : "Search"}
</button>
</div>
<div className="mt-3 text-xs text-slate-400">
{inputError ? (
<span className="text-red-600">{inputError}</span>
) : query.isError ? (
<span className="text-red-600">{query.error.message}</span>
) : data ? (
data.source === "cache" ? (
<>
Results from our cache,{" "}
<b className="text-slate-600 font-semibold">
{data.cacheAgeDays === 0
? "refreshed today"
: `${data.cacheAgeDays} day${data.cacheAgeDays === 1 ? "" : "s"} old`}
</b>{" "}
·{" "}
<button
onClick={() =>
setSearch((s) => s && { ...s, refresh: s.refresh + 1 })
}
className="text-primary font-semibold hover:underline"
>
Refresh from source
</button>
</>
) : (
<>Fresh from the national register just now</>
)
) : (
<>We look every postcode up once and remember it repeat searches are instant.</>
)}
</div>
</div>
{/* Done state */}
{done && (
<div className="mt-6 bg-white border border-slate-200 rounded-2xl px-8 py-12 text-center shadow-sm">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-amber-100 text-amber-800 mb-4">
<CheckIcon className="h-7 w-7" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">
{done.added} {done.added === 1 ? "property" : "properties"} added
</h2>
<p className="text-sm text-slate-500 max-w-md mx-auto leading-relaxed mb-2">
They&apos;re in the portfolio now, marked{" "}
<span className="inline-flex items-center gap-1.5 bg-amber-100 text-amber-800 rounded-full px-2.5 py-0.5 text-xs font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-amber-800" />
Awaiting modelling
</span>{" "}
addresses are real from day one; energy data arrives when you run
a scenario against them.
</p>
{done.skipped > 0 && (
<p className="text-xs text-slate-400 mb-2">
{done.skipped} {done.skipped === 1 ? "was" : "were"} already in
the portfolio and left untouched.
</p>
)}
<div className="mt-5 flex items-center justify-center gap-3">
<button
onClick={() => setDone(null)}
className="px-4 py-2 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-primary hover:bg-slate-50 transition"
>
Add more properties
</button>
<button
onClick={() => router.push(`/portfolio/${portfolioId}`)}
className="px-4 py-2 rounded-xl bg-primary text-white text-sm font-semibold hover:opacity-90 transition"
>
View portfolio
</button>
</div>
</div>
)}
{/* Results */}
{!done && !data && !query.isFetching && (
<div className="mt-6 border-2 border-dashed border-slate-200 rounded-2xl py-10 text-center text-sm text-slate-400">
Searched addresses appear here tick the ones that belong to this
portfolio.
</div>
)}
{!done && data && (
<div className="mt-6 bg-white border border-slate-200 rounded-2xl p-5 shadow-sm">
<div className="flex items-baseline gap-3 flex-wrap mb-4">
<h2 className="text-lg font-bold text-gray-800">{data.postcode}</h2>
<span className="text-xs text-slate-400 font-medium">
{data.candidates.length}{" "}
{data.candidates.length === 1 ? "address" : "addresses"}
</span>
{addable.length > 0 && (
<>
<button
onClick={() =>
setBasket((prev) => ({
...prev,
[data.postcode]: {
geo: data.geo,
byUprn: Object.fromEntries(
addable.map((c) => [c.uprn, c]),
),
},
}))
}
className="text-xs font-semibold text-primary hover:underline"
>
Add all
</button>
<button
onClick={() =>
setBasket((prev) => {
const next = { ...prev };
delete next[data.postcode];
return next;
})
}
className="text-xs font-semibold text-primary hover:underline"
>
Clear
</button>
</>
)}
<span className="ml-auto text-xs text-slate-500 tabular-nums">
{Object.keys(selected).length} selected
</span>
</div>
{/* Long postcodes can return ~100 addresses scroll inside the card
rather than growing the page. */}
<div className="max-h-[26rem] overflow-y-auto pr-1 -mr-1">
{data.candidates.map((c) => {
const { label, note } = describeCandidate(c);
const disabled = !c.selectable || c.alreadyInPortfolio;
const isSelected = !!selected[c.uprn];
return (
<button
key={c.uprn}
disabled={disabled}
onClick={() => toggle(data.postcode, data.geo, c)}
className={[
"w-full flex items-center gap-3 text-left px-4 py-3 rounded-xl border mb-1.5 transition",
disabled
? "opacity-50 cursor-not-allowed bg-slate-50 border-transparent"
: isSelected
? "bg-white border-primary shadow-sm"
: "bg-slate-50 border-transparent hover:bg-slate-100",
].join(" ")}
>
<input
type="checkbox"
checked={isSelected}
disabled={disabled}
readOnly
tabIndex={-1}
className="h-4 w-4 accent-black pointer-events-none shrink-0"
/>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-gray-800 truncate">
{c.address}
</span>
{note && !disabled && (
<span className="block text-xs text-slate-400">{note}</span>
)}
</span>
{disabled && (
<span className="text-[11px] text-slate-400 font-medium shrink-0">
{c.alreadyInPortfolio ? "Already in portfolio" : "Not residential"}
</span>
)}
<span
className={[
"text-[10px] font-bold uppercase tracking-wide rounded-full px-2.5 py-1 shrink-0",
disabled
? "bg-slate-200 text-slate-500"
: "bg-amber-100 text-amber-900",
].join(" ")}
>
{label}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Selection tray */}
{totalSelected > 0 && !done && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 w-[min(56rem,calc(100vw-2rem))] bg-white/95 backdrop-blur border border-slate-200 rounded-2xl shadow-2xl px-4 py-3 flex items-center gap-3 flex-wrap">
{/* Many postcodes: chips scroll inside the tray instead of growing it */}
<div className="flex items-center gap-2 flex-wrap max-h-16 overflow-y-auto">
{postcodes.map((pc) => (
<span
key={pc}
className="inline-flex items-center gap-2 bg-slate-100 rounded-full pl-3 pr-1.5 py-1 text-xs font-semibold text-gray-700"
>
{pc} · {Object.keys(basket[pc].byUprn).length}
<button
aria-label={`Remove ${pc}`}
onClick={() =>
setBasket((prev) => {
const next = { ...prev };
delete next[pc];
return next;
})
}
className="w-[18px] h-[18px] rounded-full bg-white text-slate-500 text-[10px] leading-none px-1 py-0.5 hover:text-slate-800"
>
</button>
</span>
))}
</div>
<span className="text-xs text-slate-500">
<b className="text-gray-800">{totalSelected}</b>{" "}
{totalSelected === 1 ? "property" : "properties"} across{" "}
{postcodes.length} {postcodes.length === 1 ? "postcode" : "postcodes"}
</span>
{submit.isError && (
<span className="text-xs text-red-600">{submit.error.message}</span>
)}
<button
onClick={() => submit.mutate(basket)}
disabled={submit.isLoading}
className="ml-auto px-4 py-2 rounded-xl bg-primary text-white text-sm font-semibold hover:opacity-90 transition disabled:opacity-50"
>
{submit.isLoading
? "Adding…"
: `Add ${totalSelected} ${totalSelected === 1 ? "property" : "properties"}`}
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
import { getPortfolio } from "../../utils";
import AddPropertiesClient from "./AddPropertiesClient";
export default async function Page(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const { name: portfolioName } = await getPortfolio(slug);
return <AddPropertiesClient portfolioId={slug} portfolioName={portfolioName ?? "Portfolio"} />;
}

View file

@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import {
buildWritePlan,
cacheAgeDays,
classifyOsCode,
describeCandidate,
isCacheFresh,
normalisePostcode,
postcodeCacheKeys,
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 });
});
it("orders addresses numerically, so Flat 2 comes before Flat 10", () => {
const flats = [
{ DPA: { UPRN: "1", ADDRESS: "FLAT 10, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
{ DPA: { UPRN: "2", ADDRESS: "FLAT 2, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
{ DPA: { UPRN: "3", ADDRESS: "FLAT 1, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
{ DPA: { UPRN: "4", ADDRESS: "3 BIRCH LANE, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
{ DPA: { UPRN: "5", ADDRESS: "12 BIRCH LANE, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
];
const out = toAddressCandidates(flats as never, "M20 4TF");
expect(out.map((c) => c.address)).toEqual([
"3 Birch Lane",
"12 Birch Lane",
"Flat 1, Birch Court",
"Flat 2, Birch Court",
"Flat 10, Birch Court",
]);
});
});
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("describeCandidate", () => {
const base = { selectable: true, propertyType: null, builtForm: null, classificationCode: null };
it("labels fully-mapped candidates with both facts", () => {
expect(describeCandidate({ ...base, classificationCode: "RD02", propertyType: "House", builtForm: "Detached" }))
.toEqual({ label: "House · Detached", note: null });
});
it("explains why terraced houses carry no built form", () => {
expect(describeCandidate({ ...base, classificationCode: "RD04", propertyType: "House" }))
.toEqual({ label: "House", note: "Terraced — mid or end unknown" });
// Other single-fact codes carry no note
expect(describeCandidate({ ...base, classificationCode: "RD07", propertyType: "House" }))
.toEqual({ label: "House", note: null });
expect(describeCandidate({ ...base, classificationCode: "RD06", propertyType: "Flat" }))
.toEqual({ label: "Flat", note: null });
});
it("labels selectable-but-unmapped codes Residential, with the modelling note", () => {
expect(describeCandidate({ ...base, classificationCode: "RD" }))
.toEqual({ label: "Residential", note: "No classification — type comes with modelling" });
});
it("labels unselectable candidates Non-residential", () => {
expect(describeCandidate({ ...base, selectable: false, classificationCode: "CR08" }))
.toEqual({ label: "Non-residential", note: null });
});
});
describe("postcodeCacheKeys", () => {
it("reads the canonical spaced form first, then the legacy space-less form", () => {
expect(postcodeCacheKeys("M20 4TF")).toEqual(["M20 4TF", "M204TF"]);
});
});
describe("cacheAgeDays", () => {
const now = new Date("2026-07-06T12:00:00Z");
it("floors to whole days", () => {
expect(cacheAgeDays(new Date("2026-07-03T11:00:00Z"), now)).toBe(3);
expect(cacheAgeDays(new Date("2026-07-06T09:00:00Z"), now)).toBe(0);
});
});
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,192 @@
/**
* 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); ordered naturally so
* "Flat 2" sorts before "Flat 10".
*/
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,
};
}).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 + 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,
};
}
/**
* 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,
};
}