Confirm building-part ordering on awaiting_review (#297)

Adds the multiEntryOrdering jsonb column + interactive order picker: the
largest-count multi-entry sample is shown with a building-part dropdown per
file position (one Main building + Extensions), validated as a permutation.
A PATCH route persists { count: permutation } + confirmed, and Finalise is
disabled until the ordering is confirmed when the upload is multi-entry.

Migration for the new column is intentionally not included here — generated
after origin/main is merged (its multi_entry_summary migration lands first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-02 13:46:55 +00:00
parent 38c82ebca3
commit af86d53397
7 changed files with 467 additions and 23 deletions

View file

@ -0,0 +1,178 @@
# Multi-entry building-part ordering — in-flight design notes
**Status:** Grilling complete (2026-06-02) — ready to break into issues
**Branch:** `feature/frontend_landlord_overrides`
**Author:** Jun-te (with Claude, via `/grill-me`)
A _design-in-progress_ document, not the ADR. It records the decisions reached
during grilling so the conversation can resume without re-litigating settled
questions. The flow + schema decision is promoted to
[ADR-0004](../adr/0004-multi-entry-building-part-ordering.md); new domain terms
are promoted to [CONTEXT.md](../../CONTEXT.md#building-parts).
## Goal
After address matching and classification finish, a single address row can carry
**comma-separated entries** in physical-element columns — e.g.
`Walls = "Cavity: AsBuilt (1976-1982), Cavity: FilledCavity"`,
`Roofs = "Flat: As Built, PitchedNormalLoftAccess: 200mm"`. Each entry is a
**building part** (main building + extensions). The order is ambiguous and a
**consistent per-file mistake**, so we capture the correct ordering from the user
**once per file** and persist it on the BulkUpload for a later consumer.
## Backstory / ground truth (verified against the example file + code)
- In `ARA AddressProfiling_Download_28-04-2026_10501 (2).xlsx` (32,213 data
rows): **0 UPRNs appear in more than one row** — multi-entry is
comma-separated values **inside one cell**, never multiple rows per address.
- In a multi-entry row the multi-valued columns **agree on count** (Walls=2 ∧
Roofs=2) while whole-dwelling columns stay at 1 (`Property Type` = `"House:
EndTerrace"`). So position *i* is the **same building part across every
multi-valued column**.
- The classifier today **discards** this: [`get_col_to_description_mappings`](/workspaces/home/github/Model/orchestration/landlord_description_overrides_orchestrator.py)
does `value.split(",")` into a **`set`** — orderless, deduped. Correct for the
vocabulary layer (description→enum), but it drops exactly the
position/building-part association this feature needs.
- This is the **per-Property building-part fact** territory ADR-0002 deferred
("a per-Property fact layer (not yet modelled)"). We are **not** building that
layer here — only **capturing** the ordering it will need.
## Decided
### Q1 — Order semantics: full reorder, keyed by count
Position *i* = a building part. The user supplies a **permutation per distinct
entry-count**; persisted as `{ count: permutation }`. This iteration captures
only the **largest-count** sample (see Q5).
### Q1.1 — Order scope: one ordering across all columns
A single per-count permutation realigns **every** multi-valued column at once
(index-aligned — Walls[i] and Roofs[i] are the same part). Not per-column.
Matches the data (counts agree across columns).
### Q1.2 — Mixed counts: single-value columns are whole-property
A 1-entry column (e.g. `Property Type`) is a **whole-dwelling** fact attached to
the property; only columns with N>1 are sliced into building parts. No padding.
### Q2 — Scope: capture + persist ordering only
Detect multi-entry, show one sample address + our classification, capture the
per-count ordering, persist on the BulkUpload. **Not** in scope: the
per-Property fact table or writing main/extension facts at finalise. The
ordering is stored for a later consumer.
### Q2.1 — Editable verification IS in scope (expands Q2)
The "verify classification" step lets the user **correct** a classification,
written back as `source='user'`. This deliberately picks up ADR-0002 Q7's
deferred **vocabulary** user-override write path — distinct from the per-Property
fact layer, which stays deferred.
### Q3 — Placement: on the `awaiting_review` surface
Render the flow on the existing
[OnboardingProgress](<../../src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx>)
page when `status === "awaiting_review"`. Classification finishes *before* the
combiner (both subtasks must complete → combiner → `awaiting_review`), so by the
time Finalise is offered the classification output exists. No new route.
### Q3.1 — Flow: two-step stepper, steps appear independently
- **Step 1 — Verify classification** — shows whenever **≥1 classifier column**
was mapped.
- **Step 2 — Confirm order** — shows only when **multi-entry was detected**.
- A file with classifier columns but no multi-entry shows only Step 1; a file
with neither goes straight to Finalise.
### Q3.2 — Gate: both steps gate Finalise (where each applies)
`canFinalize = status==="awaiting_review" && (noClassifierCols || verifyAck) &&
(noMultiEntry || orderingConfirmed)`. Two flags persisted. Finalise is one
click but the button stays disabled until its applicable gates are satisfied.
### Q4 — Verify step lists the sample address's entries only
Step 1 lists just the descriptions in the **one sample address** (matches "one
address"). Because a correction is per-`(portfolio, description)`, editing one
changes the mapping **portfolio-wide** for that text — the UI must say so. A
spot-check, not full-vocabulary coverage.
### Q4.1 — Write-back: Next.js upsert, `source='user'`, single row (as built)
A Next.js route handler / server action upserts the `landlord_*_overrides` row
by `(portfolio_id, description)` setting `value` + `source='user'`, validating
against the pgEnum. **Schema unchanged** — we keep ADR-0002's `UNIQUE
(portfolio_id, description)` and flip the single row's source in place. The
Python classifier's existing `ON CONFLICT … WHERE source='classifier'`
([landlord_overrides_postgres_repository.py:84-91](/workspaces/home/github/Model/infrastructure/landlord_overrides/landlord_overrides_postgres_repository.py#L84))
then never re-clobbers it.
> Considered and **rejected**: two rows per description (classifier + user) with
> read-time `user > classifier` resolution. It buys "revert to our suggestion" +
> provenance, and is cheap now (no readers exist yet), but reopens ADR-0002's
> `UNIQUE` decision and migrates Drizzle + 4 Python tables + the conflict target.
> Not worth it for this iteration; the single-row flip already gives "user wins".
> This is the first Next.js writer of a `source='user'` row.
### Q5 — Which sample: the largest-count row
Show one sample address — the row with the **most** building parts — so ordering
it reveals the fullest convention. In the common case (only N=2) that is a
single 2-part address.
### Q5.1 — Reorder UI: label each position
Lay the file's entries out as rows (position 0, 1, …), each with a building-part
dropdown (**Main building** / **Extension 1** / …). Assigning labels yields the
permutation and validates (each part used once, exactly one Main building). All
multi-valued columns are shown together, each raw entry annotated with our
classified enum, so the user sanity-checks classification **and** alignment.
### Q6 — Detection: at start, persist a summary
Compute the multi-entry summary in the **start-address-matching POST**
([route.ts:106](<../../src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/start-address-matching/route.ts#L106>))
where the full `rows` are already parsed in memory — which columns are
multi-valued, the distinct counts (with row-counts so we can pick the largest),
and the largest-count sample (address + per-column raw entries). Avoids
re-reading a 32k-row file at render. Classification enums are joined at render
from the override tables.
### Q7 — Persistence: two jsonb columns on `bulk_address_uploads`
- `multiEntrySummary jsonb` — written at start (detection).
- `multiEntryOrdering jsonb` — written at confirm: `{ count: permutation }` plus
`verifyAck` / `orderingConfirmed` flags (final shape TBD; may split flags into
their own columns).
No new table — mirrors how `columnMapping` lives on the upload row.
## Risks / load-bearing assumptions
1. **Consistent-mistake assumption.** All rows of a given count share one
ordering convention. The whole "ask once" design rests on this; if a file
mixes conventions within a count, a single per-count permutation is wrong.
2. **Largest-count-only capture.** Smaller counts stay unpopulated in the map.
A future consumer (or a later UI iteration) needs a derivation rule to apply
the convention to other counts.
3. **Normalization coupling — mitigated.** To join the sample's raw entries to
the override tables the frontend must match the backend's `split(",")`
`strip``lower`. **Resolution:** store the *normalized* description keys in
`multiEntrySummary` at start (the route already holds the rows), so the
render-time join is exact-match — no cross-repo string-normalization drift.
4. **Portfolio-wide blast radius.** A verify-step edit changes the mapping for
every row with that description, not just the sample address. Must be
messaged in the UI.
## Suggested issues (`/to-issues`)
1. Schema: two jsonb columns on `bulk_address_uploads` + migration.
2. Detection at start: compute + persist `multiEntrySummary` (with normalized
description keys).
3. Verify step: list sample descriptions → enum (join override tables),
editable; Next.js upsert route writing `source='user'`; `verifyAck` flag.
4. Order step: largest-count sample, position→part dropdowns → permutation;
persist `multiEntryOrdering`; `orderingConfirmed` flag.
5. Gate: wire `canFinalize` to the two flags; conditional stepper rendering.

View file

@ -0,0 +1,49 @@
import { setMultiEntryOrdering } 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";
const PatchSchema = z.object({
// entry-count -> permutation (part slot -> file position). See ADR-0004.
permutations: z.record(z.string(), z.array(z.number().int().nonnegative())),
});
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 { uploadId } = await params;
let body;
try {
body = PatchSchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const result = await setMultiEntryOrdering(uploadId, body.permutations);
switch (result.kind) {
case "ok":
return NextResponse.json(result.upload, { status: 200 });
case "not_found":
return NextResponse.json({ error: "Not found" }, { status: 404 });
case "wrong_state":
return NextResponse.json(
{ error: `Cannot set ordering in state '${result.current}'` },
{ status: 409 }
);
case "not_multi_entry":
return NextResponse.json({ error: "Upload has no multi-entry rows" }, { status: 409 });
case "invalid_ordering":
return NextResponse.json({ error: result.reason }, { status: 422 });
}
} catch (error) {
console.error("Failed to save multi-entry ordering:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -25,6 +25,17 @@ export interface MultiEntrySummary {
sample: MultiEntrySample | null;
}
// User-confirmed building-part ordering (ADR-0004). Keyed by entry-count so it
// can hold more than one count later; this iteration populates only the
// largest. permutations[count][k] = the 0-based file position holding building
// part k, where 0 = Main building, 1..N-1 = Extension 1..N-1.
// e.g. { "2": [1, 0] } => for 2-part rows the main building is file position 1.
export interface MultiEntryOrdering {
permutations: Record<string, number[]>;
// Set once the user confirms; gates Finalise when the upload is multi-entry.
confirmed: boolean;
}
export const bulkAddressUploads = pgTable("bulk_address_uploads", {
id: uuid("id").defaultRandom().primaryKey(),
portfolioId: text("portfolio_id").notNull(),
@ -38,6 +49,8 @@ export const bulkAddressUploads = pgTable("bulk_address_uploads", {
// Multi-entry building-part detection, computed at start-address-matching
// and read by the awaiting_review review surface (ADR-0004).
multiEntrySummary: jsonb("multi_entry_summary").$type<MultiEntrySummary>(),
// User-confirmed building-part ordering for the multi-entry sample (ADR-0004).
multiEntryOrdering: jsonb("multi_entry_ordering").$type<MultiEntryOrdering>(),
taskId: uuid("task_id"),
combinedOutputS3Uri: text("combined_output_s3_uri"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),

View file

@ -1,14 +1,22 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import {
useBulkUploadProgress,
useConfirmMultiEntryOrdering,
useFinalize,
useRequestCombine,
} from "@/lib/bulkUpload/client";
import type { MultiEntrySample } from "@/lib/bulkUpload/multiEntry";
import {
partLabel,
isPermutation,
assignmentToPermutation,
type MultiEntrySample,
} from "@/lib/bulkUpload/multiEntry";
import type { MultiEntryOrdering } from "@/app/db/schema/bulk_address_uploads";
interface Props {
portfolioSlug: string;
@ -59,14 +67,15 @@ export default function OnboardingProgress({
const isImporting = upload.status === "awaiting_review";
const canRunCombiner = taskDone && !taskFailed && upload.status === "processing";
const canFinalize = upload.status === "awaiting_review";
const isAwaitingReview = upload.status === "awaiting_review";
// Multi-entry building-part sample, shown read-only on the review surface
// (ADR-0004). Ordering confirmation arrives in a later slice.
const multiEntrySample =
upload.status === "awaiting_review"
? (upload.multiEntrySummary?.sample ?? null)
: null;
// Multi-entry building-part sample on the review surface (ADR-0004). When the
// upload is multi-entry, Finalise is gated on the user confirming the order.
const multiEntrySample = isAwaitingReview
? (upload.multiEntrySummary?.sample ?? null)
: null;
const orderingConfirmed = upload.multiEntryOrdering?.confirmed ?? false;
const canFinalize = isAwaitingReview && (!multiEntrySample || orderingConfirmed);
return (
<div className="mt-6 space-y-3">
@ -128,9 +137,16 @@ export default function OnboardingProgress({
)}
</div>
{multiEntrySample && <MultiEntrySamplePanel sample={multiEntrySample} />}
{multiEntrySample && (
<MultiEntryOrderingPanel
sample={multiEntrySample}
ordering={upload.multiEntryOrdering ?? null}
portfolioId={portfolioId}
uploadId={uploadId}
/>
)}
{(canRunCombiner || canFinalize) && (
{(canRunCombiner || isAwaitingReview) && (
<div className="flex flex-col gap-2 pt-2">
{canRunCombiner && (
<StageButton
@ -140,11 +156,13 @@ export default function OnboardingProgress({
onClick={() => combine.mutate()}
/>
)}
{canFinalize && (
{isAwaitingReview && (
<StageButton
label="Finalise"
activeLabel="Finalising…"
isPending={finalize.isPending}
disabled={!canFinalize}
disabledReason="Confirm the building-part order first"
onClick={() =>
finalize.mutate(undefined, { onSuccess: () => router.refresh() })
}
@ -175,35 +193,78 @@ export default function OnboardingProgress({
);
}
// Read-only preview of the largest-count multi-entry row (ADR-0004). Each
// comma-separated entry is a building part; the user will confirm their order
// in a later slice. Positions are shown 1-based, unlabelled for now.
function MultiEntrySamplePanel({ sample }: { sample: MultiEntrySample }) {
// Interactive building-part ordering for the largest-count multi-entry sample
// (ADR-0004). The user labels each file position with a building part (one Main
// building + Extensions); the labels must form a permutation. Confirming
// persists the ordering and unlocks Finalise.
function MultiEntryOrderingPanel({
sample,
ordering,
portfolioId,
uploadId,
}: {
sample: MultiEntrySample;
ordering: MultiEntryOrdering | null;
portfolioId: string;
uploadId: string;
}) {
const confirm = useConfirmMultiEntryOrdering(portfolioId, uploadId);
const count = sample.count;
// assignment[filePosition] = building-part slot. Seed from a stored ordering
// (slot -> position, so invert) or default to identity (main building first).
const [assignment, setAssignment] = useState<number[]>(() => {
const stored = ordering?.permutations?.[String(count)];
if (stored && stored.length === count) {
const seeded = new Array<number>(count);
stored.forEach((position, slot) => {
seeded[position] = slot;
});
return seeded;
}
return Array.from({ length: count }, (_, i) => i);
});
const confirmed = ordering?.confirmed ?? false;
const valid = isPermutation(assignment);
const setSlot = (position: number, slot: number) =>
setAssignment((prev) => prev.map((s, i) => (i === position ? slot : s)));
const onConfirm = () => {
if (!valid) return;
confirm.mutate({
permutations: { [String(count)]: assignmentToPermutation(assignment) },
});
};
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-sm font-semibold text-amber-900">
Multiple building parts detected
Confirm building-part order
</p>
<p className="mt-0.5 text-xs text-amber-800">
{sample.address ? <span className="font-medium">{sample.address}</span> : "An address"}{" "}
has {sample.count} building parts (e.g. a main building and extensions).
You&apos;ll be asked to confirm their order before finalising.
has {count} building parts. Tell us which entry is the main building and
which are extensions we&apos;ll apply the same order to every{" "}
{count}-part row in this file.
</p>
<div className="mt-3 overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="text-left text-amber-700">
<th className="py-1 pr-3 font-medium">Position</th>
<th className="py-1 pr-3 font-medium">Entry</th>
{sample.columns.map((column) => (
<th key={column.field} className="py-1 pr-3 font-medium">
{column.header}
</th>
))}
<th className="py-1 pr-3 font-medium">Building part</th>
</tr>
</thead>
<tbody>
{Array.from({ length: sample.count }).map((_, position) => (
{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) => (
@ -211,11 +272,54 @@ function MultiEntrySamplePanel({ sample }: { sample: MultiEntrySample }) {
{column.entries[position]?.raw ?? "—"}
</td>
))}
<td className="py-1 pr-3">
<select
value={assignment[position]}
onChange={(e) => setSlot(position, Number(e.target.value))}
className="rounded border border-amber-300 bg-white px-2 py-1 text-xs text-amber-900"
>
{Array.from({ length: count }).map((_, slot) => (
<option key={slot} value={slot}>
{partLabel(slot)}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!valid && (
<p className="mt-2 text-xs text-red-600">
Each part (Main building, Extension 1, ) must be used exactly once.
</p>
)}
<div className="mt-3 flex items-center gap-3">
<button
onClick={onConfirm}
disabled={!valid || confirm.isPending}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-1.5 text-xs font-bold text-white transition-opacity ${
!valid || confirm.isPending
? "bg-amber-300 cursor-not-allowed"
: "bg-amber-600 hover:opacity-90"
}`}
>
{confirm.isPending ? "Saving…" : confirmed ? "Update order" : "Confirm order"}
</button>
{confirmed && !confirm.isPending && (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-green-600">
<CheckCircleIcon className="h-4 w-4" />
Order confirmed
</span>
)}
</div>
{confirm.error && (
<p className="mt-2 text-xs text-red-600">{confirm.error.message}</p>
)}
</div>
);
}
@ -224,19 +328,25 @@ function StageButton({
label,
activeLabel,
isPending,
disabled = false,
disabledReason,
onClick,
}: {
label: string;
activeLabel: string;
isPending: boolean;
disabled?: boolean;
disabledReason?: string;
onClick: () => void;
}) {
const blocked = isPending || disabled;
return (
<button
onClick={onClick}
disabled={isPending}
disabled={blocked}
title={disabled && !isPending ? disabledReason : undefined}
className={`inline-flex items-center gap-2 self-start px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold transition-opacity ${
isPending ? "opacity-50 cursor-not-allowed" : "hover:opacity-90"
blocked ? "opacity-50 cursor-not-allowed" : "hover:opacity-90"
}`}
>
{isPending ? (

View file

@ -96,6 +96,27 @@ export function useSetColumnMapping(portfolioId: string, uploadId: string) {
});
}
export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<BulkUpload, Error, { permutations: Record<string, number[]> }>({
mutationFn: async (input) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/multi-entry-ordering`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
if (!res.ok) throw await parseError(res, "Failed to save ordering.");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bulkUploadKeys.progress(uploadId) });
},
});
}
export function useStartAddressMatching(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<{ taskId: string }, Error, void>({

View file

@ -23,8 +23,39 @@ export type {
MultiEntryColumn,
MultiEntrySample,
MultiEntrySummary,
MultiEntryOrdering,
} from "@/app/db/schema/bulk_address_uploads";
// --- Building-part ordering (ADR-0004) ---
// Label for building-part slot k: slot 0 is the Main building, the rest are
// numbered extensions.
export function partLabel(slot: number): string {
return slot === 0 ? "Main building" : `Extension ${slot}`;
}
// True when `arr` is a permutation of [0, n-1] (each slot used exactly once).
export function isPermutation(arr: number[]): boolean {
const seen = new Set(arr);
return (
seen.size === arr.length &&
arr.every((n) => Number.isInteger(n) && n >= 0 && n < arr.length)
);
}
// The UI collects, per file position, which building-part slot it holds
// (`assignment[pos] = slot`). Storage is keyed the other way —
// `permutation[slot] = pos` — so a consumer can ask "which file position holds
// the main building?". Both are permutations of [0, n-1]; this inverts one to
// the other.
export function assignmentToPermutation(assignment: number[]): number[] {
const permutation = new Array<number>(assignment.length);
assignment.forEach((slot, position) => {
permutation[slot] = position;
});
return permutation;
}
export const EMPTY_MULTI_ENTRY_SUMMARY: MultiEntrySummary = {
multiValuedFields: [],
countDistribution: {},

View file

@ -7,6 +7,7 @@ import type { BulkUpload, BulkUploadStatus, ProgressView, TaskSummary } from "./
import { validateColumnMapping, classifierMapping } from "./columnFields";
import { SUBTASK_SERVICE } from "./types";
import type { MultiEntrySummary } from "./multiEntry";
import { isPermutation } from "./multiEntry";
const REMAP_ALLOWED: ReadonlySet<BulkUploadStatus> = new Set([
"ready_for_processing",
@ -117,6 +118,47 @@ export async function saveMultiEntrySummary(
.where(eq(bulkAddressUploads.id, uploadId));
}
export type SetOrderingOutcome =
| { kind: "ok"; upload: BulkUpload }
| { kind: "not_found" }
| { kind: "wrong_state"; current: string }
| { kind: "not_multi_entry" }
| { kind: "invalid_ordering"; reason: string };
// Persist the user-confirmed building-part ordering (ADR-0004). Allowed only at
// awaiting_review and only when the upload is multi-entry. Validates that the
// largest count is provided and every supplied permutation is a bijection of
// its positions, then marks it confirmed (which gates Finalise).
export async function setMultiEntryOrdering(
uploadId: string,
permutations: Record<string, number[]>,
): Promise<SetOrderingOutcome> {
const upload = await loadById(uploadId);
if (!upload) return { kind: "not_found" };
if (upload.status !== "awaiting_review")
return { kind: "wrong_state", current: upload.status };
const sample = upload.multiEntrySummary?.sample ?? null;
if (!sample) return { kind: "not_multi_entry" };
const largest = String(sample.count);
if (!permutations[largest])
return { kind: "invalid_ordering", reason: `Missing ordering for ${sample.count} parts.` };
for (const [count, permutation] of Object.entries(permutations)) {
if (permutation.length !== Number(count) || !isPermutation(permutation))
return { kind: "invalid_ordering", reason: `Ordering for ${count} parts is not a valid arrangement.` };
}
const [updated] = await db
.update(bulkAddressUploads)
.set({ multiEntryOrdering: { permutations, confirmed: true } })
.where(eq(bulkAddressUploads.id, uploadId))
.returning();
if (!updated) return { kind: "not_found" };
return { kind: "ok", upload: updated };
}
export type SetMappingOutcome =
| { kind: "ok"; upload: BulkUpload }
| { kind: "not_found" }