/** * Public interest creation — extracted from `/api/public/interests/route.ts` * per the C.4 audit finding ("Public POST routes bypass service layer"). The * pre-extraction route was 346 lines of inline DB logic + audit + email * fan-out, which made unit testing the dedup, ownership, and address rules * effectively impossible without spinning up a full HTTP request fixture. * * After extraction: * - The route handles HTTP concerns: rate-limit, port resolution from * headers, parseBody validation, audit-log + email side-effect dispatch. * - This service handles the transactional trio creation (client + yacht * + interest, plus optional company + membership + address). * * The companion routes — `/api/public/website-inquiries/route.ts` (pure raw * capture; no entity creation) and `/api/public/residential-inquiries/route.ts` * (residential funnel, separate schema) — were intentionally NOT extracted * here. Their bodies are smaller and their concerns don't overlap with the * marina-funnel logic this service encapsulates. */ import { and, eq, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { withTransaction } from '@/lib/db/utils'; import { interests, interestBerths } from '@/lib/db/schema/interests'; import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients'; import { berths } from '@/lib/db/schema/berths'; import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts'; import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { parsePhone } from '@/lib/i18n/phone'; import type { CountryCode } from '@/lib/i18n/countries'; import type { publicInterestSchema } from '@/lib/validators/interests'; import type { z } from 'zod'; type PublicInterestData = z.infer; type Tx = typeof db; export interface CreatePublicInterestArgs { portId: string; data: PublicInterestData; } export interface CreatePublicInterestResult { interestId: string; clientId: string; yachtId: string; companyId: string | null; berthId: string | null; resolvedMooringNumber: string | null; fullName: string; firstName: string; } export async function createPublicInterest( args: CreatePublicInterestArgs, ): Promise { const { portId, data } = args; // 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. The inquiry-funnel audit // flagged that the previous exact match was case-sensitive — // capital-letter resubmissions spawned duplicate client+yacht+ // interest rows. Match LOWER(value) instead so foo@x.com and // Foo@X.COM dedupe to the same client. let clientId: string; const normalizedEmail = data.email.trim().toLowerCase(); const existingContact = await tx.query.clientContacts.findFirst({ where: and( eq(clientContacts.channel, 'email'), sql`LOWER(${clientContacts.value}) = ${normalizedEmail}`, ), }); 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, 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, subdivisionIso: data.address.subdivisionIso ?? null, postalCode: data.address.postalCode ?? null, countryIso: data.address.countryIso ?? null, isPrimary: true, }); } } // 5. Create interest with yachtId wired up. const [newInterest] = await tx .insert(interests) .values({ portId, clientId, yachtId, source: 'website', pipelineStage: 'open', }) .returning(); if (berthId) { await tx.insert(interestBerths).values({ interestId: newInterest!.id, berthId, isPrimary: true, isSpecificInterest: true, isInEoiBundle: false, }); } return { interestId: newInterest!.id, clientId, yachtId, companyId, }; }); return { ...result, berthId, resolvedMooringNumber, fullName, firstName, }; } 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', // Store lowercased so the case-insensitive dedup match above always // hits on subsequent submissions. value: data.email.trim().toLowerCase(), isPrimary: true, }); await tx.insert(clientContacts).values({ clientId, channel: 'phone', value: data.phone, valueE164: phoneE164, valueCountry: phoneCountry, isPrimary: false, }); return clientId; }