From b86efddfdf71362bdb4257cfdee7ad573fdee5fd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 13:07:43 +0000 Subject: [PATCH 1/6] chore(adr): stop gitignoring docs/adr; add ADR-0007 for postcode-search property creation The docs/adr/** ignore rule silently ate ADRs 0004-0006 that CONTEXT.md still references. Remove it so decisions actually land in the repo. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 - ...0007-postcode-search-creates-properties.md | 60 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0007-postcode-search-creates-properties.md diff --git a/.gitignore b/.gitignore index b26a69b4..2d9e9175 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,6 @@ next-env.d.ts backlog/** -docs/adr/** # Personal Claude Code settings (per-developer, not shared) .claude/settings.local.json diff --git a/docs/adr/0007-postcode-search-creates-properties.md b/docs/adr/0007-postcode-search-creates-properties.md new file mode 100644 index 00000000..ca158d4b --- /dev/null +++ b/docs/adr/0007-postcode-search-creates-properties.md @@ -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); +0004–0006 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. From 5b224677dc619400c5472e380a0bb67e8b71fa1b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 13:32:18 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(postcode-search):=20pure=20domain=20co?= =?UTF-8?q?re=20=E2=80=94=20normalisation,=20OS-code=20mapping,=20write=20?= =?UTF-8?q?plan?= 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, + }; +} From 302e200ed67d10d524598b1b7ddd7d374ef78a5f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 13:38:54 +0000 Subject: [PATCH 3/6] docs: handover for postcode-search build continuation Co-Authored-By: Claude Fable 5 --- docs/wip/postcode-search-handover.md | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/wip/postcode-search-handover.md diff --git a/docs/wip/postcode-search-handover.md b/docs/wip/postcode-search-handover.md new file mode 100644 index 00000000..abb69454 --- /dev/null +++ b/docs/wip/postcode-search-handover.md @@ -0,0 +1,58 @@ +# Handover: Add properties by postcode search + +**Branch:** `feature/add-properties-by-postcode` (2 commits pushed, tree clean). +**Context date:** 2026-07-06. **Goal journey:** add properties → create scenarios (done, PR #353) → trigger modelling (soon). + +## State + +Done and verified (`npx tsc --noEmit` clean, 329 tests pass): +- **Domain core** `src/lib/postcodeSearch/model.ts` + `model.test.ts` (9 tests): + `normalisePostcode`, `classifyOsCode`, `isCacheFresh` (30-day), `toAddressCandidates` + (DPA>LPI, UPRN-dedup, postcode-stripped title-case address), `buildWritePlan` + (property row + 0–2 override facts). +- **ADR-0007** (`docs/adr/`) — all decisions + rationale. `docs/adr/**` gitignore rule removed + (it had eaten ADRs 0004–0006; ask team if recoverable). +- **CONTEXT.md** — "Postcode search" entry; VocabularyMapping now has two producers. +- `os_places` added to `OverrideSourceValues` (schema only — **migration not yet generated**). +- **Approved design mockup** (build the page to this): https://claude.ai/code/artifact/d05a3ab0-48ed-41c3-94b4-b13106b7f372 + +## Remaining work (in order) + +1. **Enum migration**: run `./generate_migration.sh` for `override_source` + `os_places` + (no `.sql` migrations live in-repo; script is repo-root). +2. **Routes** under `src/app/api/portfolio/[portfolioId]/properties/`: + - `search/route.ts` GET `?postcode=`: normalise → `postcode_search` cache read + (`isCacheFresh`; row has unique `postcode` + jsonb `resultData`) → on miss/stale call + `lookupPostcode` (`src/app/api/postcode/LookupPostcode.ts`; **add + `parliamentary_constituency` to its result type**) + OS pages fetch + (`LookupOsPlaces.ts`) → upsert cache → return `toAddressCandidates` + geo + + `alreadyInPortfolio` flags (one query: uprns in portfolio) + cache age. + - `route.ts` POST submit `{candidates, geo}` (re-validate server-side): transaction over + `buildWritePlan` rows — insert `property` `ON CONFLICT DO NOTHING` (partial unique + `uq_property_portfolio_uprn`), upsert `landlord_property_type_overrides` / + `landlord_built_form_type_overrides` (`(portfolio_id, description)` unique, + description = OS code, source `os_places`), insert `property_overrides` snapshots + (`building_part=0`, `original_spreadsheet_description` = OS code). Return + `{added, skipped}`. Next-15 rules: `await params`, `{ error }` shape. +3. **Page** `/portfolio/[slug]/properties/add` + **"Add properties" dropdown** beside Export + in `src/app/portfolio/[slug]/components/PropertyTable.tsx` ("Search by postcode" + + "Bulk excel import — Soon"). Then retire toolbar `AddNew` dropdown. TanStack Query v4 + mutations; no useEffect/useMemo (CLAUDE.md). + +## How to verify (patterns proven this session) + +- DB: `.env.local` creds; NODE_PATH node scripts with `pg` (`ssl:{rejectUnauthorized:false}`). +- Live app: `./node_modules/.bin/next dev -p 3111`; mint a session cookie with + `next-auth/jwt` `encode` + `NEXTAUTH_SECRET` from `.env.local`, email `@domna.homes`, + cookie `next-auth.session-token`. Server-rendered data is greppable in the RSC payload. +- Test portfolio: **814** (new-approach). Searched-in properties will render blank/awaiting + modelling — that's by design (ADR-0007), not a bug. +- `OS_API_KEY` env var powers OS Places. + +## Gotchas + +- Properties inserted now are "new approach" (`updated_at >= 2026-06-01`, `epcSources.ts`) — + every read path expects an EPC graph that won't exist until modelling. Expected. +- Never per-scenario/per-property probe loops on `plan` (1.6M rows) — grouped/scoped queries + only (see `docs/wip/new-modelling-ui-handover.md` perf notes). +- PR #353 (Scenarios tab) is separate, open; its ADR is numbered 0003. From 8a0ad0c0dc01210d9a882bc26f296a95aa230e39 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 14:33:22 +0000 Subject: [PATCH 4/6] feat(postcode-search): routes, add-properties page, PropertyTable entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the add-properties-by-postcode journey (ADR-0007) on top of the TDD'd domain core: - GET /api/portfolio/[id]/properties/search?postcode= — postcodes.io for validation + geography (admin_district, parliamentary_constituency) every time; OS Places through the 30-day postcode_search read-through cache (refresh=1 bypasses it). Returns candidates with alreadyInPortfolio flags from one grouped uprn query. Cache reads accept legacy space-less keys; writes use the canonical spaced form. - POST /api/portfolio/[id]/properties — re-derives facts server-side from classification codes (client propertyType never trusted), then one transaction: property rows via uq_property_portfolio_uprn ON CONFLICT DO NOTHING, vocabulary upserts with os_places provenance (DO NOTHING preserves user rows; snapshots mirror the resolved value), property_overrides snapshots (building_part 0, original description = OS code), and property_details_spatial per-property coordinates. Returns {added, skipped}. - OS Places fetches now request output_srs=EPSG:4326 so records carry LAT/LNG (no mapper read the BNG X/Y coordinates). - /portfolio/[slug]/properties/add built to the approved mockup: search card with cache-age line, per-postcode result cards, cross-postcode selection tray, done state. TanStack Query v4, no useEffect/useMemo. - "Add properties" dropdown beside Export in PropertyTable (search by postcode + bulk excel import "Soon"); toolbar AddNew dropdown retired. - New display/cache helpers (describeCandidate, postcodeCacheKeys, cacheAgeDays) TDD'd in the domain core. Verified live against portfolio 814: search (cache + refresh), submit (added 2, all four write layers checked in DB), dedup re-submit (skipped 2), alreadyInPortfolio flags, page + dropdown render. Co-Authored-By: Claude Fable 5 --- .../[portfolioId]/properties/route.ts | 197 +++++++++ .../[portfolioId]/properties/search/route.ts | 126 ++++++ src/app/api/postcode/LookupOsPlaces.ts | 5 +- src/app/api/postcode/LookupPostcode.ts | 1 + src/app/components/portfolio/AddNew.tsx | 158 ------- src/app/components/portfolio/Toolbar.tsx | 19 +- .../portfolio/[slug]/(portfolio)/layout.tsx | 6 +- .../[slug]/components/PropertyTable.tsx | 53 ++- .../properties/add/AddPropertiesClient.tsx | 401 ++++++++++++++++++ .../portfolio/[slug]/properties/add/page.tsx | 11 + src/lib/postcodeSearch/model.test.ts | 42 ++ src/lib/postcodeSearch/model.ts | 39 ++ 12 files changed, 876 insertions(+), 182 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/properties/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/properties/search/route.ts delete mode 100644 src/app/components/portfolio/AddNew.tsx create mode 100644 src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx create mode 100644 src/app/portfolio/[slug]/properties/add/page.tsx diff --git a/src/app/api/portfolio/[portfolioId]/properties/route.ts b/src/app/api/portfolio/[portfolioId]/properties/route.ts new file mode 100644 index 00000000..0e35c1e6 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/properties/route.ts @@ -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; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + const seen = new Set(); + const plans: ReturnType[] = []; + 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(); + 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(); + 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 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/properties/search/route.ts b/src/app/api/portfolio/[portfolioId]/properties/search/route.ts new file mode 100644 index 00000000..97671b4a --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/properties/search/route.ts @@ -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 }); + } +} diff --git a/src/app/api/postcode/LookupOsPlaces.ts b/src/app/api/postcode/LookupOsPlaces.ts index 720e984f..1998ccfa 100644 --- a/src/app/api/postcode/LookupOsPlaces.ts +++ b/src/app/api/postcode/LookupOsPlaces.ts @@ -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" }); diff --git a/src/app/api/postcode/LookupPostcode.ts b/src/app/api/postcode/LookupPostcode.ts index c9376f42..9e240a1e 100644 --- a/src/app/api/postcode/LookupPostcode.ts +++ b/src/app/api/postcode/LookupPostcode.ts @@ -7,6 +7,7 @@ export interface PostcodeLookupResult { country: string; region: string | null; admin_district: string | null; + parliamentary_constituency: string | null; latitude: number; longitude: number; }; diff --git a/src/app/components/portfolio/AddNew.tsx b/src/app/components/portfolio/AddNew.tsx deleted file mode 100644 index cef42cb9..00000000 --- a/src/app/components/portfolio/AddNew.tsx +++ /dev/null @@ -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>; -} - -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 ( - <> - setIsBulkUploadOpen(false)} - portfolioId={portfolioId} - /> - - - - New Property - - - - -
- {/* Remote Assessment */} - - {({ active }) => ( - - )} - - - {/* CSV Upload */} - - {({ active }) => ( - - )} - - - {/* Bulk Upload (Coming Soon) */} - - {({ active }) => ( - - )} - -
-
-
- - ); -} diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 5f50f030..05c2bfa8 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -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(null); const [isPending, startTransition] = useTransition(); @@ -110,11 +105,6 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { ); })} - {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) { - - ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/layout.tsx index ac7be23c..bf5c76d9 100644 --- a/src/app/portfolio/[slug]/(portfolio)/layout.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/layout.tsx @@ -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 (
@@ -29,7 +27,7 @@ export default async function PortfolioLayout(props: {
- +
{children} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index e12550ea..8fd7aed1 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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() {

- Hover over “New Property” to start adding + Use “Add properties” to start adding properties to your portfolio.

@@ -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 )} + + {/* Add properties */} + + + + + + + router.push(`/portfolio/${portfolioId}/properties/add`) + } + > + + + + Search by postcode + + + Find addresses and add them in a few clicks + + + + + + + + Bulk excel import + + + Upload a spreadsheet of addresses + + + + Soon + + + +
diff --git a/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx new file mode 100644 index 00000000..34cfe027 --- /dev/null +++ b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx @@ -0,0 +1,401 @@ +"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 } +>; + +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(null); + const [search, setSearch] = useState<{ pc: string; refresh: number } | null>(null); + const [basket, setBasket] = useState({}); + const [done, setDone] = useState<{ added: number; skipped: number } | null>(null); + + const query = useQuery({ + 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 ( +
+ {/* Breadcrumb */} +
+ + {portfolioName} / Portfolio + {" "} + / Add properties +
+ +

+ Bring your properties in. +

+

+ 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. +

+ + {/* Search card */} +
+ + Search by postcode + +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") doSearch(); + }} + placeholder='e.g. "M20 4TF"' + autoFocus + 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" + /> + +
+
+ {inputError ? ( + {inputError} + ) : query.isError ? ( + {query.error.message} + ) : data ? ( + data.source === "cache" ? ( + <> + Results from our cache,{" "} + + {data.cacheAgeDays === 0 + ? "refreshed today" + : `${data.cacheAgeDays} day${data.cacheAgeDays === 1 ? "" : "s"} old`} + {" "} + ·{" "} + + + ) : ( + <>Fresh from the national register just now + ) + ) : ( + <>We look every postcode up once and remember it — repeat searches are instant. + )} +
+
+ + {/* Done state */} + {done && ( +
+
+ +
+

+ {done.added} {done.added === 1 ? "property" : "properties"} added +

+

+ They're in the portfolio now, marked{" "} + + + Awaiting modelling + {" "} + — addresses are real from day one; energy data arrives when you run + a scenario against them. +

+ {done.skipped > 0 && ( +

+ {done.skipped} {done.skipped === 1 ? "was" : "were"} already in + the portfolio and left untouched. +

+ )} +
+ + +
+
+ )} + + {/* Results */} + {!done && !data && !query.isFetching && ( +
+ Searched addresses appear here — tick the ones that belong to this + portfolio. +
+ )} + + {!done && data && ( +
+
+

{data.postcode}

+ + {data.candidates.length}{" "} + {data.candidates.length === 1 ? "address" : "addresses"} + + {addable.length > 0 && ( + <> + + + + )} + + {Object.keys(selected).length} selected + +
+ + {data.candidates.map((c) => { + const { label, note } = describeCandidate(c); + const disabled = !c.selectable || c.alreadyInPortfolio; + const isSelected = !!selected[c.uprn]; + return ( + + ); + })} +
+ )} + + {/* Selection tray */} + {totalSelected > 0 && !done && ( +
+ {postcodes.map((pc) => ( + + {pc} · {Object.keys(basket[pc].byUprn).length} + + + ))} + + {totalSelected}{" "} + {totalSelected === 1 ? "property" : "properties"} across{" "} + {postcodes.length} {postcodes.length === 1 ? "postcode" : "postcodes"} + + {submit.isError && ( + {submit.error.message} + )} + +
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/properties/add/page.tsx b/src/app/portfolio/[slug]/properties/add/page.tsx new file mode 100644 index 00000000..9d4139d7 --- /dev/null +++ b/src/app/portfolio/[slug]/properties/add/page.tsx @@ -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 ; +} diff --git a/src/lib/postcodeSearch/model.test.ts b/src/lib/postcodeSearch/model.test.ts index 335d7d96..5268f10d 100644 --- a/src/lib/postcodeSearch/model.test.ts +++ b/src/lib/postcodeSearch/model.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { buildWritePlan, + cacheAgeDays, classifyOsCode, + describeCandidate, isCacheFresh, normalisePostcode, + postcodeCacheKeys, toAddressCandidates, } from "./model"; @@ -91,6 +94,45 @@ describe("buildWritePlan", () => { }); }); +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"); diff --git a/src/lib/postcodeSearch/model.ts b/src/lib/postcodeSearch/model.ts index 364b2796..92bb9d09 100644 --- a/src/lib/postcodeSearch/model.ts +++ b/src/lib/postcodeSearch/model.ts @@ -136,6 +136,45 @@ export function buildWritePlan(c: AddressCandidate, geo: PostcodeGeo) { }; } +/** + * 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(); From 158d5ded92c87a8d831164dae3fde9187c4afc49 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 15:13:49 +0000 Subject: [PATCH 5/6] fix(postcode-search): PR #355 review feedback - Order address candidates naturally so Flat 2 sorts before Flat 10 (TDD'd in the domain core; numeric locale compare). - Disable spell check / autocomplete / autocorrect on the postcode input. - Long result lists and the tray's postcode chips now scroll inside their containers instead of growing the page. - gitignore docs/wip/** and untrack the postcode-search handover doc. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + docs/wip/postcode-search-handover.md | 58 ------------------- .../properties/add/AddPropertiesClient.tsx | 10 ++++ src/lib/postcodeSearch/model.test.ts | 18 ++++++ src/lib/postcodeSearch/model.ts | 7 ++- 5 files changed, 36 insertions(+), 60 deletions(-) delete mode 100644 docs/wip/postcode-search-handover.md diff --git a/.gitignore b/.gitignore index 2d9e9175..04f701b5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ next-env.d.ts backlog/** +# Session working notes / handovers — not for review (PR #355 feedback) +docs/wip/** + # Personal Claude Code settings (per-developer, not shared) .claude/settings.local.json diff --git a/docs/wip/postcode-search-handover.md b/docs/wip/postcode-search-handover.md deleted file mode 100644 index abb69454..00000000 --- a/docs/wip/postcode-search-handover.md +++ /dev/null @@ -1,58 +0,0 @@ -# Handover: Add properties by postcode search - -**Branch:** `feature/add-properties-by-postcode` (2 commits pushed, tree clean). -**Context date:** 2026-07-06. **Goal journey:** add properties → create scenarios (done, PR #353) → trigger modelling (soon). - -## State - -Done and verified (`npx tsc --noEmit` clean, 329 tests pass): -- **Domain core** `src/lib/postcodeSearch/model.ts` + `model.test.ts` (9 tests): - `normalisePostcode`, `classifyOsCode`, `isCacheFresh` (30-day), `toAddressCandidates` - (DPA>LPI, UPRN-dedup, postcode-stripped title-case address), `buildWritePlan` - (property row + 0–2 override facts). -- **ADR-0007** (`docs/adr/`) — all decisions + rationale. `docs/adr/**` gitignore rule removed - (it had eaten ADRs 0004–0006; ask team if recoverable). -- **CONTEXT.md** — "Postcode search" entry; VocabularyMapping now has two producers. -- `os_places` added to `OverrideSourceValues` (schema only — **migration not yet generated**). -- **Approved design mockup** (build the page to this): https://claude.ai/code/artifact/d05a3ab0-48ed-41c3-94b4-b13106b7f372 - -## Remaining work (in order) - -1. **Enum migration**: run `./generate_migration.sh` for `override_source` + `os_places` - (no `.sql` migrations live in-repo; script is repo-root). -2. **Routes** under `src/app/api/portfolio/[portfolioId]/properties/`: - - `search/route.ts` GET `?postcode=`: normalise → `postcode_search` cache read - (`isCacheFresh`; row has unique `postcode` + jsonb `resultData`) → on miss/stale call - `lookupPostcode` (`src/app/api/postcode/LookupPostcode.ts`; **add - `parliamentary_constituency` to its result type**) + OS pages fetch - (`LookupOsPlaces.ts`) → upsert cache → return `toAddressCandidates` + geo + - `alreadyInPortfolio` flags (one query: uprns in portfolio) + cache age. - - `route.ts` POST submit `{candidates, geo}` (re-validate server-side): transaction over - `buildWritePlan` rows — insert `property` `ON CONFLICT DO NOTHING` (partial unique - `uq_property_portfolio_uprn`), upsert `landlord_property_type_overrides` / - `landlord_built_form_type_overrides` (`(portfolio_id, description)` unique, - description = OS code, source `os_places`), insert `property_overrides` snapshots - (`building_part=0`, `original_spreadsheet_description` = OS code). Return - `{added, skipped}`. Next-15 rules: `await params`, `{ error }` shape. -3. **Page** `/portfolio/[slug]/properties/add` + **"Add properties" dropdown** beside Export - in `src/app/portfolio/[slug]/components/PropertyTable.tsx` ("Search by postcode" + - "Bulk excel import — Soon"). Then retire toolbar `AddNew` dropdown. TanStack Query v4 - mutations; no useEffect/useMemo (CLAUDE.md). - -## How to verify (patterns proven this session) - -- DB: `.env.local` creds; NODE_PATH node scripts with `pg` (`ssl:{rejectUnauthorized:false}`). -- Live app: `./node_modules/.bin/next dev -p 3111`; mint a session cookie with - `next-auth/jwt` `encode` + `NEXTAUTH_SECRET` from `.env.local`, email `@domna.homes`, - cookie `next-auth.session-token`. Server-rendered data is greppable in the RSC payload. -- Test portfolio: **814** (new-approach). Searched-in properties will render blank/awaiting - modelling — that's by design (ADR-0007), not a bug. -- `OS_API_KEY` env var powers OS Places. - -## Gotchas - -- Properties inserted now are "new approach" (`updated_at >= 2026-06-01`, `epcSources.ts`) — - every read path expects an EPC graph that won't exist until modelling. Expected. -- Never per-scenario/per-property probe loops on `plan` (1.6M rows) — grouped/scoped queries - only (see `docs/wip/new-modelling-ui-handover.md` perf notes). -- PR #353 (Scenarios tab) is separate, open; its ADR is numbered 0003. diff --git a/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx index 34cfe027..1e9ed6a1 100644 --- a/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx +++ b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx @@ -160,6 +160,9 @@ export default function AddPropertiesClient({ }} 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" />
+ {/* Long postcodes can return ~100 addresses — scroll inside the card + rather than growing the page. */} +
{data.candidates.map((c) => { const { label, note } = describeCandidate(c); const disabled = !c.selectable || c.alreadyInPortfolio; @@ -350,12 +356,15 @@ export default function AddPropertiesClient({ ); })} +
)} {/* Selection tray */} {totalSelected > 0 && !done && (
+ {/* Many postcodes: chips scroll inside the tray instead of growing it */} +
{postcodes.map((pc) => ( ))} +
{totalSelected}{" "} {totalSelected === 1 ? "property" : "properties"} across{" "} diff --git a/src/lib/postcodeSearch/model.test.ts b/src/lib/postcodeSearch/model.test.ts index 5268f10d..3c1449eb 100644 --- a/src/lib/postcodeSearch/model.test.ts +++ b/src/lib/postcodeSearch/model.test.ts @@ -62,6 +62,24 @@ describe("toAddressCandidates", () => { }); 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", () => { diff --git a/src/lib/postcodeSearch/model.ts b/src/lib/postcodeSearch/model.ts index 92bb9d09..289cdb2d 100644 --- a/src/lib/postcodeSearch/model.ts +++ b/src/lib/postcodeSearch/model.ts @@ -58,7 +58,8 @@ const titleCase = (s: string) => /** * 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). + * suffix stripped (it lives in its own column); ordered naturally so + * "Flat 2" sorts before "Flat 10". */ export function toAddressCandidates( items: OsItem[], @@ -90,7 +91,9 @@ export function toAddressCandidates( 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 { From b2d91504f0fbdbd3e7c6df450a2a662af23f6f7b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 15:17:22 +0000 Subject: [PATCH 6/6] docs(context): postcode-search entry + VocabularyMapping's second producer Left uncommitted by the previous session; restores the "### Building parts" heading it had accidentally swallowed. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index cdd69ab7..3105f6a3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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**: