Files
pn-new-crm/src/lib/services/documenso-client.ts
Matt Ciaccio fc7595faf8 fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:

* 38 client components / 56 toast.error sites converted to
  toastError(err) so the new admin error inspector becomes usable from
  user-reported issues — every failed inline-edit, save, send, archive,
  upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
  the existing AppError subclasses.  Adds new error codes:
  DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
  DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
  IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
  UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
  post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
  (client-merge "already been merged", expense/interest "couldn't find
  that …", documenso "signing service didn't respond").

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00

545 lines
18 KiB
TypeScript

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> {
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 documensoFetch(
path: string,
options?: RequestInit,
portId?: string,
): 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) {
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) {
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
internalMessage: `${path}${res.status}`,
});
}
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `${path}${res.status}: ${err}`,
});
}
return res.json();
}
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
// `id` form that this codebase consumes everywhere downstream.
function normalizeDocument(raw: unknown): DocumensoDocument {
const r = (raw ?? {}) as Record<string, unknown>;
const id = String(r.documentId ?? r.id ?? '');
const status = String(r.status ?? 'PENDING');
const recipientsRaw = (r.recipients 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,
}));
return { id, status, recipients };
}
export interface DocumensoRecipient {
name: string;
email: string;
role: string;
signingOrder: number;
}
export interface DocumensoDocument {
id: string;
status: string;
recipients: Array<{
id: string;
name: string;
email: string;
role: string;
signingOrder: number;
status: string;
signingUrl?: string;
embeddedUrl?: 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;
}
export async function createDocument(
title: string,
pdfBase64: string,
recipients: DocumensoRecipient[],
portId?: string,
): 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',
);
}
return documensoFetch(
'/api/v1/documents',
{
method: 'POST',
body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }),
},
portId,
).then(normalizeDocument);
}
export async function generateDocumentFromTemplate(
templateId: number,
payload: Record<string, unknown>,
portId?: string,
): Promise<DocumensoDocument> {
const safePayload = applyPayloadRedirect(payload);
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ templateId },
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
);
}
return documensoFetch(
`/api/v1/templates/${templateId}/generate-document`,
{
method: 'POST',
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.
*/
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);
}
return documensoFetch(
`/api/v1/documents/${docId}/send`,
{
method: 'POST',
},
portId,
).then(normalizeDocument);
}
export async function getDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
return documensoFetch(`/api/v1/documents/${docId}`, undefined, portId).then(normalizeDocument);
}
/**
* 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;
}
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 } = await resolveCreds(portId);
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. */
export async function checkDocumensoHealth(
portId?: string,
): Promise<{ ok: boolean; status?: number; error?: string }> {
try {
const { baseUrl, apiKey } = await resolveCreds(portId);
const res = await fetchWithTimeout(`${baseUrl}/api/v1/health`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
return { ok: res.ok, status: res.status };
} 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).
export type DocumensoFieldType = 'SIGNATURE' | 'INITIALS' | 'DATE' | 'TEXT' | 'EMAIL';
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}`,
});
}
}