Merge pull request #367 from Hestia-Homes/feature/uprn-confirmation-onboarding

Confirm UPRNs before finalise: two-tab bulk-upload review + retire Unmatched tab (ADR-0057)
This commit is contained in:
Jun-te Kim 2026-07-08 10:28:09 +01:00 committed by GitHub
commit 4c65bca0c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 13706 additions and 54 deletions

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { upsertUprnCorrection } from "@/lib/bulkUpload/addressMatches";
// Confirm one flagged combiner row (ADR-0057): either assign a UPRN chosen from
// OS Places, or assert the address has none. Keyed by `source_row_id` (there is
// no property.id yet — the finaliser creates identities). Upsert semantics, so
// re-confirming overwrites. The confirmation is overlaid onto the combiner CSV
// at dispatchFinaliser.
const AssignSchema = z.object({
sourceRowId: z.string().min(1),
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
address: z.string().min(1),
postcode: z.string().min(1),
});
const NoUprnSchema = z.object({
sourceRowId: z.string().min(1),
markedNoUprn: z.literal(true),
});
const BodySchema = z.union([NoUprnSchema, AssignSchema]);
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const parsed = BodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 },
);
}
await upsertUprnCorrection(uploadId, parsed.data, session.user?.email ?? undefined);
return NextResponse.json({ ok: true }, { status: 200 });
}

View file

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getFlaggedAddresses } from "@/lib/bulkUpload/addressMatches";
// Read-only: the combiner rows address2uprn could not confidently match
// (unmatched / ambiguous_duplicate / invalid_postcode), joined with any saved
// correction. Drives the pre-finalise "Addresses" review tab (ADR-0057); the
// Finalise gate blocks until every flagged row is resolved.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const flagged = await getFlaggedAddresses(uploadId);
const unresolved = flagged.filter((f) => !f.resolved).length;
return NextResponse.json({ flagged, unresolved }, { status: 200 });
}

View file

@ -35,6 +35,14 @@ export async function POST(
return NextResponse.json({ error: "Combiner not finished" }, { status: 409 });
case "missing_task":
return NextResponse.json({ error: "Upload has no task to finalise" }, { status: 409 });
case "unresolved_addresses":
return NextResponse.json(
{
error: `${result.count} address${result.count === 1 ? "" : "es"} still need a UPRN. Resolve them on the Addresses tab before finalising.`,
unresolvedAddresses: result.count,
},
{ status: 409 },
);
case "wrong_state":
return NextResponse.json(
{ error: `Upload not ready to finalize (state: ${result.current})` },

View file

@ -0,0 +1,90 @@
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 } from "@/app/db/schema/property";
import { and, eq } from "drizzle-orm";
// 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,
props: { params: Promise<{ portfolioId: string; propertyId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId, propertyId } = await props.params;
if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(propertyId)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
const parsed = patchSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 },
);
}
const { uprn, address, postcode, userInputtedAddress, userInputtedPostcode } =
parsed.data;
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 });
}
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 });
}
}

View file

