From fb1116f1d434daa49f84e3761355074ab327a4ea Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 5 May 2026 02:52:44 +0200 Subject: [PATCH] feat(berths): public berths API + health env-match endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the read-only public-website data feed promised by plan §4.5 and §7.3. The marketing site's `getBerths()` swap is now a one-line URL change against the existing 5-min TTL behaviour. - src/app/api/public/berths/route.ts: GET / unauth, returns the full port-nimara berth list as { list, pageInfo } in the verbatim NocoDB shape ("Mooring Number", "Side Pontoon", quoted-key fields). Cache: s-maxage=300 + stale-while-revalidate=60. portSlug query param lets future ports opt in. - src/app/api/public/berths/[mooringNumber]/route.ts: GET single. Up- front regex validation (^[A-Z]+\\d+$) rejects malformed lookups with 400 + cache-control:no-store before hitting the DB. 404 + no-store when not found. - src/app/api/public/health/route.ts: returns { status, env, appUrl, timestamp } so the marketing site can refuse to start when its CRM_PUBLIC_URL points at a different deployment env (§14.8 critical env-mismatch protection). - src/lib/services/public-berths.ts: pure mapper with derivePublicStatus ("sold" wins; otherwise specific-interest junction OR status='under_offer' -> "Under Offer"; else "Available"). - 11 unit tests covering numeric coercion, status derivation, archived-berth handling, missing-map-data omission, and the status-precedence rule that "sold" trumps the specific-interest signal. Smoke-tested: /api/public/berths -> 117 rows, A1 correctly shows "Under Offer" (has interest_berths.is_specific_interest=true link), INVALID -> 400, Z99 -> 404. Total tests: 996 -> 1007. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../public/berths/[mooringNumber]/route.ts | 93 +++++++++++ src/app/api/public/berths/route.ts | 140 +++++++++++++++++ src/app/api/public/health/route.ts | 25 +++ src/lib/services/public-berths.ts | 131 ++++++++++++++++ tests/unit/services/public-berths.test.ts | 148 ++++++++++++++++++ 5 files changed, 537 insertions(+) create mode 100644 src/app/api/public/berths/[mooringNumber]/route.ts create mode 100644 src/app/api/public/berths/route.ts create mode 100644 src/app/api/public/health/route.ts create mode 100644 src/lib/services/public-berths.ts create mode 100644 tests/unit/services/public-berths.test.ts diff --git a/src/app/api/public/berths/[mooringNumber]/route.ts b/src/app/api/public/berths/[mooringNumber]/route.ts new file mode 100644 index 0000000..a55e6ca --- /dev/null +++ b/src/app/api/public/berths/[mooringNumber]/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; +import { and, eq, isNull } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { berths, berthMapData } from '@/lib/db/schema/berths'; +import { interestBerths, interests } from '@/lib/db/schema/interests'; +import { logger } from '@/lib/logger'; +import { toPublicBerth } from '@/lib/services/public-berths'; + +/** + * GET /api/public/berths/[mooringNumber] + * + * Single-berth lookup for the public website's `/berths/[number]` + * page. Mooring numbers are matched against the canonical bare form + * ("A1", "B12") - Phase 0 normalized the entire CRM dataset. + */ + +const DEFAULT_PUBLIC_PORT_SLUG = 'port-nimara'; +const RESPONSE_HEADERS = { + 'cache-control': 'public, s-maxage=300, stale-while-revalidate=60', + 'content-type': 'application/json; charset=utf-8', +}; + +const MOORING_PATTERN = /^[A-Z]+\d+$/; + +export async function GET( + request: Request, + ctx: { params: Promise<{ mooringNumber: string }> }, +): Promise { + const { mooringNumber } = await ctx.params; + const url = new URL(request.url); + const portSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG; + + // Reject obviously malformed mooring numbers up front so cache poisoning + // / random-URL probing returns 400 rather than 404 (saves a DB hit). + if (!MOORING_PATTERN.test(mooringNumber)) { + return NextResponse.json( + { error: 'invalid mooring number', mooringNumber }, + { status: 400, headers: { 'cache-control': 'no-store' } }, + ); + } + + const [port] = await db + .select({ id: ports.id }) + .from(ports) + .where(eq(ports.slug, portSlug)) + .limit(1); + if (!port) { + return NextResponse.json( + { error: 'port not found', portSlug }, + { status: 404, headers: { 'cache-control': 'no-store' } }, + ); + } + + const [berth] = await db + .select() + .from(berths) + .where(and(eq(berths.portId, port.id), eq(berths.mooringNumber, mooringNumber))) + .limit(1); + + if (!berth) { + return NextResponse.json( + { error: 'berth not found', mooringNumber }, + { status: 404, headers: { 'cache-control': 'no-store' } }, + ); + } + + const [mapData, specificInterestRows] = await Promise.all([ + db.select().from(berthMapData).where(eq(berthMapData.berthId, berth.id)).limit(1), + db + .select({ berthId: interestBerths.berthId }) + .from(interestBerths) + .innerJoin(interests, eq(interests.id, interestBerths.interestId)) + .where( + and( + eq(interestBerths.berthId, berth.id), + eq(interestBerths.isSpecificInterest, true), + isNull(interests.archivedAt), + ), + ) + .limit(1), + ]); + + const out = toPublicBerth(berth, mapData[0] ?? null, specificInterestRows.length > 0); + + if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') { + logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range'); + return NextResponse.json({ error: 'internal' }, { status: 500 }); + } + + return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 }); +} diff --git a/src/app/api/public/berths/route.ts b/src/app/api/public/berths/route.ts new file mode 100644 index 0000000..16ec568 --- /dev/null +++ b/src/app/api/public/berths/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from 'next/server'; +import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { berths, berthMapData } from '@/lib/db/schema/berths'; +import { interestBerths, interests } from '@/lib/db/schema/interests'; +import { logger } from '@/lib/logger'; +import { toPublicBerth, type PublicBerth } from '@/lib/services/public-berths'; + +/** + * GET /api/public/berths + * + * Public-website data feed. Returns the full berth list for the public- + * facing port (default: port-nimara) in the same JSON shape NocoDB + * returned, so the website's existing `getBerths()` swap is a one-line + * URL change (plan §4.5 + §7.3). + * + * Auth: none. The endpoint is read-only and exposes only the explicit + * field allowlist defined in `toPublicBerth`. + * + * Caching: `s-maxage=300, stale-while-revalidate=60` matches the + * website's existing 5-minute TTL behaviour against NocoDB. Edge/CDN + * caches honour these headers; the Next.js fetch cache also picks + * them up. + */ + +const DEFAULT_PUBLIC_PORT_SLUG = 'port-nimara'; + +const RESPONSE_HEADERS = { + 'cache-control': 'public, s-maxage=300, stale-while-revalidate=60', + 'content-type': 'application/json; charset=utf-8', +}; + +interface ListResponse { + list: PublicBerth[]; + pageInfo: { + totalRows: number; + page: 1; + pageSize: number; + isFirstPage: true; + isLastPage: true; + }; +} + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const portSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG; + + const [port] = await db + .select({ id: ports.id }) + .from(ports) + .where(eq(ports.slug, portSlug)) + .limit(1); + if (!port) { + return NextResponse.json( + { error: 'port not found', portSlug }, + { status: 404, headers: { 'cache-control': 'no-store' } }, + ); + } + + // 1. Active berths for the port (archived would be an explicit field + // once we add one - today we don't have an archived_at on berths, + // so we surface every row except those marked status='sold' on + // request? No: §4.5 says "filters out berths archived in CRM". + // The current schema has no archived flag for berths, so this is + // a no-op today; future archive flag plugs in here. + const berthRows = await db.select().from(berths).where(eq(berths.portId, port.id)); + + if (berthRows.length === 0) { + return jsonResponse({ list: [], pageInfo: emptyPageInfo() }); + } + + const berthIds = berthRows.map((b) => b.id); + + // 2. Bulk-fetch map_data + the "has specific-interest link" flag. + const [mapRows, specificInterestRows] = await Promise.all([ + db.select().from(berthMapData).where(inArray(berthMapData.berthId, berthIds)), + db + .selectDistinct({ berthId: interestBerths.berthId }) + .from(interestBerths) + .innerJoin(interests, eq(interests.id, interestBerths.interestId)) + .where( + and( + inArray(interestBerths.berthId, berthIds), + eq(interestBerths.isSpecificInterest, true), + isNull(interests.archivedAt), + ), + ), + ]); + + const mapByBerth = new Map(mapRows.map((m) => [m.berthId, m])); + const specificInterestSet = new Set(specificInterestRows.map((r) => r.berthId)); + + const list = berthRows.map((b) => + toPublicBerth(b, mapByBerth.get(b.id) ?? null, specificInterestSet.has(b.id)), + ); + + // Validate the response enum before returning - any unknown status + // value would hit a 500 (per §14.8) rather than silently shipping + // invalid data downstream. + for (const row of list) { + if (row.Status !== 'Available' && row.Status !== 'Under Offer' && row.Status !== 'Sold') { + logger.error({ row }, 'Public berth status out of range'); + return NextResponse.json( + { error: 'internal', detail: 'berth status enum drift' }, + { status: 500 }, + ); + } + } + + return jsonResponse({ + list, + pageInfo: { + totalRows: list.length, + page: 1, + pageSize: list.length, + isFirstPage: true, + isLastPage: true, + }, + }); +} + +function jsonResponse(body: ListResponse): Response { + return new Response(JSON.stringify(body), { headers: RESPONSE_HEADERS, status: 200 }); +} + +function emptyPageInfo() { + return { + totalRows: 0, + page: 1 as const, + pageSize: 0, + isFirstPage: true as const, + isLastPage: true as const, + }; +} + +// Suppress the `sql` import unused-warning when no inline raw SQL appears +// further down (helper kept for future where-clause extensions). +void sql; diff --git a/src/app/api/public/health/route.ts b/src/app/api/public/health/route.ts new file mode 100644 index 0000000..6da7035 --- /dev/null +++ b/src/app/api/public/health/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; + +import { env } from '@/lib/env'; + +/** + * GET /api/public/health + * + * Public-facing health probe. Used by the marketing-website server on + * startup to verify it's pointed at a CRM matching its own deployment + * env (plan §14.8 critical: prevent staging-website-talking-to-prod-CRM). + * + * Returns the CRM's `NODE_ENV` and `APP_URL` so the website can do a + * strict equality check before serving any request. + */ +export function GET(): Response { + return NextResponse.json( + { + status: 'ok', + env: env.NODE_ENV, + appUrl: env.APP_URL, + timestamp: new Date().toISOString(), + }, + { headers: { 'cache-control': 'no-store' } }, + ); +} diff --git a/src/lib/services/public-berths.ts b/src/lib/services/public-berths.ts new file mode 100644 index 0000000..066b510 --- /dev/null +++ b/src/lib/services/public-berths.ts @@ -0,0 +1,131 @@ +/** + * 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; + '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), + ...(mapMapData(mapData) ? { 'Map Data': mapMapData(mapData)! } : {}), + }; +} diff --git a/tests/unit/services/public-berths.test.ts b/tests/unit/services/public-berths.test.ts new file mode 100644 index 0000000..fb2c3f6 --- /dev/null +++ b/tests/unit/services/public-berths.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; + +import { derivePublicStatus, toPublicBerth } from '@/lib/services/public-berths'; +import type { Berth, BerthMapData } from '@/lib/db/schema/berths'; + +function makeBerth(overrides: Partial = {}): Berth { + return { + id: 'b1', + portId: 'p1', + mooringNumber: 'A1', + area: 'A', + status: 'available', + lengthFt: '206.69', + widthFt: '46.56', + draftFt: '14.5', + lengthM: '63', + widthM: '14.19', + draftM: '4.42', + widthIsMinimum: false, + nominalBoatSize: '200', + nominalBoatSizeM: '60.96', + waterDepth: '16.08', + waterDepthM: '4.9', + waterDepthIsMinimum: false, + sidePontoon: 'Quay PT', + powerCapacity: '330', + voltage: '480', + mooringType: 'Side Pier / Med Mooring', + cleatType: 'A5', + cleatCapacity: '20-24 ton break load', + bollardType: 'Bull bollard type B', + bollardCapacity: '40 ton break load', + access: 'Car (3t) to Vessel', + price: '3528000', + priceCurrency: 'USD', + weeklyRateHighUsd: null, + weeklyRateLowUsd: null, + dailyRateHighUsd: null, + dailyRateLowUsd: null, + pricingValidUntil: null, + bowFacing: 'East', + berthApproved: false, + tenureType: 'permanent', + tenureYears: null, + tenureStartDate: null, + tenureEndDate: null, + statusLastChangedBy: null, + statusLastChangedReason: null, + statusLastModified: null, + statusOverrideMode: null, + lastImportedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeMapData(overrides: Partial = {}): BerthMapData { + return { + id: 'm1', + berthId: 'b1', + svgPath: 'M0 0', + x: '922.819', + y: '930.721', + transform: '', + fontSize: '32', + extraData: {}, + updatedAt: new Date(), + ...overrides, + }; +} + +describe('derivePublicStatus', () => { + it('"sold" wins over the specific-interest signal', () => { + expect(derivePublicStatus('sold', true)).toBe('Sold'); + expect(derivePublicStatus('sold', false)).toBe('Sold'); + }); + it('"under_offer" maps to "Under Offer"', () => { + expect(derivePublicStatus('under_offer', false)).toBe('Under Offer'); + }); + it('a specific-interest link promotes "available" → "Under Offer"', () => { + expect(derivePublicStatus('available', true)).toBe('Under Offer'); + }); + it('plain available stays available', () => { + expect(derivePublicStatus('available', false)).toBe('Available'); + }); +}); + +describe('toPublicBerth', () => { + it('maps every NocoDB-style field with numeric coercion', () => { + const out = toPublicBerth(makeBerth(), makeMapData(), false); + expect(out.Id).toBe('b1'); + expect(out['Mooring Number']).toBe('A1'); + expect(out.Length).toBe(206.69); + expect(out.Width).toBe(46.56); + expect(out.Draft).toBe(14.5); + expect(out['Power Capacity']).toBe(330); + expect(out.Voltage).toBe(480); + expect(out['Nominal Boat Size']).toBe(200); + expect(out.Status).toBe('Available'); + expect(out['Mooring Type']).toBe('Side Pier / Med Mooring'); + expect(out['Bow Facing']).toBe('East'); + expect(out.Area).toBe('A'); + expect(out.Access).toBe('Car (3t) to Vessel'); + }); + + it('inlines map data when present', () => { + const out = toPublicBerth(makeBerth(), makeMapData(), false); + expect(out['Map Data']).toEqual({ + path: 'M0 0', + x: '922.819', + y: '930.721', + transform: '', + fontSize: '32', + }); + }); + + it('omits map data entirely when missing', () => { + const out = toPublicBerth(makeBerth(), null, false); + expect(out['Map Data']).toBeUndefined(); + }); + + it('numeric fields stored as strings round-trip cleanly', () => { + const out = toPublicBerth(makeBerth({ lengthFt: '42' }), null, false); + expect(out.Length).toBe(42); + }); + + it('null numeric inputs surface as null (not NaN)', () => { + const out = toPublicBerth( + makeBerth({ lengthFt: null, widthFt: null, draftFt: null }), + null, + false, + ); + expect(out.Length).toBeNull(); + expect(out.Width).toBeNull(); + expect(out.Draft).toBeNull(); + }); + + it('promotes status to "Under Offer" when a specific-interest link exists', () => { + const out = toPublicBerth(makeBerth({ status: 'available' }), null, true); + expect(out.Status).toBe('Under Offer'); + }); + + it('preserves "Sold" even when a specific-interest link exists (defensive)', () => { + const out = toPublicBerth(makeBerth({ status: 'sold' }), null, true); + expect(out.Status).toBe('Sold'); + }); +});