Merge pull request #366 from Hestia-Homes/feature/uprn-confirmation-page

Confirm UPRNs before finalise: two-tab bulk-upload review (ADR-0057)
This commit is contained in:
Jun-te Kim 2026-07-07 17:39:42 +01:00 committed by GitHub
commit 4cfbbaabef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 12795 additions and 56 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,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

@ -1821,6 +1821,13 @@
"when": 1783345567793,
"tag": "0260_natural_enchantress",
"breakpoints": true
},
{
"idx": 261,
"version": "7",
"when": 1783439171770,
"tag": "0261_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,
@ -82,6 +84,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
@ -90,6 +95,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) {
@ -152,11 +164,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">
@ -224,47 +242,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) && (
@ -284,11 +349,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

@ -1,7 +1,4 @@
import PropertyTable from "../components/PropertyTable";
import UnmatchedProperties from "../components/UnmatchedProperties";
import PortfolioTabs from "../components/PortfolioTabs";
import { getUnmatchedProperties } from "@/lib/properties/unmatched";
export const revalidate = 60;
@ -14,15 +11,10 @@ export default async function Page(props: {
const params = await props.params;
const portfolioId = params.slug;
const unmatched = await getUnmatchedProperties(portfolioId);
return (
<PortfolioTabs
needsAttentionCount={unmatched.length}
properties={<PropertyTable portfolioId={portfolioId} />}
needsAttention={
<UnmatchedProperties properties={unmatched} 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

@ -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

@ -20,6 +20,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",
@ -677,6 +681,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
@ -705,6 +710,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
@ -748,10 +761,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,