feat(portfolio): match unmatched properties to a residential UPRN

Turns the "Needs attention" tab into an actionable per-property match flow:
edit the address, confirm the postcode, search Ordnance Survey, and pick from
the RESIDENTIAL addresses in that postcode. Every candidate is a real OS
address, so picking one assigns a guaranteed valid, residential UPRN — the
property then leaves the tab and joins the main table.

- MatchAddress dialog: postcode search + residential-only candidate list
  (non-residential filtered out; already-in-portfolio shown but non-selectable,
  each row shows its UPRN). Replaces the edit-only EditAddress.
- PATCH /properties/[propertyId]: assigns uprn + address + postcode (user input
  kept as the fallback); (portfolio_id, uprn) clash → friendly 409.
- client.ts: searchAddresses() + useAssignUprn().

Reuses the existing /properties/search endpoint, which read-through caches OS
Places results per postcode in postcode_search (30-day TTL) — repeat searches
of the same postcode hit our DB, not the OS API (surfaced as a "from cache"
hint in the dialog). No backend trigger needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-07 11:33:01 +00:00
parent aab3984b5c
commit 3f8920acd0
5 changed files with 339 additions and 157 deletions

View file

@ -6,18 +6,26 @@ import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { and, eq } from "drizzle-orm";
// Edit an unmatched property's address / postcode. Keyed by (property,
// portfolio) so a caller can only touch a property in a portfolio they
// addressed. UPRN assignment (via the address search) is deliberately out of
// scope here — this only corrects the address text.
const patchSchema = z
.object({
address: z.string().trim().optional(),
postcode: z.string().trim().optional(),
})
.refine((v) => v.address !== undefined || v.postcode !== undefined, {
message: "Nothing to update",
});
// Assign a chosen Ordnance Survey address (from the postcode search) to an
// existing property: writes the canonical uprn/address/postcode and keeps what
// the user typed as the user-inputted fallback. Keyed by (property, portfolio)
// so a caller can only touch a property in a portfolio they addressed. The
// candidate is guaranteed residential + UPRN-bearing by the picker UI.
const patchSchema = z.object({
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
address: z.string().trim().min(1, "Address is required"),
postcode: z.string().trim().min(1, "Postcode is required"),
// What the user typed in the edit fields — kept as the raw fallback, mirroring
// how the bulk finaliser stores user_inputted_* alongside the matched values.
userInputtedAddress: z.string().trim().optional(),
userInputtedPostcode: z.string().trim().optional(),
});
function isUniqueViolation(err: unknown): boolean {
const code = (err as { code?: string })?.code;
const message = (err as { message?: string })?.message ?? "";
return code === "23505" || message.includes("uq_property_portfolio_uprn");
}
export async function PATCH(
request: NextRequest,
@ -40,25 +48,43 @@ export async function PATCH(
{ status: 400 },
);
}
const { address, postcode } = parsed.data;
const { uprn, address, postcode, userInputtedAddress, userInputtedPostcode } =
parsed.data;
const updated = await db
.update(property)
.set({
...(address !== undefined ? { address } : {}),
...(postcode !== undefined ? { postcode } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(property.id, BigInt(propertyId)),
eq(property.portfolioId, BigInt(portfolioId)),
),
)
.returning({ id: property.id });
try {
const updated = await db
.update(property)
.set({
uprn: BigInt(uprn),
address,
postcode,
...(userInputtedAddress !== undefined ? { userInputtedAddress } : {}),
...(userInputtedPostcode !== undefined ? { userInputtedPostcode } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(property.id, BigInt(propertyId)),
eq(property.portfolioId, BigInt(portfolioId)),
),
)
.returning({ id: property.id });
if (updated.length === 0) {
return NextResponse.json({ error: "Property not found" }, { status: 404 });
if (updated.length === 0) {
return NextResponse.json({ error: "Property not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
} catch (err) {
if (isUniqueViolation(err)) {
return NextResponse.json(
{
error:
"That UPRN is already assigned to another property in this portfolio.",
},
{ status: 409 },
);
}
console.error("PATCH /properties/[propertyId] error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

View file

@ -1,120 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { PencilSquareIcon } from "@heroicons/react/24/outline";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { useUpdateAddress } from "@/lib/properties/client";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
// Row action for an unmatched property: correct its address / postcode. UPRN
// matching is a later step — this just saves the text. On success the page
// re-fetches so the row shows the updated values.
export default function EditAddress({
property,
portfolioId,
}: {
property: UnmatchedProperty;
portfolioId: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [address, setAddress] = useState(property.address ?? "");
const [postcode, setPostcode] = useState(property.postcode ?? "");
const update = useUpdateAddress(portfolioId, property.id);
function onSave() {
update.mutate(
{ address: address.trim(), postcode: postcode.trim() },
{
onSuccess: () => {
setOpen(false);
router.refresh();
},
},
);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
>
<PencilSquareIcon className="h-3.5 w-3.5" />
Edit address
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit address</DialogTitle>
<DialogDescription>
Correct the address and postcode for this property.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-semibold text-gray-600">
Address
</label>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="e.g. 20 Brenchley Road"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-gray-600">
Postcode
</label>
<input
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
placeholder="e.g. BR5 2TD"
onKeyDown={(e) => {
if (e.key === "Enter" && !update.isPending) onSave();
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
</div>
<DialogFooter>
{update.error && (
<p className="mr-auto self-center text-xs text-red-600">
{update.error.message}
</p>
)}
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
<button
type="button"
onClick={onSave}
disabled={update.isPending}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
{update.isPending ? "Saving…" : "Save"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,239 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
MagnifyingGlassIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { describeCandidate } from "@/lib/postcodeSearch/model";
import {
searchAddresses,
useAssignUprn,
type SearchCandidate,
} from "@/lib/properties/client";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
// Row action for an unmatched property: edit the address, confirm the postcode,
// search Ordnance Survey, and pick from the RESIDENTIAL addresses in that
// postcode. Picking assigns that candidate's UPRN — every candidate is a real
// OS address, so this guarantees a valid, residential UPRN. On success the
// property leaves the "Needs attention" tab (the page re-fetches).
export default function MatchAddress({
property,
portfolioId,
}: {
property: UnmatchedProperty;
portfolioId: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [address, setAddress] = useState(property.address ?? "");
const [postcode, setPostcode] = useState(property.postcode ?? "");
// null = not searched yet; [] = searched, no residential matches.
const [residential, setResidential] = useState<SearchCandidate[] | null>(null);
const [selected, setSelected] = useState<SearchCandidate | null>(null);
const [source, setSource] = useState<"cache" | "live" | null>(null);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const assign = useAssignUprn(portfolioId, property.id);
async function runSearch() {
setSearching(true);
setSearchError(null);
setResidential(null);
setSelected(null);
try {
const result = await searchAddresses(portfolioId, postcode);
// Residential only — we never assign a non-residential UPRN.
setResidential(result.candidates.filter((c) => c.selectable));
setSource(result.source);
} catch (e) {
setSearchError(e instanceof Error ? e.message : "Search failed.");
} finally {
setSearching(false);
}
}
function onAssign() {
if (!selected) return;
assign.mutate(
{
uprn: selected.uprn,
address: selected.address,
postcode: selected.postcode,
userInputtedAddress: address.trim() || undefined,
userInputtedPostcode: postcode.trim() || undefined,
},
{
onSuccess: () => {
setOpen(false);
router.refresh();
},
},
);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
>
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
Find UPRN
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Match to a UPRN</DialogTitle>
<DialogDescription>
Confirm the postcode and pick the matching address. Only
residential addresses from Ordnance Survey are shown, so the UPRN
is always valid.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-semibold text-gray-600">
Address
</label>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="e.g. 20 Brenchley Road"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-semibold text-gray-600">
Postcode
</label>
<input
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
placeholder="e.g. BR5 2TD"
onKeyDown={(e) => {
if (e.key === "Enter" && postcode.trim() && !searching)
runSearch();
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<button
type="button"
onClick={runSearch}
disabled={!postcode.trim() || searching}
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
<MagnifyingGlassIcon className="h-4 w-4" />
{searching ? "Searching…" : "Search"}
</button>
</div>
{searchError && <p className="text-xs text-red-600">{searchError}</p>}
{residential && (
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600">
Residential addresses
</p>
{source === "cache" && (
<span className="text-[10px] font-medium uppercase tracking-wide text-gray-400">
from cache
</span>
)}
</div>
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-100">
{residential.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-gray-400">
No residential addresses found for that postcode.
</p>
) : (
<ul className="divide-y divide-gray-50">
{residential.map((c) => {
const { label } = describeCandidate(c);
const disabled = c.alreadyInPortfolio;
const isSelected = selected?.uprn === c.uprn;
return (
<li key={c.uprn}>
<button
type="button"
disabled={disabled}
onClick={() => setSelected(c)}
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
disabled
? "cursor-not-allowed text-gray-400"
: isSelected
? "bg-amber-50"
: "hover:bg-gray-50"
}`}
>
<CheckCircleIcon
className={`mt-0.5 h-4 w-4 shrink-0 ${
isSelected ? "text-amber-600" : "text-gray-200"
}`}
/>
<span className="min-w-0">
<span className="block truncate text-gray-800">
{c.address}
</span>
<span className="text-[11px] text-gray-500">
UPRN {c.uprn} · {label}
{c.alreadyInPortfolio
? " · already in portfolio"
: ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
<DialogFooter>
{assign.error && (
<p className="mr-auto self-center text-xs text-red-600">
{assign.error.message}
</p>
)}
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
<button
type="button"
onClick={onAssign}
disabled={!selected || assign.isPending}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
{assign.isPending ? "Assigning…" : "Assign UPRN"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -3,7 +3,7 @@ import {
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
import EditAddress from "./EditAddress";
import MatchAddress from "./MatchAddress";
// The "Needs attention" tab: properties with no UPRN, separated out of the main
// table until they're matched. Each row lets the user correct the address /
@ -81,7 +81,7 @@ export default function UnmatchedProperties({
{p.postcode ?? <span className="text-gray-400"></span>}
</p>
<div className="col-span-3 flex justify-end">
<EditAddress property={p} portfolioId={portfolioId} />
<MatchAddress property={p} portfolioId={portfolioId} />
</div>
</li>
))}

View file

@ -7,14 +7,51 @@ async function parseError(res: Response, fallback: string): Promise<Error> {
return new Error(body?.error ?? fallback);
}
export interface UpdateAddressInput {
// One address candidate returned by the portfolio postcode search, plus the
// server-computed flag for candidates already assigned in this portfolio.
export interface SearchCandidate {
uprn: string;
address: string;
postcode: string;
classificationCode: string | null;
selectable: boolean; // residential (per OS classification)
propertyType: string | null;
builtForm: string | null;
alreadyInPortfolio: boolean;
}
// Save an edited address / postcode onto a property.
export function useUpdateAddress(portfolioId: string, propertyId: string) {
return useMutation<{ ok: true }, Error, UpdateAddressInput>({
export interface AddressSearchResult {
postcode: string;
source: "cache" | "live";
candidates: SearchCandidate[];
}
// Run the postcode → address-candidate search. Reuses the add-properties
// endpoint, which read-through caches OS Places results per postcode in the
// postcode_search table (30-day TTL) — so repeat searches of the same postcode
// hit our DB, not the OS API. Throws with the server's { error } on failure.
export async function searchAddresses(
portfolioId: string,
postcode: string,
): Promise<AddressSearchResult> {
const res = await fetch(
`/api/portfolio/${portfolioId}/properties/search?postcode=${encodeURIComponent(postcode)}`,
);
if (!res.ok) throw await parseError(res, "Address search failed.");
return res.json();
}
export interface AssignUprnInput {
uprn: string;
address: string;
postcode: string;
userInputtedAddress?: string;
userInputtedPostcode?: string;
}
// Assign a chosen candidate's UPRN + address to a property.
export function useAssignUprn(portfolioId: string, propertyId: string) {
return useMutation<{ ok: true }, Error, AssignUprnInput>({
mutationFn: async (input) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/properties/${propertyId}`,
@ -24,7 +61,7 @@ export function useUpdateAddress(portfolioId: string, propertyId: string) {
body: JSON.stringify(input),
},
);
if (!res.ok) throw await parseError(res, "Failed to save address.");
if (!res.ok) throw await parseError(res, "Failed to assign UPRN.");
return res.json();
},
});