Files
pn-new-crm/src/lib/import/adapters/berths.ts
Matt 25988dbfad fix(audit): import cluster — M27 (commit idempotency), M25 (in-file dedup preview), M26 (undo destructive-update reporting), L33 (mapping/mooring), L35 (port-auth doc)
M25 DB unique-index backstop deferred: needs a migration (column + backfill +
insert-stamp trigger + dedup) — tracked as a follow-up. The classify in-file
dedup (preview accuracy) ships now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:41:00 +02:00

123 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { createBerth, updateBerth } from '@/lib/services/berths.service';
import type { ImportAdapter, MappedRow } from '../types';
/**
* Accepted import spellings of a mooring: letters, an optional separating
* hyphen, optional leading zeros, then 16 digits. The 6-digit cap (audit
* L33(b)) rejects absurd numbers that would overflow JS's safe-integer range
* during canonicalization — a real marina mooring is at most a few thousand.
* Canonicalization strips the hyphen + leading zeros and upper-cases the
* letters, so the *output* always conforms to the canonical `^[A-Z]+\d+$`.
*/
const MOORING_INPUT_RE = /^[A-Za-z]+-?0*\d{1,6}$/;
/** Canonical stored form — the post-canonicalization invariant. */
const MOORING_CANON_RE = /^[A-Z]+\d+$/;
/** Canonicalize a mooring to the unified `^[A-Z]+\d+$` form ("A1", "D32"):
* uppercase letters, drop a hyphen + leading zeros on the number. The number
* is normalized digit-wise (no parseInt) so values up to the 6-digit input
* cap survive without floating-point/MAX_SAFE_INTEGER precision loss. */
function canonMoo(raw: string): string {
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec(raw.trim());
if (!m) return raw.trim().toUpperCase();
// Drop leading zeros without parseInt; keep a lone "0" as "0".
const digits = m[2]!.replace(/^0+(?=\d)/, '');
return `${m[1]!.toUpperCase()}${digits}`;
}
const num = (s: string | undefined): number | undefined =>
s === undefined || s === '' ? undefined : Number(s);
export const berthsAdapter: ImportAdapter = {
key: 'berths',
label: 'Berths',
order: 4,
dependsOn: [],
targetFields: [
{
key: 'mooringNumber',
label: 'Mooring number',
required: true,
aliases: ['mooring', 'berth', 'berthnumber'],
zod: z
.string()
.regex(MOORING_INPUT_RE, 'Use a form like A1, B12, E18 (max 6 digits)')
// Defense in depth: whatever the input spelling, the canonical form
// must conform to ^[A-Z]+\d+$ (audit L33(b)).
.refine((v) => MOORING_CANON_RE.test(canonMoo(v)), 'Invalid mooring format'),
},
{
key: 'area',
label: 'Area',
required: true,
aliases: ['dock', 'zone'],
zod: z.string().min(1),
},
{ key: 'lengthFt', label: 'Length (ft)', required: false, zod: z.coerce.number() },
{ key: 'widthFt', label: 'Width (ft)', required: false, zod: z.coerce.number() },
{ key: 'draftFt', label: 'Draft (ft)', required: false, zod: z.coerce.number() },
{ key: 'price', label: 'Price', required: false, zod: z.coerce.number() },
{
key: 'priceCurrency',
label: 'Currency',
required: false,
zod: z.string().length(3),
},
{
key: 'status',
label: 'Status',
required: false,
zod: z.enum(['available', 'under_offer', 'sold']),
},
],
matchKey: (row) => (row.mooringNumber ? canonMoo(row.mooringNumber) : null),
findExisting: async (portId, key) => {
const row = await db.query.berths.findFirst({
where: and(eq(berths.portId, portId), eq(berths.mooringNumber, key)),
columns: { id: true },
});
return row ?? null;
},
insert: async (row, _resolved, ctx) => {
const b = await createBerth(ctx.portId, buildBerthInput(row), ctx.meta);
return { id: b.id };
},
update: async (id, row, _resolved, ctx) => {
const full = buildBerthInput(row);
// Update spec fields only — mooring is the match key, and status has its
// own dedicated endpoint (not part of UpdateBerthInput).
await updateBerth(
id,
ctx.portId,
{
area: full.area,
lengthFt: full.lengthFt,
widthFt: full.widthFt,
draftFt: full.draftFt,
price: full.price,
priceCurrency: full.priceCurrency,
},
ctx.meta,
);
},
};
function buildBerthInput(row: MappedRow) {
return {
mooringNumber: canonMoo(row.mooringNumber!),
area: row.area!, // required field — present after validation
lengthFt: num(row.lengthFt),
widthFt: num(row.widthFt),
draftFt: num(row.draftFt),
price: num(row.price),
priceCurrency: row.priceCurrency,
status: (row.status as 'available' | 'under_offer' | 'sold' | undefined) ?? 'available',
};
}