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, clientAddresses } from '@/lib/db/schema/clients'; import { berths } from '@/lib/db/schema/berths'; import { ports } from '@/lib/db/schema/ports'; import { createAuditLog } from '@/lib/audit'; import { errorResponse, RateLimitError } from '@/lib/errors'; import { publicInterestSchema } from '@/lib/validators/interests'; import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service'; // ─── 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 }); } // Resolve the full name const fullName = data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : (data.fullName ?? 'Unknown'); const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest'; // Resolve berth by mooring number (if provided) let berthId: string | null = null; let resolvedMooringNumber: string | null = data.mooringNumber ?? null; if (data.mooringNumber) { const berth = await db.query.berths.findFirst({ where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)), }); if (berth) { berthId = berth.id; resolvedMooringNumber = berth.mooringNumber; } } // 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) { const existingClient = await db.query.clients.findFirst({ where: eq(clients.id, existingContact.clientId), }); if (existingClient && existingClient.portId === portId) { clientId = existingClient.id; // Update preferred contact method if provided if (data.preferredContactMethod) { await db .update(clients) .set({ preferredContactMethod: data.preferredContactMethod }) .where(eq(clients.id, clientId)); } } else { clientId = await createNewClient(portId, fullName, data); } } else { clientId = await createNewClient(portId, fullName, data); } // Store address if provided if (data.address && Object.values(data.address).some(Boolean)) { await db.insert(clientAddresses).values({ clientId, portId, label: 'Primary', streetAddress: data.address.street ?? null, city: data.address.city ?? null, stateProvince: data.address.stateProvince ?? null, postalCode: data.address.postalCode ?? null, country: data.address.country ?? null, isPrimary: true, }); } // Create the interest const [interest] = await db .insert(interests) .values({ portId, clientId, berthId, 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', berthId }, metadata: { type: 'public_registration', ip }, ipAddress: ip, userAgent: req.headers.get('user-agent') ?? 'unknown', }); // Fire notifications asynchronously (non-blocking) const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), columns: { slug: true }, }); void sendInquiryNotifications({ portId, portSlug: port?.slug ?? portId, interestId: interest!.id, clientFullName: fullName, clientEmail: data.email, clientPhone: data.phone, mooringNumber: resolvedMooringNumber, firstName, }); return NextResponse.json( { data: { id: interest!.id, message: 'Interest registered successfully' } }, { status: 201 }, ); } catch (error) { return errorResponse(error); } } async function createNewClient( portId: string, fullName: string, data: { email: string; phone: string; companyName?: string; yachtName?: string; yachtLengthFt?: number; yachtWidthFt?: number; yachtDraftFt?: number; preferredBerthSize?: string; preferredContactMethod?: string; }, ): Promise { const [newClient] = await db .insert(clients) .values({ portId, 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, preferredContactMethod: data.preferredContactMethod, source: 'website', }) .returning(); const clientId = newClient!.id; await db.insert(clientContacts).values({ clientId, channel: 'email', value: data.email, isPrimary: true, }); await db.insert(clientContacts).values({ clientId, channel: 'phone', value: data.phone, isPrimary: false, }); return clientId; }