feat(documenso): full v2 endpoint coverage + sequential signing + redirectUrl

Wire up the remaining version-aware paths so a port pointed at Documenso 2.x
takes the v2 endpoint on every CRUD operation, with two new v2-only settings
exposed in admin UI.

documenso-client.ts:
- createDocument: v2 multipart /envelope/create + getDocument follow-up to
  return the full doc shape (v1 path unchanged)
- sendDocument: v2 /envelope/distribute (returns per-recipient signingUrl in
  the same response — eliminates the v1 separate-GET round-trip)
- sendReminder: v2 /envelope/redistribute with recipientIds filter
- downloadSignedPdf: v2 /envelope/{id}/download
- CreateDocumentMeta type: { subject, message, redirectUrl, signingOrder }
  threaded through v1 + v2 paths (v1 ignores signingOrder)

port-config.ts:
- New settings: documenso_signing_order (PARALLEL/SEQUENTIAL, v2-only),
  documenso_redirect_url (both versions honour)
- PortDocumensoConfig gains signingOrder + redirectUrl

documenso-payload.ts:
- DocumensoTemplatePayload.meta gains signingOrder
- buildDocumensoPayload reads from options.signingOrder, omits when null

document-templates.ts (EOI template flow):
- Pass docCfg.signingOrder + docCfg.redirectUrl into buildDocumensoPayload

documents.service.ts (sendForSigning uploaded-doc flow):
- Pass portId to documensoCreate + documensoSend (was missing)
- Thread signingOrder + redirectUrl via the new meta param

Admin Documenso settings page:
- v2 benefits card updated: now lists envelope CRUD, one-call send,
  sequential enforcement, post-sign redirect as wired (was roadmap)
- Roadmap callout pruned to the three remaining deferred items:
  template/use migration, /envelope/update, non-SIGNER recipient roles
- New "v2 signing behaviour" SettingsFormCard with the two new settings

