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 1–6 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', }; }