diff --git a/src/app/api/public/interests/route.ts b/src/app/api/public/interests/route.ts index ae2ae42..4e731fe 100644 --- a/src/app/api/public/interests/route.ts +++ b/src/app/api/public/interests/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; +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'; @@ -35,7 +39,14 @@ function checkRateLimit(ip: string): void { entry.count += 1; } -// POST /api/public/interests — unauthenticated public interest registration +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'; @@ -50,7 +61,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Port context required' }, { status: 400 }); } - // Resolve the full name const fullName = data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` @@ -58,10 +68,10 @@ export async function POST(req: NextRequest) { const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest'; - // Resolve berth by mooring number (if provided) + // 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)), @@ -72,74 +82,172 @@ export async function POST(req: NextRequest) { } } - // 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), + // ─── 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 (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)); + if (existingContact) { + const existingClient = await tx.query.clients.findFirst({ + where: eq(clients.id, existingContact.clientId), + }); + if (existingClient && existingClient.portId === portId) { + clientId = existingClient.id; + if (data.preferredContactMethod) { + await tx + .update(clients) + .set({ preferredContactMethod: data.preferredContactMethod }) + .where(eq(clients.id, clientId)); + } + } else { + clientId = await createClientInTx(tx, portId, fullName, data); } } else { - clientId = await createNewClient(portId, fullName, data); + clientId = await createClientInTx(tx, 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, + // 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, + 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', }); - } - // Create the interest - const [interest] = await db - .insert(interests) - .values({ - portId, + // 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, + postalCode: data.address.postalCode ?? null, + country: data.address.country ?? 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, - berthId, - source: 'website', - pipelineStage: 'open', - notes: data.notes, - }) - .returning(); + yachtId, + companyId, + }; + }); + // ─── Post-commit side-effects (fire-and-forget) ───────────────────────── void createAuditLog({ userId: null as unknown as string, portId, action: 'create', entityType: 'interest', - entityId: interest!.id, - newValue: { clientId, source: 'website', pipelineStage: 'open', berthId }, + 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', }); - // Fire notifications asynchronously (non-blocking) const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), columns: { slug: true }, @@ -148,7 +256,7 @@ export async function POST(req: NextRequest) { void sendInquiryNotifications({ portId, portSlug: port?.slug ?? portId, - interestId: interest!.id, + interestId: result.interestId, clientFullName: fullName, clientEmail: data.email, clientPhone: data.phone, @@ -157,7 +265,7 @@ export async function POST(req: NextRequest) { }); return NextResponse.json( - { data: { id: interest!.id, message: 'Interest registered successfully' } }, + { data: { id: result.interestId, message: 'Interest registered successfully' } }, { status: 201 }, ); } catch (error) { @@ -165,46 +273,33 @@ export async function POST(req: NextRequest) { } } -async function createNewClient( +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function createClientInTx( + tx: Tx, portId: string, fullName: string, - data: { - email: string; - phone: string; - companyName?: string; - yachtName?: string; - yachtLengthFt?: number; - yachtWidthFt?: number; - yachtDraftFt?: number; - preferredBerthSize?: string; - preferredContactMethod?: string; - }, + data: Pick, ): Promise { - const [newClient] = await db + const [newClient] = await tx .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({ + await tx.insert(clientContacts).values({ clientId, channel: 'email', value: data.email, isPrimary: true, }); - await db.insert(clientContacts).values({ + await tx.insert(clientContacts).values({ clientId, channel: 'phone', value: data.phone, diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts index a462020..243461a 100644 --- a/src/lib/validators/interests.ts +++ b/src/lib/validators/interests.ts @@ -74,6 +74,42 @@ const addressSchema = z.object({ country: z.string().max(100).optional(), }); +// Nested yacht block. Public submissions must now include yacht data because the +// route inserts a yacht row as part of the trio (client + yacht + interest). +const publicYachtSchema = z.object({ + name: z.string().min(1).max(200), + hullNumber: z.string().max(100).optional(), + registration: z.string().max(100).optional(), + flag: z.string().max(100).optional(), + yearBuilt: z.coerce.number().int().min(1800).max(2100).optional(), + lengthFt: z.coerce.number().positive().optional(), + widthFt: z.coerce.number().positive().optional(), + draftFt: z.coerce.number().positive().optional(), +}); + +// Optional company block. If provided, the route upserts a company row (match +// case-insensitively by (portId, name)) and adds an active membership linking +// the submitting client to the company with the chosen role. +const publicCompanySchema = z.object({ + name: z.string().min(1).max(200), + legalName: z.string().max(200).optional(), + taxId: z.string().max(100).optional(), + incorporationCountry: z.string().max(100).optional(), + role: z + .enum([ + 'director', + 'officer', + 'broker', + 'representative', + 'legal_counsel', + 'employee', + 'shareholder', + 'other', + ]) + .optional() + .default('representative'), +}); + export const publicInterestSchema = z .object({ // New: first/last split @@ -85,15 +121,26 @@ export const publicInterestSchema = z phone: z.string().min(1), preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(), mooringNumber: z.string().max(50).optional(), - companyName: z.string().optional(), + // NEW: required structured yacht block. Public submissions after the + // data-model refactor MUST include yacht data. + yacht: publicYachtSchema, + // NEW: optional company block — creates/upserts a company and adds a + // membership linking the submitting client to it. + company: publicCompanySchema.optional(), + source: z.literal('website').default('website'), + notes: z.string().max(2000).optional(), + address: addressSchema.optional(), + + // ─── Deprecated flat fields ──────────────────────────────────────────── + // Kept in the schema so strict parse does not reject submissions from + // legacy callers, but the route IGNORES them in favor of `yacht` / `company`. + // Remove once all inbound integrations have migrated. yachtName: z.string().optional(), yachtLengthFt: z.coerce.number().positive().optional(), yachtWidthFt: z.coerce.number().positive().optional(), yachtDraftFt: z.coerce.number().positive().optional(), preferredBerthSize: z.string().optional(), - source: z.literal('website').default('website'), - notes: z.string().max(2000).optional(), - address: addressSchema.optional(), + companyName: z.string().optional(), }) .refine((data) => data.fullName || (data.firstName && data.lastName), { message: 'Either fullName or both firstName and lastName are required', diff --git a/tests/integration/public-interest-trio.test.ts b/tests/integration/public-interest-trio.test.ts new file mode 100644 index 0000000..dc6c613 --- /dev/null +++ b/tests/integration/public-interest-trio.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { and, eq, isNull } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { clients, clientContacts } from '@/lib/db/schema/clients'; +import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts'; +import { companies, companyMemberships } from '@/lib/db/schema/companies'; +import { interests } from '@/lib/db/schema/interests'; +import { makePort } from '../helpers/factories'; +import { makeMockRequest } from '../helpers/route-tester'; + +// Mock fire-and-forget side-effects so the test doesn't hit Redis / external services. +vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); +vi.mock('@/lib/queue', () => ({ + getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }), +})); +vi.mock('@/lib/services/inquiry-notifications.service', () => ({ + sendInquiryNotifications: vi.fn().mockResolvedValue(undefined), +})); + +// The rate-limiter is keyed by IP header and persists across requests inside a +// single process. Use a unique IP per test call to avoid 429s. +let ipCounter = 1; +function uniqueIp(): string { + ipCounter += 1; + return `10.0.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`; +} + +describe('POST /api/public/interests — trio creation', () => { + let POST: typeof import('@/app/api/public/interests/route').POST; + + beforeAll(async () => { + // Import after mocks are registered. + const mod = await import('@/app/api/public/interests/route'); + POST = mod.POST; + }); + + it('creates client + yacht + interest atomically', async () => { + const port = await makePort(); + const email = `trio-client-${Math.random().toString(36).slice(2, 8)}@test.local`; + + const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Alice', + lastName: 'Mariner', + email, + phone: '+10000000001', + yacht: { + name: 'Sea Star', + lengthFt: 52, + widthFt: 14, + draftFt: 6, + }, + }, + }); + const res = await POST(req); + expect(res.status).toBe(201); + const body = await res.json(); + const interestId: string = body.data.id; + + const [interest] = await db.select().from(interests).where(eq(interests.id, interestId)); + expect(interest).toBeDefined(); + expect(interest!.portId).toBe(port.id); + expect(interest!.pipelineStage).toBe('open'); + expect(interest!.yachtId).not.toBeNull(); + expect(interest!.clientId).not.toBeNull(); + + // Yacht exists, owned by the client + const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!)); + expect(yacht).toBeDefined(); + expect(yacht!.name).toBe('Sea Star'); + expect(yacht!.currentOwnerType).toBe('client'); + expect(yacht!.currentOwnerId).toBe(interest!.clientId); + + // Ownership history row created + const historyRows = await db + .select() + .from(yachtOwnershipHistory) + .where(eq(yachtOwnershipHistory.yachtId, yacht!.id)); + expect(historyRows.length).toBe(1); + expect(historyRows[0]!.endDate).toBeNull(); + expect(historyRows[0]!.ownerType).toBe('client'); + expect(historyRows[0]!.ownerId).toBe(interest!.clientId); + + // Client has email + phone contacts + const contacts = await db + .select() + .from(clientContacts) + .where(eq(clientContacts.clientId, interest!.clientId)); + expect(contacts.some((c) => c.channel === 'email' && c.value === email)).toBe(true); + expect(contacts.some((c) => c.channel === 'phone' && c.value === '+10000000001')).toBe(true); + }); + + it('creates client + company + membership + company-owned yacht + interest when company provided', async () => { + const port = await makePort(); + const email = `trio-co-${Math.random().toString(36).slice(2, 8)}@test.local`; + const companyName = `Nautical Holdings ${Math.random().toString(36).slice(2, 8)}`; + + const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Bob', + lastName: 'Director', + email, + phone: '+10000000002', + yacht: { name: 'Corporate Cruiser', lengthFt: 80 }, + company: { + name: companyName, + role: 'director', + }, + }, + }); + const res = await POST(req); + expect(res.status).toBe(201); + const body = await res.json(); + const interestId: string = body.data.id; + + const [interest] = await db.select().from(interests).where(eq(interests.id, interestId)); + expect(interest).toBeDefined(); + expect(interest!.yachtId).not.toBeNull(); + + // Yacht owned by the company + const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!)); + expect(yacht!.currentOwnerType).toBe('company'); + + // Company exists and matches + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, yacht!.currentOwnerId)); + expect(company!.name).toBe(companyName); + expect(company!.portId).toBe(port.id); + + // Ownership-history points at the company + const historyRows = await db + .select() + .from(yachtOwnershipHistory) + .where(eq(yachtOwnershipHistory.yachtId, yacht!.id)); + expect(historyRows.length).toBe(1); + expect(historyRows[0]!.ownerType).toBe('company'); + expect(historyRows[0]!.ownerId).toBe(company!.id); + + // Active membership linking client -> company + const memberships = await db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, company!.id), + eq(companyMemberships.clientId, interest!.clientId), + isNull(companyMemberships.endDate), + ), + ); + expect(memberships.length).toBe(1); + expect(memberships[0]!.role).toBe('director'); + }); + + it('reuses existing client when email matches (same port)', async () => { + const port = await makePort(); + const email = `trio-reuse-${Math.random().toString(36).slice(2, 8)}@test.local`; + + const firstReq = makeMockRequest( + 'POST', + `http://localhost/api/public/interests?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Carol', + lastName: 'Returning', + email, + phone: '+10000000003', + yacht: { name: 'First Boat' }, + }, + }, + ); + const firstRes = await POST(firstReq); + expect(firstRes.status).toBe(201); + const firstBody = await firstRes.json(); + + const [firstInterest] = await db + .select() + .from(interests) + .where(eq(interests.id, firstBody.data.id)); + const originalClientId = firstInterest!.clientId; + + // Second submission with the same email + const secondReq = makeMockRequest( + 'POST', + `http://localhost/api/public/interests?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Carol', + lastName: 'Returning', + email, + phone: '+10000000003', + yacht: { name: 'Second Boat' }, + }, + }, + ); + const secondRes = await POST(secondReq); + expect(secondRes.status).toBe(201); + const secondBody = await secondRes.json(); + + const [secondInterest] = await db + .select() + .from(interests) + .where(eq(interests.id, secondBody.data.id)); + expect(secondInterest!.clientId).toBe(originalClientId); + + // A second yacht row was created (not deduped) — each submission is its + // own inquiry about a possibly-different yacht. + const clientsMatching = await db.select().from(clients).where(eq(clients.id, originalClientId)); + expect(clientsMatching.length).toBe(1); + + const [secondYacht] = await db + .select() + .from(yachts) + .where(eq(yachts.id, secondInterest!.yachtId!)); + expect(secondYacht!.name).toBe('Second Boat'); + expect(secondYacht!.id).not.toBe(firstInterest!.yachtId); + }); + + it('reuses existing company when name matches case-insensitively (same port)', async () => { + const port = await makePort(); + const email1 = `trio-coreuse1-${Math.random().toString(36).slice(2, 8)}@test.local`; + const email2 = `trio-coreuse2-${Math.random().toString(36).slice(2, 8)}@test.local`; + const companyName = `Harbor Partners ${Math.random().toString(36).slice(2, 8)}`; + + const firstReq = makeMockRequest( + 'POST', + `http://localhost/api/public/interests?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Dana', + lastName: 'Founder', + email: email1, + phone: '+10000000004', + yacht: { name: 'Flagship' }, + company: { name: companyName, role: 'director' }, + }, + }, + ); + const firstRes = await POST(firstReq); + expect(firstRes.status).toBe(201); + const firstBody = await firstRes.json(); + const [firstInterest] = await db + .select() + .from(interests) + .where(eq(interests.id, firstBody.data.id)); + const [firstYacht] = await db + .select() + .from(yachts) + .where(eq(yachts.id, firstInterest!.yachtId!)); + const originalCompanyId = firstYacht!.currentOwnerId; + + // Second submission — same company name, different casing, different client + const secondReq = makeMockRequest( + 'POST', + `http://localhost/api/public/interests?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Evan', + lastName: 'Employee', + email: email2, + phone: '+10000000005', + yacht: { name: 'Second Flagship' }, + company: { name: companyName.toUpperCase(), role: 'employee' }, + }, + }, + ); + const secondRes = await POST(secondReq); + expect(secondRes.status).toBe(201); + const secondBody = await secondRes.json(); + const [secondInterest] = await db + .select() + .from(interests) + .where(eq(interests.id, secondBody.data.id)); + const [secondYacht] = await db + .select() + .from(yachts) + .where(eq(yachts.id, secondInterest!.yachtId!)); + expect(secondYacht!.currentOwnerId).toBe(originalCompanyId); + + // Only one company row exists for that (portId, lowered name) + const allCompanies = await db.select().from(companies).where(eq(companies.portId, port.id)); + const matching = allCompanies.filter((c) => c.name.toLowerCase() === companyName.toLowerCase()); + expect(matching.length).toBe(1); + + // Second client has its own membership in the same company + const memberships = await db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, originalCompanyId), + eq(companyMemberships.clientId, secondInterest!.clientId), + isNull(companyMemberships.endDate), + ), + ); + expect(memberships.length).toBe(1); + expect(memberships[0]!.role).toBe('employee'); + }); +});