diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index 634eb9a0..a1a3cb14 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -391,8 +391,13 @@ export async function createDocument( return getDocument(envelopeId, portId); } - // v1: existing path. Meta keys are accepted at the top level. - return documensoFetch( + // v1: existing path. Meta keys are accepted at the top level. We still send + // `document` (base64) for older Documenso servers that store it inline, but + // Documenso 2.x's v1-compat endpoint instead returns a presigned `uploadUrl` + // and expects the PDF bytes to be PUT there (the base64 is ignored). So when + // the create response carries an `uploadUrl`, upload the bytes to it — without + // this the document is created with NO content (signers see a blank PDF). + const raw = (await documensoFetch( '/api/v1/documents', { method: 'POST', @@ -412,7 +417,39 @@ export async function createDocument( }), }, portId, - ).then(normalizeDocument); + )) as Record; + + const uploadUrl = typeof raw.uploadUrl === 'string' ? raw.uploadUrl : null; + if (uploadUrl) { + const pdfBuffer = Buffer.from(pdfBase64, 'base64'); + let putRes: Response; + try { + putRes = await fetchWithTimeout(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': 'application/pdf' }, + body: pdfBuffer, + }); + } catch (err) { + if (err instanceof FetchTimeoutError) { + throw new CodedError('DOCUMENSO_TIMEOUT', { + internalMessage: `v1 createDocument uploadUrl PUT timed out after ${err.timeoutMs}ms`, + }); + } + throw err; + } + if (!putRes.ok) { + const errText = await putRes.text().catch(() => ''); + logger.error( + { status: putRes.status, err: errText, portId }, + 'Documenso v1 createDocument uploadUrl PUT failed - document has no content', + ); + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `v1 createDocument uploadUrl PUT → ${putRes.status}: ${errText}`, + }); + } + } + + return normalizeDocument(raw); } export async function generateDocumentFromTemplate( @@ -1340,6 +1377,10 @@ export async function placeFields( pageY: Math.round((f.pageY / 100) * dims.height), pageWidth: Math.round((f.pageWidth / 100) * dims.width), pageHeight: Math.round((f.pageHeight / 100) * dims.height), + // Pass fieldMeta through on v1 too (Documenso 2.x's v1-compat endpoint + // accepts it) so TEXT fields like "Place of Signing" keep their label / + // required / placeholder. Older v1 servers ignore unknown keys. + ...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}), }; // Retry transient failures so one flaky 5xx mid-loop doesn't leave // the document with a partial field set. 3 attempts at 250 / 500 / @@ -1425,6 +1466,93 @@ export function computeDefaultSignatureLayout( })); } +/** + * EOI page-3 signature-block layout — the six fields template 8 carries, so + * the in-app pathway (local pdf-lib fill + flatten → upload as a Documenso + * document) produces a signed EOI that matches the legacy template output + * exactly. Coordinates are percent of page, captured verbatim from template 8. + * + * Client (signer 1) gets Signature + Name + Place-of-Signing (TEXT) + Date. + * Developer (signer 2) gets Name + Signature. The approver (signer 3) carries + * no fields. `fieldMeta` is passed through to Documenso (v1 + v2) so the + * Place-of-Signing field keeps its label / required / placeholder. + */ +export function computeEoiSignatureLayout( + clientRecipientId: number | string, + developerRecipientId: number | string, +): DocumensoFieldPlacement[] { + return [ + { + recipientId: clientRecipientId, + type: 'SIGNATURE', + pageNumber: 3, + pageX: 39.64497370960451, + pageY: 64.81957098456644, + pageWidth: 21.21662173851308, + pageHeight: 4.303685358613111, + fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' }, + }, + { + recipientId: clientRecipientId, + type: 'NAME', + pageNumber: 3, + pageX: 14.34911393977768, + pageY: 64.81957098456644, + pageWidth: 24.33234194973456, + pageHeight: 4.303685358613111, + fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' }, + }, + { + recipientId: clientRecipientId, + type: 'TEXT', + pageNumber: 3, + pageX: 14.49704042881816, + pageY: 57.4932908677896, + pageWidth: 24.4807121661721, + pageHeight: 4.40865329418904, + fieldMeta: { + type: 'text', + label: 'Place of Signing', + readOnly: false, + required: true, + textAlign: 'left', + placeholder: 'Anguilla, AI', + characterLimit: 0, + }, + }, + { + recipientId: clientRecipientId, + type: 'DATE', + pageNumber: 3, + pageX: 39.79290246256028, + pageY: 57.4932908677896, + pageWidth: 21.06824925816024, + pageHeight: 4.40865329418904, + fieldMeta: { type: 'date', fontSize: 10, overflow: 'auto', textAlign: 'left' }, + }, + { + recipientId: developerRecipientId, + type: 'NAME', + pageNumber: 3, + pageX: 14.34911393977768, + pageY: 72.56877244919716, + pageWidth: 24.33234194973456, + pageHeight: 3.988781551885322, + fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' }, + }, + { + recipientId: developerRecipientId, + type: 'SIGNATURE', + pageNumber: 3, + pageX: 39.64497370960451, + pageY: 72.56877244919716, + pageWidth: 21.21662173851308, + pageHeight: 3.988781551885322, + fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' }, + }, + ]; +} + /** * Void/cancel a Documenso document. * diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 6f850a66..f5955bf2 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -17,11 +17,14 @@ import { emitToRoom } from '@/lib/socket/server'; import { buildStoragePath } from '@/lib/minio'; import { getStorageBackend } from '@/lib/storage'; import { env } from '@/lib/env'; +import { logger } from '@/lib/logger'; import { getCountryName } from '@/lib/i18n/countries'; import { createDocument as documensoCreate, sendDocument as documensoSend, generateDocumentFromTemplate as documensoGenerateFromTemplate, + placeFields as documensoPlaceFields, + computeEoiSignatureLayout, } from '@/lib/services/documenso-client'; import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload'; import { getPortDocumensoConfig } from '@/lib/services/port-config'; @@ -714,7 +717,15 @@ async function generateAndSignViaInApp( } const pdfBase64 = Buffer.concat(chunks).toString('base64'); - // Create Documenso document + // Per-port Documenso config for the post-signing redirect + signing order + // (parity with the documenso-template pathway). + const docCfg = await getPortDocumensoConfig(portId); + + // Create the Documenso document from the locally-filled + flattened PDF. + // Because the detail fields are flattened by pdf-lib (clean 12pt + multiline + // address wrapping), Documenso never re-renders them — it only collects + // signatures. This is what fixes the auto-sized/clipped detail text the + // Documenso template-fill pathway produced. const documensoDoc = await documensoCreate( template.name, pdfBase64, @@ -724,10 +735,38 @@ async function generateAndSignViaInApp( role: s.role, signingOrder: s.signingOrder, })), + portId, + { + redirectUrl: docCfg.redirectUrl ?? env.APP_URL, + ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), + }, ); + // Place the EOI page-3 signature block. The flattened PDF carries no + // Documenso fields, so place the six fields (client Signature/Name/ + // Place-of-Signing/Date, developer Name/Signature) at template 8's + // coordinates, mapped by signing order (1 = client, 2 = developer; the + // approver signs no fields). + if (template.templateType === 'eoi') { + const byOrder = new Map(documensoDoc.recipients.map((r) => [r.signingOrder, r.id])); + const clientRecipientId = byOrder.get(1); + const developerRecipientId = byOrder.get(2); + if (clientRecipientId && developerRecipientId) { + await documensoPlaceFields( + documensoDoc.id, + computeEoiSignatureLayout(clientRecipientId, developerRecipientId), + portId, + ); + } else { + logger.warn( + { docId: documensoDoc.id, recipients: documensoDoc.recipients.length }, + 'EOI in-app pathway: could not resolve client/developer recipients for signature-field placement', + ); + } + } + // Send document for signing - await documensoSend(documensoDoc.id); + await documensoSend(documensoDoc.id, portId); // Update our document record with Documenso ID and status await db @@ -740,6 +779,46 @@ async function generateAndSignViaInApp( }) .where(eq(documents.id, documentRecord.id)); + // Persist per-recipient signer rows so the EOI tab's signing-progress panel + // and the webhook handler (which matches by token / email) work — parity + // with the documenso-template pathway. Strip the `(was: …)` / + // `(placeholder)` suffixes EMAIL_REDIRECT_TO bakes into names. + if (documensoDoc.recipients.length > 0) { + await db.insert(documentSigners).values( + documensoDoc.recipients.map((r) => { + const cleanName = (r.name || r.email) + .replace(/\s*\(was:[^)]*\)/i, '') + .replace(/\s*\(placeholder\b[^)]*\)/i, '') + .trim(); + const role = + r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1 + ? 'client' + : normalizeSignerRole(r.role); + return { + documentId: documentRecord.id, + signerName: cleanName || r.email, + signerEmail: r.email, + signerRole: role, + signingOrder: r.signingOrder, + status: 'pending' as const, + signingUrl: r.signingUrl ?? null, + embeddedUrl: r.embeddedUrl ?? null, + signingToken: r.token ?? null, + invitedAt: null, + }; + }), + ); + } + + // Stamp the interest's EOI milestone so the Overview tab flips to + // "EOI sent / awaiting signatures" — parity with the template pathway. + if (context.interestId) { + await db + .update(interests) + .set({ eoiDocStatus: 'sent', dateEoiSent: new Date(), updatedAt: new Date() }) + .where(eq(interests.id, context.interestId)); + } + void createAuditLog({ userId: meta.userId, portId, @@ -794,48 +873,152 @@ async function generateAndSignViaDocumensoTemplate( // platform to one Documenso instance per CRM process. const docCfg = await getPortDocumensoConfig(portId); - // v2 prefillFields-by-ID emission requires a field-name → field-ID map - // populated by the admin "Sync from Documenso" button. Absent (or partial) - // map → payload skips prefillFields and v2 accepts the legacy formValues - // shape via backward compat. - const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service'); - const fieldMap = await getEoiFieldMap(portId); - - // Pick which side of the yacht's stored dimensions ships to Documenso. + // Pick which side of the yacht's stored dimensions ships to the PDF. // The drawer's toggle drives this; if the caller omitted it, default to // whichever unit the rep originally typed in (yacht.lengthUnit). Legacy // yachts without a unit column default to 'ft'. const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft'; - const payload = buildDocumensoPayload( - eoiContext, + // Document title used by both fill methods + the documents row. + const docTitle = `Expression of Interest – ${eoiContext.client.fullName}`; + + let documensoDoc; + let localFileId: string | null = null; + + if (docCfg.eoiFillMethod === 'local') { + // LOCAL-FILL (default): fill + flatten the source PDF ourselves (pdf-lib, + // fixed 12pt + multiline address wrapping), upload the flattened PDF to + // Documenso as a document, and place ONLY the page-3 signature fields. + // Documenso never renders the body text, so it can't auto-size/clip it — + // this is the fix for the oversized/clipped detail fields the Documenso + // template-fill produced. Still flows through Documenso for signing, so + // branded invites, embedded signing, webhooks, and emails are unchanged. + const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, { dimensionUnit }); + + const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); + const fileId = crypto.randomUUID(); + const storagePath = buildStoragePath( + port?.slug ?? portId, + 'eoi', + context.interestId, + fileId, + 'pdf', + ); { - interestId: context.interestId, - clientRecipientId: docCfg.clientRecipientId, - developerRecipientId: docCfg.developerRecipientId, - approvalRecipientId: docCfg.approvalRecipientId, - developerName: signers.developer.name, - developerEmail: signers.developer.email, - approverName: signers.approver.name, - approverEmail: signers.approver.email, - // Prefer per-port post-signing redirect (typically marketing-site - // /sign/success on v2). Falls back to APP_URL on v1 / when unset. - redirectUrl: docCfg.redirectUrl ?? env.APP_URL, - // v2-only signing-order enforcement. v1 instances ignore this key. - ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), - dimensionUnit, - }, - fieldMap, - ); + const buffer = Buffer.from(pdfBytes); + const backend = await getStorageBackend(); + await backend.put(storagePath, buffer, { + contentType: 'application/pdf', + sizeBytes: buffer.length, + }); + } + const [fileRecord] = await db + .insert(files) + .values({ + portId, + clientId: context.clientId ?? null, + filename: 'expression-of-interest.pdf', + originalName: 'Expression of Interest.pdf', + mimeType: 'application/pdf', + sizeBytes: String(pdfBytes.byteLength), + storagePath, + storageBucket: env.MINIO_BUCKET, + category: 'eoi', + uploadedBy: meta.userId, + }) + .returning(); + localFileId = fileRecord!.id; - const documensoDoc = await documensoGenerateFromTemplate( - docCfg.eoiTemplateId, - payload as unknown as Record, - portId, - ); + const created = await documensoCreate( + docTitle, + Buffer.from(pdfBytes).toString('base64'), + [ + { + name: eoiContext.client.fullName, + email: eoiContext.client.primaryEmail ?? '', + role: 'signer', + signingOrder: 1, + }, + { + name: signers.developer.name, + email: signers.developer.email, + role: 'signer', + signingOrder: 2, + }, + { + name: signers.approver.name, + email: signers.approver.email, + role: 'approver', + signingOrder: 3, + }, + ], + portId, + { + redirectUrl: docCfg.redirectUrl ?? env.APP_URL, + ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), + }, + ); - // Record a documents row referencing the Documenso document. No local file - - // Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere). + // Place the six page-3 signature fields at template-8 coordinates, mapped + // by signing order (1 = client, 2 = developer; approver signs no fields). + const byOrder = new Map(created.recipients.map((r) => [r.signingOrder, r.id])); + const clientRid = byOrder.get(1); + const developerRid = byOrder.get(2); + if (clientRid && developerRid) { + await documensoPlaceFields( + created.id, + computeEoiSignatureLayout(clientRid, developerRid), + portId, + ); + } else { + logger.warn( + { docId: created.id, recipients: created.recipients.length }, + 'EOI local-fill: could not resolve client/developer recipients for field placement', + ); + } + + // v2 envelopes don't return signing URLs until distribute; v1 returns them + // on create. Distribute (suppressing Documenso's own emails via + // distributionMethod:NONE on v2 / DRAFT-stays-quiet on v1) only when + // they're missing, so document_signers.signing_url is populated for the + // branded "Send invitation" flow regardless of API version. + const needsDistribute = created.recipients.some((r) => !r.signingUrl); + documensoDoc = needsDistribute ? await documensoSend(created.id, portId) : created; + } else { + // DOCUMENSO TEMPLATE FILL (legacy fallback, eoi_fill_method='documenso'): + // Documenso fills the template's AcroForm fields from the payload. Note it + // auto-sizes/clips long values — kept only as a per-port escape hatch. + // v2 prefillFields-by-ID needs a field-name → field-ID map from the admin + // "Sync from Documenso" button; absent it, v2 ignores the legacy formValues. + const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service'); + const fieldMap = await getEoiFieldMap(portId); + const payload = buildDocumensoPayload( + eoiContext, + { + interestId: context.interestId, + clientRecipientId: docCfg.clientRecipientId, + developerRecipientId: docCfg.developerRecipientId, + approvalRecipientId: docCfg.approvalRecipientId, + developerName: signers.developer.name, + developerEmail: signers.developer.email, + approverName: signers.approver.name, + approverEmail: signers.approver.email, + redirectUrl: docCfg.redirectUrl ?? env.APP_URL, + ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), + dimensionUnit, + }, + fieldMap, + ); + documensoDoc = await documensoGenerateFromTemplate( + docCfg.eoiTemplateId, + payload as unknown as Record, + portId, + ); + } + + // Record a documents row referencing the Documenso document. Local-fill + // attaches the flattened PDF we stored; template-fill has no local file + // (Documenso owns the PDF; signed copy arrives via webhook). const [documentRecord] = await db .insert(documents) .values({ @@ -843,8 +1026,9 @@ async function generateAndSignViaDocumensoTemplate( clientId: context.clientId ?? null, interestId: context.interestId, documentType: 'eoi', - title: payload.title, + title: docTitle, status: 'sent', + fileId: localFileId, documensoId: documensoDoc.id, documensoNumericId: documensoDoc.numericId, isManualUpload: false, diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index 8b532ab2..cdb14942 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -49,6 +49,11 @@ export const SETTING_KEYS = { // timing-safe comparison. documensoWebhookSecret: 'documenso_webhook_secret', eoiDefaultPathway: 'eoi_default_pathway', + // EOI body-text fill method: 'local' (CRM fills + flattens the PDF, clean + // 12pt + multiline address wrap, Documenso signs only) vs 'documenso' + // (legacy: Documenso fills the template AcroForm fields and auto-sizes / + // clips them). Toggleable per-port in admin → Documenso. + eoiFillMethod: 'eoi_fill_method', // Identity of the developer + approver that the template's static // recipient slots get filled with. Old system hardcoded these // (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys @@ -316,6 +321,16 @@ export interface PortDocumensoConfig { apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none'; eoiTemplateId: number; defaultPathway: EoiPathway; + /** + * EOI body-text fill method: + * - 'local' : CRM fills + flattens the source PDF (pdf-lib, fixed 12pt + + * multiline address wrapping), then uploads the flattened PDF + * to Documenso for signature placement only. Renders cleanly. + * - 'documenso': legacy — Documenso fills the template's AcroForm fields via + * the template-generate API (auto-sizes the text → clips it). + * Toggleable per-port in admin → Documenso. Defaults to 'local'. + */ + eoiFillMethod: 'local' | 'documenso'; /** Documenso template recipient slot IDs (per-instance numeric). */ clientRecipientId: number; developerRecipientId: number; @@ -387,6 +402,7 @@ export async function getPortDocumensoConfig(portId: string): Promise(SETTING_KEYS.documensoDeveloperRecipientId, portId), readSetting(SETTING_KEYS.documensoApprovalRecipientId, portId), readSetting(SETTING_KEYS.eoiDefaultPathway, portId), + readSetting<'local' | 'documenso'>(SETTING_KEYS.eoiFillMethod, portId), readSetting(SETTING_KEYS.documensoDeveloperName, portId), readSetting(SETTING_KEYS.documensoDeveloperEmail, portId), readSetting(SETTING_KEYS.documensoApproverName, portId), @@ -464,6 +481,9 @@ export async function getPortDocumensoConfig(portId: string): Promise { + const CLIENT = 101; + const DEV = 102; + const fields = computeEoiSignatureLayout(CLIENT, DEV); + + it('produces exactly the 6 page-3 EOI signature fields', () => { + expect(fields).toHaveLength(6); + expect(fields.every((f) => f.pageNumber === 3)).toBe(true); + }); + + it('maps client recipient to Signature + Name + Place-of-Signing + Date', () => { + const client = fields.filter((f) => f.recipientId === CLIENT); + expect(client.map((f) => f.type).sort()).toEqual(['DATE', 'NAME', 'SIGNATURE', 'TEXT']); + }); + + it('maps developer recipient to Name + Signature only', () => { + const dev = fields.filter((f) => f.recipientId === DEV); + expect(dev.map((f) => f.type).sort()).toEqual(['NAME', 'SIGNATURE']); + }); + + it('carries the Place-of-Signing label + required so the signer is prompted', () => { + const place = fields.find((f) => f.recipientId === CLIENT && f.type === 'TEXT'); + expect(place?.fieldMeta?.label).toBe('Place of Signing'); + expect(place?.fieldMeta?.required).toBe(true); + }); + + it('positions fields at template-8 coordinates (page-3 signature block)', () => { + const sig = fields.find((f) => f.recipientId === CLIENT && f.type === 'SIGNATURE'); + expect(sig?.pageX).toBeCloseTo(39.645, 2); + expect(sig?.pageY).toBeCloseTo(64.82, 1); + const devSig = fields.find((f) => f.recipientId === DEV && f.type === 'SIGNATURE'); + expect(devSig?.pageY).toBeCloseTo(72.57, 1); + }); +});