import { NextRequest, NextResponse } from 'next/server'; import { and, eq, isNull, sql } from 'drizzle-orm'; import type { z } from 'zod'; import { db } from '@/lib/db'; import { withTransaction } from '@/lib/db/utils'; 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 { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts'; import { companies, companyMemberships } from '@/lib/db/schema/companies'; 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'; import { parsePhone } from '@/lib/i18n/phone'; import type { CountryCode } from '@/lib/i18n/countries'; // ─── 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; } type PublicInterestData = z.infer; // `withTransaction` exposes its tx argument as `typeof db` (see lib/db/utils.ts). // Keep the helper aligned with that. type Tx = typeof db; // POST /api/public/interests — unauthenticated public interest registration. // Creates the trio (client + yacht + interest) plus an optional company + // membership, all inside a single transaction. 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 }); } // Server-side phone normalization for older website builds that post raw // international/national strings. Newer builds may pre-fill phoneE164/Country. let phoneE164 = data.phoneE164 ?? null; let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null; if (!phoneE164) { const parsed = parsePhone(data.phone, phoneCountry ?? undefined); phoneE164 = parsed.e164; phoneCountry = parsed.country ?? phoneCountry; } 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). Read-only lookup — safe // to do outside the transaction. 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; } } // ─── Transactional trio creation ──────────────────────────────────────── const result = await withTransaction(async (tx) => { // 1. Find or create client by email (case-sensitive contact match, same // behavior as before the refactor). let clientId: string; const existingContact = await tx.query.clientContacts.findFirst({ where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)), }); if (existingContact) { const existingClient = await tx.query.clients.findFirst({ where: eq(clients.id, existingContact.clientId), }); if (existingClient && existingClient.portId === portId) { clientId = existingClient.id; const updates: Partial = {}; if (data.preferredContactMethod) { updates.preferredContactMethod = data.preferredContactMethod; } if (data.nationalityIso && !existingClient.nationalityIso) { updates.nationalityIso = data.nationalityIso; } if (Object.keys(updates).length > 0) { await tx.update(clients).set(updates).where(eq(clients.id, clientId)); } } else { clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry); } } else { clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry); } // 2. Optional: upsert company + add membership let companyId: string | null = null; if (data.company) { const existingCompany = await tx.query.companies.findFirst({ where: and( eq(companies.portId, portId), sql`lower(${companies.name}) = lower(${data.company.name})`, ), }); if (existingCompany) { companyId = existingCompany.id; } else { const [newCompany] = await tx .insert(companies) .values({ portId, name: data.company.name, legalName: data.company.legalName ?? null, taxId: data.company.taxId ?? null, incorporationCountry: data.company.incorporationCountry ?? null, incorporationCountryIso: data.company.incorporationCountryIso ?? null, incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null, status: 'active', }) .returning(); companyId = newCompany!.id; } // Add active membership only if one doesn't already exist (open row). const existingMembership = await tx.query.companyMemberships.findFirst({ where: and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.clientId, clientId), isNull(companyMemberships.endDate), ), }); if (!existingMembership) { await tx.insert(companyMemberships).values({ companyId, clientId, role: data.company.role ?? 'representative', startDate: new Date(), isPrimary: false, }); } } // 3. Create yacht. Owner is the company when provided, else the client. const ownerType: 'client' | 'company' = companyId ? 'company' : 'client'; const ownerId = companyId ?? clientId; const [newYacht] = await tx .insert(yachts) .values({ portId, name: data.yacht.name, hullNumber: data.yacht.hullNumber ?? null, registration: data.yacht.registration ?? null, flag: data.yacht.flag ?? null, yearBuilt: data.yacht.yearBuilt ?? null, lengthFt: data.yacht.lengthFt != null ? String(data.yacht.lengthFt) : null, widthFt: data.yacht.widthFt != null ? String(data.yacht.widthFt) : null, draftFt: data.yacht.draftFt != null ? String(data.yacht.draftFt) : null, currentOwnerType: ownerType, currentOwnerId: ownerId, status: 'active', }) .returning(); const yachtId = newYacht!.id; // 3a. Open ownership_history row for the new yacht. await tx.insert(yachtOwnershipHistory).values({ yachtId, ownerType, ownerId, startDate: new Date(), endDate: null, createdBy: 'public-submission', }); // 4. Store address if provided AND no primary address exists yet. if (data.address && Object.values(data.address).some(Boolean)) { const existingAddr = await tx.query.clientAddresses.findFirst({ where: and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)), }); if (!existingAddr) { await tx.insert(clientAddresses).values({ clientId, portId, label: 'Primary', streetAddress: data.address.street ?? null, city: data.address.city ?? null, stateProvince: data.address.stateProvince ?? null, subdivisionIso: data.address.subdivisionIso ?? null, postalCode: data.address.postalCode ?? null, country: data.address.country ?? null, countryIso: data.address.countryIso ?? null, isPrimary: true, }); } } // 5. Create interest with yachtId wired up. const [newInterest] = await tx .insert(interests) .values({ portId, clientId, berthId, yachtId, source: 'website', pipelineStage: 'open', notes: data.notes, }) .returning(); return { interestId: newInterest!.id, clientId, yachtId, companyId, }; }); // ─── Post-commit side-effects (fire-and-forget) ───────────────────────── void createAuditLog({ userId: null as unknown as string, portId, action: 'create', entityType: 'interest', entityId: result.interestId, newValue: { clientId: result.clientId, yachtId: result.yachtId, companyId: result.companyId, source: 'website', pipelineStage: 'open', 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: fullName, clientEmail: data.email, clientPhone: data.phone, mooringNumber: resolvedMooringNumber, firstName, }); return NextResponse.json( { data: { id: result.interestId, message: 'Interest registered successfully' } }, { status: 201 }, ); } catch (error) { return errorResponse(error); } } // ─── Helpers ───────────────────────────────────────────────────────────────── async function createClientInTx( tx: Tx, portId: string, fullName: string, data: Pick, phoneE164: string | null, phoneCountry: CountryCode | null, ): Promise { const [newClient] = await tx .insert(clients) .values({ portId, fullName, preferredContactMethod: data.preferredContactMethod, nationalityIso: data.nationalityIso ?? null, source: 'website', }) .returning(); const clientId = newClient!.id; await tx.insert(clientContacts).values({ clientId, channel: 'email', value: data.email, isPrimary: true, }); await tx.insert(clientContacts).values({ clientId, channel: 'phone', value: data.phone, valueE164: phoneE164, valueCountry: phoneCountry, isPrimary: false, }); return clientId; }