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. */ // Hard-coded allowlist for the public read-only feed. Adding a port here // is a deliberate decision (not silent enumeration via ?portSlug=), so a // future private tenant can't be exposed by accident. const PUBLIC_PORT_SLUGS = new Set(['port-nimara']); 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 requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG; if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) { return NextResponse.json( { error: 'port is not part of the public berths feed', portSlug: requestedSlug }, { status: 404, headers: { 'cache-control': 'no-store' } }, ); } const portSlug = requestedSlug; // 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), // Closed deals (won/lost/cancelled) don't promote to "Under // Offer" - won flows through berths.status='sold' handled in // derivePublicStatus; lost/cancelled means back on the market. isNull(interests.outcome), ), ) .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 }); }