Files
pn-new-crm/src/lib/services/public-berths.ts
Matt 72ab7180cf feat(public-berths): expose booleans, metric variants, timestamps
Bring the public berth feed to verbatim NocoDB parity (all fields
except Price, which is held pending an explicit policy decision per
the audit follow-ups Q4). Adds:

- Berth Approved (boolean)
- Water Depth (number)
- Width Is Minimum / Water Depth Is Minimum (boolean)
- Length / Width / Draft / Water Depth / Nominal Boat Size (Metric)
- CreatedAt / UpdatedAt (ISO strings, useful for cache invalidation)

Booleans pass through as nullable to preserve NocoDB's tri-state
checkbox semantics (true / false / unset). Test fixtures cover the
new fields end-to-end including the null-passthrough case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:16:42 +02:00

154 lines
5.6 KiB
TypeScript

/**
* 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)! } : {}),
};
}