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 { getCountryName } from '@/lib/i18n/countries'; 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; }; /** Optional. The EOI's Section 3 yacht block is left blank when 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; } | null; company: { name: string; legalName: string | null; taxId: string | null; billingAddress: string | null; } | null; /** Inferred from the yacht's polymorphic owner. Falls back to the interest's * client when no yacht is linked (so the EOI's signing party is still * resolvable). */ owner: { type: 'client' | 'company'; name: string; legalName?: string; }; /** Optional. The EOI's Section 3 berth-number is left blank when null. */ berth: { mooringNumber: string; area: string | null; lengthFt: string | null; price: string | null; priceCurrency: string; tenureType: string; } | null; 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. The hard gate matches the EOI document's top paragraph * (Section 2 — name, address, email): without those the EOI is unsignable * and we throw. Yacht and berth (Section 3) are optional — the rendered PDF * leaves those fields blank when not set. */ 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'); } // Parallelise independent reads. Yacht and berth are both nullable — // the EOI's Section 3 stays blank when they're absent. const [yacht, berth, client, port] = await Promise.all([ interest.yachtId ? db.query.yachts.findFirst({ where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)), }) : Promise.resolve(undefined), interest.berthId ? db.query.berths.findFirst({ where: and(eq(berths.id, interest.berthId), eq(berths.portId, portId)), }) : Promise.resolve(undefined), 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 (!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. Country is rendered as the localized name (English by // default for documents) from the ISO code. const [primaryAddress] = await db .select({ streetAddress: clientAddresses.streetAddress, city: clientAddresses.city, countryIso: clientAddresses.countryIso, }) .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.countryIso ? getCountryName(primaryAddress.countryIso, 'en') : '', } : null; // EOI hard gate: the document's top paragraph (Section 2) requires Name, // Address, and Email. Without these the rendered EOI is unsignable. Yacht // and berth (Section 3) are intentionally optional and may be left blank. const missing: string[] = []; if (!client.fullName?.trim()) missing.push('client name'); if (!firstEmail?.value?.trim()) missing.push('client email'); if (!clientAddress || !clientAddress.street.trim()) missing.push('client address'); if (missing.length > 0) { throw new ValidationError( `Cannot generate EOI — missing required client details: ${missing.join(', ')}.`, ); } // Owner block. When a yacht is linked, derive from the yacht's polymorphic // owner. When no yacht is linked, fall back to the interest's client so the // EOI's signing party is still resolvable. let ownerBlock: EoiContext['owner']; let companyBlock: EoiContext['company'] = null; if (!yacht) { ownerBlock = { type: 'client', name: client.fullName }; } else 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, countryIso: companyAddresses.countryIso, }) .from(companyAddresses) .where(and(eq(companyAddresses.companyId, company.id), eq(companyAddresses.isPrimary, true))) .limit(1); const billingAddress = companyPrimaryAddress ? [ companyPrimaryAddress.streetAddress, companyPrimaryAddress.city, companyPrimaryAddress.countryIso ? getCountryName(companyPrimaryAddress.countryIso, 'en') : null, ] .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.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null, primaryEmail: firstEmail?.value ?? null, primaryPhone: firstPhone?.value ?? null, address: clientAddress, }, yacht: 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, } : null, company: companyBlock, owner: ownerBlock, berth: berth ? { mooringNumber: berth.mooringNumber, area: berth.area, lengthFt: berth.lengthFt, price: berth.price, priceCurrency: berth.priceCurrency, tenureType: berth.tenureType, } : null, interest: { stage: interest.pipelineStage, leadCategory: interest.leadCategory, dateFirstContact: interest.dateFirstContact, notes: interest.notes, }, port: { name: port.name, defaultCurrency: port.defaultCurrency, }, date: { today, year, }, }; }