feat(berths): pre-flight duplicate check on bulk-add wizard

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:48:16 +02:00
parent d912f02b97
commit ca172fa2b8
2 changed files with 237 additions and 78 deletions

View File

@@ -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);
}
}),
);