merge: PR2 — Documenso v1/v2 abstraction (Phase A)

This commit is contained in:
Matt Ciaccio
2026-04-28 02:22:11 +02:00
5 changed files with 551 additions and 5 deletions

View File

@@ -23,6 +23,7 @@ const envSchema = z.object({
// Documenso
DOCUMENSO_API_URL: z.string().url(),
DOCUMENSO_API_KEY: z.string().min(1),
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),

View File

@@ -1,16 +1,23 @@
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config';
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 };
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 };
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
}
async function documensoFetch(
@@ -169,3 +176,198 @@ export async function checkDocumensoHealth(
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 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<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 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}`);
}
}

View File

@@ -24,6 +24,7 @@ import {
createDocument as documensoCreate,
sendDocument as documensoSend,
downloadSignedPdf,
voidDocument as documensoVoid,
} from '@/lib/services/documenso-client';
import type {
CreateDocumentInput,
@@ -832,7 +833,20 @@ export async function cancelDocument(
throw new ConflictError(`Document is already ${existing.status}`);
}
// PR2 will wire the Documenso void here.
// CRM is the system of record for cancellation status. A transient
// Documenso failure shouldn't block the user from marking the doc cancelled
// here — voidDocument already treats 404 as success, and the periodic
// webhook receiver will reconcile if the remote void eventually lands.
if (existing.documensoId) {
try {
await documensoVoid(existing.documensoId, portId);
} catch (err) {
logger.warn(
{ err, documentId, documensoId: existing.documensoId },
'Documenso void failed; cancelling locally anyway',
);
}
}
const [updated] = await db
.update(documents)

View File

@@ -27,6 +27,7 @@ export const SETTING_KEYS = {
// Documenso / EOI
documensoApiUrlOverride: 'documenso_api_url_override',
documensoApiKeyOverride: 'documenso_api_key_override',
documensoApiVersionOverride: 'documenso_api_version_override',
documensoEoiTemplateId: 'documenso_eoi_template_id',
eoiDefaultPathway: 'eoi_default_pathway',
@@ -119,18 +120,21 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
// ─── Documenso ──────────────────────────────────────────────────────────────
export type EoiPathway = 'documenso-template' | 'inapp';
export type DocumensoApiVersion = 'v1' | 'v2';
export interface PortDocumensoConfig {
apiUrl: string;
apiKey: string;
apiVersion: DocumensoApiVersion;
eoiTemplateId: string | null;
defaultPathway: EoiPathway;
}
export async function getPortDocumensoConfig(portId: string): Promise<PortDocumensoConfig> {
const [apiUrl, apiKey, eoiTemplateId, defaultPathway] = await Promise.all([
const [apiUrl, apiKey, apiVersion, eoiTemplateId, defaultPathway] = await Promise.all([
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
readSetting<DocumensoApiVersion>(SETTING_KEYS.documensoApiVersionOverride, portId),
readSetting<string>(SETTING_KEYS.documensoEoiTemplateId, portId),
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
]);
@@ -138,6 +142,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
return {
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL,
apiKey: apiKey ?? env.DOCUMENSO_API_KEY,
apiVersion: apiVersion ?? env.DOCUMENSO_API_VERSION,
eoiTemplateId: eoiTemplateId ?? null,
defaultPathway: defaultPathway ?? 'documenso-template',
};

View File

@@ -0,0 +1,324 @@
/**
* Unit tests for the version-aware Documenso placement abstraction.
* Covers v1/v2 dispatch, percent→pixel coord conversion for v1, and the pure
* default-signature layout math for 1/2/3/5 recipients.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/services/port-config', () => ({
getPortDocumensoConfig: vi.fn(),
}));
import * as portConfig from '@/lib/services/port-config';
import {
__resetDocumensoCachesForTests,
computeDefaultSignatureLayout,
placeFields,
placeDefaultSignatureFields,
voidDocument,
} from '@/lib/services/documenso-client';
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
__resetDocumensoCachesForTests();
});
afterEach(() => {
fetchMock.mockReset();
vi.unstubAllGlobals();
vi.mocked(portConfig.getPortDocumensoConfig).mockReset();
});
function configurePort(version: 'v1' | 'v2'): void {
vi.mocked(portConfig.getPortDocumensoConfig).mockResolvedValue({
apiUrl: 'https://documenso.test',
apiKey: 'sk_test',
apiVersion: version,
eoiTemplateId: null,
defaultPathway: 'documenso-template',
});
}
function okResponse(body: unknown = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('computeDefaultSignatureLayout', () => {
it('returns one centered field for a single recipient', () => {
const fields = computeDefaultSignatureLayout([{ id: 1, pageNumber: 3 }]);
expect(fields).toHaveLength(1);
expect(fields[0]).toMatchObject({
recipientId: 1,
type: 'SIGNATURE',
pageNumber: 3,
pageWidth: 20, // 80/1 capped at 20
pageHeight: 6,
pageY: 88,
});
expect(fields[0]!.pageX).toBeCloseTo(40, 5); // 50 - 20/2
});
it('staggers two recipients without overlap', () => {
const fields = computeDefaultSignatureLayout([
{ id: 1, pageNumber: 1 },
{ id: 2, pageNumber: 1 },
]);
expect(fields).toHaveLength(2);
expect(fields[1]!.pageX).toBeGreaterThan(fields[0]!.pageX + fields[0]!.pageWidth - 0.001);
});
it('keeps total row width <= 80% for 5 recipients', () => {
const fields = computeDefaultSignatureLayout(
[1, 2, 3, 4, 5].map((id) => ({ id, pageNumber: 1 })),
);
const totalWidth = fields[fields.length - 1]!.pageX + fields[0]!.pageWidth - fields[0]!.pageX;
expect(totalWidth).toBeLessThanOrEqual(80 + 0.001);
expect(fields.every((f) => f.pageX >= 0)).toBe(true);
expect(fields.every((f) => f.pageX + f.pageWidth <= 100)).toBe(true);
});
it('returns empty array for zero recipients', () => {
expect(computeDefaultSignatureLayout([])).toEqual([]);
});
});
describe('placeFields v2 dispatch', () => {
beforeEach(() => configurePort('v2'));
it('makes a single bulk POST to envelope/field/create-many', async () => {
fetchMock.mockResolvedValueOnce(okResponse());
await placeFields(
'env-123',
[
{
recipientId: 'rec-a',
type: 'SIGNATURE',
pageNumber: 1,
pageX: 25,
pageY: 88,
pageWidth: 20,
pageHeight: 6,
fieldMeta: { label: 'Sign here' },
},
],
'port-1',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://documenso.test/api/v2/envelope/field/create-many');
expect((init as RequestInit).method).toBe('POST');
const body = JSON.parse(String((init as RequestInit).body));
expect(body.envelopeId).toBe('env-123');
expect(body.fields[0]).toMatchObject({
recipientId: 'rec-a',
type: 'SIGNATURE',
positionX: 25,
positionY: 88,
width: 20,
height: 6,
fieldMeta: { label: 'Sign here' },
});
});
it('throws on non-2xx response', async () => {
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }));
await expect(
placeFields(
'env-123',
[
{
recipientId: 'rec-a',
type: 'SIGNATURE',
pageNumber: 1,
pageX: 0,
pageY: 0,
pageWidth: 10,
pageHeight: 10,
},
],
'port-1',
),
).rejects.toThrow(/v2 placeFields/);
});
});
describe('placeFields v1 dispatch', () => {
beforeEach(() => configurePort('v1'));
it('issues one POST per field with pixel coords on a default A4 page', async () => {
fetchMock.mockResolvedValue(okResponse());
await placeFields(
'doc-123',
[
{
recipientId: 42,
type: 'SIGNATURE',
pageNumber: 1,
pageX: 50, // 50% of 595 = 298 (rounded)
pageY: 88, // 88% of 842 = 741
pageWidth: 20, // 20% of 595 = 119
pageHeight: 6, // 6% of 842 = 51
},
{
recipientId: 43,
type: 'TEXT',
pageNumber: 2,
pageX: 10,
pageY: 10,
pageWidth: 30,
pageHeight: 5,
},
],
'port-1',
);
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstCall = fetchMock.mock.calls[0]!;
expect(firstCall[0]).toBe('https://documenso.test/api/v1/documents/doc-123/fields');
const firstBody = JSON.parse(String((firstCall[1] as RequestInit).body));
expect(firstBody).toMatchObject({
recipientId: 42,
type: 'SIGNATURE',
pageNumber: 1,
});
expect(firstBody.pageX).toBe(298);
expect(firstBody.pageY).toBe(741);
expect(firstBody.pageWidth).toBe(119);
expect(firstBody.pageHeight).toBe(51);
});
it('coerces string recipientId to number on v1', async () => {
fetchMock.mockResolvedValue(okResponse());
await placeFields(
'doc-1',
[
{
recipientId: '99',
type: 'SIGNATURE',
pageNumber: 1,
pageX: 0,
pageY: 0,
pageWidth: 1,
pageHeight: 1,
},
],
'port-1',
);
const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body));
expect(body.recipientId).toBe(99);
});
it('throws on non-2xx response', async () => {
fetchMock.mockResolvedValueOnce(new Response('nope', { status: 422 }));
await expect(
placeFields(
'doc-1',
[
{
recipientId: 1,
type: 'SIGNATURE',
pageNumber: 1,
pageX: 0,
pageY: 0,
pageWidth: 1,
pageHeight: 1,
},
],
'port-1',
),
).rejects.toThrow(/v1 placeField/);
});
});
describe('placeDefaultSignatureFields integration', () => {
it('places staggered defaults on v2 envelope', async () => {
configurePort('v2');
fetchMock.mockResolvedValueOnce(okResponse());
await placeDefaultSignatureFields(
'env-x',
[
{ id: 'r1', pageNumber: 4 },
{ id: 'r2', pageNumber: 4 },
{ id: 'r3', pageNumber: 4 },
],
'port-1',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body));
expect(body.fields).toHaveLength(3);
expect(body.fields.every((f: { type: string }) => f.type === 'SIGNATURE')).toBe(true);
expect(body.fields.every((f: { pageNumber: number }) => f.pageNumber === 4)).toBe(true);
});
it('skips the API call entirely with zero recipients', async () => {
configurePort('v1');
await placeDefaultSignatureFields('doc-y', [], 'port-1');
expect(fetchMock).not.toHaveBeenCalled();
});
it('issues N per-field POSTs with pixel-converted coords on v1', async () => {
configurePort('v1');
fetchMock.mockResolvedValue(okResponse());
await placeDefaultSignatureFields(
'doc-z',
[
{ id: 7, pageNumber: 1 },
{ id: 8, pageNumber: 1 },
],
'port-1',
);
expect(fetchMock).toHaveBeenCalledTimes(2);
for (const call of fetchMock.mock.calls) {
expect(call[0]).toBe('https://documenso.test/api/v1/documents/doc-z/fields');
const body = JSON.parse(String((call[1] as RequestInit).body));
expect(body.type).toBe('SIGNATURE');
expect(body.pageNumber).toBe(1);
// 88% of 842 = 741 (footer band)
expect(body.pageY).toBe(741);
// height = 6% of 842 = 51
expect(body.pageHeight).toBe(51);
// width = 20% of 595 = 119
expect(body.pageWidth).toBe(119);
}
});
});
describe('voidDocument', () => {
it('issues DELETE to /api/v1/documents/{id} on v1', async () => {
configurePort('v1');
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
await voidDocument('doc-1', 'port-1');
expect(fetchMock).toHaveBeenCalledWith(
'https://documenso.test/api/v1/documents/doc-1',
expect.objectContaining({ method: 'DELETE' }),
);
});
it('issues DELETE to /api/v2/envelope/{id} on v2', async () => {
configurePort('v2');
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
await voidDocument('env-1', 'port-1');
expect(fetchMock).toHaveBeenCalledWith(
'https://documenso.test/api/v2/envelope/env-1',
expect.objectContaining({ method: 'DELETE' }),
);
});
it('treats 404 as idempotent success', async () => {
configurePort('v1');
fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 }));
await expect(voidDocument('doc-1', 'port-1')).resolves.toBeUndefined();
});
it('throws on other non-2xx responses', async () => {
configurePort('v2');
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }));
await expect(voidDocument('env-1', 'port-1')).rejects.toThrow(/voidDocument/);
});
});