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 { berthTenancies } from '@/lib/db/schema/tenancies'; import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; import { isPermanentTenureType, 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 { try { 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), isNull(berths.archivedAt), ), ) .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), ]); // Tenancies-module status flip: an active permanent-class tenancy // pushes the berth to "Sold" when the module is enabled for this port. let hasActivePermanentTenancy = false; if (await isTenanciesModuleEnabled(port.id)) { const activeTenancy = await db .select({ tenureType: berthTenancies.tenureType }) .from(berthTenancies) .where( and( eq(berthTenancies.portId, port.id), eq(berthTenancies.berthId, berth.id), eq(berthTenancies.status, 'active'), ), ) .limit(1); hasActivePermanentTenancy = activeTenancy.some((row) => isPermanentTenureType(row.tenureType), ); } const out = toPublicBerth( berth, mapData[0] ?? null, specificInterestRows.length > 0, hasActivePermanentTenancy, ); 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'); throw new Error('Public berth status out of range'); } return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 }); } catch (err) { return errorResponse(err); } }