@ -0,0 +1,15 @@
CREATE TABLE "bulk_upload_uprn_corrections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"upload_id" uuid NOT NULL,
"source_row_id" text NOT NULL,
"uprn" text,
"address" text,
"postcode" text,
"marked_no_uprn" boolean DEFAULT false NOT NULL,
"user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "bulk_upload_uprn_corrections_upload_row_unique" UNIQUE("upload_id","source_row_id")
);
--> statement-breakpoint
ALTER TABLE "bulk_upload_uprn_corrections" ADD CONSTRAINT "bulk_upload_uprn_corrections_upload_id_bulk_address_uploads_id_fk" FOREIGN KEY ("upload_id") REFERENCES "public"."bulk_address_uploads"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -1849,6 +1849,13 @@
"when": 1783430551377,
"tag": "0264_yummy_master_chief",
"breakpoints": true
},
{
"idx": 265,
"version": "7",
"when": 1783446767021,
"tag": "0265_uprn_corrections",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,46 @@
import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core";
import { bulkAddressUploads } from "./bulk_address_uploads";
// User confirmations of a combiner row's UPRN, captured on the pre-finalise
// "Addresses" review tab (ADR-0057). address2uprn withholds ambiguous / no-UPRN
// matches; the user resolves each flagged row via OS Places here, keyed by the
// combiner CSV's `source_row_id` (there is no `property.id` yet — the finaliser
// creates identities). At dispatchFinaliser these corrections are overlaid onto
// the combiner CSV before the finaliser reads it, so the confirmed UPRN drives
// both the identity insert and property_overrides in one pass — no post-finalise
// backfill (which is why the portfolio "Unmatched" tab is retired).
//
// A row is resolved when it has a numeric `uprn` OR `markedNoUprn = true` (the
// user asserts no UPRN exists — an accepted terminal state; the finaliser
// leaves it as a no-UPRN property).
export const bulkUploadUprnCorrections = pgTable(
"bulk_upload_uprn_corrections",
{
id: uuid("id").defaultRandom().primaryKey(),
uploadId: uuid("upload_id")
.notNull()
.references(() => bulkAddressUploads.id, { onDelete: "cascade" }),
// The combiner CSV join key (synthetic UUID minted at start-address-matching).
sourceRowId: text("source_row_id").notNull(),
// The confirmed UPRN; null when markedNoUprn.
uprn: text("uprn"),
address: text("address"),
postcode: text("postcode"),
// The user asserts this address has no UPRN — resolved without one.
markedNoUprn: boolean("marked_no_uprn").notNull().default(false),
userId: text("user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
// One correction per combiner row — the confirm action upserts on this.
uploadRowUnique: unique("bulk_upload_uprn_corrections_upload_row_unique").on(
t.uploadId,
t.sourceRowId,
),
}),
);

View file

@ -0,0 +1,322 @@
"use client";
import { useState } from "react";
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, type SearchCandidate } from "@/lib/properties/client";
import { useSaveUprnCorrection, type FlaggedAddress } from "@/lib/bulkUpload/client";
// The pre-finalise "Addresses" review tab (ADR-0057). Lists the combiner rows
// address2uprn could not confidently match; the user resolves each via Ordnance
// Survey (residential candidates only → a guaranteed valid UPRN) or marks it as
// having no UPRN. Confirmations are saved as corrections (keyed by
// source_row_id) and overlaid onto the combiner CSV at finalise.
export default function AddressesPanel({
flagged,
portfolioId,
uploadId,
}: {
flagged: FlaggedAddress[];
portfolioId: string;
uploadId: string;
}) {
return (
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/40 p-4">
<div>
<h3 className="text-sm font-semibold text-gray-800">
Addresses that need a UPRN
</h3>
<p className="mt-0.5 text-xs text-gray-500">
We couldn&apos;t confidently match these to Ordnance Survey. Pick the
right address or mark it as having no UPRN so onboarding creates the
correct property.
</p>
</div>
{flagged.length === 0 ? (
<p className="text-xs text-gray-400">Nothing to resolve.</p>
) : (
<ul className="divide-y divide-amber-100 rounded-lg border border-amber-100 bg-white">
{flagged.map((row) => (
<AddressRow
key={row.sourceRowId}
row={row}
portfolioId={portfolioId}
uploadId={uploadId}
/>
))}
</ul>
)}
</div>
);
}
function statusLabel(status: string): string {
switch (status) {
case "ambiguous_duplicate":
return "Ambiguous match";
case "low_score":
return "Low-confidence match";
case "unmatched":
return "No match";
case "invalid_postcode":
return "Invalid postcode";
case "error":
return "Match error";
default:
return status;
}
}
function AddressRow({
row,
portfolioId,
uploadId,
}: {
row: FlaggedAddress;
portfolioId: string;
uploadId: string;
}) {
const save = useSaveUprnCorrection(portfolioId, uploadId);
const [open, setOpen] = useState(false);
const [address, setAddress] = useState(row.address ?? "");
const [postcode, setPostcode] = useState(row.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);
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;
save.mutate(
{
sourceRowId: row.sourceRowId,
uprn: selected.uprn,
address: selected.address,
postcode: selected.postcode,
},
{ onSuccess: () => setOpen(false) },
);
}
function onNoUprn() {
save.mutate({ sourceRowId: row.sourceRowId, markedNoUprn: true });
}
return (
<li className="flex items-start justify-between gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-800" title={row.address}>
{row.address || "(no address)"}
</p>
<p className="text-[11px] text-gray-500">
{row.postcode || "no postcode"}
{row.lexiscore != null ? ` · score ${row.lexiscore.toFixed(2)}` : ""}
{" · "}
<span className="font-medium text-amber-700">{statusLabel(row.status)}</span>
</p>
{row.resolved && (
<p className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-green-700">
<CheckCircleIcon className="h-3.5 w-3.5" />
{row.markedNoUprn
? "Marked: no UPRN"
: `UPRN ${row.resolvedUprn}${row.resolvedAddress ? ` · ${row.resolvedAddress}` : ""}`}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{row.resolved ? (
<button
type="button"
onClick={() => setOpen(true)}
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800"
>
Change
</button>
) : (
<>
<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>
<button
type="button"
onClick={onNoUprn}
disabled={save.isPending}
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
No UPRN
</button>
</>
)}
</div>
<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 isSelected = selected?.uprn === c.uprn;
return (
<li key={c.uprn}>
<button
type="button"
onClick={() => setSelected(c)}
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
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 flex-1">
<span
className="block truncate text-gray-800"
title={c.address}
>
{c.address}
</span>
<span className="block truncate text-[11px] text-gray-500">
UPRN {c.uprn} · {label}
{c.alreadyInPortfolio ? " · already in portfolio" : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
<DialogFooter>
{save.error && (
<p className="mr-auto self-center text-xs text-red-600">
{save.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 || save.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"
>
{save.isPending ? "Assigning…" : "Assign UPRN"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</li>
);
}

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import {
useAddressMatches,
useBulkUploadProgress,
useConfirmMultiEntryOrdering,
useConfirmVerification,
@ -14,6 +15,7 @@ import {
useSampleClassifications,
type SampleClassifications,
} from "@/lib/bulkUpload/client";
import AddressesPanel from "./AddressesPanel";
import {
partLabel,
isPermutation,
@ -69,6 +71,9 @@ export default function OnboardingProgress({
});
const combine = useRequestCombine(portfolioId, uploadId);
const finalize = useFinalize(portfolioId, uploadId);
const [reviewTab, setReviewTab] = useState<"classification" | "addresses">(
"classification",
);
// Read-only classifications for the multi-entry sample (issue #298). Fetched
// only once a sample exists at awaiting_review. Hook stays above the early
@ -77,6 +82,13 @@ export default function OnboardingProgress({
progress.data?.upload.status === "awaiting_review" &&
!!progress.data.upload.multiEntrySummary?.sample;
const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady);
// Combiner rows address2uprn could not confidently match (ADR-0057). Fetched
// once at awaiting_review; drives the Addresses tab + the Finalise gate.
const addressMatches = useAddressMatches(
portfolioId,
uploadId,
progress.data?.upload.status === "awaiting_review",
);
if (progress.isError) return null;
if (!progress.data) {
@ -139,11 +151,17 @@ export default function OnboardingProgress({
(n, descriptions) => n + descriptions.length,
0,
);
// Flagged addresses (no confident UPRN) must all be resolved before finalise,
// else a withheld row lands as an unintended no-UPRN property (ADR-0057).
const flaggedCount = addressMatches.data?.flagged.length ?? 0;
const unresolvedAddresses = addressMatches.data?.unresolved ?? 0;
const showAddressTab = isAwaitingReview && flaggedCount > 0;
const canFinalize =
isAwaitingReview &&
(!needsVerify || verifyAck) &&
(!needsOrdering || orderingConfirmed) &&
unknownTotal === 0;
unknownTotal === 0 &&
unresolvedAddresses === 0;
return (
<div className="mt-6 space-y-3">
@ -211,47 +229,94 @@ export default function OnboardingProgress({
)}
</div>
{needsVerify && sample && (
<VerifyClassificationPanel
sample={sample}
classifications={classifications.data?.classifications ?? {}}
verified={verifyAck}
stepLabel={showStepNumbers ? "Step 1" : undefined}
portfolioId={portfolioId}
uploadId={uploadId}
/>
{/* Two-tab review at awaiting_review: classification checks | address/UPRN
confirmation (ADR-0057). The Addresses tab only appears when there are
flagged rows; otherwise the classification panels render as before. */}
{showAddressTab && (
<div className="flex gap-4 border-b border-gray-200 text-sm">
<button
type="button"
onClick={() => setReviewTab("classification")}
className={`-mb-px border-b-2 px-1 pb-2 transition-colors ${
reviewTab === "classification"
? "border-[#14163d] font-medium text-[#14163d]"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Classification
</button>
<button
type="button"
onClick={() => setReviewTab("addresses")}
className={`-mb-px flex items-center gap-1.5 border-b-2 px-1 pb-2 transition-colors ${
reviewTab === "addresses"
? "border-[#14163d] font-medium text-[#14163d]"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Addresses
{unresolvedAddresses > 0 && (
<span className="rounded-full bg-amber-500 px-1.5 text-[10px] font-semibold text-white">
{unresolvedAddresses}
</span>
)}
</button>
</div>
)}
{isAwaitingReview && unknownTotal > 0 && (
<UnresolvedClassificationsPanel
unknown={unknownByField}
portfolioId={portfolioId}
uploadId={uploadId}
/>
)}
{needsOrdering && orderingSamples.length > 0 && (
<div className="space-y-3">
{orderingSamples.map(([count, orderSample], i) => (
<MultiEntryOrderingPanel
key={count}
sample={orderSample}
ordering={upload.multiEntryOrdering ?? null}
{(!showAddressTab || reviewTab === "classification") && (
<>
{needsVerify && sample && (
<VerifyClassificationPanel
sample={sample}
classifications={classifications.data?.classifications ?? {}}
// Number the panels only when there's also a verify step or more
// than one count, so a lone ordering panel stays unnumbered.
stepLabel={
showStepNumbers
? `Step ${i + 2}`
: orderingSamples.length > 1
? `Part group ${i + 1}`
: undefined
}
verified={verifyAck}
stepLabel={showStepNumbers ? "Step 1" : undefined}
portfolioId={portfolioId}
uploadId={uploadId}
/>
))}
</div>
)}
{isAwaitingReview && unknownTotal > 0 && (
<UnresolvedClassificationsPanel
unknown={unknownByField}
portfolioId={portfolioId}
uploadId={uploadId}
/>
)}
{needsOrdering && orderingSamples.length > 0 && (
<div className="space-y-3">
{orderingSamples.map(([count, orderSample], i) => (
<MultiEntryOrderingPanel
key={count}
sample={orderSample}
ordering={upload.multiEntryOrdering ?? null}
classifications={classifications.data?.classifications ?? {}}
// Number the panels only when there's also a verify step or more
// than one count, so a lone ordering panel stays unnumbered.
stepLabel={
showStepNumbers
? `Step ${i + 2}`
: orderingSamples.length > 1
? `Part group ${i + 1}`
: undefined
}
portfolioId={portfolioId}
uploadId={uploadId}
/>
))}
</div>
)}
</>
)}
{showAddressTab && reviewTab === "addresses" && (
<AddressesPanel
flagged={addressMatches.data?.flagged ?? []}
portfolioId={portfolioId}
uploadId={uploadId}
/>
)}
{(canRunCombiner || isAwaitingReview) && (
@ -271,11 +336,13 @@ export default function OnboardingProgress({
isPending={finalize.isPending}
disabled={!canFinalize}
disabledReason={
unknownTotal > 0
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
: needsVerify && !verifyAck
? "Verify the classification first"
: "Confirm the building-part order first"
unresolvedAddresses > 0
? `Resolve ${unresolvedAddresses} address${unresolvedAddresses === 1 ? "" : "es"} on the Addresses tab first`
: unknownTotal > 0
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
: needsVerify && !verifyAck
? "Verify the classification first"
: "Confirm the building-part order first"
}
onClick={() =>
finalize.mutate(undefined, { onSuccess: () => router.refresh() })

View file

@ -11,9 +11,10 @@ export default async function Page(props: {
const params = await props.params;
const portfolioId = params.slug;
return (
<>
<PropertyTable portfolioId={portfolioId} />
</>
);
// UPRN matching is now confirmed during bulk-upload onboarding (ADR-0057), so
// properties arrive already matched — the portfolio-level "Unmatched" tab is
// retired. The tab components (PortfolioTabs / UnmatchedProperties /
// getUnmatchedProperties) are left in place for now, pending a follow-up that
// reconciles legacy no-UPRN properties onboarded before this feature.
return <PropertyTable portfolioId={portfolioId} />;
}

View file

@ -6,8 +6,10 @@ import {
SparklesIcon,
BuildingOfficeIcon,
ShieldCheckIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import {
getPropertyMeta,
getConditionReport,
@ -67,6 +69,43 @@ export default async function BuildingPassportHome(props: {
}) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
// A property with no UPRN isn't matched to an Ordnance Survey address yet, so
// the whole UPRN-keyed passport (spatial data, installed measures, EPC) has
// nothing to show — and getSpatialData(BigInt(null)) would 500. Surface a
// clear "not matched" state and link back to the portfolio instead.
if (!propertyMeta.uprn) {
return (
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-8 text-center shadow-sm">
<ExclamationTriangleIcon className="mx-auto h-10 w-10 text-amber-500" />
<h1 className="mt-4 text-lg font-bold text-amber-900">
Not matched to a UPRN yet
</h1>
<p className="mx-auto mt-1 max-w-md text-sm text-amber-700">
{propertyMeta.address ? (
<>
<span className="font-medium">{propertyMeta.address}</span>{" "}
hasn&apos;t{" "}
</>
) : (
"This property hasn't "
)}
been matched to an Ordnance Survey address, so its building passport
isn&apos;t available yet. Match it to a UPRN from the portfolio to
unlock the passport.
</p>
<Link
href={`/portfolio/${params.slug}`}
className="mt-6 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"
>
Back to portfolio
</Link>
</div>
</div>
);
}
const conditionReport = await getConditionReport(params.propertyId);
const spatial = await getSpatialData(propertyMeta.uprn);
const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn));

View file

@ -0,0 +1,242 @@
"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 flex-1">
<span
className="block truncate text-gray-800"
title={c.address}
>
{c.address}
</span>
<span className="block truncate 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

@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// Two-tab shell for the portfolio landing view: the main properties table, and
// a "Needs attention" tab for properties that aren't matched to a UPRN yet.
// Both panels stay mounted (hidden with CSS) so the table keeps its filter /
// pagination state when the user flicks between tabs.
export default function PortfolioTabs({
needsAttentionCount,
properties,
needsAttention,
}: {
needsAttentionCount: number;
properties: React.ReactNode;
needsAttention: React.ReactNode;
}) {
const [tab, setTab] = useState<"properties" | "needs-attention">("properties");
// Nothing unmatched → no tab strip at all, just the properties table.
if (needsAttentionCount === 0) return <>{properties}</>;
return (
<div>
<div className="mb-4 flex items-center gap-1 border-b border-gray-200">
<TabButton
active={tab === "properties"}
onClick={() => setTab("properties")}
>
Properties
</TabButton>
<TabButton
active={tab === "needs-attention"}
onClick={() => setTab("needs-attention")}
>
<ExclamationTriangleIcon className="h-4 w-4" />
Needs attention
{needsAttentionCount > 0 && (
<span className="inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-amber-100 px-1.5 text-[11px] font-bold text-amber-800">
{needsAttentionCount}
</span>
)}
</TabButton>
</div>
<div className={tab === "properties" ? "" : "hidden"}>{properties}</div>
<div className={tab === "needs-attention" ? "" : "hidden"}>
{needsAttention}
</div>
</div>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors ${
active
? "border-primary text-primary"
: "border-transparent text-gray-500 hover:text-gray-800"
}`}
>
{children}
</button>
);
}

View file

@ -18,6 +18,7 @@ import {
} from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { HomeIcon } from "@heroicons/react/24/outline";
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
import { sapToEpc } from "@/app/utils";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
@ -308,6 +309,7 @@ export default function PropertyTable({
}) {
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [committedAddress, setCommittedAddress] = useState("");
const [committedPostcode, setCommittedPostcode] = useState("");
@ -655,25 +657,28 @@ export default function PropertyTable({
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled
className="flex items-start gap-3 rounded-lg p-3"
className="flex items-start gap-3 cursor-pointer rounded-lg p-3"
onSelect={() => setBulkImportOpen(true)}
>
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-400 shrink-0" />
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-700 shrink-0" />
<span className="flex-1">
<span className="block text-sm font-semibold text-gray-500">
<span className="block text-sm font-semibold text-gray-900">
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>
<BulkUploadComingSoonModal
isOpen={bulkImportOpen}
onClose={() => setBulkImportOpen(false)}
portfolioId={portfolioId}
/>
{/* Run modelling */}
<button
onClick={() => router.push(`/portfolio/${portfolioId}/modelling/run`)}

View file

@ -0,0 +1,92 @@
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
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 /
// postcode (UPRN matching is a later step).
export default function UnmatchedProperties({
properties,
portfolioId,
}: {
properties: UnmatchedProperty[];
portfolioId: string;
}) {
const count = properties.length;
if (count === 0) {
return (
<div className="rounded-2xl border border-gray-100 bg-white px-6 py-16 text-center shadow-sm">
<CheckCircleIcon className="mx-auto h-9 w-9 text-emerald-500" />
<p className="mt-3 text-sm font-semibold text-gray-700">
All properties are matched
</p>
<p className="mt-1 text-xs text-gray-400">
Every property has a UPRN nothing needs attention.
</p>
</div>
);
}
return (
<section
aria-label="Properties without a UPRN"
className="overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 shadow-sm"
>
<div className="flex items-start gap-3 border-b border-amber-100 px-6 py-4">
<ExclamationTriangleIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-amber-900">
Properties without a UPRN
</h2>
<span className="inline-flex items-center rounded-full bg-amber-200/70 px-2 py-0.5 text-xs font-semibold text-amber-800">
{count}
</span>
</div>
<p className="mt-0.5 max-w-xl text-xs text-amber-700">
{count === 1 ? "This property" : "These properties"} couldn&apos;t be
matched by AraAddressMatcher automatically. Double check the{" "}
{count === 1 ? "address" : "addresses"} and Ara will try again with
AraAddressMatcherPlus.
</p>
</div>
</div>
<div className="bg-white">
<div className="grid grid-cols-12 gap-3 border-b border-gray-100 bg-gray-50/80 px-6 py-2 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
<div className="col-span-6">Address</div>
<div className="col-span-3">Postcode</div>
<div className="col-span-3 text-right">Action</div>
</div>
<ul className="divide-y divide-gray-50">
{properties.map((p) => (
<li
key={p.id}
className="grid grid-cols-12 items-center gap-3 px-6 py-3"
>
<p
className="col-span-6 truncate text-sm text-gray-800"
title={p.address ?? undefined}
>
{p.address ?? (
<span className="italic text-gray-400">No address</span>
)}
</p>
<p className="col-span-3 text-sm text-gray-600">
{p.postcode ?? <span className="text-gray-400"></span>}
</p>
<div className="col-span-3 flex justify-end">
<MatchAddress property={p} portfolioId={portfolioId} />
</div>
</li>
))}
</ul>
</div>
</section>
);
}

View file

@ -739,6 +739,9 @@ export async function getPropertiesCount(
${epcJoins}
${planJoin}
WHERE p.portfolio_id = ${portfolioId}
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
-- resolved, so they're excluded from the main table + its count.
AND p.uprn IS NOT NULL
${combinedWhere}
`);
@ -831,6 +834,9 @@ export async function getProperties(
ON epc.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId}
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
-- resolved, so they're excluded from the main table.
AND p.uprn IS NOT NULL
${combinedWhere}
LIMIT ${limit} OFFSET ${offset};
`);

View file

@ -0,0 +1,246 @@
import { eq } from "drizzle-orm";
import * as XLSX from "xlsx";
import { db } from "@/app/db/db";
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
import { bulkUploadUprnCorrections } from "@/app/db/schema/bulk_upload_uprn_corrections";
import { createRetrofitDataS3Client } from "@/app/utils/s3";
import { SOURCE_ROW_ID_COLUMN } from "./s3Keys";
// Columns address2uprn writes onto each combiner row (ADR-0057). `status` is the
// per-row outcome; a withheld/ambiguous/no-match row has an empty `uprn`.
const UPRN_COL = "address2uprn_uprn";
const STATUS_COL = "address2uprn_status";
const LEXISCORE_COL = "address2uprn_lexiscore";
// A combiner row is confidently matched only when address2uprn kept a UPRN AND
// did not flag it. Everything else — unmatched, ambiguous_duplicate,
// invalid_postcode, error, or a legacy row with no status but no UPRN — needs
// the user to resolve it on the Addresses tab before finalise.
function isConfidentlyMatched(row: Record<string, string>): boolean {
const uprn = (row[UPRN_COL] ?? "").trim();
const status = (row[STATUS_COL] ?? "").trim();
if (!uprn) return false;
return status === "" || status === "matched";
}
export interface FlaggedAddressRow {
sourceRowId: string;
address: string;
postcode: string;
status: string;
lexiscore: number | null;
// The candidate address2uprn found but did not trust (null when none).
suggestedUprn: string | null;
// The user's confirmation (from bulk_upload_uprn_corrections), if any.
resolvedUprn: string | null;
resolvedAddress: string | null;
markedNoUprn: boolean;
// Resolved = user picked a UPRN OR asserted there is none.
resolved: boolean;
}
function parseS3Uri(uri: string): { bucket: string; key: string } {
const url = new URL(uri); // s3://bucket/key...
const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname;
if (!url.hostname || !key) throw new Error(`Malformed S3 URI: ${uri}`);
return { bucket: url.hostname, key };
}
async function readCombinerRows(s3Uri: string): Promise<Record<string, string>[]> {
const { bucket, key } = parseS3Uri(s3Uri);
const s3 = createRetrofitDataS3Client();
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
const body = result.Body?.toString("utf-8");
if (!body) return [];
const wb = XLSX.read(body, { type: "string" });
const sheet = wb.Sheets[wb.SheetNames[0]];
// Every cell as a string so a numeric UPRN never arrives as a float.
return XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: "", raw: false });
}
function buildAddress(row: Record<string, string>): string {
return ["Address 1", "Address 2", "Address 3"]
.map((c) => (row[c] ?? "").trim())
.filter(Boolean)
.join(", ");
}
/**
* The combiner rows that need user confirmation, joined with any saved
* correction. Reads the combiner CSV from `combinedOutputS3Uri` (ADR-0057).
* Returns [] until the combiner has run.
*/
export async function getFlaggedAddresses(
uploadId: string,
): Promise<FlaggedAddressRow[]> {
const [row] = await db
.select({ combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri })
.from(bulkAddressUploads)
.where(eq(bulkAddressUploads.id, uploadId));
if (!row?.combinedOutputS3Uri) return [];
const [rows, corrections] = await Promise.all([
readCombinerRows(row.combinedOutputS3Uri),
db
.select()
.from(bulkUploadUprnCorrections)
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId)),
]);
const correctionByRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
return rows
.filter((r) => !isConfidentlyMatched(r))
.map((r) => {
const sourceRowId = (r[SOURCE_ROW_ID_COLUMN] ?? "").trim();
const uprn = (r[UPRN_COL] ?? "").trim();
const rawScore = (r[LEXISCORE_COL] ?? "").trim();
const lexiscore = rawScore === "" ? null : Number(rawScore);
const c = correctionByRow.get(sourceRowId);
const resolvedUprn = c?.uprn ?? null;
const markedNoUprn = c?.markedNoUprn ?? false;
return {
sourceRowId,
address: buildAddress(r),
postcode: (r["postcode"] ?? "").trim(),
status: (r[STATUS_COL] ?? (uprn ? "matched" : "unmatched")).trim(),
lexiscore: lexiscore !== null && Number.isFinite(lexiscore) ? lexiscore : null,
suggestedUprn: uprn || null,
resolvedUprn,
resolvedAddress: c?.address ?? null,
markedNoUprn,
resolved: resolvedUprn !== null || markedNoUprn,
};
});
}
/** Count of flagged rows the user has not yet resolved — gates Finalise. */
export async function countUnresolvedFlaggedAddresses(uploadId: string): Promise<number> {
const flagged = await getFlaggedAddresses(uploadId);
return flagged.filter((f) => !f.resolved).length;
}
const ADDRESS_COL = "address2uprn_address";
const OVERLAY_STATUS_CONFIRMED = "user_confirmed";
const OVERLAY_STATUS_NO_UPRN = "user_no_uprn";
/**
* Overlay the user's saved corrections onto the combiner CSV and write the
* result to a sibling `…​.confirmed.csv`, returning its S3 URI (ADR-0057). The
* finaliser reads this so a confirmed UPRN drives both the identity insert and
* property_overrides in one pass. No corrections the original URI is returned
* unchanged (no rewrite). A `markedNoUprn` row keeps an empty UPRN (accepted
* terminal state the finaliser leaves it as a no-UPRN property).
*/
export async function buildConfirmedCombinerUri(
uploadId: string,
combinedOutputS3Uri: string,
): Promise<string> {
const corrections = await db
.select()
.from(bulkUploadUprnCorrections)
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId));
if (corrections.length === 0) return combinedOutputS3Uri;
const byRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
const { bucket, key } = parseS3Uri(combinedOutputS3Uri);
const s3 = createRetrofitDataS3Client();
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
const body = result.Body?.toString("utf-8");
if (!body) return combinedOutputS3Uri;
const wb = XLSX.read(body, { type: "string" });
const sheet = wb.Sheets[wb.SheetNames[0]];
// Array-of-arrays so every column + its order is preserved exactly; we patch
// only the address2uprn cells of corrected rows.
const aoa = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
defval: "",
raw: false,
blankrows: false,
});
const headers = (aoa[0] ?? []).map((h) => String(h));
const iRow = headers.indexOf(SOURCE_ROW_ID_COLUMN);
const iUprn = headers.indexOf(UPRN_COL);
const iStatus = headers.indexOf(STATUS_COL);
const iAddr = headers.indexOf(ADDRESS_COL);
if (iRow < 0 || iUprn < 0) return combinedOutputS3Uri; // not an address2uprn CSV
for (let r = 1; r < aoa.length; r++) {
const line = aoa[r];
const srid = String(line[iRow] ?? "").trim();
const c = byRow.get(srid);
if (!c) continue;
if (c.markedNoUprn) {
line[iUprn] = "";
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_NO_UPRN;
} else {
line[iUprn] = c.uprn ?? "";
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_CONFIRMED;
if (iAddr >= 0 && c.address) line[iAddr] = c.address;
}
}
const csv = XLSX.utils.sheet_to_csv(XLSX.utils.aoa_to_sheet(aoa));
const confirmedKey = `${key.replace(/\.csv$/i, "")}.confirmed.csv`;
await s3
.putObject({ Bucket: bucket, Key: confirmedKey, Body: csv, ContentType: "text/csv" })
.promise();
return `s3://${bucket}/${confirmedKey}`;
}
export type UprnCorrectionInput =
| {
sourceRowId: string;
uprn: string;
address: string;
postcode: string;
markedNoUprn?: false;
}
| { sourceRowId: string; markedNoUprn: true };
/** Upsert one combiner row's confirmation (assign a UPRN, or mark no-UPRN). */
export async function upsertUprnCorrection(
uploadId: string,
input: UprnCorrectionInput,
userId?: string,
): Promise<void> {
const values = input.markedNoUprn
? {
uploadId,
sourceRowId: input.sourceRowId,
uprn: null,
address: null,
postcode: null,
markedNoUprn: true as const,
userId: userId ?? null,
}
: {
uploadId,
sourceRowId: input.sourceRowId,
uprn: input.uprn,
address: input.address,
postcode: input.postcode,
markedNoUprn: false as const,
userId: userId ?? null,
};
await db
.insert(bulkUploadUprnCorrections)
.values(values)
.onConflictDoUpdate({
target: [
bulkUploadUprnCorrections.uploadId,
bulkUploadUprnCorrections.sourceRowId,
],
set: {
uprn: values.uprn,
address: values.address,
postcode: values.postcode,
markedNoUprn: values.markedNoUprn,
userId: values.userId,
updatedAt: new Date(),
},
});
}

View file

@ -150,6 +150,74 @@ export function useSampleClassifications(
});
}
// One combiner row that address2uprn could not confidently match (ADR-0057),
// plus any saved correction. Mirrors the server FlaggedAddressRow.
export interface FlaggedAddress {
sourceRowId: string;
address: string;
postcode: string;
status: string;
lexiscore: number | null;
suggestedUprn: string | null;
resolvedUprn: string | null;
resolvedAddress: string | null;
markedNoUprn: boolean;
resolved: boolean;
}
export interface AddressMatchesView {
flagged: FlaggedAddress[];
unresolved: number;
}
export function useAddressMatches(
portfolioId: string,
uploadId: string,
enabled: boolean,
) {
return useQuery<AddressMatchesView, Error>({
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
enabled,
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-matches`,
);
if (!res.ok) throw await parseError(res, "Failed to load address matches.");
const body = await res.json();
return {
flagged: (body.flagged ?? []) as FlaggedAddress[],
unresolved: Number(body.unresolved ?? 0),
};
},
});
}
export type SaveCorrectionInput =
| { sourceRowId: string; uprn: string; address: string; postcode: string }
| { sourceRowId: string; markedNoUprn: true };
export function useSaveUprnCorrection(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<void, Error, SaveCorrectionInput>({
mutationFn: async (input) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-corrections`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
if (!res.ok) throw await parseError(res, "Failed to save address.");
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
});
},
});
}
export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<BulkUpload, Error, { permutations: Record<string, number[]> }>({

View file

@ -16,6 +16,10 @@ import { retrofitDataS3Bucket } from "@/app/utils/s3";
import { SUBTASK_SERVICE } from "./types";
import type { MultiEntrySummary } from "./multiEntry";
import { isPermutation } from "./multiEntry";
import {
buildConfirmedCombinerUri,
countUnresolvedFlaggedAddresses,
} from "./addressMatches";
const REMAP_ALLOWED: ReadonlySet<BulkUploadStatus> = new Set([
"ready_for_processing",
@ -603,6 +607,7 @@ export type DispatchFinaliserOutcome =
| { kind: "not_yet_combined" }
| { kind: "wrong_state"; current: string }
| { kind: "missing_task" }
| { kind: "unresolved_addresses"; count: number }
| { kind: "trigger_failed"; status: number; message: string };
// Dispatch the async bulk_upload_finaliser (ADR-0005). Replaces the old
@ -631,6 +636,14 @@ export async function dispatchFinaliser(args: {
const upload = guarded.upload;
if (!upload.taskId) return { kind: "missing_task" };
// ADR-0057: every combiner row address2uprn could not confidently match must
// be resolved on the Addresses tab (a chosen UPRN or an explicit no-UPRN)
// before finalise — otherwise a withheld row would land as an unintended
// no-UPRN property. Gate before the CAS claim so a blocked upload stays in
// awaiting_review.
const unresolved = await countUnresolvedFlaggedAddresses(args.uploadId);
if (unresolved > 0) return { kind: "unresolved_addresses", count: unresolved };
// CAS: atomically claim the dispatch. Only the request that flips
// awaiting_review → finalising proceeds; a concurrent one updates 0 rows.
const claimed = await db
@ -674,10 +687,18 @@ export async function dispatchFinaliser(args: {
? `s3://${retrofitDataS3Bucket()}/${classifierCsvKey(upload.portfolioId, args.uploadId)}`
: null;
// Overlay the user's confirmed UPRNs onto the combiner CSV and point the
// finaliser at the corrected copy (ADR-0057). No corrections ⇒ the original
// URI is returned unchanged.
const confirmedS3Uri = await buildConfirmedCombinerUri(
args.uploadId,
upload.combinedOutputS3Uri!,
);
const payload = {
task_id: upload.taskId,
sub_task_id: subTask.id,
s3_uri: upload.combinedOutputS3Uri,
s3_uri: confirmedS3Uri,
portfolio_id: Number(upload.portfolioId),
bulk_upload_id: args.uploadId,
classifier_s3_uri: classifierS3Uri,

View file

@ -0,0 +1,68 @@
"use client";
import { useMutation } from "@tanstack/react-query";
async function parseError(res: Response, fallback: string): Promise<Error> {
const body = await res.json().catch(() => ({}));
return new Error(body?.error ?? fallback);
}
// 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;
}
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}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
if (!res.ok) throw await parseError(res, "Failed to assign UPRN.");
return res.json();
},
});
}

View file

@ -0,0 +1,47 @@
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { and, asc, eq, isNull } from "drizzle-orm";
export interface UnmatchedProperty {
id: string;
address: string | null;
postcode: string | null;
}
// Properties in a portfolio that have no UPRN — i.e. were never matched to an
// Ordnance Survey address record. We surface these separately from the main
// property table so the customer can correct the address/postcode and re-run
// the address→UPRN search (the "advanced ARA search", wired up in a follow-up).
// `markedForDeletion` rows are excluded so soft-deleted properties don't show.
export async function getUnmatchedProperties(
portfolioId: string,
): Promise<UnmatchedProperty[]> {
if (!/^\d+$/.test(portfolioId)) return [];
const pid = BigInt(portfolioId);
const rows = await db
.select({
id: property.id,
address: property.address,
postcode: property.postcode,
userInputtedAddress: property.userInputtedAddress,
userInputtedPostcode: property.userInputtedPostcode,
})
.from(property)
.where(
and(
eq(property.portfolioId, pid),
isNull(property.uprn),
eq(property.markedForDeletion, false),
),
)
.orderBy(asc(property.address));
// Prefer the canonical address; fall back to whatever the user typed on
// import so the row is still identifiable while it awaits a match.
return rows.map((r) => ({
id: String(r.id),
address: r.address ?? r.userInputtedAddress ?? null,
postcode: r.postcode ?? r.userInputtedPostcode ?? null,
}));
}