Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1363 lines
51 KiB
TypeScript
1363 lines
51 KiB
TypeScript
import pRetry, { AbortError } from 'p-retry';
|
|
|
|
import { env } from '@/lib/env';
|
|
import { CodedError } from '@/lib/errors';
|
|
import { logger } from '@/lib/logger';
|
|
import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config';
|
|
import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout';
|
|
|
|
interface DocumensoCreds {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
apiVersion: DocumensoApiVersion;
|
|
}
|
|
|
|
async function resolveCreds(portId?: string): Promise<DocumensoCreds> {
|
|
// env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional — the
|
|
// canonical config lives in admin settings. Empty fallbacks let the call
|
|
// proceed; if both env + admin are blank, the downstream fetch hits an
|
|
// empty URL and errors with a clear "Documenso not configured" upstream
|
|
// (vs. crashing at type-check or boot).
|
|
if (!portId) {
|
|
return {
|
|
baseUrl: env.DOCUMENSO_API_URL ?? '',
|
|
apiKey: env.DOCUMENSO_API_KEY ?? '',
|
|
apiVersion: env.DOCUMENSO_API_VERSION,
|
|
};
|
|
}
|
|
const cfg = await getPortDocumensoConfig(portId);
|
|
return {
|
|
baseUrl: cfg.apiUrl ?? '',
|
|
apiKey: cfg.apiKey ?? '',
|
|
apiVersion: cfg.apiVersion,
|
|
};
|
|
}
|
|
|
|
async function documensoFetchOnce(
|
|
path: string,
|
|
options: RequestInit | undefined,
|
|
portId: string | undefined,
|
|
): Promise<unknown> {
|
|
const { baseUrl, apiKey } = await resolveCreds(portId);
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout(`${baseUrl}${path}`, {
|
|
...options,
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
...options?.headers,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof FetchTimeoutError) {
|
|
// Retry timeouts — transient network issue.
|
|
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
|
internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
logger.error({ path, status: res.status, err, portId }, 'Documenso API error');
|
|
if (res.status === 401 || res.status === 403) {
|
|
// Auth failures are not retryable — wrong key won't fix itself.
|
|
throw new AbortError(
|
|
new CodedError('DOCUMENSO_AUTH_FAILURE', {
|
|
internalMessage: `${path} → ${res.status}`,
|
|
}),
|
|
);
|
|
}
|
|
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
|
// 4xx (other than 429) means we sent something Documenso rejected —
|
|
// retrying won't help. 429 (rate-limit) goes through the retry path
|
|
// with backoff so we politely re-attempt after delay.
|
|
throw new AbortError(
|
|
new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `${path} → ${res.status}: ${err}`,
|
|
}),
|
|
);
|
|
}
|
|
// 5xx + 429 → transient, retry.
|
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `${path} → ${res.status}: ${err}`,
|
|
});
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Wraps every Documenso call in p-retry: 3 attempts total (1 + 2 retries)
|
|
* with exponential backoff (1s, 4s) + jitter. AbortError short-circuits
|
|
* for auth failures and 4xx-not-429 — those will never succeed on retry.
|
|
*
|
|
* This recovers the "single connection blip drops the whole signing flow"
|
|
* scenario the audit's services pass flagged.
|
|
*/
|
|
async function documensoFetch(
|
|
path: string,
|
|
options?: RequestInit,
|
|
portId?: string,
|
|
): Promise<unknown> {
|
|
return pRetry(() => documensoFetchOnce(path, options, portId), {
|
|
retries: 2,
|
|
factor: 2,
|
|
minTimeout: 1000,
|
|
randomize: true,
|
|
onFailedAttempt: (ctx) => {
|
|
logger.warn(
|
|
{
|
|
path,
|
|
portId,
|
|
attempt: ctx.attemptNumber,
|
|
retriesLeft: ctx.retriesLeft,
|
|
err: ctx.error.message,
|
|
},
|
|
'Documenso fetch retry',
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
// Documenso 2.x has THREE potential ID fields on responses depending on the
|
|
// endpoint:
|
|
// - `envelopeId: string` — the public 'envelope_xxx' identifier. This is
|
|
// what every downstream endpoint expects (/envelope/update,
|
|
// /envelope/distribute, /envelope/{id}, DELETE etc).
|
|
// - `documentId: number|string` — an alias on some responses.
|
|
// - `id` — on /template/use this is the INTERNAL numeric
|
|
// pk (e.g. 17). On other endpoints `id` is sometimes the envelope_xxx
|
|
// string. On v1.13 `id` is the only field and represents the document.
|
|
//
|
|
// Resolution order: envelopeId (most reliable, v2-only) → documentId →
|
|
// id. We coerce to string everywhere downstream. A previous version of
|
|
// this normalizer used `documentId ?? id` which picked up the numeric
|
|
// internal pk from /template/use, broke envelope/update + envelope/distribute
|
|
// with "Invalid envelope ID", and silently failed every title-change +
|
|
// distribute on freshly-created envelopes.
|
|
function normalizeDocument(raw: unknown): DocumensoDocument {
|
|
const r = (raw ?? {}) as Record<string, unknown>;
|
|
const id = String(r.envelopeId ?? r.documentId ?? r.id ?? '');
|
|
// Documenso v2 also exposes a numeric internal pk (`id`) alongside the
|
|
// envelope_xxx string — webhooks ONLY carry the numeric id, so we
|
|
// surface it separately so the webhook resolver can match by either.
|
|
// For v1 responses `id` IS the (numeric) document id, so this is the
|
|
// same value as `id` above. For v2 with envelopeId set, this captures
|
|
// the internal pk that the webhook payload uses.
|
|
const numericIdRaw = r.id;
|
|
const numericId =
|
|
typeof numericIdRaw === 'number'
|
|
? String(numericIdRaw)
|
|
: typeof numericIdRaw === 'string' && /^\d+$/.test(numericIdRaw)
|
|
? numericIdRaw
|
|
: null;
|
|
const status = String(r.status ?? 'PENDING');
|
|
// v1.32+ payloads carry a `Recipient` (capital R) array as a legacy
|
|
// duplicate of `recipients` — fall through to it so we still resolve
|
|
// tokens / URLs when only the legacy field is populated.
|
|
const recipientsRaw =
|
|
(r.recipients as Array<Record<string, unknown>> | undefined) ??
|
|
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
|
|
[];
|
|
const recipients = recipientsRaw.map((rec) => ({
|
|
id: String(rec.recipientId ?? rec.id ?? ''),
|
|
name: String(rec.name ?? ''),
|
|
email: String(rec.email ?? ''),
|
|
role: String(rec.role ?? ''),
|
|
signingOrder: Number(rec.signingOrder ?? 0),
|
|
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
|
|
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
|
|
embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined,
|
|
// Per-recipient signing token — required on the v1 Recipient model,
|
|
// present on every v2 envelope-distribute response. Documenso uses
|
|
// it as the URL tail (`/sign/<token>`) so it also matches what we
|
|
// see on subsequent webhook deliveries.
|
|
token: typeof rec.token === 'string' ? rec.token : undefined,
|
|
}));
|
|
return { id, numericId, status, recipients };
|
|
}
|
|
|
|
export interface DocumensoRecipient {
|
|
name: string;
|
|
email: string;
|
|
role: string;
|
|
signingOrder: number;
|
|
}
|
|
|
|
export interface DocumensoDocument {
|
|
id: string;
|
|
/** Documenso v2 numeric internal pk. Populated alongside the
|
|
* envelope_xxx `id` so callers can persist both — webhooks use this
|
|
* one. Null when the response didn't include a numeric id. */
|
|
numericId: string | null;
|
|
status: string;
|
|
recipients: Array<{
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role: string;
|
|
signingOrder: number;
|
|
status: string;
|
|
signingUrl?: string;
|
|
embeddedUrl?: string;
|
|
/** v1 + v2 recipient token. Used to populate
|
|
* `document_signers.signing_token` so the webhook handler can
|
|
* match recipients without leaning on email (which may be reused
|
|
* across roles). */
|
|
token?: string;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
|
|
* email so Documenso doesn't accidentally email real clients during a
|
|
* data import / migration dry-run. Names are prefixed with the original
|
|
* email so the recipient (you) can tell who would have received the doc.
|
|
*
|
|
* In production this env var is unset and recipients flow through unchanged.
|
|
*/
|
|
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
|
|
if (!env.EMAIL_REDIRECT_TO) return recipients;
|
|
return recipients.map((r) => ({
|
|
...r,
|
|
name: `${r.name} (was: ${r.email})`,
|
|
email: env.EMAIL_REDIRECT_TO!,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Same idea for the template-generate endpoint, which takes a payload
|
|
* shape with recipient email/name nested inside `formValues` (Documenso
|
|
* v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes.
|
|
*/
|
|
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
|
|
if (!env.EMAIL_REDIRECT_TO) return payload;
|
|
const out: Record<string, unknown> = { ...payload };
|
|
// 2.x recipient shape
|
|
if (Array.isArray(out.recipients)) {
|
|
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
|
|
...r,
|
|
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
|
|
email: env.EMAIL_REDIRECT_TO,
|
|
}));
|
|
}
|
|
// v1.13 formValues shape - keys vary per template; key by anything that
|
|
// looks like an email field. The conservative approach: only touch keys
|
|
// that already hold a string and end with `Email` / `email`.
|
|
if (out.formValues && typeof out.formValues === 'object') {
|
|
const fv = { ...(out.formValues as Record<string, unknown>) };
|
|
for (const key of Object.keys(fv)) {
|
|
if (/email$/i.test(key) && typeof fv[key] === 'string') {
|
|
fv[key] = env.EMAIL_REDIRECT_TO;
|
|
}
|
|
}
|
|
out.formValues = fv;
|
|
}
|
|
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) {
|
|
logger.info(
|
|
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
|
|
'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,
|
|
...(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);
|
|
}
|
|
|
|
export async function generateDocumentFromTemplate(
|
|
templateId: number,
|
|
payload: Record<string, unknown>,
|
|
portId?: string,
|
|
): Promise<DocumensoDocument> {
|
|
const safePayload = applyPayloadRedirect(payload);
|
|
const { apiVersion } = await resolveCreds(portId);
|
|
if (env.EMAIL_REDIRECT_TO) {
|
|
logger.info(
|
|
{ templateId, apiVersion },
|
|
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
|
|
);
|
|
}
|
|
|
|
// v2 uses POST /api/v2/template/use with `prefillFields` keyed by field ID.
|
|
// The payload builder emits `prefillFields` when a cached field name→ID map
|
|
// exists for the port — see `buildDocumensoPayload` + `documenso-template-
|
|
// sync.service.ts`. When no map is cached we still hit /template/use but
|
|
// skip prefillFields (recipients-only); v2 instances ignore the legacy
|
|
// `formValues` field, so emit it only on v1 paths.
|
|
//
|
|
// v1 (incl. Documenso 1.13.x) uses the legacy
|
|
// /api/v1/templates/{id}/generate-document with `formValues` by name.
|
|
if (apiVersion === 'v2') {
|
|
const v2Payload = safePayload as Record<string, unknown>;
|
|
|
|
// Title PATCH must happen BEFORE distribution because Documenso v2
|
|
// restricts `envelope/update` to DRAFT envelopes only. So the v2
|
|
// flow is: 1) /template/use without distribute → DRAFT envelope, 2)
|
|
// /envelope/update with the title, 3) /envelope/distribute → PENDING
|
|
// envelope with signingUrls populated. Step 3 is REQUIRED because
|
|
// v2 doesn't return signingUrls from /template/use — without it
|
|
// `document_signers.signing_url` stays null and the manual
|
|
// "Send invitation" button errors with "Signer has no Documenso URL".
|
|
const created = await documensoFetch(
|
|
`/api/v2/template/use`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...v2Payload, templateId }),
|
|
},
|
|
portId,
|
|
).then(normalizeDocument);
|
|
|
|
const desiredTitle =
|
|
typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null;
|
|
// `/template/use` silently drops the `meta` field on the request body —
|
|
// signingOrder, subject, message, redirectUrl all inherit from the
|
|
// template's stored defaults. To enforce the per-port `documenso_signing_
|
|
// order` (PARALLEL vs SEQUENTIAL) and per-port subject/message, replay
|
|
// the meta fields through `/envelope/update` while the envelope is still
|
|
// DRAFT (update is rejected once distributed).
|
|
const payloadMeta = (v2Payload.meta as Record<string, unknown> | undefined) ?? {};
|
|
const updateMeta: Record<string, unknown> = {};
|
|
for (const key of ['signingOrder', 'subject', 'message', 'redirectUrl', 'language'] as const) {
|
|
const value = payloadMeta[key];
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
updateMeta[key] = value;
|
|
}
|
|
}
|
|
const hasMetaPatch = Object.keys(updateMeta).length > 0;
|
|
if (desiredTitle || hasMetaPatch) {
|
|
try {
|
|
const updateBody: Record<string, unknown> = { envelopeId: created.id };
|
|
if (desiredTitle) updateBody.data = { title: desiredTitle };
|
|
if (hasMetaPatch) updateBody.meta = updateMeta;
|
|
const updateResponse = await documensoFetch(
|
|
`/api/v2/envelope/update`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updateBody),
|
|
},
|
|
portId,
|
|
);
|
|
// Log the raw response so we can debug when Documenso's UI keeps
|
|
// showing the template PDF filename despite our update succeeding.
|
|
// The update endpoint returns `{success: true}` on a clean ack;
|
|
// anything else hints that the title field wasn't accepted.
|
|
logger.info(
|
|
{ docId: created.id, desiredTitle, updateMeta, updateResponse },
|
|
'Documenso envelope update — response',
|
|
);
|
|
// Belt-and-braces verify: re-read the envelope and confirm the
|
|
// title persisted. Documenso v2's listing surface has been known
|
|
// to render the underlying PDF filename rather than envelope.title
|
|
// — surfacing the actual returned `title` here lets us tell
|
|
// whether the API accepted our value (and the UI is the issue)
|
|
// vs the update silently no-op'd.
|
|
try {
|
|
const verify = (await documensoFetch(
|
|
`/api/v2/envelope/${created.id}`,
|
|
{ method: 'GET' },
|
|
portId,
|
|
)) as Record<string, unknown>;
|
|
logger.info(
|
|
{
|
|
docId: created.id,
|
|
desiredTitle,
|
|
actualTitle: verify?.title,
|
|
titleMatches: verify?.title === desiredTitle,
|
|
actualMeta: verify?.documentMeta ?? verify?.envelopeMeta ?? verify?.meta,
|
|
},
|
|
'Documenso envelope update — verification',
|
|
);
|
|
} catch {
|
|
// GET verify is best-effort; don't fail generate on it.
|
|
}
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ docId: created.id, updateMeta, err: err instanceof Error ? err.message : err },
|
|
'Documenso envelope update failed — created envelope keeps template default title/meta',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Distribute the envelope so per-recipient signing URLs are minted.
|
|
// Without this, the recipients returned by /template/use have
|
|
// `signingUrl: null` and our "Send invitation" button errors out
|
|
// with "Signer has no Documenso URL yet."
|
|
//
|
|
// Documenso v2's distribute fires its own emails by default, but
|
|
// our payload sets `meta.distributionMethod: 'NONE'` so it just
|
|
// mints the URLs without emailing — our branded
|
|
// `sendSigningInvitation` is the dispatcher.
|
|
//
|
|
// We replace `created` with the distribute response because that's
|
|
// the call that actually returns recipients with `signingUrl`
|
|
// populated; downstream code (the document_signers insert in
|
|
// generateAndSignViaDocumensoTemplate) reads from this object.
|
|
// CRITICAL: pass `meta.distributionMethod: 'NONE'` in the distribute
|
|
// body. `/template/use` doesn't accept a `meta` field at all — our
|
|
// payload's `meta.distributionMethod: 'NONE'` is silently dropped at
|
|
// template-use time, so the envelope inherits the TEMPLATE's
|
|
// distributionMethod (which defaults to EMAIL). Without overriding
|
|
// it on the distribute call, Documenso fires its own emails the
|
|
// moment distribute runs — which clashes with our branded
|
|
// `sendSigningInvitation` flow and ignores the per-port
|
|
// `eoi_send_mode: 'manual'` setting. The override here is the
|
|
// authoritative one for v2 envelopes.
|
|
try {
|
|
const distributed = (await documensoFetch(
|
|
`/api/v2/envelope/distribute`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
envelopeId: created.id,
|
|
meta: { distributionMethod: 'NONE' },
|
|
}),
|
|
},
|
|
portId,
|
|
)) as Record<string, unknown>;
|
|
const normalized = normalizeDocument({
|
|
envelopeId: distributed.id ?? created.id,
|
|
// Distribute doesn't return the numeric id, so we synthesize it
|
|
// from the original /template/use response by passing the numeric
|
|
// id as Documenso's `id` field — normalizeDocument picks it up
|
|
// as numericId. Without this, the row would lose its numeric id
|
|
// on distribute and webhooks couldn't resolve back to it.
|
|
id: created.numericId,
|
|
status: 'PENDING',
|
|
recipients: distributed.recipients,
|
|
});
|
|
return normalized;
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ docId: created.id, err: err instanceof Error ? err.message : err },
|
|
'Documenso envelope distribute failed — signingUrl will be null. Send-invitation will fail until the envelope is distributed.',
|
|
);
|
|
return created;
|
|
}
|
|
}
|
|
|
|
return documensoFetch(
|
|
`/api/v1/templates/${templateId}/generate-document`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(safePayload),
|
|
},
|
|
portId,
|
|
).then(normalizeDocument);
|
|
}
|
|
|
|
/**
|
|
* Tell Documenso to actually email the document to its recipients. The
|
|
* recipients themselves are set at create-time (and rerouted to
|
|
* EMAIL_REDIRECT_TO when set), but this is a belt-and-braces guard for
|
|
* documents that may have been created BEFORE the redirect was turned on
|
|
* (i.e. real-recipient documents now triggered by an automation while
|
|
* we're trying to hold comms). When the redirect is on we skip the API
|
|
* call entirely and return a synthetic "still pending" response.
|
|
*/
|
|
/**
|
|
* v2-only: distribute an envelope. Moves it from DRAFT → PENDING and
|
|
* mints per-recipient signing URLs. Does NOT email recipients when the
|
|
* envelope's `meta.distributionMethod` is `NONE` (our default — branded
|
|
* emails are dispatched by `sendSigningInvitation`).
|
|
*
|
|
* Direct call bypassing `sendDocument`'s dev-mode short-circuit. The
|
|
* self-heal path for envelopes created before the auto-distribute fix
|
|
* shipped uses this so the URLs actually get minted in dev too —
|
|
* `EMAIL_REDIRECT_TO` already rewrites recipient emails to a safe
|
|
* address at envelope-creation time, so distribute can't accidentally
|
|
* email a real client.
|
|
*/
|
|
export async function distributeEnvelopeV2(
|
|
envelopeId: string,
|
|
portId?: string,
|
|
): Promise<DocumensoDocument> {
|
|
// Architectural rule (Matt 2026-05-15): ALL outbound emails go through
|
|
// our branded `sendSigningInvitation` path — Documenso never fires its
|
|
// own emails for our envelopes. `meta.distributionMethod: 'NONE'`
|
|
// here is the ONLY place where this contract is actually enforced
|
|
// for v2 envelopes (the corresponding flag in /template/use is
|
|
// silently dropped because that endpoint doesn't accept a meta field).
|
|
const distributed = (await documensoFetch(
|
|
`/api/v2/envelope/distribute`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
envelopeId,
|
|
meta: { distributionMethod: 'NONE' },
|
|
}),
|
|
},
|
|
portId,
|
|
)) as Record<string, unknown>;
|
|
return normalizeDocument({
|
|
id: distributed.id ?? envelopeId,
|
|
status: 'PENDING',
|
|
recipients: distributed.recipients,
|
|
});
|
|
}
|
|
|
|
export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
|
|
if (env.EMAIL_REDIRECT_TO) {
|
|
logger.warn(
|
|
{ docId, portId, redirect: env.EMAIL_REDIRECT_TO },
|
|
'sendDocument SKIPPED - EMAIL_REDIRECT_TO is set, outbound comms paused',
|
|
);
|
|
// Return the existing doc shape so downstream code doesn't see an
|
|
// unexpected null. The document remains in DRAFT/PENDING from
|
|
// 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).
|
|
// `meta.distributionMethod: 'NONE'` is the authoritative way to suppress
|
|
// Documenso's own emails for v2 envelopes — see distributeEnvelopeV2
|
|
// for the full rationale. Branded sends are routed through
|
|
// `sendSigningInvitation` separately.
|
|
const distributed = (await documensoFetch(
|
|
'/api/v2/envelope/distribute',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
envelopeId: docId,
|
|
meta: { distributionMethod: 'NONE' },
|
|
}),
|
|
},
|
|
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`,
|
|
{
|
|
method: 'POST',
|
|
},
|
|
portId,
|
|
).then(normalizeDocument);
|
|
}
|
|
|
|
export async function getDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
|
|
const { apiVersion } = await resolveCreds(portId);
|
|
// v1: GET /api/v1/documents/{id}
|
|
// v2: GET /api/v2/envelope/{id} — same response normalizer (id ↔ documentId,
|
|
// recipientId ↔ id handled by normalizeDocument).
|
|
const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`;
|
|
return documensoFetch(path, undefined, portId).then(normalizeDocument);
|
|
}
|
|
|
|
// ─── Template introspection ─────────────────────────────────────────────────
|
|
|
|
export interface DocumensoTemplateRecipient {
|
|
id: number;
|
|
role: string; // 'SIGNER' | 'APPROVER' | 'CC' | 'VIEWER'
|
|
signingOrder: number;
|
|
name?: string;
|
|
email?: string;
|
|
}
|
|
|
|
export interface DocumensoTemplateField {
|
|
id: number;
|
|
type: string;
|
|
/**
|
|
* The human label assigned in the template editor — for v2 templates this
|
|
* comes from `field.fieldMeta.label`; for v1 templates it's available as
|
|
* `field.fieldMeta.label` too (the shape was preserved). Used as the key
|
|
* for the cached field-name → ID map that drives v2's `prefillFields`.
|
|
*/
|
|
label?: string;
|
|
}
|
|
|
|
export interface DocumensoTemplate {
|
|
id: number;
|
|
title: string;
|
|
recipients: DocumensoTemplateRecipient[];
|
|
fields: DocumensoTemplateField[];
|
|
/**
|
|
* v2 only. Each entry corresponds to one underlying PDF file on the
|
|
* template — usually a single envelope item per template, but Documenso
|
|
* 2.x supports stitching multiple PDFs together. Used by the sync flow
|
|
* to download each PDF and inspect its native AcroForm fields.
|
|
*/
|
|
envelopeItems: Array<{ id: string }>;
|
|
/**
|
|
* v2 only. The template's stored meta — signing order, distribution
|
|
* method, redirect URL. Surfaced in the sync report so the admin can
|
|
* confirm what the template itself is configured to do at envelope
|
|
* creation time. /template/use does NOT accept a signingOrder override,
|
|
* so these values are what every generated envelope inherits.
|
|
*/
|
|
meta: {
|
|
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
|
|
distributionMethod: 'EMAIL' | 'NONE' | null;
|
|
redirectUrl: string | null;
|
|
};
|
|
}
|
|
|
|
function normalizeTemplate(raw: unknown): DocumensoTemplate {
|
|
const r = (raw ?? {}) as Record<string, unknown>;
|
|
const id = Number(r.templateId ?? r.id ?? 0);
|
|
const title = String(r.title ?? '');
|
|
const recipientsRaw =
|
|
(r.recipients as Array<Record<string, unknown>> | undefined) ??
|
|
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
|
|
[];
|
|
const recipients: DocumensoTemplateRecipient[] = recipientsRaw.map((rec) => ({
|
|
id: Number(rec.recipientId ?? rec.id ?? 0),
|
|
role: String(rec.role ?? 'SIGNER'),
|
|
signingOrder: Number(rec.signingOrder ?? 0),
|
|
name: typeof rec.name === 'string' ? rec.name : undefined,
|
|
email: typeof rec.email === 'string' ? rec.email : undefined,
|
|
}));
|
|
const fieldsRaw = (r.fields as Array<Record<string, unknown>> | undefined) ?? [];
|
|
const fields: DocumensoTemplateField[] = fieldsRaw.map((f) => {
|
|
const fieldMeta = (f.fieldMeta as Record<string, unknown> | undefined) ?? {};
|
|
return {
|
|
id: Number(f.id ?? 0),
|
|
type: String(f.type ?? ''),
|
|
label: typeof fieldMeta.label === 'string' ? fieldMeta.label : undefined,
|
|
};
|
|
});
|
|
const itemsRaw = (r.envelopeItems as Array<Record<string, unknown>> | undefined) ?? [];
|
|
const envelopeItems = itemsRaw
|
|
.map((it) => ({ id: typeof it.id === 'string' ? it.id : '' }))
|
|
.filter((it) => it.id);
|
|
|
|
// templateMeta on v2 carries the signing order + distribution method +
|
|
// post-sign redirect. v1 templates put the same data on the doc root, so
|
|
// try both shapes.
|
|
const metaRaw =
|
|
(r.templateMeta as Record<string, unknown> | undefined) ?? (r as Record<string, unknown>);
|
|
const signingOrderRaw = metaRaw.signingOrder;
|
|
const distributionRaw = metaRaw.distributionMethod;
|
|
const redirectRaw = metaRaw.redirectUrl;
|
|
const meta: DocumensoTemplate['meta'] = {
|
|
signingOrder:
|
|
signingOrderRaw === 'PARALLEL' || signingOrderRaw === 'SEQUENTIAL' ? signingOrderRaw : null,
|
|
distributionMethod:
|
|
distributionRaw === 'EMAIL' || distributionRaw === 'NONE' ? distributionRaw : null,
|
|
redirectUrl: typeof redirectRaw === 'string' && redirectRaw ? redirectRaw : null,
|
|
};
|
|
|
|
return { id, title, recipients, fields, envelopeItems, meta };
|
|
}
|
|
|
|
/**
|
|
* v2-only: download the raw PDF bytes for one envelope-item (each template
|
|
* is backed by 1+ envelope items, one per uploaded PDF). The sync flow
|
|
* uses this to inspect the PDF's AcroForm field names, surfacing whether
|
|
* the operator's fillable PDF matches the CRM's expected field-label set.
|
|
*
|
|
* v1 templates aren't supported here — the v1 download endpoint requires
|
|
* a documentId, not a templateId, and v1 doesn't expose envelope items.
|
|
*/
|
|
export async function downloadEnvelopeItemPdf(
|
|
envelopeItemId: string,
|
|
portId?: string,
|
|
version: 'signed' | 'original' = 'original',
|
|
): Promise<Buffer> {
|
|
const { baseUrl, apiKey } = await resolveCreds(portId);
|
|
const res = await fetchWithTimeout(
|
|
`${baseUrl}/api/v2/envelope/item/${envelopeItemId}/download?version=${version}`,
|
|
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
);
|
|
if (!res.ok) {
|
|
const errText = await res.text().catch(() => '');
|
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `download envelope item ${envelopeItemId} → ${res.status} ${errText}`,
|
|
});
|
|
}
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|
|
|
|
/**
|
|
* Fetch a Documenso template by ID. Used by the admin "Sync from Documenso"
|
|
* flow to discover recipient slot IDs + template field IDs without forcing
|
|
* the operator to type them in by hand.
|
|
*
|
|
* - v2 path: `GET /api/v2/template/{templateId}` (returns envelope-shape JSON)
|
|
* - v1 path: `GET /api/v1/templates/{templateId}` (returns legacy doc shape;
|
|
* recipient + field arrays are present but with `id` instead of
|
|
* `recipientId`/`templateId`).
|
|
*/
|
|
export async function getTemplate(templateId: number, portId?: string): Promise<DocumensoTemplate> {
|
|
const { apiVersion } = await resolveCreds(portId);
|
|
const path =
|
|
apiVersion === 'v2' ? `/api/v2/template/${templateId}` : `/api/v1/templates/${templateId}`;
|
|
return documensoFetch(path, undefined, portId).then(normalizeTemplate);
|
|
}
|
|
|
|
/**
|
|
* v2-only: resolve a Documenso template by its envelope ID (the
|
|
* `envelope_xxxxxxxx` string that appears in the Documenso UI URL). The
|
|
* admin pastes that URL slug into the Sync input and we look up the
|
|
* matching numeric template id via `GET /api/v2/template`. Returns null
|
|
* when no template matches.
|
|
*
|
|
* Documenso 2.x's template editor URL is
|
|
* `https://.../templates/envelope_xxxxxxxx`, but the numeric `id` is not
|
|
* surfaced anywhere in the UI — so admins have no way to enter the
|
|
* numeric id by hand. This resolver bridges the gap.
|
|
*/
|
|
export async function findTemplateIdByEnvelopeId(
|
|
envelopeId: string,
|
|
portId?: string,
|
|
): Promise<number | null> {
|
|
const { apiVersion } = await resolveCreds(portId);
|
|
if (apiVersion !== 'v2') return null;
|
|
// Paginate through templates (perPage maxes at ~100 on the upstream).
|
|
// Most installs have <50 templates so the first page is usually enough.
|
|
let page = 1;
|
|
const perPage = 100;
|
|
while (page < 20) {
|
|
const res = (await documensoFetch(
|
|
`/api/v2/template?page=${page}&perPage=${perPage}`,
|
|
undefined,
|
|
portId,
|
|
)) as { data?: Array<Record<string, unknown>>; templates?: Array<Record<string, unknown>> };
|
|
const rows = res.data ?? res.templates ?? [];
|
|
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
for (const row of rows) {
|
|
const rowEnvelopeId = String(row.envelopeId ?? '');
|
|
if (rowEnvelopeId === envelopeId) {
|
|
const numericId = Number(row.id ?? 0);
|
|
return Number.isInteger(numericId) && numericId > 0 ? numericId : null;
|
|
}
|
|
}
|
|
if (rows.length < perPage) return null;
|
|
page += 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Email a signing reminder to one recipient. Skipped entirely when
|
|
* EMAIL_REDIRECT_TO is set - the recipient's stored email may still be
|
|
* a real client address from before the redirect was enabled.
|
|
*/
|
|
export async function sendReminder(
|
|
docId: string,
|
|
signerId: string,
|
|
portId?: string,
|
|
): Promise<void> {
|
|
if (env.EMAIL_REDIRECT_TO) {
|
|
logger.warn(
|
|
{ docId, signerId, portId, redirect: env.EMAIL_REDIRECT_TO },
|
|
'sendReminder SKIPPED - EMAIL_REDIRECT_TO is set, outbound comms paused',
|
|
);
|
|
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`,
|
|
{
|
|
method: 'POST',
|
|
},
|
|
portId,
|
|
);
|
|
}
|
|
|
|
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
|
|
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
|
// v2 download is a two-step lookup: there's no /envelope/{id}/download path
|
|
// (that 404s — see audit-2026-05-15). The canonical flow is:
|
|
// 1. GET /envelope/{id} → read envelopeItems[0].id
|
|
// 2. GET /envelope/item/{itemId}/download?version=signed
|
|
// v1 keeps the direct /documents/{id}/download single-call path.
|
|
if (apiVersion === 'v2') {
|
|
const envelope = (await documensoFetch(
|
|
`/api/v2/envelope/${docId}`,
|
|
{ method: 'GET' },
|
|
portId,
|
|
)) as { envelopeItems?: Array<{ id?: string }> };
|
|
const itemId = envelope.envelopeItems?.[0]?.id;
|
|
if (!itemId) {
|
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `v2 envelope ${docId} has no envelopeItems — cannot download signed PDF`,
|
|
});
|
|
}
|
|
return downloadEnvelopeItemPdf(itemId, portId, 'signed');
|
|
}
|
|
const path = `/api/v1/documents/${docId}/download`;
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout(`${baseUrl}${path}`, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof FetchTimeoutError) {
|
|
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
|
internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
logger.error({ docId, status: res.status, err, portId }, 'Documenso download error');
|
|
if (res.status === 401 || res.status === 403) {
|
|
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
|
|
internalMessage: `${path} → ${res.status}`,
|
|
});
|
|
}
|
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `${path} → ${res.status}: ${err}`,
|
|
});
|
|
}
|
|
|
|
const arrayBuffer = await res.arrayBuffer();
|
|
return Buffer.from(arrayBuffer);
|
|
}
|
|
|
|
/** Convenience health-check used by the admin "Test connection" button.
|
|
*
|
|
* v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` — the old v1
|
|
* path is gone. So we probe the appropriate cheap list endpoint per
|
|
* version (`GET /api/v2/document` for v2, `GET /api/v1/health` for v1)
|
|
* and treat a 401 as "creds rejected" and a 200 as "all good". A 404
|
|
* means the URL points at something that isn't Documenso. */
|
|
export async function checkDocumensoHealth(
|
|
portId?: string,
|
|
): Promise<{ ok: boolean; status?: number; error?: string; apiVersion?: DocumensoApiVersion }> {
|
|
try {
|
|
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
|
const path = apiVersion === 'v2' ? '/api/v2/document' : '/api/v1/health';
|
|
const res = await fetchWithTimeout(`${baseUrl}${path}`, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
});
|
|
// 2xx = full success; 401/403 = creds wrong but URL right (still a
|
|
// partial-success signal — the admin should know it's an auth issue,
|
|
// not a typoed URL). 404 = wrong URL.
|
|
return { ok: res.ok, status: res.status, apiVersion };
|
|
} catch (err) {
|
|
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
|
}
|
|
}
|
|
|
|
// ─── Version-aware abstractions (Phase A PR2) ─────────────────────────────────
|
|
//
|
|
// Documenso v1.13 and v2.x diverge on field placement and document deletion:
|
|
//
|
|
// v1.13: per-field POST /api/v1/documents/{id}/fields with PIXEL coords;
|
|
// DELETE /api/v1/documents/{id} for void.
|
|
// v2.x: bulk POST /api/v2/envelope/field/create-many with PERCENT
|
|
// coords (0-100) and rich `fieldMeta`;
|
|
// DELETE /api/v2/envelope/{id} for void.
|
|
//
|
|
// Callers always work in PERCENT (0-100). For v1 the abstraction multiplies by
|
|
// the page dimensions returned by Documenso (cached per docId for the lifetime
|
|
// of the process - fields for a given doc usually go in a single batch).
|
|
|
|
/**
|
|
* Every field type Documenso supports across v1 and v2. The earlier
|
|
* subset (SIGNATURE/INITIALS/DATE/TEXT/EMAIL) covered the EOI flow's
|
|
* needs but locks out custom-uploaded contracts/reservations that
|
|
* may need checkboxes (e.g. "Lease vs Purchase"), dropdowns (e.g.
|
|
* "Berth class A/B/C"), or radio groups. Extending now so the
|
|
* field-placement UI can surface the full palette without later
|
|
* widening this type and patching every call site.
|
|
*
|
|
* Per-type fieldMeta expectations (passed through verbatim):
|
|
* - SIGNATURE / FREE_SIGNATURE / INITIALS / DATE / EMAIL / NAME — no meta
|
|
* - TEXT — { text?: string, label?: string, required?: bool, readOnly?: bool }
|
|
* - NUMBER — { numberFormat?: string, min?: number, max?: number, required?: bool }
|
|
* - CHECKBOX — { values: Array<{ checked: bool, value: string }>, validationRule?: string }
|
|
* - DROPDOWN — { values: Array<{ value: string }>, defaultValue?: string }
|
|
* - RADIO — { values: Array<{ checked: bool, value: string }> }
|
|
*
|
|
* `fieldMeta` is sent verbatim to v2's create-many endpoint and
|
|
* silently ignored by v1 (which doesn't accept the property). v1
|
|
* rendering of TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO falls back to
|
|
* blank-input behaviour without the meta.
|
|
*/
|
|
export type DocumensoFieldType =
|
|
| 'SIGNATURE'
|
|
| 'FREE_SIGNATURE'
|
|
| 'INITIALS'
|
|
| 'DATE'
|
|
| 'EMAIL'
|
|
| 'NAME'
|
|
| 'TEXT'
|
|
| 'NUMBER'
|
|
| 'CHECKBOX'
|
|
| 'DROPDOWN'
|
|
| 'RADIO';
|
|
|
|
/**
|
|
* Typed metadata shapes per field type — surfaces what fieldMeta
|
|
* actually carries in well-known cases. Used by the field-placement
|
|
* UI to render the right config form per field type. Pass-through to
|
|
* Documenso retains the loose `Record<string, unknown>` shape so we
|
|
* can ship without locking down every property.
|
|
*/
|
|
export interface DocumensoTextFieldMeta {
|
|
text?: string;
|
|
label?: string;
|
|
required?: boolean;
|
|
readOnly?: boolean;
|
|
}
|
|
export interface DocumensoNumberFieldMeta {
|
|
numberFormat?: string;
|
|
min?: number;
|
|
max?: number;
|
|
required?: boolean;
|
|
}
|
|
export interface DocumensoChoiceOption {
|
|
value: string;
|
|
/** Whether the option is pre-selected. Applies to checkbox + radio. */
|
|
checked?: boolean;
|
|
}
|
|
export interface DocumensoChoiceFieldMeta {
|
|
values: DocumensoChoiceOption[];
|
|
defaultValue?: string;
|
|
validationRule?: string;
|
|
}
|
|
|
|
/**
|
|
* Returns true when this field type expects a fieldMeta payload from
|
|
* the placement UI (so the UI can prompt the rep to configure
|
|
* options, defaults, validation, etc). Field types not in this list
|
|
* carry no per-instance configuration beyond position + recipient.
|
|
*/
|
|
export function fieldTypeNeedsMeta(type: DocumensoFieldType): boolean {
|
|
return (
|
|
type === 'TEXT' ||
|
|
type === 'NUMBER' ||
|
|
type === 'CHECKBOX' ||
|
|
type === 'DROPDOWN' ||
|
|
type === 'RADIO'
|
|
);
|
|
}
|
|
|
|
export interface DocumensoFieldPlacement {
|
|
/** Documenso recipient id; v1 expects number, v2 string - coerced internally. */
|
|
recipientId: number | string;
|
|
type: DocumensoFieldType;
|
|
pageNumber: number;
|
|
/** All four are 0-100 percent of page dimensions. */
|
|
pageX: number;
|
|
pageY: number;
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
/** Optional v2 fieldMeta - passed through verbatim, ignored on v1. */
|
|
fieldMeta?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface DocumensoPageDimensions {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt
|
|
|
|
const pageDimensionCache = new Map<string, DocumensoPageDimensions>();
|
|
|
|
/** Test seam - clears the page-dimension memoization. */
|
|
export function __resetDocumensoCachesForTests(): void {
|
|
pageDimensionCache.clear();
|
|
}
|
|
|
|
async function getPageDimensions(docId: string, portId?: string): Promise<DocumensoPageDimensions> {
|
|
const cached = pageDimensionCache.get(docId);
|
|
if (cached) return cached;
|
|
// v1 doesn't expose page dimensions cleanly via the public API; the auto-
|
|
// placement use case is footer-anchored signature fields, where a default A4
|
|
// page rendered by Documenso is a safe assumption. Real page dims can be
|
|
// wired in a follow-up by parsing the document/document-data endpoints.
|
|
void portId;
|
|
pageDimensionCache.set(docId, DEFAULT_PAGE_DIMENSIONS);
|
|
return DEFAULT_PAGE_DIMENSIONS;
|
|
}
|
|
|
|
/**
|
|
* Place one or more fields on a Documenso document. Coordinates are PERCENT
|
|
* (0-100) and converted to pixels for v1 internally.
|
|
*
|
|
* v1: dispatches one POST per field (no bulk endpoint).
|
|
* v2: single bulk POST.
|
|
*/
|
|
export async function placeFields(
|
|
docId: string,
|
|
fields: DocumensoFieldPlacement[],
|
|
portId?: string,
|
|
): Promise<void> {
|
|
if (fields.length === 0) return;
|
|
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
|
|
|
if (apiVersion === 'v2') {
|
|
const v2Fields = fields.map((f) => ({
|
|
recipientId: String(f.recipientId),
|
|
type: f.type,
|
|
pageNumber: f.pageNumber,
|
|
positionX: f.pageX,
|
|
positionY: f.pageY,
|
|
width: f.pageWidth,
|
|
height: f.pageHeight,
|
|
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
|
}));
|
|
// Note: v2 endpoint shape (envelopeId/recipientId types) must be
|
|
// confirmed against a live Documenso 2.x instance - see PR11 realapi
|
|
// suite. Spec risk register flags this drift as the top v2 risk.
|
|
const res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/field/create-many`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ envelopeId: docId, fields: v2Fields }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
logger.error({ docId, status: res.status, err, portId }, 'Documenso v2 placeFields error');
|
|
if (res.status === 401 || res.status === 403) {
|
|
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
|
|
internalMessage: `v2 placeFields ${docId} → ${res.status}`,
|
|
});
|
|
}
|
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `v2 placeFields ${docId} → ${res.status}: ${err}`,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const dims = await getPageDimensions(docId, portId);
|
|
for (const f of fields) {
|
|
const body = {
|
|
recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId,
|
|
type: f.type,
|
|
pageNumber: f.pageNumber,
|
|
pageX: Math.round((f.pageX / 100) * dims.width),
|
|
pageY: Math.round((f.pageY / 100) * dims.height),
|
|
pageWidth: Math.round((f.pageWidth / 100) * dims.width),
|
|
pageHeight: Math.round((f.pageHeight / 100) * dims.height),
|
|
};
|
|
// Retry transient failures so one flaky 5xx mid-loop doesn't leave
|
|
// the document with a partial field set. 3 attempts at 250 / 500 /
|
|
// 1000 ms; 4xx responses (validation errors) fail-fast.
|
|
let lastError: { status: number; body: string } | null = null;
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const res = await fetchWithTimeout(`${baseUrl}/api/v1/documents/${docId}/fields`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (res.ok) {
|
|
lastError = null;
|
|
break;
|
|
}
|
|
const errBody = await res.text().catch(() => '');
|
|
lastError = { status: res.status, body: errBody };
|
|
// Don't retry on 4xx — that's a validation error, won't change.
|
|
if (res.status >= 400 && res.status < 500) break;
|
|
// Backoff: 250ms, 500ms (skipped on the 3rd iteration because we exit).
|
|
if (attempt < 2) {
|
|
await new Promise((r) => setTimeout(r, 250 * Math.pow(2, attempt)));
|
|
}
|
|
}
|
|
if (lastError) {
|
|
logger.error(
|
|
{ docId, status: lastError.status, err: lastError.body, portId },
|
|
'Documenso v1 placeField error',
|
|
);
|
|
if (lastError.status === 401 || lastError.status === 403) {
|
|
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
|
|
internalMessage: `v1 placeField ${docId} → ${lastError.status}`,
|
|
});
|
|
}
|
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `v1 placeField ${docId} → ${lastError.status}: ${lastError.body}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-position one SIGNATURE field per recipient at the last-page footer,
|
|
* staggered horizontally so multiple signers don't overlap. Used by the
|
|
* upload-path wizard - admins can refine in Documenso afterwards.
|
|
*
|
|
* Layout (percent of page):
|
|
* y = 88 (footer band)
|
|
* height = 6
|
|
* width = min(20, 80 / N)
|
|
* x = i * (80/N) + (40 - 80/N * N / 2) (centered row)
|
|
*/
|
|
export async function placeDefaultSignatureFields(
|
|
docId: string,
|
|
recipients: Array<{ id: number | string; pageNumber: number }>,
|
|
portId?: string,
|
|
): Promise<void> {
|
|
if (recipients.length === 0) return;
|
|
const fields: DocumensoFieldPlacement[] = computeDefaultSignatureLayout(recipients);
|
|
await placeFields(docId, fields, portId);
|
|
}
|
|
|
|
/** Pure function exported for unit testing layout math. */
|
|
export function computeDefaultSignatureLayout(
|
|
recipients: Array<{ id: number | string; pageNumber: number }>,
|
|
): DocumensoFieldPlacement[] {
|
|
const n = recipients.length;
|
|
if (n === 0) return [];
|
|
const slot = Math.min(20, 80 / n); // percent width per signer
|
|
const rowWidth = slot * n;
|
|
const startX = 50 - rowWidth / 2;
|
|
return recipients.map((r, i) => ({
|
|
recipientId: r.id,
|
|
type: 'SIGNATURE',
|
|
pageNumber: r.pageNumber,
|
|
pageX: Math.max(0, startX + i * slot),
|
|
pageY: 88,
|
|
pageWidth: slot,
|
|
pageHeight: 6,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Void/cancel a Documenso document.
|
|
*
|
|
* v1: DELETE /api/v1/documents/{id}
|
|
* v2: DELETE /api/v2/envelope/{id}
|
|
*
|
|
* Idempotent on 404 (already gone) - logs and resolves.
|
|
*/
|
|
export async function voidDocument(docId: string, portId?: string): Promise<void> {
|
|
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
|
const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`;
|
|
const res = await fetchWithTimeout(`${baseUrl}${path}`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
});
|
|
if (res.status === 404) {
|
|
logger.warn({ docId, portId }, 'Documenso voidDocument: already deleted');
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
logger.error({ docId, status: res.status, err, portId }, 'Documenso voidDocument error');
|
|
if (res.status === 401 || res.status === 403) {
|
|
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
|
|
internalMessage: `voidDocument ${docId} → ${res.status}`,
|
|
});
|
|
}
|
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
|
internalMessage: `voidDocument ${docId} → ${res.status}: ${err}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update an envelope's metadata while it's still in DRAFT or PENDING — title,
|
|
* subject, message, redirect URL, signing order, language. v2-only feature
|
|
* (Documenso 1.13.x has no equivalent; admins on v1 need to void + recreate).
|
|
*
|
|
* Returns the normalized document shape so callers can persist the latest
|
|
* title locally in the `documents` row.
|
|
*/
|
|
export async function updateEnvelope(
|
|
docId: string,
|
|
patch: {
|
|
title?: string;
|
|
meta?: {
|
|
subject?: string;
|
|
message?: string;
|
|
redirectUrl?: string;
|
|
language?: string;
|
|
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
|
|
timezone?: string;
|
|
dateFormat?: string;
|
|
};
|
|
},
|
|
portId?: string,
|
|
): Promise<DocumensoDocument> {
|
|
const { apiVersion } = await resolveCreds(portId);
|
|
if (apiVersion !== 'v2') {
|
|
throw new CodedError('DOCUMENSO_V1_NOT_SUPPORTED', {
|
|
internalMessage:
|
|
'updateEnvelope requires Documenso 2.x — the v1.13.x API has no envelope/update endpoint',
|
|
});
|
|
}
|
|
// v2 update is POST /api/v2/envelope/update with the envelopeId in the
|
|
// body — NOT a PATCH against a per-id path. The body splits document
|
|
// properties (title, externalId, visibility, email) under `data` from
|
|
// email/signing settings under `meta`. Restricted to DRAFT envelopes.
|
|
const body: Record<string, unknown> = { envelopeId: docId };
|
|
if (patch.title !== undefined) {
|
|
body.data = { title: patch.title };
|
|
}
|
|
if (patch.meta) {
|
|
body.meta = patch.meta;
|
|
}
|
|
return documensoFetch(
|
|
`/api/v2/envelope/update`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
},
|
|
portId,
|
|
).then(normalizeDocument);
|
|
}
|