feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-06-02 12:41:00 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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+$/;
|
|
|
|
|
|
|
feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
|
|
|
|
/** Canonicalize a mooring to the unified `^[A-Z]+\d+$` form ("A1", "D32"):
|
2026-06-02 12:41:00 +02:00
|
|
|
|
* 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. */
|
feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
|
|
|
|
function canonMoo(raw: string): string {
|
|
|
|
|
|
const m = /^([A-Za-z]+)-?0*(\d+)$/.exec(raw.trim());
|
2026-06-02 12:41:00 +02:00
|
|
|
|
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}`;
|
feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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'],
|
2026-06-02 12:41:00 +02:00
|
|
|
|
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'),
|
feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
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',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|