import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { createAuditLog } from '@/lib/audit'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; import { publicInterestSchema } from '@/lib/validators/interests'; import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service'; import { createPublicInterest } from '@/lib/services/public-interest.service'; /** * Throws RateLimitError if the IP has exceeded the public-form quota. * Backed by the Redis sliding-window limiter so the cap survives restarts * and is shared across worker processes. */ async function gateRateLimit(ip: string): Promise { const result = await checkRateLimit(ip, rateLimiters.publicForm); if (!result.allowed) { const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)); throw new RateLimitError(retryAfter); } } // POST /api/public/interests - unauthenticated public interest registration. // The transactional trio creation (client + yacht + interest, plus optional // company + membership) lives in `createPublicInterest()` so it's testable // without an HTTP fixture. This handler is the thin HTTP shell: rate-limit, // port resolution, body parsing, then post-commit audit log + email fan-out. export async function POST(req: NextRequest) { try { const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; await gateRateLimit(ip); const data = await parseBody(req, publicInterestSchema); // Resolve portId from query param or header (public endpoints need explicit port) const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id'); if (!portId) throw new ValidationError('Port context required'); const result = await createPublicInterest({ portId, data }); // ─── Post-commit side-effects (fire-and-forget) ───────────────────────── // `AuditLogParams.userId` is `string | null`; null is the documented // "system-generated" sentinel and matches `audit_logs.user_id` being // nullable in the schema. void createAuditLog({ userId: null, portId, action: 'create', entityType: 'interest', entityId: result.interestId, newValue: { clientId: result.clientId, yachtId: result.yachtId, companyId: result.companyId, source: 'website', pipelineStage: 'enquiry', berthId: result.berthId, }, metadata: { type: 'public_registration', ip }, ipAddress: ip, userAgent: req.headers.get('user-agent') ?? 'unknown', }); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), columns: { slug: true }, }); void sendInquiryNotifications({ portId, portSlug: port?.slug ?? portId, interestId: result.interestId, clientFullName: result.fullName, clientEmail: data.email, clientPhone: data.phone, mooringNumber: result.resolvedMooringNumber, firstName: result.firstName, }); // L34 carve-out note: this is a public website intake POST (external // contract). Unlike the sibling intake routes it already uses the // canonical `{ data }` envelope — the external marketing site is // coded against THIS shape, so keep `{ data: { id, message } }` and do // not "normalize" it toward the bespoke `{ success }`/bare shapes used // by the other public intake endpoints. return NextResponse.json( { data: { id: result.interestId, message: 'Interest registered successfully' } }, { status: 201 }, ); } catch (error) { return errorResponse(error); } }