import { NextRequest, NextResponse } from 'next/server'; import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { createAuditLog } from '@/lib/audit'; import { errorResponse, RateLimitError } from '@/lib/errors'; import { publicInterestSchema } from '@/lib/validators/interests'; // ─── Simple in-memory rate limiter ─────────────────────────────────────────── // Max 5 requests per hour per IP const ipHits = new Map(); const WINDOW_MS = 60 * 60 * 1000; // 1 hour const MAX_HITS = 5; function checkRateLimit(ip: string): void { const now = Date.now(); const entry = ipHits.get(ip); if (!entry || now > entry.resetAt) { ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS }); return; } if (entry.count >= MAX_HITS) { const retryAfter = Math.ceil((entry.resetAt - now) / 1000); throw new RateLimitError(retryAfter); } entry.count += 1; } // POST /api/public/interests — unauthenticated public interest registration export async function POST(req: NextRequest) { try { const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; checkRateLimit(ip); const body = await req.json(); const data = publicInterestSchema.parse(body); // 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) { return NextResponse.json({ error: 'Port context required' }, { status: 400 }); } // Find or create client by email let clientId: string; const existingContact = await db.query.clientContacts.findFirst({ where: and( eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email), ), }); if (existingContact) { // Find the client associated with this contact const existingClient = await db.query.clients.findFirst({ where: eq(clients.id, existingContact.clientId), }); if (existingClient && existingClient.portId === portId) { clientId = existingClient.id; } else { // Create new client for this port const [newClient] = await db .insert(clients) .values({ portId, fullName: data.fullName, companyName: data.companyName, yachtName: data.yachtName, yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined, yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined, yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined, berthSizeDesired: data.preferredBerthSize, source: 'website', }) .returning(); clientId = newClient!.id; await db.insert(clientContacts).values({ clientId, channel: 'email', value: data.email, isPrimary: true, }); if (data.phone) { await db.insert(clientContacts).values({ clientId, channel: 'phone', value: data.phone, isPrimary: false, }); } } } else { // Create brand-new client const [newClient] = await db .insert(clients) .values({ portId, fullName: data.fullName, companyName: data.companyName, yachtName: data.yachtName, yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined, yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined, yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined, berthSizeDesired: data.preferredBerthSize, source: 'website', }) .returning(); clientId = newClient!.id; await db.insert(clientContacts).values({ clientId, channel: 'email', value: data.email, isPrimary: true, }); if (data.phone) { await db.insert(clientContacts).values({ clientId, channel: 'phone', value: data.phone, isPrimary: false, }); } } // Create the interest const [interest] = await db .insert(interests) .values({ portId, clientId, source: 'website', pipelineStage: 'open', notes: data.notes, }) .returning(); void createAuditLog({ userId: null as unknown as string, portId, action: 'create', entityType: 'interest', entityId: interest!.id, newValue: { clientId, source: 'website', pipelineStage: 'open' }, metadata: { type: 'public_registration', ip }, ipAddress: ip, userAgent: req.headers.get('user-agent') ?? 'unknown', }); return NextResponse.json( { data: { id: interest!.id, message: 'Interest registered successfully' } }, { status: 201 }, ); } catch (error) { return errorResponse(error); } }