Show + edit sample classifications on awaiting_review (#298, #299)

#298: read the classifier's resolved enums for the multi-entry sample's
entries (joined from landlord_*_overrides by normalized description) and show
each beside the raw text, or "not classified" when absent.

#299: make each classification editable — a dropdown of the category's valid
enum values whose selection upserts the override by (portfolio, description)
with source='user', so the classifier never re-clobbers it. UI notes the
portfolio-wide blast radius. Verification ack is folded into the existing
order-confirm (no separate flag/migration); editing is optional review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-02 14:10:05 +00:00
parent 65a5f57321
commit c9b7d71843
4 changed files with 328 additions and 6 deletions

View file

@ -0,0 +1,59 @@
import { getSampleClassifications, setClassificationOverride } from "@/lib/bulkUpload/server";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
// Read-only: the classifier's resolved enums for the multi-entry sample's
// entries, keyed by field -> description -> value (ADR-0004, issue #298).
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 classifications = await getSampleClassifications(uploadId);
return NextResponse.json({ classifications }, { status: 200 });
}
const PatchSchema = z.object({
field: z.string(),
description: z.string(),
value: z.string(),
});
// Correct one classification, written as a user override (source='user'). The
// edit is per-(portfolio, description), so it applies portfolio-wide (issue #299).
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { portfolioId } = await params;
let body;
try {
body = PatchSchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const result = await setClassificationOverride(
portfolioId,
body.field,
body.description,
body.value,
);
if (result.kind === "invalid")
return NextResponse.json({ error: result.reason }, { status: 422 });
return NextResponse.json({ ok: true }, { status: 200 });
} catch (error) {
console.error("Failed to save classification override:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -7,8 +7,11 @@ import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import {
useBulkUploadProgress,
useConfirmMultiEntryOrdering,
useEditClassification,
useFinalize,
useRequestCombine,
useSampleClassifications,
type SampleClassifications,
} from "@/lib/bulkUpload/client";
import {
partLabel,
@ -17,6 +20,20 @@ import {
type MultiEntrySample,
} from "@/lib/bulkUpload/multiEntry";
import type { MultiEntryOrdering } from "@/app/db/schema/bulk_address_uploads";
import {
PropertyTypeValues,
BuiltFormTypeValues,
WallTypeValues,
RoofTypeValues,
} from "@/app/db/schema/landlord_overrides";
// Valid enum options per classifier category, for the editable dropdowns (#299).
const CATEGORY_VALUES: Record<string, readonly string[]> = {
property_type: PropertyTypeValues,
built_form_type: BuiltFormTypeValues,
wall_type: WallTypeValues,
roof_type: RoofTypeValues,
};
interface Props {
portfolioSlug: string;
@ -39,6 +56,14 @@ export default function OnboardingProgress({
const combine = useRequestCombine(portfolioId, uploadId);
const finalize = useFinalize(portfolioId, uploadId);
// Read-only classifications for the multi-entry sample (issue #298). Fetched
// only once a sample exists at awaiting_review. Hook stays above the early
// returns so its order is stable.
const sampleReady =
progress.data?.upload.status === "awaiting_review" &&
!!progress.data.upload.multiEntrySummary?.sample;
const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady);
if (progress.isError) return null;
if (!progress.data) {
return (
@ -141,6 +166,7 @@ export default function OnboardingProgress({
<MultiEntryOrderingPanel
sample={multiEntrySample}
ordering={upload.multiEntryOrdering ?? null}
classifications={classifications.data ?? {}}
portfolioId={portfolioId}
uploadId={uploadId}
/>
@ -200,15 +226,18 @@ export default function OnboardingProgress({
function MultiEntryOrderingPanel({
sample,
ordering,
classifications,
portfolioId,
uploadId,
}: {
sample: MultiEntrySample;
ordering: MultiEntryOrdering | null;
classifications: SampleClassifications;
portfolioId: string;
uploadId: string;
}) {
const confirm = useConfirmMultiEntryOrdering(portfolioId, uploadId);
const editClassification = useEditClassification(portfolioId, uploadId);
const count = sample.count;
// assignment[filePosition] = building-part slot. Seed from a stored ordering
@ -267,11 +296,41 @@ function MultiEntryOrderingPanel({
{Array.from({ length: count }).map((_, position) => (
<tr key={position} className="border-t border-amber-100 text-amber-900">
<td className="py-1 pr-3 text-amber-600">{position + 1}</td>
{sample.columns.map((column) => (
<td key={column.field} className="py-1 pr-3">
{column.entries[position]?.raw ?? "—"}
</td>
))}
{sample.columns.map((column) => {
const entry = column.entries[position];
const classified = entry
? classifications[column.field]?.[entry.description] ?? ""
: "";
const options = CATEGORY_VALUES[column.field] ?? [];
return (
<td key={column.field} className="py-1 pr-3 align-top">
<div>{entry?.raw ?? "—"}</div>
{entry && (
<select
value={classified}
onChange={(e) =>
editClassification.mutate({
field: column.field,
description: entry.description,
value: e.target.value,
})
}
disabled={editClassification.isPending}
className="mt-0.5 max-w-[12rem] rounded border border-amber-300 bg-white px-1.5 py-0.5 text-[11px] text-amber-900"
>
<option value="" disabled>
{classified ? classified : "not classified"}
</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
)}
</td>
);
})}
<td className="py-1 pr-3">
<select
value={assignment[position]}
@ -291,6 +350,15 @@ function MultiEntryOrderingPanel({
</table>
</div>
<p className="mt-2 text-[11px] text-amber-700">
Correcting a classification updates that description for{" "}
<span className="font-medium">every</span> row across the portfolio, not
just this address.
</p>
{editClassification.error && (
<p className="mt-1 text-xs text-red-600">{editClassification.error.message}</p>
)}
{!valid && (
<p className="mt-2 text-xs text-red-600">
Each part (Main building, Extension 1, ) must be used exactly once.

View file

@ -96,6 +96,50 @@ export function useSetColumnMapping(portfolioId: string, uploadId: string) {
});
}
// field -> description -> resolved enum, for the multi-entry sample (issue #298).
export type SampleClassifications = Record<string, Record<string, string>>;
export function useEditClassification(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<void, Error, { field: string; description: string; value: string }>({
mutationFn: async (input) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/classifications`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
if (!res.ok) throw await parseError(res, "Failed to save classification.");
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...bulkUploadKeys.progress(uploadId), "classifications"],
});
},
});
}
export function useSampleClassifications(
portfolioId: string,
uploadId: string,
enabled: boolean,
) {
return useQuery<SampleClassifications, Error>({
queryKey: [...bulkUploadKeys.progress(uploadId), "classifications"],
enabled,
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/classifications`,
);
if (!res.ok) throw await parseError(res, "Failed to load classifications.");
const body = await res.json();
return body.classifications as SampleClassifications;
},
});
}
export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<BulkUpload, Error, { permutations: Record<string, number[]> }>({

View file

@ -1,8 +1,18 @@
import { db } from "@/app/db/db";
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
import {
landlordPropertyTypeOverrides,
landlordBuiltFormTypeOverrides,
landlordWallTypeOverrides,
landlordRoofTypeOverrides,
PropertyTypeValues,
BuiltFormTypeValues,
WallTypeValues,
RoofTypeValues,
} from "@/app/db/schema/landlord_overrides";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { count, desc, eq, sql } from "drizzle-orm";
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
import type { BulkUpload, BulkUploadStatus, ProgressView, TaskSummary } from "./types";
import { validateColumnMapping, classifierMapping } from "./columnFields";
import { SUBTASK_SERVICE } from "./types";
@ -118,6 +128,147 @@ export async function saveMultiEntrySummary(
.where(eq(bulkAddressUploads.id, uploadId));
}
// Classifier field -> resolved enum, keyed by the normalized description that
// the classifier persisted. Empty inner maps / absent fields mean "not
// classified (yet)". Read-only (ADR-0004, issue #298).
export type SampleClassifications = Record<string, Record<string, string>>;
// Look up the classifier's resolved enums for one field's descriptions. One
// branch per category keeps drizzle's per-table enum typing intact.
async function lookupOverrides(
field: string,
portfolioId: bigint,
descriptions: string[],
): Promise<Array<{ description: string; value: string }> | null> {
switch (field) {
case "property_type":
return db
.select({ description: landlordPropertyTypeOverrides.description, value: landlordPropertyTypeOverrides.value })
.from(landlordPropertyTypeOverrides)
.where(and(eq(landlordPropertyTypeOverrides.portfolioId, portfolioId), inArray(landlordPropertyTypeOverrides.description, descriptions)));
case "built_form_type":
return db
.select({ description: landlordBuiltFormTypeOverrides.description, value: landlordBuiltFormTypeOverrides.value })
.from(landlordBuiltFormTypeOverrides)
.where(and(eq(landlordBuiltFormTypeOverrides.portfolioId, portfolioId), inArray(landlordBuiltFormTypeOverrides.description, descriptions)));
case "wall_type":
return db
.select({ description: landlordWallTypeOverrides.description, value: landlordWallTypeOverrides.value })
.from(landlordWallTypeOverrides)
.where(and(eq(landlordWallTypeOverrides.portfolioId, portfolioId), inArray(landlordWallTypeOverrides.description, descriptions)));
case "roof_type":
return db
.select({ description: landlordRoofTypeOverrides.description, value: landlordRoofTypeOverrides.value })
.from(landlordRoofTypeOverrides)
.where(and(eq(landlordRoofTypeOverrides.portfolioId, portfolioId), inArray(landlordRoofTypeOverrides.description, descriptions)));
default:
return null;
}
}
// The classifier's enums for the multi-entry sample's entries, joined by the
// normalized description (exact match — the summary stored it the way the
// classifier persists it, so no re-normalization here). Read-only.
export async function getSampleClassifications(
uploadId: string,
): Promise<SampleClassifications> {
const upload = await loadById(uploadId);
const sample = upload?.multiEntrySummary?.sample;
if (!upload || !sample) return {};
const portfolioId = BigInt(upload.portfolioId);
const result: SampleClassifications = {};
for (const column of sample.columns) {
const descriptions = [...new Set(column.entries.map((e) => e.description))];
if (descriptions.length === 0) continue;
const rows = await lookupOverrides(column.field, portfolioId, descriptions);
if (!rows) continue;
result[column.field] = Object.fromEntries(rows.map((r) => [r.description, r.value]));
}
return result;
}
// Valid enum values per classifier category, for validating a user edit.
const CATEGORY_VALUES: Record<string, readonly string[]> = {
property_type: PropertyTypeValues,
built_form_type: BuiltFormTypeValues,
wall_type: WallTypeValues,
roof_type: RoofTypeValues,
};
// Upsert one user override (source='user') into the right category table. One
// branch per table keeps drizzle's per-table typing intact; the unique
// (portfolio_id, description) drives the conflict. Sets source='user' so the
// classifier's `ON CONFLICT … WHERE source='classifier'` never re-clobbers it.
async function upsertUserOverride(
field: string,
portfolioId: bigint,
description: string,
value: string,
): Promise<void> {
const now = new Date();
switch (field) {
case "property_type":
await db.insert(landlordPropertyTypeOverrides)
.values({ portfolioId, description, value, source: "user" })
.onConflictDoUpdate({
target: [landlordPropertyTypeOverrides.portfolioId, landlordPropertyTypeOverrides.description],
set: { value, source: "user", updatedAt: now },
});
return;
case "built_form_type":
await db.insert(landlordBuiltFormTypeOverrides)
.values({ portfolioId, description, value, source: "user" })
.onConflictDoUpdate({
target: [landlordBuiltFormTypeOverrides.portfolioId, landlordBuiltFormTypeOverrides.description],
set: { value, source: "user", updatedAt: now },
});
return;
case "wall_type":
await db.insert(landlordWallTypeOverrides)
.values({ portfolioId, description, value, source: "user" })
.onConflictDoUpdate({
target: [landlordWallTypeOverrides.portfolioId, landlordWallTypeOverrides.description],
set: { value, source: "user", updatedAt: now },
});
return;
case "roof_type":
await db.insert(landlordRoofTypeOverrides)
.values({ portfolioId, description, value, source: "user" })
.onConflictDoUpdate({
target: [landlordRoofTypeOverrides.portfolioId, landlordRoofTypeOverrides.description],
set: { value, source: "user", updatedAt: now },
});
return;
}
}
export type SetClassificationOutcome =
| { kind: "ok" }
| { kind: "invalid"; reason: string };
// Correct one classification, persisted as a user override (ADR-0004, issue
// #299). Keyed by (portfolio, description), so it changes the mapping for every
// row with that description portfolio-wide. The description is normalized to
// match the classifier's stored key.
export async function setClassificationOverride(
portfolioId: string,
field: string,
description: string,
value: string,
): Promise<SetClassificationOutcome> {
const allowed = CATEGORY_VALUES[field];
if (!allowed) return { kind: "invalid", reason: `Unknown category '${field}'` };
if (!allowed.includes(value))
return { kind: "invalid", reason: `'${value}' is not a valid ${field}` };
const normalized = description.trim().toLowerCase();
if (!normalized) return { kind: "invalid", reason: "Empty description" };
await upsertUserOverride(field, BigInt(portfolioId), normalized, value);
return { kind: "ok" };
}
export type SetOrderingOutcome =
| { kind: "ok"; upload: BulkUpload }
| { kind: "not_found" }