diff --git a/src/lib/services/eoi-context.ts b/src/lib/services/eoi-context.ts new file mode 100644 index 0000000..650c977 --- /dev/null +++ b/src/lib/services/eoi-context.ts @@ -0,0 +1,264 @@ +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, + }, + }; +} diff --git a/tests/unit/services/eoi-context.test.ts b/tests/unit/services/eoi-context.test.ts new file mode 100644 index 0000000..a1d1d27 --- /dev/null +++ b/tests/unit/services/eoi-context.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from 'vitest'; + +import { buildEoiContext } from '@/lib/services/eoi-context'; +import { makePort, makeClient, makeCompany, makeBerth, makeYacht } from '../../helpers/factories'; +import { db } from '@/lib/db'; +import { interests, clientContacts, clientAddresses, companyAddresses } from '@/lib/db/schema'; +import { ValidationError, NotFoundError } from '@/lib/errors'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function insertInterest(args: { + portId: string; + clientId: string; + yachtId?: string | null; + berthId?: string | null; + pipelineStage?: string; +}) { + const [row] = await db + .insert(interests) + .values({ + portId: args.portId, + clientId: args.clientId, + yachtId: args.yachtId ?? null, + berthId: args.berthId ?? null, + pipelineStage: args.pipelineStage ?? 'open', + }) + .returning(); + return row!; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('buildEoiContext', () => { + it('returns a fully-populated context for a client-owned yacht', async () => { + const port = await makePort(); + const client = await makeClient({ + portId: port.id, + overrides: { fullName: 'Alice Test', nationality: 'US' }, + }); + + // Insert contacts. + await db.insert(clientContacts).values([ + { + clientId: client.id, + channel: 'email', + value: 'alice@example.com', + isPrimary: true, + }, + { + clientId: client.id, + channel: 'phone', + value: '+1-555-1234', + isPrimary: true, + }, + ]); + + // Insert primary address. + await db.insert(clientAddresses).values({ + clientId: client.id, + portId: port.id, + streetAddress: '1 Harbour Way', + city: 'Anguilla', + country: 'AI', + isPrimary: true, + }); + + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Sea Breeze', + overrides: { + lengthFt: '60', + widthFt: '20', + draftFt: '8', + hullNumber: 'HN-1', + flag: 'US', + yearBuilt: 2020, + }, + }); + + const berth = await makeBerth({ + portId: port.id, + overrides: { mooringNumber: 'M-42', area: 'North', lengthFt: '70' }, + }); + + const interest = await insertInterest({ + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + pipelineStage: 'in_communication', + }); + + const ctx = await buildEoiContext(interest.id, port.id); + + // Client assertions. + expect(ctx.client.fullName).toBe('Alice Test'); + expect(ctx.client.nationality).toBe('US'); + expect(ctx.client.primaryEmail).toBe('alice@example.com'); + expect(ctx.client.primaryPhone).toBe('+1-555-1234'); + expect(ctx.client.address).toEqual({ + street: '1 Harbour Way', + city: 'Anguilla', + country: 'AI', + }); + + // Yacht assertions. + expect(ctx.yacht.name).toBe('Sea Breeze'); + expect(ctx.yacht.hullNumber).toBe('HN-1'); + expect(ctx.yacht.yearBuilt).toBe(2020); + + // Berth assertions. + expect(ctx.berth.mooringNumber).toBe('M-42'); + expect(ctx.berth.area).toBe('North'); + + // Interest assertions. + expect(ctx.interest.stage).toBe('in_communication'); + + // Port assertions. + expect(ctx.port.name).toBe(port.name); + expect(ctx.port.defaultCurrency).toBe(port.defaultCurrency); + + // Date assertions. + expect(ctx.date.today).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(ctx.date.year).toMatch(/^\d{4}$/); + + // Owner assertions. + expect(ctx.owner.type).toBe('client'); + expect(ctx.owner.name).toBe('Alice Test'); + + // Company field. + expect(ctx.company).toBeNull(); + }); + + it('returns a fully-populated context for a company-owned yacht', async () => { + const port = await makePort(); + const company = await makeCompany({ + portId: port.id, + overrides: { name: 'Acme Shipping', legalName: 'Acme Shipping Ltd.' }, + }); + const client = await makeClient({ + portId: port.id, + overrides: { fullName: 'Bob Contact' }, + }); + + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'company', + ownerId: company.id, + name: 'Acme Runner', + }); + + const berth = await makeBerth({ portId: port.id }); + + const interest = await insertInterest({ + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + }); + + const ctx = await buildEoiContext(interest.id, port.id); + + expect(ctx.owner.type).toBe('company'); + expect(ctx.owner.name).toBe('Acme Shipping'); + expect(ctx.owner.legalName).toBe('Acme Shipping Ltd.'); + expect(ctx.company).not.toBeNull(); + expect(ctx.company!.name).toBe('Acme Shipping'); + expect(ctx.company!.legalName).toBe('Acme Shipping Ltd.'); + }); + + it('includes company billingAddress when company has a primary address', async () => { + const port = await makePort(); + const company = await makeCompany({ + portId: port.id, + overrides: { name: 'Billing Co' }, + }); + await db.insert(companyAddresses).values({ + companyId: company.id, + portId: port.id, + streetAddress: '99 Commerce St', + city: 'Valley', + country: 'AI', + isPrimary: true, + }); + + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'company', + ownerId: company.id, + }); + const berth = await makeBerth({ portId: port.id }); + + const interest = await insertInterest({ + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + }); + + const ctx = await buildEoiContext(interest.id, port.id); + + expect(ctx.company).not.toBeNull(); + expect(ctx.company!.billingAddress).not.toBeNull(); + expect(ctx.company!.billingAddress).toContain('99 Commerce St'); + expect(ctx.company!.billingAddress).toContain('Valley'); + expect(ctx.company!.billingAddress).toContain('AI'); + }); + + it('throws ValidationError when interest has no yacht', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const berth = await makeBerth({ portId: port.id }); + + const interest = await insertInterest({ + portId: port.id, + clientId: client.id, + yachtId: null, + berthId: berth.id, + }); + + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no yacht/i); + }); + + it('throws ValidationError when interest has no berth', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + + const interest = await insertInterest({ + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: null, + }); + + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no berth/i); + }); + + it('throws NotFoundError for non-existent interest', async () => { + const port = await makePort(); + await expect(buildEoiContext('fake-id', port.id)).rejects.toThrow(NotFoundError); + }); + + it('is tenant-scoped (interest from different port throws NotFoundError)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const client = await makeClient({ portId: portA.id }); + const yacht = await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: client.id, + }); + const berth = await makeBerth({ portId: portA.id }); + + const interest = await insertInterest({ + portId: portA.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + }); + + await expect(buildEoiContext(interest.id, portB.id)).rejects.toThrow(NotFoundError); + }); +});