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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 18:43:41 +02:00
parent 13d07e3906
commit f8255cedb8
6 changed files with 414 additions and 3 deletions

View File

@@ -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<string, unknown>,
);
// 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 };
}