From 18119644ae71922bdccbd736af1cb8e2d539367a Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 5 May 2026 02:07:58 +0200 Subject: [PATCH] feat(berths): nocodb berth import script + helpers + unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idempotent NocoDB Berths -> CRM `berths` import script with full re-run safety. Re-running picks up NocoDB additions/edits without clobbering CRM-side overrides (compares updated_at vs last_imported_at, 1-second tolerance for sub-second clock drift). --force overrides the edit guard. Mitigates the §14.1 critical/high cases: - Mooring collisions: unique (port_id, mooring_number) on the table. - Concurrent runs: pg_advisory_xact_lock on a stable BIGINT key. - Numeric-with-units inputs: parseDecimalWithUnit() strips trailing ft/m/kw/v/usd/$ markers before parsing. - Metric drift: NocoDB's metric formula columns are ignored; metric values recomputed from imperial via 0.3048 + round-to-2-decimals to match NocoDB's `precision: 2` columns and avoid spurious diffs. - Map Data shape: zod-validated; failures are skipped rather than aborting the import. - Status enum mapping: NocoDB display strings -> CRM snake_case. - NocoDB row deleted: reported as "orphaned in CRM"; never auto- deleted (rep decides via admin UI in a future phase). Pure helpers (parseDecimalWithUnit, mapStatus, parseMapData, extractNumerics, mapRow, buildPlan) live in src/lib/services/berth-import.ts so vitest can exercise the mapping logic without triggering the script's top-level db connection. 40 new unit tests (956 -> 996 passing). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/import-berths-from-nocodb.ts | 405 +++++++++++++++++++++++ src/lib/services/berth-import.ts | 245 ++++++++++++++ tests/unit/services/berth-import.test.ts | 366 ++++++++++++++++++++ 3 files changed, 1016 insertions(+) create mode 100644 scripts/import-berths-from-nocodb.ts create mode 100644 src/lib/services/berth-import.ts create mode 100644 tests/unit/services/berth-import.test.ts diff --git a/scripts/import-berths-from-nocodb.ts b/scripts/import-berths-from-nocodb.ts new file mode 100644 index 0000000..5f2b12f --- /dev/null +++ b/scripts/import-berths-from-nocodb.ts @@ -0,0 +1,405 @@ +/** + * Idempotent NocoDB Berths → CRM `berths` import. + * + * Re-running picks up NocoDB additions/edits without clobbering CRM-side + * overrides: rows where `updated_at > last_imported_at` are treated as + * human-edited and skipped (use `--force` to override). Map Data JSON + * is validated and upserted into `berth_map_data` as a separate step. + * + * Usage: + * pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug port-nimara] + * pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug port-nimara] + * pnpm tsx scripts/import-berths-from-nocodb.ts --apply --force + * pnpm tsx scripts/import-berths-from-nocodb.ts --apply --update-snapshot + * + * Edge cases mitigated (see plan §14.1): + * - Mooring collisions : unique (port_id, mooring_number) on the table. + * - Concurrent runs : pg_advisory_xact_lock on a stable key. + * - Numeric-with-units : parseDecimalWithUnit() strips trailing units. + * - Metric drift : NocoDB metric formula columns are ignored; + * metric values are recomputed from imperial. + * - Map Data shape : zod-validated; failures are skipped silently + * rather than aborting the whole import. + * - Status enum : NocoDB display strings → CRM snake_case. + * - NocoDB row deleted : reported as "orphaned in CRM"; not auto-deleted. + */ + +import 'dotenv/config'; +import { eq, sql } from 'drizzle-orm'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { berths, berthMapData } from '@/lib/db/schema/berths'; +import { fetchAllRows, loadNocoDbConfig, NOCO_TABLES } from '@/lib/dedup/nocodb-source'; +import { + buildPlan, + mapRow, + type Action, + type ImportedBerth, + type PlanEntry, + type ExistingBerthRow, +} from '@/lib/services/berth-import'; + +// ─── CLI ──────────────────────────────────────────────────────────────────── + +interface CliArgs { + dryRun: boolean; + apply: boolean; + portSlug: string; + force: boolean; + updateSnapshot: boolean; +} + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { + dryRun: false, + apply: false, + portSlug: 'port-nimara', + force: false, + updateSnapshot: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]!; + if (a === '--dry-run') args.dryRun = true; + else if (a === '--apply') args.apply = true; + else if (a === '--port-slug') args.portSlug = argv[++i] ?? 'port-nimara'; + else if (a === '--force') args.force = true; + else if (a === '--update-snapshot') args.updateSnapshot = true; + else if (a === '-h' || a === '--help') { + printHelp(); + process.exit(0); + } else { + console.error(`Unknown argument: ${a}`); + printHelp(); + process.exit(1); + } + } + if (!args.dryRun && !args.apply) { + console.error('Must specify either --dry-run or --apply.'); + printHelp(); + process.exit(1); + } + return args; +} + +function printHelp(): void { + console.log(`Usage: + pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug ] + pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug ] [--force] [--update-snapshot] + +Flags: + --dry-run Read NocoDB + diff vs CRM. No writes. + --apply Apply the plan to the DB. + --port-slug Target port slug (default: port-nimara). + --force Overwrite rows where CRM updated_at > last_imported_at. + --update-snapshot Rewrite src/lib/db/seed-data/berths.json after apply. + -h, --help Show this help. +`); +} + +// ─── Stable advisory lock key ─────────────────────────────────────────────── +// 64-bit BIGINT - first 4 bytes spell "BRTH" so it's grep-able in pg_locks. +const BERTH_IMPORT_LOCK_KEY = 0x4252544800000001n; + +// ─── Apply ────────────────────────────────────────────────────────────────── + +interface ApplyResult { + inserted: number; + updated: number; + skipped: number; + mapDataWritten: number; + warnings: string[]; +} + +async function apply( + portId: string, + plan: PlanEntry[], + orphans: ExistingBerthRow[], + importedAt: Date, +): Promise { + const result: ApplyResult = { + inserted: 0, + updated: 0, + skipped: 0, + mapDataWritten: 0, + warnings: [], + }; + for (const orphan of orphans) { + result.warnings.push( + `Orphan: CRM has mooring="${orphan.mooringNumber}" but NocoDB no longer does (id=${orphan.id})`, + ); + } + + await db.transaction(async (tx) => { + // Stable lock so two simultaneous --apply runs serialize. + await tx.execute(sql`SELECT pg_advisory_xact_lock(${BERTH_IMPORT_LOCK_KEY})`); + + for (const entry of plan) { + if (entry.action === 'skip-edited' || entry.action === 'noop') { + result.skipped += 1; + result.warnings.push(`Skipped ${entry.imported.mooringNumber}: ${entry.reason ?? 'no-op'}`); + continue; + } + const i = entry.imported; + const n = i.numerics; + const baseValues = { + portId, + mooringNumber: i.mooringNumber, + area: i.area, + status: i.status, + lengthFt: n.lengthFt != null ? String(n.lengthFt) : null, + widthFt: n.widthFt != null ? String(n.widthFt) : null, + draftFt: n.draftFt != null ? String(n.draftFt) : null, + lengthM: n.lengthM != null ? String(n.lengthM) : null, + widthM: n.widthM != null ? String(n.widthM) : null, + draftM: n.draftM != null ? String(n.draftM) : null, + widthIsMinimum: i.widthIsMinimum, + nominalBoatSize: n.nominalBoatSize != null ? String(n.nominalBoatSize) : null, + nominalBoatSizeM: n.nominalBoatSizeM != null ? String(n.nominalBoatSizeM) : null, + waterDepth: n.waterDepth != null ? String(n.waterDepth) : null, + waterDepthM: n.waterDepthM != null ? String(n.waterDepthM) : null, + waterDepthIsMinimum: i.waterDepthIsMinimum, + sidePontoon: i.sidePontoon, + powerCapacity: n.powerCapacity != null ? String(n.powerCapacity) : null, + voltage: n.voltage != null ? String(n.voltage) : null, + mooringType: i.mooringType, + cleatType: i.cleatType, + cleatCapacity: i.cleatCapacity, + bollardType: i.bollardType, + bollardCapacity: i.bollardCapacity, + access: i.access, + price: n.price != null ? String(n.price) : null, + priceCurrency: 'USD' as const, + bowFacing: i.bowFacing, + berthApproved: i.berthApproved, + statusOverrideMode: i.statusOverrideMode, + lastImportedAt: importedAt, + updatedAt: importedAt, + }; + + let berthId: string; + if (entry.action === 'insert') { + const [inserted] = await tx + .insert(berths) + .values({ ...baseValues, tenureType: 'permanent' }) + .returning({ id: berths.id }); + berthId = inserted!.id; + result.inserted += 1; + } else { + await tx.update(berths).set(baseValues).where(eq(berths.id, entry.existing!.id)); + berthId = entry.existing!.id; + result.updated += 1; + } + + if (i.mapData) { + const mapValues = { + berthId, + svgPath: i.mapData.path ?? null, + x: i.mapData.x != null ? String(i.mapData.x) : null, + y: i.mapData.y != null ? String(i.mapData.y) : null, + transform: i.mapData.transform ?? null, + fontSize: i.mapData.fontSize != null ? String(i.mapData.fontSize) : null, + updatedAt: importedAt, + }; + await tx + .insert(berthMapData) + .values(mapValues) + .onConflictDoUpdate({ + target: berthMapData.berthId, + set: { + svgPath: mapValues.svgPath, + x: mapValues.x, + y: mapValues.y, + transform: mapValues.transform, + fontSize: mapValues.fontSize, + updatedAt: importedAt, + }, + }); + result.mapDataWritten += 1; + } + } + }); + return result; +} + +// ─── Snapshot writer (for seed-data refresh) ──────────────────────────────── + +async function writeSnapshot(imported: ImportedBerth[]): Promise { + // Ordering: idx 0..4 available (small), 5..9 under_offer (medium), + // 10..11 sold (large), then everything else by mooring number. The + // first 12 indexes feed `seed-data.ts` interest/reservation stubs. + const sortByLength = (a: ImportedBerth, b: ImportedBerth) => + (a.numerics.lengthFt ?? 0) - (b.numerics.lengthFt ?? 0); + const available = imported + .filter((b) => b.status === 'available') + .sort(sortByLength) + .slice(0, 5); + const underOffer = imported + .filter((b) => b.status === 'under_offer') + .sort(sortByLength) + .slice(0, 5); + const sold = imported + .filter((b) => b.status === 'sold') + .sort((a, b) => -sortByLength(a, b)) + .slice(0, 2); + const featured = new Set([...available, ...underOffer, ...sold].map((b) => b.mooringNumber)); + const rest = imported + .filter((b) => !featured.has(b.mooringNumber)) + .sort((a, b) => a.mooringNumber.localeCompare(b.mooringNumber, 'en', { numeric: true })); + const ordered = [...available, ...underOffer, ...sold, ...rest]; + + const payload = ordered.map((b) => ({ + legacyId: b.legacyId, + mooringNumber: b.mooringNumber, + area: b.area, + status: b.status, + lengthFt: b.numerics.lengthFt, + widthFt: b.numerics.widthFt, + draftFt: b.numerics.draftFt, + lengthM: b.numerics.lengthM, + widthM: b.numerics.widthM, + draftM: b.numerics.draftM, + widthIsMinimum: b.widthIsMinimum, + nominalBoatSize: b.numerics.nominalBoatSize, + nominalBoatSizeM: b.numerics.nominalBoatSizeM, + waterDepth: b.numerics.waterDepth, + waterDepthM: b.numerics.waterDepthM, + waterDepthIsMinimum: b.waterDepthIsMinimum, + sidePontoon: b.sidePontoon, + powerCapacity: b.numerics.powerCapacity, + voltage: b.numerics.voltage, + mooringType: b.mooringType, + cleatType: b.cleatType, + cleatCapacity: b.cleatCapacity, + bollardType: b.bollardType, + bollardCapacity: b.bollardCapacity, + access: b.access, + price: b.numerics.price, + bowFacing: b.bowFacing, + berthApproved: b.berthApproved, + statusOverrideMode: b.statusOverrideMode, + })); + + const target = path.resolve(process.cwd(), 'src/lib/db/seed-data/berths.json'); + await fs.writeFile(target, JSON.stringify(payload, null, 2) + '\n', 'utf8'); + return target; +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const config = loadNocoDbConfig(); + + const [port] = await db + .select({ id: ports.id, slug: ports.slug }) + .from(ports) + .where(eq(ports.slug, args.portSlug)) + .limit(1); + if (!port) { + console.error(`No port found with slug "${args.portSlug}".`); + process.exit(1); + } + + console.log(`> Fetching NocoDB Berths…`); + const rows = await fetchAllRows(NOCO_TABLES.berths, config); + console.log(` fetched ${rows.length} rows from NocoDB`); + + const imported: ImportedBerth[] = []; + let skippedMalformed = 0; + for (const r of rows) { + const m = mapRow(r); + if (m) imported.push(m); + else skippedMalformed += 1; + } + if (skippedMalformed > 0) { + console.warn(` ${skippedMalformed} rows skipped (missing Mooring Number)`); + } + + // De-dup against any same-mooring twins surfacing from NocoDB + // (defensive — the Berths table is keyed on Mooring Number in NocoDB). + const seen = new Set(); + const dedup: ImportedBerth[] = []; + for (const b of imported) { + if (seen.has(b.mooringNumber)) { + console.warn(` duplicate mooring "${b.mooringNumber}" in NocoDB — keeping first`); + continue; + } + seen.add(b.mooringNumber); + dedup.push(b); + } + + console.log(`> Reading current CRM berths for port "${port.slug}"…`); + const existingRows = await db + .select({ + id: berths.id, + mooringNumber: berths.mooringNumber, + updatedAt: berths.updatedAt, + lastImportedAt: berths.lastImportedAt, + }) + .from(berths) + .where(eq(berths.portId, port.id)); + console.log(` ${existingRows.length} existing rows`); + + const existingByMooring = new Map(existingRows.map((r) => [r.mooringNumber, r])); + const { plan, orphans } = buildPlan(dedup, existingByMooring, args.force); + + const counts = plan.reduce( + (acc, e) => { + acc[e.action] += 1; + return acc; + }, + { insert: 0, update: 0, 'skip-edited': 0, noop: 0 } as Record, + ); + + console.log(`> Plan:`); + console.log(` insert : ${counts.insert}`); + console.log(` update : ${counts.update}`); + console.log(` skip-edited : ${counts['skip-edited']}`); + console.log(` no-op : ${counts.noop}`); + console.log(` orphans (CRM): ${orphans.length}`); + + if (counts['skip-edited'] > 0) { + console.log(` ↳ Skipped (CRM-edited; pass --force to overwrite):`); + for (const e of plan.filter((p) => p.action === 'skip-edited').slice(0, 10)) { + console.log(` - ${e.imported.mooringNumber} ${e.reason}`); + } + if (counts['skip-edited'] > 10) console.log(` …and ${counts['skip-edited'] - 10} more`); + } + if (orphans.length > 0) { + console.log(` ↳ Orphans (in CRM but missing from NocoDB):`); + for (const o of orphans.slice(0, 10)) console.log(` - ${o.mooringNumber}`); + if (orphans.length > 10) console.log(` …and ${orphans.length - 10} more`); + } + + if (args.dryRun) { + console.log(`\n[dry-run] no writes performed.`); + return; + } + + console.log(`> Applying…`); + const result = await apply(port.id, plan, orphans, new Date()); + console.log(` inserted : ${result.inserted}`); + console.log(` updated : ${result.updated}`); + console.log(` skipped : ${result.skipped}`); + console.log(` map data writes : ${result.mapDataWritten}`); + if (result.warnings.length) { + console.log(` warnings :`); + for (const w of result.warnings.slice(0, 20)) console.log(` - ${w}`); + if (result.warnings.length > 20) console.log(` …and ${result.warnings.length - 20} more`); + } + + if (args.updateSnapshot) { + const written = await writeSnapshot(dedup); + console.log(`> Wrote ${dedup.length} rows to ${path.relative(process.cwd(), written)}`); + } +} + +main() + .then(() => process.exit(0)) + .catch((err: unknown) => { + console.error(err); + process.exit(1); + }); diff --git a/src/lib/services/berth-import.ts b/src/lib/services/berth-import.ts new file mode 100644 index 0000000..4c31aba --- /dev/null +++ b/src/lib/services/berth-import.ts @@ -0,0 +1,245 @@ +/** + * Pure helpers + plan-builder for the NocoDB → CRM berth import. + * + * Lives outside the CLI script (`scripts/import-berths-from-nocodb.ts`) + * so vitest can exercise the mapping/normalization/plan logic without + * triggering the script's top-level db connection. + */ + +import { z } from 'zod'; + +import type { NocoDbRow } from '@/lib/dedup/nocodb-source'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Strip trailing units ("63ft", "12 m") and return a JS number, or null + * if the input doesn't parse cleanly. NocoDB stores plain numerics for + * the Berth fields we care about, but defensive against future drift or + * legacy import data. + */ +export function parseDecimalWithUnit(raw: unknown): number | null { + if (raw == null) return null; + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + if (typeof raw !== 'string') return null; + const m = /^\s*(-?\d+(?:\.\d+)?)\s*(?:ft|feet|m|metres|meters|kw|v|usd|\$)?\s*$/i.exec(raw); + if (!m) return null; + const n = parseFloat(m[1]!); + return Number.isFinite(n) ? n : null; +} + +/** Round to 2 decimals to match NocoDB's `precision: 2` decimal columns. */ +export function round2(n: number | null): number | null { + if (n == null) return null; + return Math.round(n * 100) / 100; +} + +const FT_TO_M = 0.3048; + +/** Imperial → metric. Returns null when the input is null. */ +export function ftToM(ft: number | null): number | null { + if (ft == null) return null; + return round2(ft * FT_TO_M); +} + +/** NocoDB display Status → CRM internal status. Defaults to 'available'. */ +export function mapStatus(raw: unknown): 'available' | 'under_offer' | 'sold' { + switch (typeof raw === 'string' ? raw.trim() : raw) { + case 'Available': + return 'available'; + case 'Under Offer': + return 'under_offer'; + case 'Sold': + return 'sold'; + default: + return 'available'; + } +} + +const MapDataSchema = z.object({ + path: z.string().optional(), + x: z.union([z.string(), z.number()]).optional(), + y: z.union([z.string(), z.number()]).optional(), + transform: z.string().optional(), + fontSize: z.union([z.string(), z.number()]).optional(), +}); +export type MapData = z.infer; + +export function parseMapData(raw: unknown): MapData | null { + if (raw == null) return null; + const candidate = typeof raw === 'string' ? safeJsonParse(raw) : raw; + if (candidate == null) return null; + const parsed = MapDataSchema.safeParse(candidate); + return parsed.success ? parsed.data : null; +} + +function safeJsonParse(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return null; + } +} + +export function toNumberish(raw: unknown): number | null { + if (raw == null || raw === '') return null; + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + if (typeof raw === 'string') { + const n = parseFloat(raw); + return Number.isFinite(n) ? n : null; + } + return null; +} + +// ─── Numerics extractor ───────────────────────────────────────────────────── + +export interface NumericFields { + lengthFt: number | null; + widthFt: number | null; + draftFt: number | null; + lengthM: number | null; + widthM: number | null; + draftM: number | null; + nominalBoatSize: number | null; + nominalBoatSizeM: number | null; + waterDepth: number | null; + waterDepthM: number | null; + powerCapacity: number | null; + voltage: number | null; + price: number | null; +} + +export function extractNumerics(row: NocoDbRow): NumericFields { + const lengthFt = round2(parseDecimalWithUnit(row['Length'])); + const widthFt = round2(parseDecimalWithUnit(row['Width'])); + const draftFt = round2(parseDecimalWithUnit(row['Draft'])); + const waterDepth = round2(parseDecimalWithUnit(row['Water Depth'])); + const nominalBoatSize = toNumberish(row['Nominal Boat Size']); + return { + lengthFt, + widthFt, + draftFt, + lengthM: ftToM(lengthFt), + widthM: ftToM(widthFt), + draftM: ftToM(draftFt), + nominalBoatSize, + nominalBoatSizeM: ftToM(nominalBoatSize), + waterDepth, + waterDepthM: ftToM(waterDepth), + powerCapacity: toNumberish(row['Power Capacity']), + voltage: toNumberish(row['Voltage']), + price: toNumberish(row['Price']), + }; +} + +// ─── Row mapper ───────────────────────────────────────────────────────────── + +export interface ImportedBerth { + legacyId: number; + mooringNumber: string; + area: string | null; + status: 'available' | 'under_offer' | 'sold'; + numerics: NumericFields; + widthIsMinimum: boolean; + waterDepthIsMinimum: boolean; + sidePontoon: string | null; + mooringType: string | null; + cleatType: string | null; + cleatCapacity: string | null; + bollardType: string | null; + bollardCapacity: string | null; + access: string | null; + bowFacing: string | null; + berthApproved: boolean; + statusOverrideMode: string | null; + mapData: MapData | null; +} + +export function mapRow(row: NocoDbRow): ImportedBerth | null { + const mooringNumber = + typeof row['Mooring Number'] === 'string' ? row['Mooring Number'].trim() : ''; + if (!mooringNumber) return null; + return { + legacyId: row.Id, + mooringNumber, + area: typeof row['Area'] === 'string' ? row['Area'] : null, + status: mapStatus(row['Status']), + numerics: extractNumerics(row), + widthIsMinimum: row['Width Is Minimum'] === true, + waterDepthIsMinimum: row['Water Depth Is Minimum'] === true, + sidePontoon: typeof row['Side Pontoon'] === 'string' ? row['Side Pontoon'] : null, + mooringType: typeof row['Mooring Type'] === 'string' ? row['Mooring Type'] : null, + cleatType: typeof row['Cleat Type'] === 'string' ? row['Cleat Type'] : null, + cleatCapacity: typeof row['Cleat Capacity'] === 'string' ? row['Cleat Capacity'] : null, + bollardType: typeof row['Bollard Type'] === 'string' ? row['Bollard Type'] : null, + bollardCapacity: typeof row['Bollard Capacity'] === 'string' ? row['Bollard Capacity'] : null, + access: typeof row['Access'] === 'string' ? row['Access'] : null, + bowFacing: typeof row['Bow Facing'] === 'string' ? row['Bow Facing'] : null, + berthApproved: row['Berth Approved'] === true, + statusOverrideMode: + typeof row['status_override_mode'] === 'string' ? row['status_override_mode'] : null, + mapData: parseMapData(row['Map Data']), + }; +} + +// ─── Plan builder ─────────────────────────────────────────────────────────── + +export interface ExistingBerthRow { + id: string; + mooringNumber: string; + updatedAt: Date; + lastImportedAt: Date | null; +} + +export type Action = 'insert' | 'update' | 'skip-edited' | 'noop'; + +export interface PlanEntry { + action: Action; + imported: ImportedBerth; + existing: ExistingBerthRow | null; + reason?: string; +} + +export interface BuildPlanResult { + plan: PlanEntry[]; + orphans: ExistingBerthRow[]; +} + +export function buildPlan( + imported: ImportedBerth[], + existingByMooring: Map, + force: boolean, +): BuildPlanResult { + const plan: PlanEntry[] = []; + const seenMoorings = new Set(); + for (const row of imported) { + seenMoorings.add(row.mooringNumber); + const existing = existingByMooring.get(row.mooringNumber) ?? null; + if (!existing) { + plan.push({ action: 'insert', imported: row, existing: null }); + continue; + } + const lastImported = existing.lastImportedAt; + // 1-second tolerance: an UPDATE inside the apply transaction sets + // updated_at and last_imported_at to the same `importedAt` timestamp, + // but Postgres can race them by sub-second on busy boxes. + const isHumanEdited = + lastImported == null ? false : existing.updatedAt.getTime() > lastImported.getTime() + 1000; + if (isHumanEdited && !force) { + plan.push({ + action: 'skip-edited', + imported: row, + existing, + reason: `CRM updated_at=${existing.updatedAt.toISOString()} > last_imported_at=${ + lastImported?.toISOString() ?? 'null' + }`, + }); + continue; + } + plan.push({ action: 'update', imported: row, existing }); + } + const orphans = Array.from(existingByMooring.values()).filter( + (e) => !seenMoorings.has(e.mooringNumber), + ); + return { plan, orphans }; +} diff --git a/tests/unit/services/berth-import.test.ts b/tests/unit/services/berth-import.test.ts new file mode 100644 index 0000000..5401b0e --- /dev/null +++ b/tests/unit/services/berth-import.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest'; + +import { + parseDecimalWithUnit, + ftToM, + round2, + mapStatus, + parseMapData, + toNumberish, + extractNumerics, + mapRow, + buildPlan, + type ExistingBerthRow, + type ImportedBerth, +} from '@/lib/services/berth-import'; +import type { NocoDbRow } from '@/lib/dedup/nocodb-source'; + +// ─── parseDecimalWithUnit ──────────────────────────────────────────────────── + +describe('parseDecimalWithUnit', () => { + it('returns null for null/undefined', () => { + expect(parseDecimalWithUnit(null)).toBeNull(); + expect(parseDecimalWithUnit(undefined)).toBeNull(); + }); + it('returns finite numbers as-is', () => { + expect(parseDecimalWithUnit(63)).toBe(63); + expect(parseDecimalWithUnit(63.5)).toBe(63.5); + expect(parseDecimalWithUnit(0)).toBe(0); + }); + it('rejects NaN / Infinity', () => { + expect(parseDecimalWithUnit(Number.NaN)).toBeNull(); + expect(parseDecimalWithUnit(Number.POSITIVE_INFINITY)).toBeNull(); + }); + it('parses bare numeric strings', () => { + expect(parseDecimalWithUnit('42')).toBe(42); + expect(parseDecimalWithUnit('42.5')).toBe(42.5); + }); + it('strips trailing imperial / metric / power units', () => { + expect(parseDecimalWithUnit('63ft')).toBe(63); + expect(parseDecimalWithUnit('63 ft')).toBe(63); + expect(parseDecimalWithUnit('19 metres')).toBe(19); + expect(parseDecimalWithUnit('330kw')).toBe(330); + expect(parseDecimalWithUnit('480 V')).toBe(480); + }); + it('strips currency markers', () => { + expect(parseDecimalWithUnit('100 USD')).toBe(100); + expect(parseDecimalWithUnit('100$')).toBe(100); + }); + it('returns null on unparseable strings', () => { + expect(parseDecimalWithUnit('approx 60ft')).toBeNull(); + expect(parseDecimalWithUnit('na')).toBeNull(); + }); + it('returns null on non-string non-number inputs', () => { + expect(parseDecimalWithUnit({})).toBeNull(); + expect(parseDecimalWithUnit([1])).toBeNull(); + expect(parseDecimalWithUnit(true)).toBeNull(); + }); +}); + +// ─── round2 + ftToM ────────────────────────────────────────────────────────── + +describe('round2', () => { + it('rounds to 2 decimals', () => { + expect(round2(1.234)).toBe(1.23); + expect(round2(1.235)).toBe(1.24); + expect(round2(1)).toBe(1); + }); + it('passes null through', () => { + expect(round2(null)).toBeNull(); + }); +}); + +describe('ftToM', () => { + it('converts feet to meters at 0.3048', () => { + expect(ftToM(100)).toBe(30.48); + expect(ftToM(206.69)).toBeCloseTo(63.0, 1); + }); + it('rounds to 2 decimals', () => { + expect(ftToM(45)).toBe(13.72); + }); + it('passes null through', () => { + expect(ftToM(null)).toBeNull(); + }); +}); + +// ─── mapStatus ─────────────────────────────────────────────────────────────── + +describe('mapStatus', () => { + it('maps the three known display values', () => { + expect(mapStatus('Available')).toBe('available'); + expect(mapStatus('Under Offer')).toBe('under_offer'); + expect(mapStatus('Sold')).toBe('sold'); + }); + it('trims whitespace before matching', () => { + expect(mapStatus(' Sold ')).toBe('sold'); + }); + it('falls back to available for unknown / null values', () => { + expect(mapStatus(null)).toBe('available'); + expect(mapStatus(undefined)).toBe('available'); + expect(mapStatus('Pending')).toBe('available'); + expect(mapStatus(42)).toBe('available'); + }); +}); + +// ─── parseMapData ──────────────────────────────────────────────────────────── + +describe('parseMapData', () => { + it('parses a full NocoDB Map Data object', () => { + const raw = { + path: 'M838.8 897.2h200.74v44.191H838.8z', + x: '922.819', + y: '930.721', + transform: 'translate(0 409.55)', + fontSize: '32', + }; + expect(parseMapData(raw)).toEqual(raw); + }); + it('accepts numeric x / y / fontSize too', () => { + expect(parseMapData({ x: 1, y: 2, fontSize: 32 })).toEqual({ x: 1, y: 2, fontSize: 32 }); + }); + it('parses JSON-string Map Data defensively', () => { + const raw = JSON.stringify({ path: 'M0 0 L1 1', x: '5', y: '10' }); + expect(parseMapData(raw)).toEqual({ path: 'M0 0 L1 1', x: '5', y: '10' }); + }); + it('returns null for null/empty', () => { + expect(parseMapData(null)).toBeNull(); + expect(parseMapData(undefined)).toBeNull(); + expect(parseMapData('')).toBeNull(); + }); + it('returns null on shape mismatch (e.g. number where path is required)', () => { + expect(parseMapData({ path: 42 })).toBeNull(); + }); + it('returns null on malformed JSON string', () => { + expect(parseMapData('{not valid json')).toBeNull(); + }); +}); + +// ─── toNumberish ───────────────────────────────────────────────────────────── + +describe('toNumberish', () => { + it('returns numbers as-is, rejects NaN/Infinity', () => { + expect(toNumberish(42)).toBe(42); + expect(toNumberish(Number.NaN)).toBeNull(); + expect(toNumberish(Number.POSITIVE_INFINITY)).toBeNull(); + }); + it('parses numeric strings', () => { + expect(toNumberish('42')).toBe(42); + expect(toNumberish('42.5')).toBe(42.5); + }); + it('returns null on non-numeric / blank', () => { + expect(toNumberish('')).toBeNull(); + expect(toNumberish(null)).toBeNull(); + expect(toNumberish('apple')).toBeNull(); + }); +}); + +// ─── extractNumerics ───────────────────────────────────────────────────────── + +describe('extractNumerics', () => { + it('extracts the full set + computes metric values from imperial', () => { + const row: NocoDbRow = { + Id: 1, + Length: 206.69, + Width: 46.56, + Draft: 14.5, + 'Water Depth': 16.08, + 'Nominal Boat Size': 200, + 'Power Capacity': 330, + Voltage: 480, + Price: 3528000, + }; + const n = extractNumerics(row); + expect(n.lengthFt).toBe(206.69); + expect(n.widthFt).toBe(46.56); + expect(n.draftFt).toBe(14.5); + expect(n.lengthM).toBe(63); + expect(n.widthM).toBe(14.19); + expect(n.draftM).toBe(4.42); + expect(n.waterDepth).toBe(16.08); + expect(n.waterDepthM).toBe(4.9); + expect(n.nominalBoatSize).toBe(200); + expect(n.nominalBoatSizeM).toBe(60.96); + expect(n.powerCapacity).toBe(330); + expect(n.voltage).toBe(480); + expect(n.price).toBe(3528000); + }); + it('handles missing fields', () => { + const n = extractNumerics({ Id: 2 }); + expect(n.lengthFt).toBeNull(); + expect(n.lengthM).toBeNull(); + }); + it('rounds CRM values to 2 decimals to neutralize NocoDB precision drift', () => { + const n = extractNumerics({ Id: 3, Length: 12.3456789 }); + expect(n.lengthFt).toBe(12.35); + }); +}); + +// ─── mapRow ────────────────────────────────────────────────────────────────── + +describe('mapRow', () => { + const sampleRow: NocoDbRow = { + Id: 1, + 'Mooring Number': 'A1', + Area: 'A', + Status: 'Under Offer', + Length: 206.69, + Width: 46.56, + Draft: 14.5, + 'Side Pontoon': 'Quay PT', + 'Power Capacity': 330, + Voltage: 480, + 'Mooring Type': 'Side Pier / Med Mooring', + 'Cleat Type': 'A5', + 'Cleat Capacity': '20-24 ton break load', + 'Bollard Type': 'Bull bollard type B', + 'Bollard Capacity': '40 ton break load', + Access: 'Car (3t) to Vessel', + 'Bow Facing': 'East', + 'Berth Approved': false, + 'Width Is Minimum': false, + 'Water Depth': 16.08, + 'Water Depth Is Minimum': false, + 'Nominal Boat Size': 200, + Price: 3528000, + status_override_mode: 'auto', + 'Map Data': { path: 'M0 0', x: '1', y: '2', transform: '', fontSize: '32' }, + }; + + it('maps a representative NocoDB Berth row end-to-end', () => { + const out = mapRow(sampleRow); + expect(out).not.toBeNull(); + expect(out!.legacyId).toBe(1); + expect(out!.mooringNumber).toBe('A1'); + expect(out!.status).toBe('under_offer'); + expect(out!.area).toBe('A'); + expect(out!.numerics.lengthFt).toBe(206.69); + expect(out!.numerics.lengthM).toBe(63); + expect(out!.statusOverrideMode).toBe('auto'); + expect(out!.mapData?.path).toBe('M0 0'); + }); + + it('returns null when Mooring Number is missing', () => { + const rest = { ...sampleRow }; + delete (rest as Record)['Mooring Number']; + const out = mapRow({ ...rest, Id: 99 } as NocoDbRow); + expect(out).toBeNull(); + }); + + it('trims whitespace from Mooring Number', () => { + const out = mapRow({ ...sampleRow, 'Mooring Number': ' A1 ' }); + expect(out?.mooringNumber).toBe('A1'); + }); + + it('coerces Berth Approved to a strict boolean', () => { + const a = mapRow({ ...sampleRow, 'Berth Approved': true }); + const b = mapRow({ ...sampleRow, 'Berth Approved': null }); + const c = mapRow({ ...sampleRow, 'Berth Approved': 'yes' }); + expect(a?.berthApproved).toBe(true); + expect(b?.berthApproved).toBe(false); + expect(c?.berthApproved).toBe(false); + }); + + it('drops malformed Map Data gracefully', () => { + const out = mapRow({ ...sampleRow, 'Map Data': { path: 42 } as unknown }); + expect(out?.mapData).toBeNull(); + }); +}); + +// ─── buildPlan ─────────────────────────────────────────────────────────────── + +describe('buildPlan', () => { + function importedBerth(mooring: string, overrides: Partial = {}): ImportedBerth { + return { + legacyId: 0, + mooringNumber: mooring, + area: null, + status: 'available', + numerics: extractNumerics({ Id: 0 }), + widthIsMinimum: false, + waterDepthIsMinimum: false, + sidePontoon: null, + mooringType: null, + cleatType: null, + cleatCapacity: null, + bollardType: null, + bollardCapacity: null, + access: null, + bowFacing: null, + berthApproved: false, + statusOverrideMode: null, + mapData: null, + ...overrides, + }; + } + function existing( + mooring: string, + updatedAt: Date, + lastImportedAt: Date | null, + ): ExistingBerthRow { + return { id: `id-${mooring}`, mooringNumber: mooring, updatedAt, lastImportedAt }; + } + + it('plans inserts for moorings missing from CRM', () => { + const { plan, orphans } = buildPlan([importedBerth('A1')], new Map(), false); + expect(plan).toEqual([ + expect.objectContaining({ + action: 'insert', + imported: expect.objectContaining({ mooringNumber: 'A1' }), + }), + ]); + expect(orphans).toEqual([]); + }); + + it('plans updates for unedited rows', () => { + const map = new Map([ + ['A1', existing('A1', new Date('2026-04-01'), new Date('2026-04-02'))], + ]); + const { plan } = buildPlan([importedBerth('A1')], map, false); + expect(plan[0]?.action).toBe('update'); + }); + + it('skips human-edited rows by default', () => { + const map = new Map([ + ['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))], + ]); + const { plan } = buildPlan([importedBerth('A1')], map, false); + expect(plan[0]?.action).toBe('skip-edited'); + expect(plan[0]?.reason).toMatch(/updated_at=.*last_imported_at=/); + }); + + it('honours --force by updating even human-edited rows', () => { + const map = new Map([ + ['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))], + ]); + const { plan } = buildPlan([importedBerth('A1')], map, true); + expect(plan[0]?.action).toBe('update'); + }); + + it('treats lastImportedAt=null as never-imported, not human-edited (so we update)', () => { + const map = new Map([ + ['A1', existing('A1', new Date('2026-04-10'), null)], + ]); + const { plan } = buildPlan([importedBerth('A1')], map, false); + expect(plan[0]?.action).toBe('update'); + }); + + it('reports CRM-side moorings missing from the import as orphans', () => { + const map = new Map([ + ['A1', existing('A1', new Date(), null)], + ['Z99', existing('Z99', new Date(), null)], + ]); + const { plan, orphans } = buildPlan([importedBerth('A1')], map, false); + expect(plan).toHaveLength(1); + expect(orphans.map((o) => o.mooringNumber)).toEqual(['Z99']); + }); + + it('1-second tolerance: tiny updated_at lead is treated as in-sync, not edited', () => { + const lastImported = new Date('2026-04-10T12:00:00.000Z'); + const updatedAt = new Date('2026-04-10T12:00:00.500Z'); // 500ms later + const map = new Map([ + ['A1', existing('A1', updatedAt, lastImported)], + ]); + const { plan } = buildPlan([importedBerth('A1')], map, false); + expect(plan[0]?.action).toBe('update'); + }); +});