import { and, desc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { berths } from '@/lib/db/schema/berths'; import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients'; import { companies, companyAddresses } from '@/lib/db/schema/companies'; import { interests } from '@/lib/db/schema/interests'; import { ports } from '@/lib/db/schema/ports'; import { yachts } from '@/lib/db/schema/yachts'; import { NotFoundError, ValidationError } from '@/lib/errors'; // ─── Types ──────────────────────────────────────────────────────────────────── export type EoiContext = { client: { fullName: string; nationality: string | null; primaryEmail: string | null; primaryPhone: string | null; address: { street: string; city: string; country: string } | null; }; yacht: { name: string; lengthFt: string | null; widthFt: string | null; draftFt: string | null; lengthM: string | null; widthM: string | null; draftM: string | null; hullNumber: string | null; flag: string | null; yearBuilt: number | null; }; company: { name: string; legalName: string | null; taxId: string | null; billingAddress: string | null; } | null; owner: { type: 'client' | 'company'; name: string; legalName?: string; }; berth: { mooringNumber: string; area: string | null; lengthFt: string | null; price: string | null; priceCurrency: string; tenureType: string; }; interest: { stage: string; leadCategory: string | null; dateFirstContact: Date | null; notes: string | null; }; port: { name: string; defaultCurrency: string; }; date: { today: string; year: string; }; }; // ─── buildEoiContext ────────────────────────────────────────────────────────── /** * Assembles the shared context object used by EOI generation, templates, and * any other flow that needs a denormalised snapshot of an interest + its * surrounding entities (client, yacht, berth, owner, port, etc.). * * Pure read-only: no audit logs, no socket emits, no mutations. * * Tenant-scoped: every fetch is gated by `portId`, and missing rows surface * as NotFoundError. Missing yacht/berth references on the interest surface as * ValidationError, because EOI flows cannot proceed without them. */ export async function buildEoiContext(interestId: string, portId: string): Promise { // 1. Interest (tenant-scoped) const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), }); if (!interest) { throw new NotFoundError('Interest'); } // 2. Yacht reference must exist on the interest if (!interest.yachtId) { throw new ValidationError('interest has no yacht'); } // 3. Berth reference must exist on the interest if (!interest.berthId) { throw new ValidationError('interest has no berth'); } // 2 + 3 + 4 + 9: parallelise independent reads. const [yacht, berth, client, port] = await Promise.all([ db.query.yachts.findFirst({ where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)), }), db.query.berths.findFirst({ where: and(eq(berths.id, interest.berthId), eq(berths.portId, portId)), }), db.query.clients.findFirst({ where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)), }), db.query.ports.findFirst({ where: eq(ports.id, portId), }), ]); if (!yacht) throw new NotFoundError('Yacht'); if (!berth) throw new NotFoundError('Berth'); if (!client) throw new NotFoundError('Client'); if (!port) throw new NotFoundError('Port'); // 5. Primary contacts — email + phone for the interest's client. const contactRows = await db .select({ channel: clientContacts.channel, value: clientContacts.value, isPrimary: clientContacts.isPrimary, updatedAt: clientContacts.updatedAt, }) .from(clientContacts) .where(eq(clientContacts.clientId, client.id)) .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)); const firstEmail = contactRows.find((c) => c.channel === 'email'); const firstPhone = contactRows.find((c) => c.channel === 'phone') ?? contactRows.find((c) => c.channel === 'whatsapp'); // 6. Primary address. const [primaryAddress] = await db .select({ streetAddress: clientAddresses.streetAddress, city: clientAddresses.city, country: clientAddresses.country, }) .from(clientAddresses) .where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true))) .limit(1); const clientAddress = primaryAddress ? { street: primaryAddress.streetAddress ?? '', city: primaryAddress.city ?? '', country: primaryAddress.country ?? '', } : null; // 7 + 8. Yacht owner (polymorphic) + optional company billing address. let ownerBlock: EoiContext['owner']; let companyBlock: EoiContext['company'] = null; if (yacht.currentOwnerType === 'client') { // The yacht-owning client may or may not be the same as the interest's client. const ownerClient = yacht.currentOwnerId === client.id ? client : await db.query.clients.findFirst({ where: and(eq(clients.id, yacht.currentOwnerId), eq(clients.portId, portId)), }); if (!ownerClient) throw new NotFoundError('Client'); ownerBlock = { type: 'client', name: ownerClient.fullName }; } else if (yacht.currentOwnerType === 'company') { const company = await db.query.companies.findFirst({ where: and(eq(companies.id, yacht.currentOwnerId), eq(companies.portId, portId)), }); if (!company) throw new NotFoundError('Company'); ownerBlock = { type: 'company', name: company.name, ...(company.legalName ? { legalName: company.legalName } : {}), }; const [companyPrimaryAddress] = await db .select({ streetAddress: companyAddresses.streetAddress, city: companyAddresses.city, country: companyAddresses.country, }) .from(companyAddresses) .where(and(eq(companyAddresses.companyId, company.id), eq(companyAddresses.isPrimary, true))) .limit(1); const billingAddress = companyPrimaryAddress ? [ companyPrimaryAddress.streetAddress, companyPrimaryAddress.city, companyPrimaryAddress.country, ] .filter((s): s is string => Boolean(s)) .join(', ') || null : null; companyBlock = { name: company.name, legalName: company.legalName, taxId: company.taxId, billingAddress, }; } else { throw new ValidationError(`unknown yacht owner type: ${String(yacht.currentOwnerType)}`); } // 10. Date. const now = new Date(); const today = now.toISOString().slice(0, 10); const year = String(now.getFullYear()); return { client: { fullName: client.fullName, nationality: client.nationality, primaryEmail: firstEmail?.value ?? null, primaryPhone: firstPhone?.value ?? null, address: clientAddress, }, yacht: { name: yacht.name, lengthFt: yacht.lengthFt, widthFt: yacht.widthFt, draftFt: yacht.draftFt, lengthM: yacht.lengthM, widthM: yacht.widthM, draftM: yacht.draftM, hullNumber: yacht.hullNumber, flag: yacht.flag, yearBuilt: yacht.yearBuilt, }, company: companyBlock, owner: ownerBlock, berth: { mooringNumber: berth.mooringNumber, area: berth.area, lengthFt: berth.lengthFt, price: berth.price, priceCurrency: berth.priceCurrency, tenureType: berth.tenureType, }, interest: { stage: interest.pipelineStage, leadCategory: interest.leadCategory, dateFirstContact: interest.dateFirstContact, notes: interest.notes, }, port: { name: port.name, defaultCurrency: port.defaultCurrency, }, date: { today, year, }, }; }