/** * Public-API berth shape + mapper. * * The public website's `/server/utils/berths.ts` previously read directly * from the legacy NocoDB Berths table; after Phase 3 it calls the CRM * `/api/public/berths` endpoint. The response shape must be a verbatim * match for the NocoDB output so the website's Vue templates and * `Berth` type don't need code changes (plan §7.3). * * Lives outside the route handler so vitest can exercise the mapping * + status-derivation logic without spinning up a Next.js request. */ import type { Berth, BerthMapData } from '@/lib/db/schema/berths'; export interface PublicMapData { path: string; x: string; y: string; transform: string; fontSize: string; } /** NocoDB display strings - the public website's `Berth.Status` enum. */ export type PublicStatus = 'Available' | 'Under Offer' | 'Sold'; /** * Verbatim NocoDB-style payload. Keys are quoted with capital-first * spacing because the upstream consumer (the public website) iterates * them by literal string key (e.g. `berth["Mooring Number"]`). */ export interface PublicBerth { Id: string; 'Mooring Number': string; Length: number | null; Draft: number | null; 'Side Pontoon': string | null; 'Power Capacity': number | null; Voltage: number | null; Status: PublicStatus; Width: number | null; Area: string | null; 'Mooring Type': string | null; 'Bow Facing': string | null; 'Cleat Type': string | null; 'Cleat Capacity': string | null; 'Bollard Type': string | null; 'Bollard Capacity': string | null; 'Nominal Boat Size': number | null; Access: string | null; 'Berth Approved': boolean | null; 'Water Depth': number | null; 'Width Is Minimum': boolean | null; 'Water Depth Is Minimum': boolean | null; 'Length (Metric)': number | null; 'Width (Metric)': number | null; 'Draft (Metric)': number | null; 'Water Depth (Metric)': number | null; 'Nominal Boat Size (Metric)': number | null; CreatedAt: string; UpdatedAt: string; 'Map Data'?: PublicMapData; } // ─── Helpers ──────────────────────────────────────────────────────────────── function toNumber(value: unknown): number | null { if (value === null || value === undefined || value === '') return null; const n = typeof value === 'number' ? value : parseFloat(String(value)); return Number.isFinite(n) ? n : null; } function toString(value: unknown): string | null { if (value === null || value === undefined) return null; if (typeof value === 'string') return value; if (typeof value === 'number') return String(value); return null; } /** * Derive the public-facing status string from the CRM's internal status * + the per-berth `is_specific_interest` flag. Per plan §1: * * Public "Under Offer" = berth.status = 'sold'? No - that's "Sold". * berth.status = 'under_offer' OR * at least one interest_berths row marked * is_specific_interest = true ON an active * (non-archived) interest. */ export function derivePublicStatus( internalStatus: string, hasSpecificInterest: boolean, ): PublicStatus { if (internalStatus === 'sold') return 'Sold'; if (internalStatus === 'under_offer' || hasSpecificInterest) return 'Under Offer'; return 'Available'; } function mapMapData(row: BerthMapData | null | undefined): PublicMapData | undefined { if (!row) return undefined; return { path: row.svgPath ?? '', x: row.x === null || row.x === undefined ? '' : String(row.x), y: row.y === null || row.y === undefined ? '' : String(row.y), transform: row.transform ?? '', fontSize: row.fontSize === null || row.fontSize === undefined ? '' : String(row.fontSize), }; } /** * Map a single CRM berth row + its (optional) map_data + a flag about * whether the berth has an active specific-interest link, into the * public response shape. The flag is computed once for a batch by the * route handler; this function is pure. */ export function toPublicBerth( berth: Berth, mapData: BerthMapData | null | undefined, hasSpecificInterest: boolean, ): PublicBerth { return { Id: berth.id, 'Mooring Number': berth.mooringNumber, Length: toNumber(berth.lengthFt), Draft: toNumber(berth.draftFt), Width: toNumber(berth.widthFt), 'Side Pontoon': toString(berth.sidePontoon), 'Power Capacity': toNumber(berth.powerCapacity), Voltage: toNumber(berth.voltage), Status: derivePublicStatus(berth.status, hasSpecificInterest), Area: toString(berth.area), 'Mooring Type': toString(berth.mooringType), 'Bow Facing': toString(berth.bowFacing), 'Cleat Type': toString(berth.cleatType), 'Cleat Capacity': toString(berth.cleatCapacity), 'Bollard Type': toString(berth.bollardType), 'Bollard Capacity': toString(berth.bollardCapacity), 'Nominal Boat Size': toNumber(berth.nominalBoatSize), Access: toString(berth.access), 'Berth Approved': berth.berthApproved, 'Water Depth': toNumber(berth.waterDepth), 'Width Is Minimum': berth.widthIsMinimum, 'Water Depth Is Minimum': berth.waterDepthIsMinimum, 'Length (Metric)': toNumber(berth.lengthM), 'Width (Metric)': toNumber(berth.widthM), 'Draft (Metric)': toNumber(berth.draftM), 'Water Depth (Metric)': toNumber(berth.waterDepthM), 'Nominal Boat Size (Metric)': toNumber(berth.nominalBoatSizeM), CreatedAt: berth.createdAt.toISOString(), UpdatedAt: berth.updatedAt.toISOString(), ...(mapMapData(mapData) ? { 'Map Data': mapMapData(mapData)! } : {}), }; }