diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index 66f2cd0..e658ed2 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -22,8 +22,14 @@ import { import { Label } from '@/components/ui/label'; import { apiFetch } from '@/lib/api/client'; +/** Required for the EOI's top paragraph (Section 2) — without these the + * document is unsignable, so generation is blocked. Yacht and berth fields + * belong to Section 3 and may be left blank. */ interface EoiPrerequisites { hasName: boolean; + hasEmail: boolean; + hasAddress: boolean; + /** Optional — info-only checks. Generation proceeds without them. */ hasYacht: boolean; hasBerth: boolean; } @@ -35,10 +41,15 @@ interface EoiGenerateDialogProps { prerequisites: EoiPrerequisites; } -const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [ - { key: 'hasName', label: 'Client has full name' }, - { key: 'hasYacht', label: 'Yacht linked to interest' }, - { key: 'hasBerth', label: 'Berth linked to interest' }, +const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [ + { key: 'hasName', label: 'Client name' }, + { key: 'hasAddress', label: 'Client address' }, + { key: 'hasEmail', label: 'Client email' }, +]; + +const OPTIONAL_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [ + { key: 'hasYacht', label: 'Yacht linked (name + dimensions)' }, + { key: 'hasBerth', label: 'Berth linked (mooring number)' }, ]; const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template'; @@ -65,7 +76,7 @@ export function EoiGenerateDialog({ const [error, setError] = useState(null); const [selectedTemplate, setSelectedTemplate] = useState(DOCUMENSO_TEMPLATE_VALUE); - const allMet = Object.values(prerequisites).every(Boolean); + const requiredMet = REQUIRED_LABELS.every(({ key }) => prerequisites[key]); // Load in-app EOI templates so the operator can pick one as an alternative // to the Documenso external-signing flow. @@ -79,7 +90,7 @@ export function EoiGenerateDialog({ const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]); const handleGenerate = async () => { - if (!allMet) return; + if (!requiredMet) return; setIsGenerating(true); setError(null); @@ -138,22 +149,59 @@ export function EoiGenerateDialog({ -
-

Prerequisites

- {PREREQUISITE_LABELS.map(({ key, label }) => ( -
- - {prerequisites[key] ? '✓' : '✗'} - - - {label} - -
- ))} +
+
+

+ Required (Section 2 of the EOI) +

+ {REQUIRED_LABELS.map(({ key, label }) => ( +
+ + {prerequisites[key] ? '✓' : '✗'} + + + {label} + +
+ ))} +
+ +
+

+ Optional (Section 3 — left blank if absent) +

+ {OPTIONAL_LABELS.map(({ key, label }) => ( +
+ + {prerequisites[key] ? '✓' : '–'} + + + {label} + +
+ ))} +
+ + {!requiredMet ? ( +

+ Add the missing required details on the client's record before generating the + EOI. +

+ ) : null}
@@ -163,7 +211,7 @@ export function EoiGenerateDialog({ - diff --git a/src/components/interests/interest-documents-tab.tsx b/src/components/interests/interest-documents-tab.tsx index 9bc4ca6..046e7b3 100644 --- a/src/components/interests/interest-documents-tab.tsx +++ b/src/components/interests/interest-documents-tab.tsx @@ -18,6 +18,9 @@ interface InterestData { yachtId?: string | null; berthId?: string | null; clientName?: string | null; + /** Surfaced by getInterestById for the EOI prerequisites checklist. */ + clientPrimaryEmail?: string | null; + clientHasAddress?: boolean; } export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) { @@ -33,7 +36,11 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) }); const prerequisites = { + // Required (EOI Section 2 — top paragraph): name, address, email. hasName: Boolean(interest?.clientName), + hasEmail: Boolean(interest?.clientPrimaryEmail), + hasAddress: Boolean(interest?.clientHasAddress), + // Optional (EOI Section 3): yacht + berth. Render blank when absent. hasYacht: Boolean(interest?.yachtId), hasBerth: Boolean(interest?.berthId), }; diff --git a/src/lib/pdf/fill-eoi-form.ts b/src/lib/pdf/fill-eoi-form.ts index 20618b9..c0d40ad 100644 --- a/src/lib/pdf/fill-eoi-form.ts +++ b/src/lib/pdf/fill-eoi-form.ts @@ -80,11 +80,13 @@ export async function fillEoiFormFields( setText(form, 'Name', context.client.fullName); setText(form, 'Email', context.client.primaryEmail ?? ''); setText(form, 'Address', formatAddress(context.client.address)); - setText(form, 'Yacht Name', context.yacht.name); - setText(form, 'Length', context.yacht.lengthFt ?? ''); - setText(form, 'Width', context.yacht.widthFt ?? ''); - setText(form, 'Draft', context.yacht.draftFt ?? ''); - setText(form, 'Berth Number', context.berth.mooringNumber); + // Yacht + berth (EOI Section 3) are optional — leave the AcroForm fields + // blank when the interest hasn't been linked to either. + setText(form, 'Yacht Name', context.yacht?.name ?? ''); + setText(form, 'Length', context.yacht?.lengthFt ?? ''); + setText(form, 'Width', context.yacht?.widthFt ?? ''); + setText(form, 'Draft', context.yacht?.draftFt ?? ''); + setText(form, 'Berth Number', context.berth?.mooringNumber ?? ''); setCheckbox(form, 'Purchase', true); setCheckbox(form, 'Lease_10', false); diff --git a/src/lib/services/documenso-payload.ts b/src/lib/services/documenso-payload.ts index 9c30036..0ab98c9 100644 --- a/src/lib/services/documenso-payload.ts +++ b/src/lib/services/documenso-payload.ts @@ -128,11 +128,13 @@ export function buildDocumensoPayload( Name: context.client.fullName, Email: context.client.primaryEmail ?? '', Address: formatAddress(context.client.address), - 'Yacht Name': context.yacht.name, - Length: context.yacht.lengthFt ?? '', - Width: context.yacht.widthFt ?? '', - Draft: context.yacht.draftFt ?? '', - 'Berth Number': context.berth.mooringNumber, + // Yacht + berth are optional EOI fields; when not linked, render as + // empty strings so the corresponding template inputs stay blank. + 'Yacht Name': context.yacht?.name ?? '', + Length: context.yacht?.lengthFt ?? '', + Width: context.yacht?.widthFt ?? '', + Draft: context.yacht?.draftFt ?? '', + 'Berth Number': context.berth?.mooringNumber ?? '', Lease_10: false, Purchase: true, }, diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 5b1da63..c20770c 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -237,18 +237,20 @@ export async function resolveTemplate( 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 ?? ''; + // Yacht tokens — `eoi.yacht` is null when no yacht is linked + // (Section 3 of the EOI is optional). Tokens render as empty strings + // in that case so the template still produces output. + 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 ?? ''; + 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. @@ -281,29 +283,31 @@ export async function resolveTemplate( 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; + // Berth tokens — also optional. Render empty when no berth is linked. + 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.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. + // buildEoiContext throws ValidationError when the EOI's required client + // fields (name/email/address — Section 2) are missing. For non-EOI + // templates (correspondence, welcome letters, etc.) those gates don't + // apply — 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) + !/missing required client details|interest has no (yacht|berth)/i.test(err.message) ) { throw err; } diff --git a/src/lib/services/eoi-context.ts b/src/lib/services/eoi-context.ts index 48a596d..c11a351 100644 --- a/src/lib/services/eoi-context.ts +++ b/src/lib/services/eoi-context.ts @@ -20,6 +20,7 @@ export type EoiContext = { 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; @@ -31,18 +32,22 @@ export type EoiContext = { 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; @@ -50,7 +55,7 @@ export type EoiContext = { price: string | null; priceCurrency: string; tenureType: string; - }; + } | null; interest: { stage: string; leadCategory: string | null; @@ -77,8 +82,10 @@ export type EoiContext = { * 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. + * 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) @@ -89,24 +96,19 @@ export async function buildEoiContext(interestId: string, portId: string): Promi 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. + // 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([ - 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)), - }), + 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)), }), @@ -115,8 +117,6 @@ export async function buildEoiContext(interestId: string, portId: string): Promi }), ]); - if (!yacht) throw new NotFoundError('Yacht'); - if (!berth) throw new NotFoundError('Berth'); if (!client) throw new NotFoundError('Client'); if (!port) throw new NotFoundError('Port'); @@ -157,11 +157,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi } : null; - // 7 + 8. Yacht owner (polymorphic) + optional company billing address. + // 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.currentOwnerType === 'client') { + 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 @@ -228,28 +245,32 @@ export async function buildEoiContext(interestId: string, portId: string): Promi 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, - }, + 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: { - mooringNumber: berth.mooringNumber, - area: berth.area, - lengthFt: berth.lengthFt, - price: berth.price, - priceCurrency: berth.priceCurrency, - tenureType: berth.tenureType, - }, + 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, diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index a1cbf3a..ddca80a 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -1,8 +1,8 @@ -import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests, interestTags } from '@/lib/db/schema/interests'; -import { clients } from '@/lib/db/schema/clients'; +import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients'; import { berths } from '@/lib/db/schema/berths'; import { yachts } from '@/lib/db/schema/yachts'; import { companyMemberships } from '@/lib/db/schema/companies'; @@ -282,6 +282,24 @@ export async function getInterestById(id: string, portId: string) { .from(clients) .where(eq(clients.id, interest.clientId)); + // EOI prerequisites: surface enough of the linked client's primary contact + // and address so the Documents tab can show the readiness checklist + // (Required: name, email, address — Section 2 of the EOI document). + const [emailContact] = await db + .select({ value: clientContacts.value }) + .from(clientContacts) + .where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email'))) + .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)) + .limit(1); + + const [addressRow] = await db + .select({ id: clientAddresses.id }) + .from(clientAddresses) + .where( + and(eq(clientAddresses.clientId, interest.clientId), eq(clientAddresses.isPrimary, true)), + ) + .limit(1); + let berthMooringNumber: string | null = null; if (interest.berthId) { const [berthRow] = await db @@ -300,6 +318,8 @@ export async function getInterestById(id: string, portId: string) { return { ...interest, clientName: clientRow?.fullName ?? null, + clientPrimaryEmail: emailContact?.value ?? null, + clientHasAddress: !!addressRow, berthMooringNumber, tags: tagRows, }; diff --git a/tests/integration/document-templates-eoi.test.ts b/tests/integration/document-templates-eoi.test.ts index fecb1fe..437cb80 100644 --- a/tests/integration/document-templates-eoi.test.ts +++ b/tests/integration/document-templates-eoi.test.ts @@ -268,6 +268,21 @@ describe('resolveTemplate — company-owned yacht', () => { portId: port.id, overrides: { fullName: 'Bob Contact' }, }); + // EOI gate now requires client primary email + address. + await db.insert(clientContacts).values({ + clientId: client.id, + channel: 'email', + value: 'bob@example.com', + isPrimary: true, + }); + await db.insert(clientAddresses).values({ + clientId: client.id, + portId: port.id, + streetAddress: '1 Marina Way', + city: 'Anguilla', + countryIso: 'AI', + isPrimary: true, + }); const yacht = await makeYacht({ portId: port.id, ownerType: 'company', diff --git a/tests/unit/services/documenso-payload.test.ts b/tests/unit/services/documenso-payload.test.ts index 2f05b5e..17ebedd 100644 --- a/tests/unit/services/documenso-payload.test.ts +++ b/tests/unit/services/documenso-payload.test.ts @@ -91,8 +91,9 @@ describe('buildDocumensoPayload', () => { }); it('defaults missing yacht dimensions to empty strings', () => { + const baseYacht = makeContext().yacht!; const ctx = makeContext({ - yacht: { ...makeContext().yacht, lengthFt: null, widthFt: null, draftFt: null }, + yacht: { ...baseYacht, lengthFt: null, widthFt: null, draftFt: null }, }); const payload = buildDocumensoPayload(ctx, OPTIONS); expect(payload.formValues.Length).toBe(''); @@ -100,6 +101,16 @@ describe('buildDocumensoPayload', () => { expect(payload.formValues.Draft).toBe(''); }); + it('renders empty Section 3 when yacht and berth are not linked', () => { + const ctx = makeContext({ yacht: null, berth: null }); + const payload = buildDocumensoPayload(ctx, OPTIONS); + expect(payload.formValues['Yacht Name']).toBe(''); + expect(payload.formValues.Length).toBe(''); + expect(payload.formValues.Width).toBe(''); + expect(payload.formValues.Draft).toBe(''); + expect(payload.formValues['Berth Number']).toBe(''); + }); + it('formats empty address when client has no address', () => { const ctx = makeContext({ client: { ...makeContext().client, address: null } }); const payload = buildDocumensoPayload(ctx, OPTIONS); diff --git a/tests/unit/services/eoi-context.test.ts b/tests/unit/services/eoi-context.test.ts index da54487..c63f17b 100644 --- a/tests/unit/services/eoi-context.test.ts +++ b/tests/unit/services/eoi-context.test.ts @@ -28,6 +28,33 @@ async function insertInterest(args: { return row!; } +/** + * Adds the EOI-required client details (primary email + primary address) so + * `buildEoiContext` clears its hard gate. Tests that exercise non-EOI-gating + * behavior should call this once per client they create. + */ +async function seedClientEoiPrereqs(args: { + clientId: string; + portId: string; + email?: string; + street?: string; +}) { + await db.insert(clientContacts).values({ + clientId: args.clientId, + channel: 'email', + value: args.email ?? `client-${args.clientId.slice(0, 8)}@example.com`, + isPrimary: true, + }); + await db.insert(clientAddresses).values({ + clientId: args.clientId, + portId: args.portId, + streetAddress: args.street ?? '1 Harbour Way', + city: 'Anguilla', + countryIso: 'AI', + isPrimary: true, + }); +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe('buildEoiContext', () => { @@ -107,13 +134,13 @@ describe('buildEoiContext', () => { }); // Yacht assertions. - expect(ctx.yacht.name).toBe('Sea Breeze'); - expect(ctx.yacht.hullNumber).toBe('HN-1'); - expect(ctx.yacht.yearBuilt).toBe(2020); + 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'); + expect(ctx.berth?.mooringNumber).toBe('M-42'); + expect(ctx.berth?.area).toBe('North'); // Interest assertions. expect(ctx.interest.stage).toBe('in_communication'); @@ -144,6 +171,7 @@ describe('buildEoiContext', () => { portId: port.id, overrides: { fullName: 'Bob Contact' }, }); + await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const yacht = await makeYacht({ portId: port.id, @@ -187,6 +215,7 @@ describe('buildEoiContext', () => { }); const client = await makeClient({ portId: port.id }); + await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'company', @@ -211,9 +240,10 @@ describe('buildEoiContext', () => { expect(ctx.company!.billingAddress).toContain('Anguilla'); }); - it('throws ValidationError when interest has no yacht', async () => { + it('builds a valid context when yacht is missing (Section 3 left blank)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); + await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const berth = await makeBerth({ portId: port.id }); const interest = await insertInterest({ @@ -223,13 +253,18 @@ describe('buildEoiContext', () => { 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); + const ctx = await buildEoiContext(interest.id, port.id); + expect(ctx.yacht).toBeNull(); + expect(ctx.berth?.mooringNumber).toBe(berth.mooringNumber); + // Owner falls back to the interest's client when no yacht is linked. + expect(ctx.owner.type).toBe('client'); + expect(ctx.owner.name).toBe(client.fullName); }); - it('throws ValidationError when interest has no berth', async () => { + it('builds a valid context when berth is missing (Section 3 left blank)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); + await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', @@ -243,8 +278,45 @@ describe('buildEoiContext', () => { berthId: null, }); + const ctx = await buildEoiContext(interest.id, port.id); + expect(ctx.berth).toBeNull(); + expect(ctx.yacht?.name).toBe(yacht.name); + }); + + it('throws ValidationError when client has no email', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + // Address only, no email — gate should fail. + await db.insert(clientAddresses).values({ + clientId: client.id, + portId: port.id, + streetAddress: '1 Harbour Way', + city: 'Anguilla', + countryIso: 'AI', + isPrimary: true, + }); + + const interest = await insertInterest({ portId: port.id, clientId: client.id }); + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); - await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no berth/i); + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client email/i); + }); + + it('throws ValidationError when client has no primary address', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + // Email only, no address — gate should fail. + await db.insert(clientContacts).values({ + clientId: client.id, + channel: 'email', + value: 'test@example.com', + isPrimary: true, + }); + + const interest = await insertInterest({ portId: port.id, clientId: client.id }); + + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); + await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client address/i); }); it('throws NotFoundError for non-existent interest', async () => {