diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index c8a25a5..66f2cd0 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Dialog, @@ -12,12 +12,19 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; import { apiFetch } from '@/lib/api/client'; interface EoiPrerequisites { hasName: boolean; - hasEmail: boolean; - hasYachtDims: boolean; + hasYacht: boolean; hasBerth: boolean; } @@ -30,11 +37,23 @@ interface EoiGenerateDialogProps { const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [ { key: 'hasName', label: 'Client has full name' }, - { key: 'hasEmail', label: 'Client has email address' }, - { key: 'hasYachtDims', label: 'Yacht dimensions set' }, + { key: 'hasYacht', label: 'Yacht linked to interest' }, { key: 'hasBerth', label: 'Berth linked to interest' }, ]; +const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template'; + +interface InAppTemplate { + id: string; + name: string; + description?: string | null; + templateType: string; +} + +interface ListResponse { + data: InAppTemplate[]; +} + export function EoiGenerateDialog({ interestId, open, @@ -44,9 +63,21 @@ export function EoiGenerateDialog({ const queryClient = useQueryClient(); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(DOCUMENSO_TEMPLATE_VALUE); const allMet = Object.values(prerequisites).every(Boolean); + // Load in-app EOI templates so the operator can pick one as an alternative + // to the Documenso external-signing flow. + const { data: templatesRes } = useQuery({ + queryKey: ['document-templates', { templateType: 'eoi', isActive: true }], + queryFn: () => + apiFetch('/api/v1/document-templates?templateType=eoi&isActive=true'), + enabled: open, + }); + + const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]); + const handleGenerate = async () => { if (!allMet) return; @@ -54,9 +85,17 @@ export function EoiGenerateDialog({ setError(null); try { - await apiFetch('/api/v1/documents/generate-eoi', { + const isDocumensoPath = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE; + const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`; + await apiFetch(url, { method: 'POST', - body: { interestId }, + body: { + interestId, + pathway: isDocumensoPath ? 'documenso-template' : 'inapp', + // Signers are derived server-side from EOI context for both pathways + // when the template type is EOI, so the dialog doesn't collect them. + signers: [], + }, }); queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] }); @@ -74,39 +113,58 @@ export function EoiGenerateDialog({ Generate Expression of Interest - The following prerequisites must be met before generating the EOI document. + Pick how to render the EOI. Documenso is the primary path; in-app templates use the same + source PDF but render and store the PDF locally before sending for signing. -
- {PREREQUISITE_LABELS.map(({ key, label }) => ( -
- - {prerequisites[key] ? '✓' : '✗'} - - - {label} - -
- ))} +
+
+ + +
+ +
+

Prerequisites

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

{error}

- )} + {error &&

{error}

} diff --git a/src/components/interests/interest-documents-tab.tsx b/src/components/interests/interest-documents-tab.tsx index e4ec10f..0dd7f82 100644 --- a/src/components/interests/interest-documents-tab.tsx +++ b/src/components/interests/interest-documents-tab.tsx @@ -14,13 +14,9 @@ interface InterestDocumentsTabProps { interface InterestData { id: string; + yachtId?: string | null; berthId?: string | null; - client?: { - fullName?: string | null; - yachtLengthFt?: string | null; - yachtLengthM?: string | null; - contacts?: Array<{ channel: string; value: string }>; - }; + clientName?: string | null; } export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) { @@ -28,20 +24,14 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) const { data: interestRes } = useQuery({ queryKey: ['interests', interestId], - queryFn: () => - apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`), + queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`), }); const interest = interestRes?.data; const prerequisites = { - hasName: Boolean(interest?.client?.fullName), - hasEmail: Boolean( - interest?.client?.contacts?.some((c) => c.channel === 'email' && c.value), - ), - hasYachtDims: Boolean( - interest?.client?.yachtLengthFt || interest?.client?.yachtLengthM, - ), + hasName: Boolean(interest?.clientName), + hasYacht: Boolean(interest?.yachtId), hasBerth: Boolean(interest?.berthId), }; diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index d9ab274..1d21724 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -817,10 +817,34 @@ async function generateAndSignViaInApp( signers: GenerateAndSignInput['signers'], meta: AuditMeta, ) { - if (!signers || signers.length === 0) { + const template = await getTemplateById(templateId, portId); + + // For EOI templates, signers default to the same set the Documenso template + // pathway uses (interest's client + hardcoded developer + approver), so the + // UI doesn't need to collect them. Non-EOI templates still require explicit + // signers since they have no canonical recipient list. + let resolvedSigners = signers; + if ((!resolvedSigners || resolvedSigners.length === 0) && template.templateType === 'eoi') { + if (!context.interestId) { + throw new ValidationError( + 'interestId is required when generating an EOI without explicit signers', + ); + } + const eoiCtx = await buildEoiContext(context.interestId, portId); + resolvedSigners = [ + { + name: eoiCtx.client.fullName, + email: eoiCtx.client.primaryEmail ?? '', + role: 'signer', + signingOrder: 1, + }, + { name: 'David Mizrahi', email: 'dm@portnimara.com', role: 'signer', signingOrder: 2 }, + { name: 'Abbie May', email: 'sales@portnimara.com', role: 'approver', signingOrder: 3 }, + ]; + } + if (!resolvedSigners || resolvedSigners.length === 0) { throw new ValidationError('signers are required for inapp pathway'); } - const template = await getTemplateById(templateId, portId); // EOI templates fill the same source PDF as the Documenso template (so both // pathways yield the same document). Other template types stay on the @@ -845,7 +869,7 @@ async function generateAndSignViaInApp( const documensoDoc = await documensoCreate( template.name, pdfBase64, - signers.map((s) => ({ + resolvedSigners.map((s) => ({ name: s.name, email: s.email, role: s.role, @@ -873,7 +897,11 @@ async function generateAndSignViaInApp( entityType: 'document', entityId: documentRecord.id, newValue: { status: 'sent', documensoId: documensoDoc.id }, - metadata: { action: 'generate_and_sign', pathway: 'inapp', signerCount: signers.length }, + metadata: { + action: 'generate_and_sign', + pathway: 'inapp', + signerCount: resolvedSigners.length, + }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); diff --git a/tests/integration/document-templates-generate-and-sign.test.ts b/tests/integration/document-templates-generate-and-sign.test.ts index 64f03f7..3071042 100644 --- a/tests/integration/document-templates-generate-and-sign.test.ts +++ b/tests/integration/document-templates-generate-and-sign.test.ts @@ -206,16 +206,53 @@ describe('generateAndSign — inapp pathway', () => { expect(docRow?.fileId).toBeTruthy(); }); - it('throws ValidationError when signers array is empty', async () => { + it('auto-derives signers for EOI templates when none are provided', async () => { + const client = await import('@/lib/services/documenso-client'); + vi.mocked(client.createDocument).mockResolvedValue({ + id: 'doc-auto-signers', + status: 'PENDING', + recipients: [], + }); + vi.mocked(client.sendDocument).mockResolvedValue({ + id: 'doc-auto-signers', + status: 'PENDING', + recipients: [], + }); + + await generateAndSign( + setup.inAppTemplateId, + setup.portId, + { clientId: setup.clientId, interestId: setup.interestId }, + [], + 'inapp', + { ...meta, portId: setup.portId }, + ); + + expect(client.createDocument).toHaveBeenCalledOnce(); + const recipients = vi.mocked(client.createDocument).mock.calls[0]![2]; + expect(recipients).toHaveLength(3); + expect(recipients[0]?.name).toBe('Dual Path Client'); + expect(recipients[1]?.name).toBe('David Mizrahi'); + expect(recipients[2]?.role).toBe('approver'); + }); + + it('throws ValidationError when non-EOI template has no signers', async () => { + const [other] = await db + .insert(documentTemplates) + .values({ + portId: setup.portId, + name: 'Plain Letter', + templateType: 'welcome_letter', + bodyHtml: '

x

', + createdBy: 'test', + }) + .returning(); + await expect( - generateAndSign( - setup.inAppTemplateId, - setup.portId, - { clientId: setup.clientId, interestId: setup.interestId }, - [], - 'inapp', - { ...meta, portId: setup.portId }, - ), + generateAndSign(other!.id, setup.portId, { clientId: setup.clientId }, [], 'inapp', { + ...meta, + portId: setup.portId, + }), ).rejects.toThrow(ValidationError); });