From ca172fa2b8949fef12a9adfc6a831e87ecc505c1 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 19:48:16 +0200 Subject: [PATCH] feat(berths): pre-flight duplicate check on bulk-add wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk-adding berths previously failed at submit-time when any mooring number in the range was already taken — admins had to mentally diff the existing berth list against their seeded range and edit Step 2 rows out one-at-a-time. Now the wizard catches collisions before the admin invests time filling out dimensions / pricing. - `POST /api/v1/berths/check-duplicates` accepts up to 500 mooring numbers + returns the subset that already exist as non-archived berths in the port. Format validated against the canonical `^[A-Z]+\d+$` regex; permission `berths.import` (same as bulk-add). - Wizard fires the check during the Step 1 → Step 2 transition. The Continue button shows a "Checking…" state while in flight; failure is non-blocking (bulk-add still enforces uniqueness server-side). - Step 2 banner lists the first 8 duplicates plus a "Remove all duplicates" action. Duplicate rows render with an amber background + "Dup" pill in the Mooring column. - Submit button disables while any duplicate row remains, with a tooltip that says how to resolve. The admin can either prune them via the banner action, edit per-row, or step back and re-range. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/v1/berths/check-duplicates/route.ts | 64 +++++ .../admin/bulk-add-berths-wizard.tsx | 251 ++++++++++++------ 2 files changed, 237 insertions(+), 78 deletions(-) create mode 100644 src/app/api/v1/berths/check-duplicates/route.ts diff --git a/src/app/api/v1/berths/check-duplicates/route.ts b/src/app/api/v1/berths/check-duplicates/route.ts new file mode 100644 index 00000000..0af1a436 --- /dev/null +++ b/src/app/api/v1/berths/check-duplicates/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { db } from '@/lib/db'; +import { berths } from '@/lib/db/schema/berths'; + +const checkSchema = z.object({ + mooringNumbers: z + .array(z.string().regex(/^[A-Z]+\d+$/, 'Invalid mooring format')) + .min(1) + .max(500), +}); + +/** + * POST /api/v1/berths/check-duplicates + * + * Pre-flight duplicate check for the bulk-add wizard. Given an array of + * candidate mooring numbers, returns which of them already exist as + * non-archived berths in the port. Lets the wizard flag and prune + * collisions before the user fills out Step 2 dimensions, instead of + * surfacing the constraint violation at submit time. + * + * Format validation mirrors the CLAUDE.md canonical (`^[A-Z]+\d+$`). + * Archived berths are excluded — bulk-add re-using a previously-archived + * mooring number is a legitimate flow. + * + * Permission gating: `berths.import` (same scope as bulk-add itself). + */ +export const POST = withAuth( + withPermission('berths', 'import', async (req, ctx) => { + try { + const { mooringNumbers } = await parseBody(req, checkSchema); + // Dedup the input first so a wizard passing the same number twice + // doesn't cause an over-counted match. The wizard generates a + // contiguous range so dups in the input are unusual, but cheap to guard. + const unique = Array.from(new Set(mooringNumbers)); + + const existing = await db + .select({ mooringNumber: berths.mooringNumber }) + .from(berths) + .where( + and( + eq(berths.portId, ctx.portId), + inArray(berths.mooringNumber, unique), + isNull(berths.archivedAt), + ), + ); + + const duplicates = existing.map((r) => r.mooringNumber); + return NextResponse.json({ + data: { + duplicates, + checked: unique.length, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/bulk-add-berths-wizard.tsx b/src/components/admin/bulk-add-berths-wizard.tsx index df6d3f3a..3b0e9613 100644 --- a/src/components/admin/bulk-add-berths-wizard.tsx +++ b/src/components/admin/bulk-add-berths-wizard.tsx @@ -92,8 +92,12 @@ export function BulkAddBerthsWizard() { // Step 2 state const [rows, setRows] = useState([]); + /** Mooring numbers already present in the port. Populated by the + * pre-flight check fired during Step 1 → Step 2 transition. */ + const [duplicates, setDuplicates] = useState>(new Set()); + const [checkingDups, setCheckingDups] = useState(false); - function handleGenerate() { + async function handleGenerate() { const s = parseInt(rangeStart, 10); const e = parseInt(rangeEnd, 10); if (!Number.isFinite(s) || !Number.isFinite(e) || s < 0 || e < s) { @@ -105,10 +109,44 @@ export function BulkAddBerthsWizard() { return; } const seeded = genRange(letter, s, e).map((r) => ({ ...r, tenureType: tenure })); + + // Pre-flight duplicate check: ask the server which of the generated + // mooring numbers already exist as non-archived berths in this port. + // Fail-open if the request errors (the bulk-add endpoint still + // enforces uniqueness server-side; the pre-flight is a UX nicety). + setCheckingDups(true); + try { + const res = await apiFetch<{ data: { duplicates: string[] } }>( + '/api/v1/berths/check-duplicates', + { + method: 'POST', + body: { mooringNumbers: seeded.map((r) => r.mooringNumber) }, + }, + ); + setDuplicates(new Set(res.data.duplicates)); + if (res.data.duplicates.length > 0) { + toast.warning( + `${res.data.duplicates.length} mooring number${ + res.data.duplicates.length === 1 ? '' : 's' + } already exist in this port. They're flagged in Step 2.`, + ); + } + } catch { + // Pre-flight failure is non-blocking: the user still proceeds and + // the server's uniqueness constraint catches collisions at submit. + setDuplicates(new Set()); + } finally { + setCheckingDups(false); + } setRows(seeded); setStep('edit'); } + /** Drop any rows whose mooring number is a known duplicate. */ + function removeAllDuplicates() { + setRows((prev) => prev.filter((r) => !duplicates.has(r.mooringNumber))); + } + function setRowField(idx: number, key: K, value: RowDraft[K]) { setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [key]: value } : r))); } @@ -207,12 +245,23 @@ export function BulkAddBerthsWizard() { {rangeStart} … {letter} {rangeEnd}).

- + ); } + const remainingDuplicates = rows.filter((r) => duplicates.has(r.mooringNumber)); + return ( @@ -223,6 +272,32 @@ export function BulkAddBerthsWizard() { + {remainingDuplicates.length > 0 ? ( +
+
+
+ {remainingDuplicates.length} duplicate{' '} + {remainingDuplicates.length === 1 ? 'mooring number' : 'mooring numbers'} found +
+
+ {remainingDuplicates + .slice(0, 8) + .map((r) => r.mooringNumber) + .join(', ')} + {remainingDuplicates.length > 8 + ? ` and ${remainingDuplicates.length - 8} more` + : ''} + . Submit will fail on these rows. Remove them or change the range. +
+
+ +
+ ) : null}
@@ -308,81 +383,96 @@ export function BulkAddBerthsWizard() { - {rows.map((row, idx) => ( - - - - - - - - - - - ))} + {rows.map((row, idx) => { + const isDup = duplicates.has(row.mooringNumber); + return ( + + + + + + + + + + + ); + })}
{row.mooringNumber} - setRowField(idx, 'lengthFt', e.target.value)} - /> - - setRowField(idx, 'widthFt', e.target.value)} - /> - - setRowField(idx, 'draftFt', e.target.value)} - /> - - - - setRowField(idx, 'price', e.target.value)} - /> - - setRowField(idx, 'priceCurrency', v)} - className="h-7 w-24 text-xs" - /> - - -
+ {row.mooringNumber} + {isDup ? ( + + Dup + + ) : null} + + setRowField(idx, 'lengthFt', e.target.value)} + /> + + setRowField(idx, 'widthFt', e.target.value)} + /> + + setRowField(idx, 'draftFt', e.target.value)} + /> + + + + setRowField(idx, 'price', e.target.value)} + /> + + setRowField(idx, 'priceCurrency', v)} + className="h-7 w-24 text-xs" + /> + + +
@@ -392,8 +482,13 @@ export function BulkAddBerthsWizard() { ← Back