Template flow stays on /api/v1/templates/{id}/generate-document by design —
Documenso 2.x accepts v1 endpoints via backward compat; full migration to
v2 /template/use requires per-template field-ID capture (admin schema work,
deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 14:38:45 +02:00
parent ad312df8a4
commit d597e158fe
8 changed files with 365 additions and 58 deletions

View File

@@ -154,11 +154,26 @@ function applyPayloadRedirect(payload: Record<string, unknown>): Record<string,
return out;
}
/**
* Optional metadata applied to the document on creation. v1 accepts
* `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
* v2's `/envelope/create` accepts the same plus `signingOrder` for
* PARALLEL-vs-SEQUENTIAL signing enforcement.
*/
export interface CreateDocumentMeta {
subject?: string;
message?: string;
redirectUrl?: string;
/** v2 only. v1 ignores. */
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
}
export async function createDocument(
title: string,
pdfBase64: string,
recipients: DocumensoRecipient[],
portId?: string,
meta?: CreateDocumentMeta,
): Promise<DocumensoDocument> {
const safeRecipients = applyRecipientRedirect(recipients);
if (env.EMAIL_REDIRECT_TO) {
@@ -167,11 +182,100 @@ export async function createDocument(
'Documenso recipients redirected to EMAIL_REDIRECT_TO',
);
}
const { apiVersion } = await resolveCreds(portId);
if (apiVersion === 'v2') {
// v2: multipart /envelope/create with payload + files. Convert the
// base64 PDF to a Buffer and ship it under `files`. Returns
// `{ id: envelopeId }` only — caller distributes separately via
// sendDocument(envelopeId).
const { baseUrl, apiKey } = await resolveCreds(portId);
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
const form = new FormData();
const payload = {
type: 'DOCUMENT',
title,
recipients: safeRecipients.map((r, i) => ({
email: r.email,
name: r.name,
role: r.role,
signingOrder: r.signingOrder || i + 1,
})),
...(meta
? {
meta: {
...(meta.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}),
},
}
: {}),
};
form.append('payload', JSON.stringify(payload));
form.append(
'files',
new Blob([pdfBuffer], { type: 'application/pdf' }),
`${title.replace(/[^a-z0-9-_]+/gi, '-')}.pdf`,
);
let res: Response;
try {
res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `/api/v2/envelope/create timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!res.ok) {
const errText = await res.text();
logger.error(
{ status: res.status, err: errText, portId },
'Documenso v2 envelope/create error',
);
if (res.status === 401 || res.status === 403) {
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
internalMessage: `v2 envelope/create → ${res.status}`,
});
}
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `v2 envelope/create → ${res.status}: ${errText}`,
});
}
const created = (await res.json()) as Record<string, unknown>;
// v2 returns just `{ id }`. Re-fetch the full envelope so the
// caller gets recipients (without signing URLs — those come after
// distribute). Keeps shape identical to v1's createDocument response.
const envelopeId = String(created.id ?? created.documentId ?? '');
return getDocument(envelopeId, portId);
}
// v1: existing path. Meta keys are accepted at the top level.
return documensoFetch(
'/api/v1/documents',
{
method: 'POST',
body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }),
body: JSON.stringify({
title,
document: pdfBase64,
recipients: safeRecipients,
...(meta?.subject || meta?.message || meta?.redirectUrl
? {
meta: {
...(meta.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
},
}
: {}),
}),
},
portId,
).then(normalizeDocument);
@@ -219,6 +323,34 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
// Documenso's perspective.
return getDocument(docId, portId);
}
const { apiVersion } = await resolveCreds(portId);
if (apiVersion === 'v2') {
// v2: POST /api/v2/envelope/distribute with body { envelopeId }.
// Returns the envelope with per-recipient signingUrl fields populated —
// this is one of the genuine v2 wins (saves a separate GET round-trip).
const distributed = (await documensoFetch(
'/api/v2/envelope/distribute',
{
method: 'POST',
body: JSON.stringify({ envelopeId: docId }),
},
portId,
)) as Record<string, unknown>;
// Distribute response shape: { success, id, recipients: [...] }.
// The recipients carry name/email/token/role/signingOrder/signingUrl.
// Normalize by re-wrapping into the document shape that downstream
// callers already consume.
return normalizeDocument({
id: distributed.id,
// v2 doesn't return `status` on the distribute response — the call
// itself moves the envelope from DRAFT to PENDING, so PENDING is
// the correct authoritative state.
status: 'PENDING',
recipients: distributed.recipients,
});
}
return documensoFetch(
`/api/v1/documents/${docId}/send`,
{
@@ -254,6 +386,25 @@ export async function sendReminder(
);
return;
}
const { apiVersion } = await resolveCreds(portId);
if (apiVersion === 'v2') {
// v2 sends reminders via redistribute. Documenso 2.x doesn't expose a
// recipient-targeted reminder endpoint directly; instead /envelope/redistribute
// resends to all pending recipients on the envelope. Single-recipient
// targeting requires admin-side filtering. For now we redistribute the
// entire envelope, which is functionally equivalent for the typical
// case (most reminders go to the one outstanding signer).
await documensoFetch(
'/api/v2/envelope/redistribute',
{
method: 'POST',
body: JSON.stringify({ envelopeId: docId, recipientIds: [signerId] }),
},
portId,
);
return;
}
await documensoFetch(
`/api/v1/documents/${docId}/recipients/${signerId}/remind`,
{
@@ -264,8 +415,13 @@ export async function sendReminder(
}
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
const { baseUrl, apiKey } = await resolveCreds(portId);
const path = `/api/v1/documents/${docId}/download`;
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
// v2: /api/v2/envelope/{id}/download (mirrors the v1 path under the
// envelope namespace). v1: existing /documents/{id}/download.
const path =
apiVersion === 'v2'
? `/api/v2/envelope/${docId}/download`
: `/api/v1/documents/${docId}/download`;
let res: Response;
try {
res = await fetchWithTimeout(`${baseUrl}${path}`, {

View File

@@ -12,6 +12,13 @@ export interface DocumensoTemplatePayload {
subject: string;
redirectUrl: string;
distributionMethod: 'NONE' | 'EMAIL';
/**
* PARALLEL = all signers can sign in any order (default, current behaviour).
* SEQUENTIAL = signers must complete in the order their `signingOrder`
* number dictates (client → developer → approver for EOI). v2 enforces
* this server-side; v1 ignores the key and behaves as PARALLEL regardless.
*/
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
};
formValues: {
Name: string;
@@ -54,6 +61,11 @@ export interface DocumensoPayloadOptions {
approverEmail?: string;
/** Redirect URL after signing. Defaults to the app URL. */
redirectUrl?: string;
/**
* PARALLEL (default) or SEQUENTIAL — v2-only enforcement (v1 ignores).
* Set via per-port `documenso_signing_order` system_settings key.
*/
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
}
const DEFAULT_DEVELOPER_NAME = 'David Mizrahi';
@@ -129,6 +141,7 @@ export function buildDocumensoPayload(
subject: 'Your LOI is ready to be signed',
redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL,
distributionMethod: 'NONE',
...(options.signingOrder ? { signingOrder: options.signingOrder } : {}),
},
formValues: {
Name: context.client.fullName,

View File

@@ -902,7 +902,11 @@ async function generateAndSignViaDocumensoTemplate(
developerEmail: signers.developer.email,
approverName: signers.approver.name,
approverEmail: signers.approver.email,
redirectUrl: env.APP_URL,
// 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 } : {}),
});
const documensoDoc = await documensoGenerateFromTemplate(

View File

@@ -34,6 +34,7 @@ import {
voidDocument as documensoVoid,
} from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import {
listTree,
collectDescendantIds,
@@ -699,24 +700,39 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const pdfBuffer = Buffer.concat(chunks);
const pdfBase64 = pdfBuffer.toString('base64');
// Create document in Documenso + send
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
{
name: eoiSigners.developer.name,
email: eoiSigners.developer.email,
role: 'SIGNER',
signingOrder: 2,
},
{
name: eoiSigners.approver.name,
email: eoiSigners.approver.email,
role: 'SIGNER',
signingOrder: 3,
},
]);
// Read per-port v2 signing settings (PARALLEL/SEQUENTIAL + redirect URL).
// Both are optional — passing undefined yields v1's legacy behavior.
const docCfg = await getPortDocumensoConfig(portId);
await documensoSend(documensoDoc.id);
// Create document in Documenso + send. portId is required for the v2
// envelope/create code path (which routes by per-port apiVersion);
// meta.signingOrder is honoured only on v2 instances.
const documensoDoc = await documensoCreate(
doc.title,
pdfBase64,
[
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
{
name: eoiSigners.developer.name,
email: eoiSigners.developer.email,
role: 'SIGNER',
signingOrder: 2,
},
{
name: eoiSigners.approver.name,
email: eoiSigners.approver.email,
role: 'SIGNER',
signingOrder: 3,
},
],
portId,
{
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
},
);
await documensoSend(documensoDoc.id, portId);
// Update signer records with signing URLs from Documenso response
for (const docSigner of documensoDoc.recipients) {

View File

@@ -77,6 +77,16 @@ export const SETTING_KEYS = {
// uses templates rather than per-deal uploads. Optional.
documensoContractTemplateId: 'documenso_contract_template_id',
documensoReservationTemplateId: 'documenso_reservation_template_id',
// v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement on
// multi-recipient envelopes. When SEQUENTIAL is set + apiVersion=v2,
// Documenso refuses to email recipient N+1 until recipient N has signed.
// Ignored entirely on v1 instances.
documensoSigningOrder: 'documenso_signing_order',
// v2-only override of the post-signing redirect URL set on documentMeta.
// Falls back to the embedded signing host (or APP_URL) when unset. Use
// this to land signed clients on /portal/eoi-complete (or wherever
// makes sense for the workflow).
documensoRedirectUrl: 'documenso_redirect_url',
// Branding
brandingLogoUrl: 'branding_logo_url',
@@ -222,6 +232,19 @@ export interface PortDocumensoConfig {
* user's email for in-CRM signing-status updates. */
developerUserId: string | null;
approverUserId: string | null;
/**
* v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement.
* `null` keeps the upstream default (PARALLEL); a non-null value gets
* passed verbatim. v1 instances ignore this — see admin Documenso page.
*/
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
/**
* v2-only: post-signing redirect URL set on documentMeta. When null,
* the upstream Documenso default applies (Documenso's own thank-you
* page). Typically set to `{embeddedSigningHost}/sign/success` so
* signers land back on the branded marketing site.
*/
redirectUrl: string | null;
}
function toIntOrNull(raw: unknown): number | null {
@@ -255,6 +278,8 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approverLabel,
developerUserId,
approverUserId,
signingOrder,
redirectUrlOverride,
] = await Promise.all([
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
@@ -276,6 +301,8 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
readSetting<string>(SETTING_KEYS.documensoRedirectUrl, portId),
]);
return {
@@ -299,6 +326,8 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approverLabel: approverLabel ?? 'Approver',
developerUserId: developerUserId ?? null,
approverUserId: approverUserId ?? null,
signingOrder: signingOrder ?? null,
redirectUrl: redirectUrlOverride ?? null,
};
}