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 ( - <> - - - - - - Edit address - - Correct the address and postcode for this property. - - - -
-
- - 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" - /> -
-
- - 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" - /> -
-
- - - {update.error && ( -

- {update.error.message} -

- )} - - -
-
-
- - ); -} 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 ( + <> + + + + + + Match to a UPRN + + Confirm the postcode and pick the matching address. Only + residential addresses from Ordnance Survey are shown, so the UPRN + is always valid. + + + +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+ +
+ + {searchError &&

{searchError}

} + + {residential && ( +
+
+

+ Residential addresses +

+ {source === "cache" && ( + + from cache + + )} +
+
+ {residential.length === 0 ? ( +

+ No residential addresses found for that postcode. +

+ ) : ( +
    + {residential.map((c) => { + const { label } = describeCandidate(c); + const disabled = c.alreadyInPortfolio; + const isSelected = selected?.uprn === c.uprn; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ )} +
+ + + {assign.error && ( +

+ {assign.error.message} +

+ )} + + +
+
+
+ + ); +} 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(); }, });