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:
64
src/app/api/v1/berths/check-duplicates/route.ts
Normal file
64
src/app/api/v1/berths/check-duplicates/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user