feat(postcode-search): routes, add-properties page, PropertyTable entry point

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 14:33:22 +00:00
parent 463e17debc
commit 8a0ad0c0dc
12 changed files with 876 additions and 182 deletions

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,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<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
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>
{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>
)}
{/* 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">
{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>
))}
<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

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

View file

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