From 13d07e39063c2a8f1c7f2909041945a8e6aee9f1 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 16:20:53 +0200 Subject: [PATCH] feat(templates): merge-field resolver supports yacht/company/owner scopes Task 11.4. Extends resolveTemplate to use buildEoiContext when interestId is provided, populating the new yacht.*, company.*, owner.* token scopes from the shared EOI context. Legacy non-EOI templates still resolve via direct client/berth/port lookups. Deprecated client.yachtName / client.companyName / client.yacht*Ft tokens are removed from the catalog; PR 12 will drop the backing columns. berth.mooringNumber is relaxed to required:false so welcome-letter-style templates without a berth context no longer trip the required-merge-field check. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/services/document-templates.ts | 251 ++++++++--- .../document-templates-eoi.test.ts | 425 ++++++++++++++++++ 2 files changed, 620 insertions(+), 56 deletions(-) create mode 100644 tests/integration/document-templates-eoi.test.ts diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 5182274..2cd61b9 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -7,6 +7,7 @@ import { clients, clientContacts } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { ports } from '@/lib/db/schema/ports'; +import { yachts } from '@/lib/db/schema/yachts'; import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; @@ -16,7 +17,11 @@ import { minioClient, buildStoragePath } from '@/lib/minio'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { generatePdf } from '@/lib/pdf/generate'; -import { createDocument as documensoCreate, sendDocument as documensoSend } from '@/lib/services/documenso-client'; +import { + createDocument as documensoCreate, + sendDocument as documensoSend, +} from '@/lib/services/documenso-client'; +import { buildEoiContext } from '@/lib/services/eoi-context'; import { sendEmail } from '@/lib/email'; import type { CreateTemplateInput, @@ -40,16 +45,37 @@ interface AuditMeta { const MERGE_FIELDS: Record> = { client: [ { token: '{{client.fullName}}', label: 'Client Full Name', required: true }, - { token: '{{client.companyName}}', label: 'Company Name', required: false }, { token: '{{client.email}}', label: 'Primary Email', required: false }, { token: '{{client.phone}}', label: 'Primary Phone', required: false }, { token: '{{client.nationality}}', label: 'Nationality', required: false }, - { token: '{{client.yachtName}}', label: 'Yacht Name', required: false }, - { token: '{{client.yachtLengthFt}}', label: 'Yacht Length (ft)', required: false }, - { token: '{{client.yachtLengthM}}', label: 'Yacht Length (m)', required: false }, - { token: '{{client.yachtWidthFt}}', label: 'Yacht Beam (ft)', required: false }, - { token: '{{client.yachtDraftFt}}', label: 'Yacht Draft (ft)', required: false }, { token: '{{client.source}}', label: 'Lead Source', required: false }, + // Removed (PR 11): {{client.companyName}}, {{client.yachtName}}, + // {{client.yachtLengthFt}}, {{client.yachtLengthM}}, {{client.yachtWidthFt}}, + // {{client.yachtDraftFt}} — use the dedicated yacht.* / company.* scopes instead. + ], + yacht: [ + { token: '{{yacht.name}}', label: 'Yacht Name', required: false }, + { token: '{{yacht.hullNumber}}', label: 'Hull Number', required: false }, + { token: '{{yacht.registration}}', label: 'Registration', required: false }, + { token: '{{yacht.flag}}', label: 'Flag', required: false }, + { token: '{{yacht.yearBuilt}}', label: 'Year Built', required: false }, + { token: '{{yacht.lengthFt}}', label: 'Yacht Length (ft)', required: false }, + { token: '{{yacht.widthFt}}', label: 'Yacht Beam (ft)', required: false }, + { token: '{{yacht.draftFt}}', label: 'Yacht Draft (ft)', required: false }, + { token: '{{yacht.lengthM}}', label: 'Yacht Length (m)', required: false }, + { token: '{{yacht.widthM}}', label: 'Yacht Beam (m)', required: false }, + { token: '{{yacht.draftM}}', label: 'Yacht Draft (m)', required: false }, + ], + company: [ + { token: '{{company.name}}', label: 'Company Name', required: false }, + { token: '{{company.legalName}}', label: 'Company Legal Name', required: false }, + { token: '{{company.taxId}}', label: 'Company Tax ID', required: false }, + { token: '{{company.billingAddress}}', label: 'Company Billing Address', required: false }, + ], + owner: [ + { token: '{{owner.type}}', label: 'Yacht Owner Type', required: false }, + { token: '{{owner.name}}', label: 'Yacht Owner Name', required: false }, + { token: '{{owner.legalName}}', label: 'Yacht Owner Legal Name', required: false }, ], interest: [ { token: '{{interest.stage}}', label: 'Pipeline Stage', required: false }, @@ -62,7 +88,9 @@ const MERGE_FIELDS: Record, - data as Record, - ); + const { diff } = diffEntity(existing as Record, data as Record); const [updated] = await db .update(documentTemplates) @@ -261,69 +289,179 @@ export async function resolveTemplate( tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency; } - // Client tokens + // ─── EOI-style resolution ─────────────────────────────────────────────────── + // If an interestId is provided, prefer the shared buildEoiContext payload so + // that yacht.*, company.*, owner.*, and berth.* tokens all resolve from the + // same denormalised snapshot the PDF/Documenso pipelines use. + // Falls back to the legacy path below if the interest isn't EOI-ready + // (missing yacht or berth), so non-EOI templates still work. + let eoiContextLoaded = false; + if (context.interestId) { + try { + const eoi = await buildEoiContext(context.interestId, context.portId); + eoiContextLoaded = true; + + // Client tokens (from EoiContext) + tokenMap['{{client.fullName}}'] = eoi.client.fullName; + tokenMap['{{client.email}}'] = eoi.client.primaryEmail ?? ''; + tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? ''; + tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? ''; + + // Yacht tokens + tokenMap['{{yacht.name}}'] = eoi.yacht.name; + tokenMap['{{yacht.hullNumber}}'] = eoi.yacht.hullNumber ?? ''; + tokenMap['{{yacht.flag}}'] = eoi.yacht.flag ?? ''; + tokenMap['{{yacht.yearBuilt}}'] = + eoi.yacht.yearBuilt != null ? String(eoi.yacht.yearBuilt) : ''; + tokenMap['{{yacht.lengthFt}}'] = eoi.yacht.lengthFt ?? ''; + tokenMap['{{yacht.widthFt}}'] = eoi.yacht.widthFt ?? ''; + tokenMap['{{yacht.draftFt}}'] = eoi.yacht.draftFt ?? ''; + tokenMap['{{yacht.lengthM}}'] = eoi.yacht.lengthM ?? ''; + tokenMap['{{yacht.widthM}}'] = eoi.yacht.widthM ?? ''; + tokenMap['{{yacht.draftM}}'] = eoi.yacht.draftM ?? ''; + + // EoiContext doesn't expose the yacht.registration column — look it up + // separately (cheap, indexed fetch) so the token resolves when present. + try { + const interestRow = await db.query.interests.findFirst({ + where: eq(interests.id, context.interestId), + columns: { yachtId: true }, + }); + if (interestRow?.yachtId) { + const yachtRow = await db.query.yachts.findFirst({ + where: eq(yachts.id, interestRow.yachtId), + columns: { registration: true }, + }); + tokenMap['{{yacht.registration}}'] = yachtRow?.registration ?? ''; + } else { + tokenMap['{{yacht.registration}}'] = ''; + } + } catch { + tokenMap['{{yacht.registration}}'] = ''; + } + + // Company tokens (only populated when owner is a company) + tokenMap['{{company.name}}'] = eoi.company?.name ?? ''; + tokenMap['{{company.legalName}}'] = eoi.company?.legalName ?? ''; + tokenMap['{{company.taxId}}'] = eoi.company?.taxId ?? ''; + tokenMap['{{company.billingAddress}}'] = eoi.company?.billingAddress ?? ''; + + // Owner tokens + tokenMap['{{owner.type}}'] = eoi.owner.type; + tokenMap['{{owner.name}}'] = eoi.owner.name; + tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? ''; + + // Berth tokens (from EoiContext) + tokenMap['{{berth.mooringNumber}}'] = eoi.berth.mooringNumber; + tokenMap['{{berth.area}}'] = eoi.berth.area ?? ''; + tokenMap['{{berth.lengthFt}}'] = eoi.berth.lengthFt ?? ''; + tokenMap['{{berth.price}}'] = eoi.berth.price ?? ''; + tokenMap['{{berth.priceCurrency}}'] = eoi.berth.priceCurrency; + tokenMap['{{berth.tenureType}}'] = eoi.berth.tenureType; + + // Interest tokens + tokenMap['{{interest.stage}}'] = eoi.interest.stage; + tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? ''; + tokenMap['{{interest.berthNumber}}'] = eoi.berth.mooringNumber; + tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact + ? eoi.interest.dateFirstContact.toLocaleDateString('en-GB') + : ''; + tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? ''; + } catch (err) { + // buildEoiContext throws ValidationError when the interest has no yacht + // or berth; non-EOI templates don't need those. Fall through to the + // legacy resolution path below. Re-throw anything else. + if ( + !(err instanceof ValidationError) || + !/interest has no (yacht|berth)/i.test(err.message) + ) { + throw err; + } + } + } + + // ─── Legacy / non-EOI fallback ────────────────────────────────────────────── + + // Client tokens from direct client lookup (welcome letters, correspondence, + // or EOI-flow clients where we still want client.source to resolve). if (context.clientId) { const client = await db.query.clients.findFirst({ where: eq(clients.id, context.clientId), }); if (client && client.portId === context.portId) { - const contactList = await db.query.clientContacts.findMany({ - where: eq(clientContacts.clientId, context.clientId), - orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], - }); - const emailContact = contactList.find((c) => c.channel === 'email'); - const phoneContact = contactList.find((c) => c.channel === 'phone' || c.channel === 'whatsapp'); + // Always resolve source from the DB — EoiContext doesn't carry it. + if (tokenMap['{{client.source}}'] === undefined) { + tokenMap['{{client.source}}'] = client.source ?? ''; + } - tokenMap['{{client.fullName}}'] = client.fullName ?? ''; - tokenMap['{{client.companyName}}'] = client.companyName ?? ''; - tokenMap['{{client.email}}'] = emailContact?.value ?? ''; - tokenMap['{{client.phone}}'] = phoneContact?.value ?? ''; - tokenMap['{{client.nationality}}'] = client.nationality ?? ''; - tokenMap['{{client.yachtName}}'] = client.yachtName ?? ''; - tokenMap['{{client.yachtLengthFt}}'] = client.yachtLengthFt ? String(client.yachtLengthFt) : ''; - tokenMap['{{client.yachtLengthM}}'] = client.yachtLengthM ? String(client.yachtLengthM) : ''; - tokenMap['{{client.yachtWidthFt}}'] = client.yachtWidthFt ? String(client.yachtWidthFt) : ''; - tokenMap['{{client.yachtDraftFt}}'] = client.yachtDraftFt ? String(client.yachtDraftFt) : ''; - tokenMap['{{client.source}}'] = client.source ?? ''; + // Only fill client.* tokens if the EOI path didn't already populate them. + if (!eoiContextLoaded) { + const contactList = await db.query.clientContacts.findMany({ + where: eq(clientContacts.clientId, context.clientId), + orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], + }); + const emailContact = contactList.find((c) => c.channel === 'email'); + const phoneContact = contactList.find( + (c) => c.channel === 'phone' || c.channel === 'whatsapp', + ); + + tokenMap['{{client.fullName}}'] = client.fullName ?? ''; + tokenMap['{{client.email}}'] = emailContact?.value ?? ''; + tokenMap['{{client.phone}}'] = phoneContact?.value ?? ''; + tokenMap['{{client.nationality}}'] = client.nationality ?? ''; + } } } - // Interest tokens + // Interest tokens (legacy path — fills in fields EoiContext doesn't expose, + // like eoiStatus / dateEoiSigned / dateContractSigned, or populates the + // whole interest.* block when EOI resolution was skipped). if (context.interestId) { const interest = await db.query.interests.findFirst({ where: eq(interests.id, context.interestId), }); if (interest && interest.portId === context.portId) { - tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? ''; - tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? ''; + if (!eoiContextLoaded) { + tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? ''; + tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? ''; + tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact + ? new Date(interest.dateFirstContact).toLocaleDateString('en-GB') + : ''; + tokenMap['{{interest.notes}}'] = interest.notes ?? ''; + } + // These are never populated by EoiContext — always fill them in. tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? ''; - tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact - ? new Date(interest.dateFirstContact).toLocaleDateString('en-GB') - : ''; tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned ? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB') : ''; tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned ? new Date(interest.dateContractSigned).toLocaleDateString('en-GB') : ''; - tokenMap['{{interest.notes}}'] = interest.notes ?? ''; - // Berth number from interest if berthId not separately provided - if (interest.berthId && !context.berthId) { + // Derive berth number from the interest when berthId wasn't passed and + // the EOI path didn't already populate it. + if (!eoiContextLoaded && interest.berthId && !context.berthId) { const interestBerth = await db.query.berths.findFirst({ where: eq(berths.id, interest.berthId), }); - tokenMap['{{interest.berthNumber}}'] = interestBerth?.mooringNumber ?? ''; - tokenMap['{{berth.mooringNumber}}'] = interestBerth?.mooringNumber ?? ''; - } else { - tokenMap['{{interest.berthNumber}}'] = context.berthId - ? tokenMap['{{berth.mooringNumber}}'] ?? '' + if (interestBerth) { + tokenMap['{{interest.berthNumber}}'] = interestBerth.mooringNumber; + if (!tokenMap['{{berth.mooringNumber}}']) { + tokenMap['{{berth.mooringNumber}}'] = interestBerth.mooringNumber; + } + } else { + tokenMap['{{interest.berthNumber}}'] ??= ''; + } + } else if (!eoiContextLoaded) { + tokenMap['{{interest.berthNumber}}'] ??= context.berthId + ? (tokenMap['{{berth.mooringNumber}}'] ?? '') : ''; } } } - // Berth tokens - if (context.berthId) { + // Berth tokens (legacy path — when a berthId is passed directly and EOI + // resolution didn't already populate the berth block). + if (context.berthId && !eoiContextLoaded) { const berth = await db.query.berths.findFirst({ where: eq(berths.id, context.berthId), }); @@ -355,9 +493,7 @@ export async function resolveTemplate( } if (missing.length > 0) { - throw new ValidationError( - `Missing required merge field values: ${missing.join(', ')}`, - ); + throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`); } // Interpolate all tokens @@ -558,12 +694,12 @@ export async function generateAndSign( signers: GenerateAndSignInput['signers'], meta: AuditMeta, ) { - const { document: documentRecord, file } = await generateFromTemplate( + const { document: documentRecord, file } = (await generateFromTemplate( templateId, portId, context, meta, - ) as { document: DbDocument; file: DbFile }; + )) as { document: DbDocument; file: DbFile }; const template = await getTemplateById(templateId, portId); // Fetch PDF bytes from MinIO to send to Documenso @@ -611,7 +747,10 @@ export async function generateAndSign( userAgent: meta.userAgent, }); - emitToRoom(`port:${portId}`, 'document:updated', { documentId: documentRecord.id, changedFields: ['status', 'documensoId'] }); + emitToRoom(`port:${portId}`, 'document:updated', { + documentId: documentRecord.id, + changedFields: ['status', 'documensoId'], + }); return { document: { ...documentRecord, documensoId: documensoDoc.id, status: 'sent' }, file }; } diff --git a/tests/integration/document-templates-eoi.test.ts b/tests/integration/document-templates-eoi.test.ts new file mode 100644 index 0000000..95bef41 --- /dev/null +++ b/tests/integration/document-templates-eoi.test.ts @@ -0,0 +1,425 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +import { db } from '@/lib/db'; +import { documentTemplates } from '@/lib/db/schema/documents'; +import { clientAddresses, clientContacts, clients as clientsTable } from '@/lib/db/schema/clients'; +import { interests as interestsTable } from '@/lib/db/schema/interests'; +import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates'; + +import { makeBerth, makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function insertTemplate(args: { + portId: string; + bodyHtml: string; + name?: string; + templateType?: string; +}) { + const [row] = await db + .insert(documentTemplates) + .values({ + portId: args.portId, + name: args.name ?? `Tmpl ${Math.random().toString(36).slice(2, 8)}`, + templateType: args.templateType ?? 'custom', + bodyHtml: args.bodyHtml, + createdBy: 'test', + }) + .returning(); + return row!; +} + +async function insertInterest(args: { + portId: string; + clientId: string; + yachtId?: string | null; + berthId?: string | null; + pipelineStage?: string; + leadCategory?: string; + notes?: string; +}) { + const [row] = await db + .insert(interestsTable) + .values({ + portId: args.portId, + clientId: args.clientId, + yachtId: args.yachtId ?? null, + berthId: args.berthId ?? null, + pipelineStage: args.pipelineStage ?? 'open', + leadCategory: args.leadCategory ?? null, + notes: args.notes ?? null, + }) + .returning(); + return row!; +} + +// ─── MERGE_FIELDS catalog ───────────────────────────────────────────────────── + +describe('MERGE_FIELDS catalog', () => { + const catalog = getMergeFields(); + + it('includes new yacht / company / owner scopes', () => { + expect(catalog.yacht).toBeDefined(); + expect(catalog.company).toBeDefined(); + expect(catalog.owner).toBeDefined(); + + const yachtTokens = catalog.yacht!.map((f) => f.token); + expect(yachtTokens).toContain('{{yacht.name}}'); + expect(yachtTokens).toContain('{{yacht.hullNumber}}'); + expect(yachtTokens).toContain('{{yacht.lengthFt}}'); + expect(yachtTokens).toContain('{{yacht.lengthM}}'); + + const companyTokens = catalog.company!.map((f) => f.token); + expect(companyTokens).toContain('{{company.name}}'); + expect(companyTokens).toContain('{{company.legalName}}'); + expect(companyTokens).toContain('{{company.taxId}}'); + expect(companyTokens).toContain('{{company.billingAddress}}'); + + const ownerTokens = catalog.owner!.map((f) => f.token); + expect(ownerTokens).toContain('{{owner.type}}'); + expect(ownerTokens).toContain('{{owner.name}}'); + expect(ownerTokens).toContain('{{owner.legalName}}'); + }); + + it('removes deprecated client.yacht* and client.companyName tokens', () => { + const clientTokens = catalog.client!.map((f) => f.token); + expect(clientTokens).not.toContain('{{client.companyName}}'); + expect(clientTokens).not.toContain('{{client.yachtName}}'); + expect(clientTokens).not.toContain('{{client.yachtLengthFt}}'); + expect(clientTokens).not.toContain('{{client.yachtLengthM}}'); + expect(clientTokens).not.toContain('{{client.yachtWidthFt}}'); + expect(clientTokens).not.toContain('{{client.yachtDraftFt}}'); + }); + + it('keeps client.fullName as required but drops berth.mooringNumber requirement', () => { + const fullName = catalog.client!.find((f) => f.token === '{{client.fullName}}'); + expect(fullName?.required).toBe(true); + + const mooring = catalog.berth!.find((f) => f.token === '{{berth.mooringNumber}}'); + expect(mooring?.required).toBe(false); + }); +}); + +// ─── resolveTemplate — EOI scope tokens ─────────────────────────────────────── + +describe('resolveTemplate — EOI scope tokens', () => { + const EOI_TEMPLATE_BODY = [ + 'Client: {{client.fullName}} / {{client.email}} / {{client.phone}}', + 'Yacht: {{yacht.name}} HN={{yacht.hullNumber}} LenFt={{yacht.lengthFt}} LenM={{yacht.lengthM}} YB={{yacht.yearBuilt}}', + 'Owner: type={{owner.type}} name={{owner.name}} legal={{owner.legalName}}', + 'Company: name={{company.name}} legal={{company.legalName}} tax={{company.taxId}} addr={{company.billingAddress}}', + 'Berth: mooring={{berth.mooringNumber}} area={{berth.area}}', + 'Interest: stage={{interest.stage}} cat={{interest.leadCategory}} notes={{interest.notes}}', + 'Port: {{port.name}}', + ].join('\n'); + + let setup: { + portId: string; + clientId: string; + yachtId: string; + berthId: string; + interestId: string; + templateId: string; + }; + + beforeAll(async () => { + const port = await makePort(); + const client = await makeClient({ + portId: port.id, + overrides: { fullName: 'Alice Client', nationality: 'US', source: 'referral' }, + }); + await db.insert(clientContacts).values([ + { clientId: client.id, channel: 'email', value: 'alice@example.com', isPrimary: true }, + { clientId: client.id, channel: 'phone', value: '+1-555-0000', isPrimary: true }, + ]); + await db.insert(clientAddresses).values({ + clientId: client.id, + portId: port.id, + streetAddress: '1 Main St', + city: 'Town', + country: 'US', + isPrimary: true, + }); + + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Sea Breeze', + hullNumber: 'HN-100', + overrides: { + flag: 'US', + yearBuilt: 2020, + lengthFt: '60', + widthFt: '20', + draftFt: '8', + lengthM: '18.3', + widthM: '6.1', + draftM: '2.4', + }, + }); + + const berth = await makeBerth({ + portId: port.id, + overrides: { mooringNumber: 'M-42', area: 'North', lengthFt: '70', price: '100000' }, + }); + + const interest = await insertInterest({ + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + pipelineStage: 'in_communication', + leadCategory: 'tour', + notes: 'Eager buyer', + }); + + const tmpl = await insertTemplate({ + portId: port.id, + bodyHtml: EOI_TEMPLATE_BODY, + }); + + setup = { + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + interestId: interest.id, + templateId: tmpl.id, + }; + }); + + it('populates yacht.* tokens from EoiContext when interestId provided', async () => { + const resolved = await resolveTemplate(setup.templateId, { + interestId: setup.interestId, + clientId: setup.clientId, + portId: setup.portId, + }); + + expect(resolved).toContain('Yacht: Sea Breeze HN=HN-100'); + expect(resolved).toContain('LenFt=60'); + expect(resolved).toContain('LenM=18.3'); + expect(resolved).toContain('YB=2020'); + }); + + it('populates owner.type and owner.name for client-owned yacht', async () => { + const resolved = await resolveTemplate(setup.templateId, { + interestId: setup.interestId, + clientId: setup.clientId, + portId: setup.portId, + }); + + expect(resolved).toContain('Owner: type=client name=Alice Client'); + }); + + it('leaves company.* tokens empty for client-owned yachts', async () => { + const resolved = await resolveTemplate(setup.templateId, { + interestId: setup.interestId, + clientId: setup.clientId, + portId: setup.portId, + }); + + expect(resolved).toContain('Company: name= legal= tax= addr='); + }); + + it('populates berth.mooringNumber from EoiContext', async () => { + const resolved = await resolveTemplate(setup.templateId, { + interestId: setup.interestId, + clientId: setup.clientId, + portId: setup.portId, + }); + + expect(resolved).toContain('Berth: mooring=M-42 area=North'); + }); + + it('populates interest.* tokens', async () => { + const resolved = await resolveTemplate(setup.templateId, { + interestId: setup.interestId, + clientId: setup.clientId, + portId: setup.portId, + }); + + expect(resolved).toContain('Interest: stage=in_communication cat=tour notes=Eager buyer'); + }); + + it('populates client.* tokens from EoiContext', async () => { + const resolved = await resolveTemplate(setup.templateId, { + interestId: setup.interestId, + clientId: setup.clientId, + portId: setup.portId, + }); + + expect(resolved).toContain('Client: Alice Client / alice@example.com / +1-555-0000'); + }); +}); + +describe('resolveTemplate — company-owned yacht', () => { + it('populates company.* tokens and owner.legalName for company-owned yachts', async () => { + const port = await makePort(); + const company = await makeCompany({ + portId: port.id, + overrides: { + name: 'Acme Yachts', + legalName: 'Acme Yachts Ltd.', + taxId: 'TAX-123', + }, + }); + 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, + overrides: { mooringNumber: 'B-7' }, + }); + const [interest] = await db + .insert(interestsTable) + .values({ + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + pipelineStage: 'open', + }) + .returning(); + + const [tmpl] = await db + .insert(documentTemplates) + .values({ + portId: port.id, + name: 'company tmpl', + templateType: 'custom', + bodyHtml: [ + 'Owner={{owner.type}}/{{owner.name}}/{{owner.legalName}}', + 'Company={{company.name}}/{{company.legalName}}/{{company.taxId}}', + ].join(' | '), + createdBy: 'test', + }) + .returning(); + + const resolved = await resolveTemplate(tmpl!.id, { + interestId: interest!.id, + clientId: client.id, + portId: port.id, + }); + + expect(resolved).toContain('Owner=company/Acme Yachts/Acme Yachts Ltd.'); + expect(resolved).toContain('Company=Acme Yachts/Acme Yachts Ltd./TAX-123'); + }); +}); + +// ─── resolveTemplate — legacy fallback path ─────────────────────────────────── + +describe('resolveTemplate — legacy fallback (no interestId)', () => { + it('falls back to direct client lookup when no interestId is provided', async () => { + const port = await makePort(); + const client = await makeClient({ + portId: port.id, + overrides: { fullName: 'Carol NoInterest', nationality: 'UK', source: 'website' }, + }); + await db.insert(clientContacts).values({ + clientId: client.id, + channel: 'email', + value: 'carol@example.com', + isPrimary: true, + }); + + const [tmpl] = await db + .insert(documentTemplates) + .values({ + portId: port.id, + name: 'welcome', + templateType: 'welcome_letter', + bodyHtml: + 'Hello {{client.fullName}} ({{client.email}}) from {{client.nationality}} src={{client.source}}', + createdBy: 'test', + }) + .returning(); + + const resolved = await resolveTemplate(tmpl!.id, { + clientId: client.id, + portId: port.id, + }); + + expect(resolved).toContain('Hello Carol NoInterest'); + expect(resolved).toContain('carol@example.com'); + expect(resolved).toContain('from UK'); + expect(resolved).toContain('src=website'); + }); + + it('handles an interest that has no yacht without throwing (legacy fallback)', async () => { + const port = await makePort(); + const client = await makeClient({ + portId: port.id, + overrides: { fullName: 'Dave NoYacht' }, + }); + const berth = await makeBerth({ + portId: port.id, + overrides: { mooringNumber: 'B-LEG' }, + }); + const [interest] = await db + .insert(interestsTable) + .values({ + portId: port.id, + clientId: client.id, + yachtId: null, + berthId: berth.id, + pipelineStage: 'open', + leadCategory: 'casual', + }) + .returning(); + + const [tmpl] = await db + .insert(documentTemplates) + .values({ + portId: port.id, + name: 'partial', + templateType: 'correspondence', + bodyHtml: + 'Client={{client.fullName}} Stage={{interest.stage}} Cat={{interest.leadCategory}} Mooring={{berth.mooringNumber}}', + createdBy: 'test', + }) + .returning(); + + const resolved = await resolveTemplate(tmpl!.id, { + clientId: client.id, + interestId: interest!.id, + portId: port.id, + }); + + expect(resolved).toContain('Client=Dave NoYacht'); + expect(resolved).toContain('Stage=open'); + expect(resolved).toContain('Cat=casual'); + expect(resolved).toContain('Mooring=B-LEG'); + }); + + it('raises ValidationError when required client.fullName has no value', async () => { + const port = await makePort(); + const [tmpl] = await db + .insert(documentTemplates) + .values({ + portId: port.id, + name: 'no client', + templateType: 'custom', + bodyHtml: 'Hello {{client.fullName}}', + createdBy: 'test', + }) + .returning(); + + // Insert a client row with empty-string fullName to trigger the required check. + const [client] = await db + .insert(clientsTable) + .values({ portId: port.id, fullName: '' }) + .returning(); + + await expect( + resolveTemplate(tmpl!.id, { clientId: client!.id, portId: port.id }), + ).rejects.toThrow(/Missing required merge field/i); + }); +});