feat(eoi): toggleable local-fill pathway — clean detail render + address wrapping
EOI detail fields (address, name, yacht, berth) rendered oversized and
top-clipped because Documenso auto-sizes AcroForm text when *it* fills the
template (ignores the PDF's 12pt font; a taller box → bigger font → more clip,
and a 2-line address box renders huge). Proven: filling the same source PDF
locally at 12pt renders cleanly and wraps long addresses to a 2nd line.
Add a per-port `eoi_fill_method` setting (default `local`), toggleable in
admin → Documenso → Templates & signing pathway:
- local: CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
multiline address wrap), uploads the flattened PDF to Documenso,
and places ONLY the 6 page-3 signature fields. Documenso never
re-renders the body text → no clipping.
- documenso: legacy template AcroForm fill (auto-sizes/clips) — fallback only.
Both still flow through Documenso for signing, so branded invites, embedded
signing, webhooks, signer rows, and the EOI milestone are unchanged.
- computeEoiSignatureLayout(): 6 page-3 fields at template-8 coords (unit-tested)
- createDocument (v1): PUT bytes to Documenso's presigned uploadUrl (2.x v1-compat
ignores the base64 field) so the uploaded document actually has content
- placeFields (v1): pass fieldMeta through so the Place-of-Signing TEXT field
keeps its label/required
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>;
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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,19 +873,125 @@ 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';
|
||||
|
||||
// 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',
|
||||
);
|
||||
{
|
||||
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 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 } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
// 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,
|
||||
{
|
||||
@@ -818,24 +1003,22 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
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 documensoDoc = await documensoGenerateFromTemplate(
|
||||
documensoDoc = await documensoGenerateFromTemplate(
|
||||
docCfg.eoiTemplateId,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
portId,
|
||||
);
|
||||
}
|
||||
|
||||
// Record a documents row referencing the Documenso document. No local file -
|
||||
// Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere).
|
||||
// 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,
|
||||
|
||||
@@ -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<PortDocume
|
||||
developerRecipientId,
|
||||
approvalRecipientId,
|
||||
defaultPathway,
|
||||
eoiFillMethod,
|
||||
developerName,
|
||||
developerEmail,
|
||||
approverName,
|
||||
@@ -411,6 +427,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId),
|
||||
readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId),
|
||||
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
|
||||
readSetting<'local' | 'documenso'>(SETTING_KEYS.eoiFillMethod, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
|
||||
@@ -464,6 +481,9 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
approvalRecipientId:
|
||||
toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0,
|
||||
defaultPathway: defaultPathway ?? 'documenso-template',
|
||||
// Default to the local-fill method (clean render + address wrapping). Set
|
||||
// to 'documenso' per-port to fall back to Documenso's template AcroForm fill.
|
||||
eoiFillMethod: eoiFillMethod === 'documenso' ? 'documenso' : 'local',
|
||||
developerName: developerName ?? '',
|
||||
developerEmail: developerEmail ?? '',
|
||||
approverName: approverName ?? '',
|
||||
|
||||
@@ -204,6 +204,20 @@ export const REGISTRY: SettingEntry[] = [
|
||||
scope: 'port',
|
||||
defaultValue: 'documenso-template',
|
||||
},
|
||||
{
|
||||
key: 'eoi_fill_method',
|
||||
section: 'documenso.templates',
|
||||
label: 'EOI form fill method',
|
||||
description:
|
||||
"How the EOI's detail fields (name, address, yacht, berth) get filled in. Local fill (recommended) = the CRM fills + flattens the PDF itself at a fixed 12pt with multiline address wrapping, then sends it to Documenso for signatures only — text renders cleanly. Documenso template fill = Documenso fills the template's form fields and auto-sizes the text, which oversizes/clips long values. Both still go through Documenso for signing, branded emails, and embedded signing.",
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ value: 'local', label: 'Local fill — clean text + address wrapping (recommended)' },
|
||||
{ value: 'documenso', label: 'Documenso template fill — legacy (may clip long values)' },
|
||||
],
|
||||
scope: 'port',
|
||||
defaultValue: 'local',
|
||||
},
|
||||
{
|
||||
key: 'eoi_send_mode',
|
||||
section: 'documenso.templates',
|
||||
|
||||
@@ -40,6 +40,7 @@ function configurePort(version: 'v1' | 'v2'): void {
|
||||
apiVersion: version,
|
||||
eoiTemplateId: 8,
|
||||
defaultPathway: 'documenso-template',
|
||||
eoiFillMethod: 'local',
|
||||
clientRecipientId: 192,
|
||||
developerRecipientId: 193,
|
||||
approvalRecipientId: 194,
|
||||
|
||||
44
tests/unit/services/eoi-signature-layout.test.ts
Normal file
44
tests/unit/services/eoi-signature-layout.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { computeEoiSignatureLayout } from '@/lib/services/documenso-client';
|
||||
|
||||
// The EOI moves from the Documenso *template* pathway (Documenso fills the
|
||||
// AcroForm detail fields and auto-sizes/clips them) to the in-app pathway:
|
||||
// we fill + flatten the PDF locally, upload it as a Documenso *document*, then
|
||||
// place ONLY the page-3 signature fields. This layout must match template 8's
|
||||
// six fields exactly (client: Signature/Name/Place-of-Signing/Date; developer:
|
||||
// Name/Signature) so the signed EOI looks identical. Coords are percent of page.
|
||||
describe('computeEoiSignatureLayout', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user