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