mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
#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:
parent
65a5f57321
commit
c9b7d71843
4 changed files with 328 additions and 6 deletions
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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[]> }>({
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue