import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config'; interface DocumensoCreds { baseUrl: string; apiKey: string; apiVersion: DocumensoApiVersion; } async function resolveCreds(portId?: string): Promise { 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 { const { baseUrl, apiKey } = await resolveCreds(portId); const res = await fetch(`${baseUrl}${path}`, { ...options, headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', ...options?.headers, }, }); if (!res.ok) { const err = await res.text(); logger.error({ path, status: res.status, err, portId }, 'Documenso API error'); throw new Error(`Documenso API error: ${res.status}`); } 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; const id = String(r.documentId ?? r.id ?? ''); const status = String(r.status ?? 'PENDING'); const recipientsRaw = (r.recipients as Array> | 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; }>; } export async function createDocument( title: string, pdfBase64: string, recipients: DocumensoRecipient[], portId?: string, ): Promise { return documensoFetch( '/api/v1/documents', { method: 'POST', body: JSON.stringify({ title, document: pdfBase64, recipients }), }, portId, ).then(normalizeDocument); } export async function generateDocumentFromTemplate( templateId: number, payload: Record, portId?: string, ): Promise { return documensoFetch( `/api/v1/templates/${templateId}/generate-document`, { method: 'POST', body: JSON.stringify(payload), }, portId, ).then(normalizeDocument); } export async function sendDocument(docId: string, portId?: string): Promise { return documensoFetch( `/api/v1/documents/${docId}/send`, { method: 'POST', }, portId, ).then(normalizeDocument); } export async function getDocument(docId: string, portId?: string): Promise { return documensoFetch(`/api/v1/documents/${docId}`, undefined, portId).then(normalizeDocument); } export async function sendReminder( docId: string, signerId: string, portId?: string, ): Promise { await documensoFetch( `/api/v1/documents/${docId}/recipients/${signerId}/remind`, { method: 'POST', }, portId, ); } export async function downloadSignedPdf(docId: string, portId?: string): Promise { const { baseUrl, apiKey } = await resolveCreds(portId); const res = await fetch(`${baseUrl}/api/v1/documents/${docId}/download`, { headers: { Authorization: `Bearer ${apiKey}` }, }); if (!res.ok) { const err = await res.text(); logger.error({ docId, status: res.status, err, portId }, 'Documenso download error'); throw new Error(`Documenso download error: ${res.status}`); } 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 fetch(`${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; } export interface DocumensoPageDimensions { width: number; height: number; } const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt const pageDimensionCache = new Map(); /** Test seam — clears the page-dimension memoization. */ export function __resetDocumensoCachesForTests(): void { pageDimensionCache.clear(); } async function getPageDimensions(docId: string, portId?: string): Promise { 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 { 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 fetch(`${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'); throw new Error(`Documenso v2 placeFields error: ${res.status}`); } 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), }; const res = await fetch(`${baseUrl}/api/v1/documents/${docId}/fields`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.text(); logger.error({ docId, status: res.status, err, portId }, 'Documenso v1 placeField error'); throw new Error(`Documenso v1 placeField error: ${res.status}`); } } } /** * 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 { 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 { const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId); const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`; const res = await fetch(`${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'); throw new Error(`Documenso voidDocument error: ${res.status}`); } }