From f8255cedb8af2227d3b1605d2e4002e155d4d9b6 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 18:43:41 +0200 Subject: [PATCH] feat(eoi): dual-path generateAndSign (inapp + documenso-template) generateAndSign now accepts a `pathway` parameter: - `inapp` (existing): resolve in-app template -> pdfme -> MinIO -> Documenso createDocument + sendDocument. - `documenso-template` (new): build EOI context from interestId, assemble the Documenso template payload, and call Documenso's /api/v1/templates/{id}/generate-document. Documenso owns the PDF; we still record a documents row for tracking. Adds generateDocumentFromTemplate helper to the Documenso client and new env vars (DOCUMENSO_TEMPLATE_ID_EOI + client/developer/approval recipient IDs) with defaults matching the legacy flow. Covered by 6 new integration tests (637/637 green). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/generate-and-sign/route.ts | 3 +- src/lib/env.ts | 4 + src/lib/services/documenso-client.ts | 10 + src/lib/services/document-templates.ts | 95 +++++- src/lib/validators/document-templates.ts | 4 +- ...cument-templates-generate-and-sign.test.ts | 301 ++++++++++++++++++ 6 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 tests/integration/document-templates-generate-and-sign.test.ts diff --git a/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts b/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts index 35a0d2b..3147263 100644 --- a/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts +++ b/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts @@ -11,7 +11,7 @@ export const POST = withAuth( try { const body = await parseBody(req, generateAndSignSchema); const result = await generateAndSign( - params.id!, + params.id === 'documenso-template' ? null : params.id!, ctx.portId, { clientId: body.clientId, @@ -19,6 +19,7 @@ export const POST = withAuth( berthId: body.berthId, }, body.signers, + body.pathway, { userId: ctx.userId, portId: ctx.portId, diff --git a/src/lib/env.ts b/src/lib/env.ts index 48f4c40..82c09e0 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -24,6 +24,10 @@ const envSchema = z.object({ DOCUMENSO_API_URL: z.string().url(), DOCUMENSO_API_KEY: z.string().min(1), DOCUMENSO_WEBHOOK_SECRET: z.string().min(16), + DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8), + DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192), + DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193), + DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194), // Email SMTP_HOST: z.string().min(1), diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index 1651843..e600111 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -56,6 +56,16 @@ export async function createDocument( }) as Promise; } +export async function generateDocumentFromTemplate( + templateId: number, + payload: Record, +): Promise { + return documensoFetch(`/api/v1/templates/${templateId}/generate-document`, { + method: 'POST', + body: JSON.stringify(payload), + }) as Promise; +} + export async function sendDocument(docId: string): Promise { return documensoFetch(`/api/v1/documents/${docId}/send`, { method: 'POST', diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 2cd61b9..b3ad273 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -20,7 +20,9 @@ import { generatePdf } from '@/lib/pdf/generate'; import { createDocument as documensoCreate, sendDocument as documensoSend, + generateDocumentFromTemplate as documensoGenerateFromTemplate, } from '@/lib/services/documenso-client'; +import { buildDocumensoPayload } from '@/lib/services/documenso-payload'; import { buildEoiContext } from '@/lib/services/eoi-context'; import { sendEmail } from '@/lib/email'; import type { @@ -687,13 +689,41 @@ export async function generateAndSend( // ─── Generate and Sign ──────────────────────────────────────────────────────── +/** + * BR-142: EOI / NDA signing. Dual pathway: + * - `inapp`: resolve the in-app template → pdfme → upload PDF to MinIO → + * upload to Documenso and send for signing. + * - `documenso-template`: skip our PDF generation entirely; call Documenso's + * template-generate endpoint with the shared EOI context. Documenso owns + * the PDF. We still record a `documents` row for tracking. + */ export async function generateAndSign( + templateId: string | null, + portId: string, + context: GenerateInput, + signers: GenerateAndSignInput['signers'], + pathway: 'inapp' | 'documenso-template', + meta: AuditMeta, +) { + if (pathway === 'documenso-template') { + return generateAndSignViaDocumensoTemplate(portId, context, meta); + } + if (!templateId) { + throw new ValidationError('templateId is required for inapp pathway'); + } + return generateAndSignViaInApp(templateId, portId, context, signers, meta); +} + +async function generateAndSignViaInApp( templateId: string, portId: string, context: GenerateInput, signers: GenerateAndSignInput['signers'], meta: AuditMeta, ) { + if (!signers || signers.length === 0) { + throw new ValidationError('signers are required for inapp pathway'); + } const { document: documentRecord, file } = (await generateFromTemplate( templateId, portId, @@ -742,7 +772,7 @@ export async function generateAndSign( entityType: 'document', entityId: documentRecord.id, newValue: { status: 'sent', documensoId: documensoDoc.id }, - metadata: { action: 'generate_and_sign', signerCount: signers.length }, + metadata: { action: 'generate_and_sign', pathway: 'inapp', signerCount: signers.length }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); @@ -754,3 +784,66 @@ export async function generateAndSign( return { document: { ...documentRecord, documensoId: documensoDoc.id, status: 'sent' }, file }; } + +async function generateAndSignViaDocumensoTemplate( + portId: string, + context: GenerateInput, + meta: AuditMeta, +) { + if (!context.interestId) { + throw new ValidationError('interestId is required for documenso-template pathway'); + } + + const eoiContext = await buildEoiContext(context.interestId, portId); + + const payload = buildDocumensoPayload(eoiContext, { + interestId: context.interestId, + clientRecipientId: env.DOCUMENSO_CLIENT_RECIPIENT_ID, + developerRecipientId: env.DOCUMENSO_DEVELOPER_RECIPIENT_ID, + approvalRecipientId: env.DOCUMENSO_APPROVAL_RECIPIENT_ID, + redirectUrl: env.APP_URL, + }); + + const documensoDoc = await documensoGenerateFromTemplate( + env.DOCUMENSO_TEMPLATE_ID_EOI, + payload as unknown as Record, + ); + + // Record a documents row referencing the Documenso document. No local file — + // Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere). + const [documentRecord] = await db + .insert(documents) + .values({ + portId, + clientId: context.clientId ?? null, + interestId: context.interestId, + documentType: 'eoi', + title: payload.title, + status: 'sent', + documensoId: documensoDoc.id, + isManualUpload: false, + createdBy: meta.userId, + }) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'document', + entityId: documentRecord!.id, + newValue: { documensoId: documensoDoc.id, status: 'sent' }, + metadata: { + action: 'generate_and_sign', + pathway: 'documenso-template', + templateId: env.DOCUMENSO_TEMPLATE_ID_EOI, + interestId: context.interestId, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id }); + + return { document: documentRecord!, file: null }; +} diff --git a/src/lib/validators/document-templates.ts b/src/lib/validators/document-templates.ts index ea8163f..443a028 100644 --- a/src/lib/validators/document-templates.ts +++ b/src/lib/validators/document-templates.ts @@ -38,6 +38,7 @@ export const generateAndSendSchema = generateSchema.extend({ }); export const generateAndSignSchema = generateSchema.extend({ + pathway: z.enum(['inapp', 'documenso-template']).default('inapp'), signers: z .array( z.object({ @@ -47,7 +48,8 @@ export const generateAndSignSchema = generateSchema.extend({ signingOrder: z.number().int().min(1), }), ) - .min(1), + .optional() + .default([]), }); export type CreateTemplateInput = z.infer; diff --git a/tests/integration/document-templates-generate-and-sign.test.ts b/tests/integration/document-templates-generate-and-sign.test.ts new file mode 100644 index 0000000..75367bb --- /dev/null +++ b/tests/integration/document-templates-generate-and-sign.test.ts @@ -0,0 +1,301 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documents, documentTemplates } from '@/lib/db/schema/documents'; +import { clientAddresses, clientContacts } from '@/lib/db/schema/clients'; +import { interests as interestsTable } from '@/lib/db/schema/interests'; +import { ValidationError } from '@/lib/errors'; + +import { makeBerth, makeClient, makePort, makeYacht } from '../helpers/factories'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@/lib/services/documenso-client', () => ({ + createDocument: vi.fn(), + sendDocument: vi.fn(), + generateDocumentFromTemplate: vi.fn(), +})); + +vi.mock('@/lib/minio', async () => { + const actual = await vi.importActual('@/lib/minio'); + return { + ...actual, + minioClient: { + putObject: vi.fn().mockResolvedValue(undefined), + getObject: vi.fn().mockImplementation(async () => { + async function* gen() { + yield Buffer.from('fake-pdf'); + } + return gen(); + }), + }, + }; +}); + +vi.mock('@/lib/socket/server', () => ({ + emitToRoom: vi.fn(), +})); + +vi.mock('@/lib/pdf/generate', () => ({ + generatePdf: vi.fn().mockResolvedValue(new Uint8Array(Buffer.from('fake-pdf'))), +})); + +vi.mock('@/lib/audit', () => ({ + createAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function insertInApptemplate(portId: string) { + const [row] = await db + .insert(documentTemplates) + .values({ + portId, + name: 'Test EOI', + templateType: 'eoi', + bodyHtml: '

Hello {{client.fullName}} for berth {{berth.mooringNumber}}

', + createdBy: 'test', + }) + .returning(); + return row!; +} + +async function insertInterest(args: { + portId: string; + clientId: string; + yachtId: string; + berthId: string; +}) { + const [row] = await db + .insert(interestsTable) + .values({ + portId: args.portId, + clientId: args.clientId, + yachtId: args.yachtId, + berthId: args.berthId, + pipelineStage: 'open', + }) + .returning(); + return row!; +} + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +let setup: { + portId: string; + clientId: string; + yachtId: string; + berthId: string; + interestId: string; + inAppTemplateId: string; +}; + +let generateAndSign: typeof import('@/lib/services/document-templates').generateAndSign; + +beforeAll(async () => { + ({ generateAndSign } = await import('@/lib/services/document-templates')); + + const port = await makePort(); + const client = await makeClient({ + portId: port.id, + overrides: { fullName: 'Dual Path Client', nationality: 'US' }, + }); + await db.insert(clientContacts).values({ + clientId: client.id, + channel: 'email', + value: 'client@example.com', + isPrimary: true, + }); + await db.insert(clientAddresses).values({ + clientId: client.id, + portId: port.id, + streetAddress: '1 Wharf Rd', + city: 'Harbor', + country: 'US', + isPrimary: true, + }); + + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Dual Path Yacht', + overrides: { lengthFt: '45', widthFt: '14', draftFt: '6' }, + }); + + const berth = await makeBerth({ + portId: port.id, + overrides: { mooringNumber: 'DP-1' }, + }); + + const interest = await insertInterest({ + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + }); + + const template = await insertInApptemplate(port.id); + + setup = { + portId: port.id, + clientId: client.id, + yachtId: yacht.id, + berthId: berth.id, + interestId: interest.id, + inAppTemplateId: template.id, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const meta = { + userId: 'test-user', + portId: '', + ipAddress: '127.0.0.1', + userAgent: 'vitest', +}; + +// ─── Pathway: inapp ─────────────────────────────────────────────────────────── + +describe('generateAndSign — inapp pathway', () => { + it('generates PDF via pdfme, uploads to MinIO, and sends to Documenso', async () => { + const client = await import('@/lib/services/documenso-client'); + vi.mocked(client.createDocument).mockResolvedValue({ + id: 'documenso-inapp-123', + status: 'PENDING', + recipients: [], + }); + vi.mocked(client.sendDocument).mockResolvedValue({ + id: 'documenso-inapp-123', + status: 'PENDING', + recipients: [], + }); + + const result = await generateAndSign( + setup.inAppTemplateId, + setup.portId, + { clientId: setup.clientId, interestId: setup.interestId }, + [{ name: 'Client', email: 'client@example.com', role: 'signer', signingOrder: 1 }], + 'inapp', + { ...meta, portId: setup.portId }, + ); + + expect(result.file).not.toBeNull(); + expect(client.createDocument).toHaveBeenCalledOnce(); + expect(client.sendDocument).toHaveBeenCalledWith('documenso-inapp-123'); + + const [docRow] = await db + .select() + .from(documents) + .where(eq(documents.id, (result.document as { id: string }).id)); + expect(docRow?.documensoId).toBe('documenso-inapp-123'); + expect(docRow?.status).toBe('sent'); + expect(docRow?.fileId).toBeTruthy(); + }); + + it('throws ValidationError when signers array is empty', async () => { + await expect( + generateAndSign( + setup.inAppTemplateId, + setup.portId, + { clientId: setup.clientId, interestId: setup.interestId }, + [], + 'inapp', + { ...meta, portId: setup.portId }, + ), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when templateId is null', async () => { + await expect( + generateAndSign( + null, + setup.portId, + { clientId: setup.clientId, interestId: setup.interestId }, + [{ name: 'X', email: 'x@x.com', role: 'signer', signingOrder: 1 }], + 'inapp', + { ...meta, portId: setup.portId }, + ), + ).rejects.toThrow(ValidationError); + }); +}); + +// ─── Pathway: documenso-template ────────────────────────────────────────────── + +describe('generateAndSign — documenso-template pathway', () => { + it('calls Documenso template-generate endpoint and records a documents row', async () => { + const client = await import('@/lib/services/documenso-client'); + vi.mocked(client.generateDocumentFromTemplate).mockResolvedValue({ + id: 'documenso-template-456', + status: 'PENDING', + recipients: [], + }); + + const result = await generateAndSign( + null, + setup.portId, + { clientId: setup.clientId, interestId: setup.interestId }, + [], + 'documenso-template', + { ...meta, portId: setup.portId }, + ); + + expect(result.file).toBeNull(); + expect(client.generateDocumentFromTemplate).toHaveBeenCalledOnce(); + const [templateArg, payloadArg] = vi.mocked(client.generateDocumentFromTemplate).mock.calls[0]!; + expect(typeof templateArg).toBe('number'); + expect(payloadArg).toMatchObject({ + externalId: `loi-${setup.interestId}`, + formValues: { + Name: 'Dual Path Client', + 'Yacht Name': 'Dual Path Yacht', + 'Berth Number': 'DP-1', + }, + }); + + const [docRow] = await db + .select() + .from(documents) + .where(eq(documents.id, (result.document as { id: string }).id)); + expect(docRow?.documensoId).toBe('documenso-template-456'); + expect(docRow?.status).toBe('sent'); + expect(docRow?.documentType).toBe('eoi'); + expect(docRow?.interestId).toBe(setup.interestId); + expect(docRow?.fileId).toBeNull(); + }); + + it('throws ValidationError when interestId is missing', async () => { + await expect( + generateAndSign(null, setup.portId, { clientId: setup.clientId }, [], 'documenso-template', { + ...meta, + portId: setup.portId, + }), + ).rejects.toThrow(ValidationError); + }); + + it('does NOT call createDocument / sendDocument / minio for this pathway', async () => { + const client = await import('@/lib/services/documenso-client'); + vi.mocked(client.generateDocumentFromTemplate).mockResolvedValue({ + id: 'documenso-template-789', + status: 'PENDING', + recipients: [], + }); + + await generateAndSign( + null, + setup.portId, + { clientId: setup.clientId, interestId: setup.interestId }, + [], + 'documenso-template', + { ...meta, portId: setup.portId }, + ); + + expect(client.createDocument).not.toHaveBeenCalled(); + expect(client.sendDocument).not.toHaveBeenCalled(); + }); +});