diff --git a/src/lib/env.ts b/src/lib/env.ts index b51639b..aaf3b0c 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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), diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index 2f8ff0a..fccd2f7 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -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 { - 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; +} + +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}`); + } +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 5abf139..513f1a7 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -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) diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index a4f774b..5da645f 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -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 { - const [apiUrl, apiKey, eoiTemplateId, defaultPathway] = await Promise.all([ + const [apiUrl, apiKey, apiVersion, eoiTemplateId, defaultPathway] = await Promise.all([ readSetting(SETTING_KEYS.documensoApiUrlOverride, portId), readSetting(SETTING_KEYS.documensoApiKeyOverride, portId), + readSetting(SETTING_KEYS.documensoApiVersionOverride, portId), readSetting(SETTING_KEYS.documensoEoiTemplateId, portId), readSetting(SETTING_KEYS.eoiDefaultPathway, portId), ]); @@ -138,6 +142,7 @@ export async function getPortDocumensoConfig(portId: string): Promise ({ + 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/); + }); +});