From 3f8920acd00563c983db91bcf4364223c1f1c4fa Mon Sep 17 00:00:00 2001
From: Jun-te Kim
Date: Tue, 7 Jul 2026 11:33:01 +0000
Subject: [PATCH] feat(portfolio): match unmatched properties to a residential
UPRN
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../properties/[propertyId]/route.ts | 86 ++++---
.../[slug]/components/EditAddress.tsx | 120 ---------
.../[slug]/components/MatchAddress.tsx | 239 ++++++++++++++++++
.../[slug]/components/UnmatchedProperties.tsx | 4 +-
src/lib/properties/client.ts | 47 +++-
5 files changed, 339 insertions(+), 157 deletions(-)
delete mode 100644 src/app/portfolio/[slug]/components/EditAddress.tsx
create mode 100644 src/app/portfolio/[slug]/components/MatchAddress.tsx
diff --git a/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts
index 4bf3ecbe..16b3e28d 100644
--- a/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts
+++ b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts
@@ -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 });
}
diff --git a/src/app/portfolio/[slug]/components/EditAddress.tsx b/src/app/portfolio/[slug]/components/EditAddress.tsx
deleted file mode 100644
index 087d1252..00000000
--- a/src/app/portfolio/[slug]/components/EditAddress.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
-
- >
- );
-}
diff --git a/src/app/portfolio/[slug]/components/MatchAddress.tsx b/src/app/portfolio/[slug]/components/MatchAddress.tsx
new file mode 100644
index 00000000..b446bd00
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/MatchAddress.tsx
@@ -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(null);
+ const [selected, setSelected] = useState(null);
+ const [source, setSource] = useState<"cache" | "live" | null>(null);
+ const [searching, setSearching] = useState(false);
+ const [searchError, setSearchError] = useState(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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx
index a1f5d03d..024d4225 100644
--- a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx
+++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx
@@ -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 ?? —}
-
+
))}
diff --git a/src/lib/properties/client.ts b/src/lib/properties/client.ts
index 44848424..36480528 100644
--- a/src/lib/properties/client.ts
+++ b/src/lib/properties/client.ts
@@ -7,14 +7,51 @@ async function parseError(res: Response, fallback: string): Promise {
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 {
+ 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();
},
});