chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -13,7 +13,7 @@ interface DocumensoCreds {
|
||||
}
|
||||
|
||||
interface ResolvedCreds extends DocumensoCreds {
|
||||
/** Provenance of the API key — surfaces in error messages so an
|
||||
/** Provenance of the API key - surfaces in error messages so an
|
||||
* operator can tell at a glance whether a 401 is the env fallback's
|
||||
* stale key vs. a per-port admin entry. */
|
||||
apiKeySource: 'port' | 'global' | 'env' | 'default' | 'none';
|
||||
@@ -21,7 +21,7 @@ interface ResolvedCreds extends DocumensoCreds {
|
||||
}
|
||||
|
||||
async function resolveCreds(portId?: string): Promise<ResolvedCreds> {
|
||||
// env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional — the
|
||||
// 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
|
||||
@@ -63,7 +63,7 @@ async function documensoFetchOnce(
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FetchTimeoutError) {
|
||||
// Retry timeouts — transient network issue.
|
||||
// Retry timeouts - transient network issue.
|
||||
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
||||
internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
|
||||
});
|
||||
@@ -75,7 +75,7 @@ async function documensoFetchOnce(
|
||||
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.
|
||||
// Auth failures are not retryable - wrong key won't fix itself.
|
||||
// Surface the resolver source in the error message so the operator
|
||||
// sees "key resolved from env fallback" vs "per-port override" and
|
||||
// knows whether to edit the deploy env or the port admin row.
|
||||
@@ -87,7 +87,7 @@ async function documensoFetchOnce(
|
||||
);
|
||||
}
|
||||
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
||||
// 4xx (other than 429) means we sent something Documenso rejected —
|
||||
// 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(
|
||||
@@ -108,7 +108,7 @@ async function documensoFetchOnce(
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
@@ -140,11 +140,11 @@ async function documensoFetch(
|
||||
|
||||
// Documenso 2.x has THREE potential ID fields on responses depending on the
|
||||
// endpoint:
|
||||
// - `envelopeId: string` — the public 'envelope_xxx' identifier. This is
|
||||
// - `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
|
||||
// - `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.
|
||||
//
|
||||
@@ -158,7 +158,7 @@ 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
|
||||
// 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
|
||||
@@ -172,7 +172,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
: 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
|
||||
// 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) ??
|
||||
@@ -187,7 +187,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
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,
|
||||
// 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.
|
||||
@@ -206,7 +206,7 @@ export interface DocumensoRecipient {
|
||||
export interface DocumensoDocument {
|
||||
id: string;
|
||||
/** Documenso v2 numeric internal pk. Populated alongside the
|
||||
* envelope_xxx `id` so callers can persist both — webhooks use this
|
||||
* 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;
|
||||
@@ -308,7 +308,7 @@ export async function createDocument(
|
||||
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
|
||||
// `{ id: envelopeId }` only - caller distributes separately via
|
||||
// sendDocument(envelopeId).
|
||||
const { baseUrl, apiKey } = await resolveCreds(portId);
|
||||
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
|
||||
@@ -372,7 +372,7 @@ export async function createDocument(
|
||||
}
|
||||
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
|
||||
// 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);
|
||||
@@ -418,7 +418,7 @@ export async function generateDocumentFromTemplate(
|
||||
|
||||
// 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-
|
||||
// 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.
|
||||
@@ -433,7 +433,7 @@ export async function generateDocumentFromTemplate(
|
||||
// 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
|
||||
// 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(
|
||||
@@ -448,7 +448,7 @@ export async function generateDocumentFromTemplate(
|
||||
|
||||
const desiredTitle =
|
||||
typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null;
|
||||
// `/template/use` silently drops the `meta` field on the request body —
|
||||
// `/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
|
||||
@@ -483,12 +483,12 @@ export async function generateDocumentFromTemplate(
|
||||
// anything else hints that the title field wasn't accepted.
|
||||
logger.info(
|
||||
{ docId: created.id, desiredTitle, updateMeta, updateResponse },
|
||||
'Documenso envelope update — response',
|
||||
'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
|
||||
// - 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 {
|
||||
@@ -505,7 +505,7 @@ export async function generateDocumentFromTemplate(
|
||||
titleMatches: verify?.title === desiredTitle,
|
||||
actualMeta: verify?.documentMeta ?? verify?.envelopeMeta ?? verify?.meta,
|
||||
},
|
||||
'Documenso envelope update — verification',
|
||||
'Documenso envelope update - verification',
|
||||
);
|
||||
} catch {
|
||||
// GET verify is best-effort; don't fail generate on it.
|
||||
@@ -513,7 +513,7 @@ export async function generateDocumentFromTemplate(
|
||||
} 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',
|
||||
'Documenso envelope update failed - created envelope keeps template default title/meta',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -525,7 +525,7 @@ export async function generateDocumentFromTemplate(
|
||||
//
|
||||
// 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
|
||||
// mints the URLs without emailing - our branded
|
||||
// `sendSigningInvitation` is the dispatcher.
|
||||
//
|
||||
// We replace `created` with the distribute response because that's
|
||||
@@ -533,12 +533,12 @@ export async function generateDocumentFromTemplate(
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
@@ -559,7 +559,7 @@ export async function generateDocumentFromTemplate(
|
||||
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
|
||||
// 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,
|
||||
@@ -570,7 +570,7 @@ export async function generateDocumentFromTemplate(
|
||||
} 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.',
|
||||
'Documenso envelope distribute failed - signingUrl will be null. Send-invitation will fail until the envelope is distributed.',
|
||||
);
|
||||
return created;
|
||||
}
|
||||
@@ -599,12 +599,12 @@ export async function generateDocumentFromTemplate(
|
||||
/**
|
||||
* 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
|
||||
* 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 —
|
||||
* 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.
|
||||
@@ -614,7 +614,7 @@ export async function distributeEnvelopeV2(
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
// Architectural rule (Matt 2026-05-15): ALL outbound emails go through
|
||||
// our branded `sendSigningInvitation` path — Documenso never fires its
|
||||
// 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
|
||||
@@ -653,10 +653,10 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
|
||||
|
||||
if (apiVersion === 'v2') {
|
||||
// v2: POST /api/v2/envelope/distribute with body { envelopeId }.
|
||||
// Returns the envelope with per-recipient signingUrl fields populated —
|
||||
// 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
|
||||
// Documenso's own emails for v2 envelopes - see distributeEnvelopeV2
|
||||
// for the full rationale. Branded sends are routed through
|
||||
// `sendSigningInvitation` separately.
|
||||
const distributed = (await documensoFetch(
|
||||
@@ -676,7 +676,7 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
|
||||
// callers already consume.
|
||||
return normalizeDocument({
|
||||
id: distributed.id,
|
||||
// v2 doesn't return `status` on the distribute response — the call
|
||||
// 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',
|
||||
@@ -696,7 +696,7 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
|
||||
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,
|
||||
// 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);
|
||||
@@ -716,7 +716,7 @@ export interface DocumensoTemplateField {
|
||||
id: number;
|
||||
type: string;
|
||||
/**
|
||||
* The human label assigned in the template editor — for v2 templates this
|
||||
* 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`.
|
||||
@@ -731,13 +731,13 @@ export interface DocumensoTemplate {
|
||||
fields: DocumensoTemplateField[];
|
||||
/**
|
||||
* v2 only. Each entry corresponds to one underlying PDF file on the
|
||||
* template — usually a single envelope item per template, but Documenso
|
||||
* 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
|
||||
* 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,
|
||||
@@ -804,7 +804,7 @@ function normalizeTemplate(raw: unknown): DocumensoTemplate {
|
||||
* 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
|
||||
* 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(
|
||||
@@ -836,7 +836,7 @@ export async function downloadEnvelopeItemPdf(
|
||||
* comfortably covers every observed Documenso instance).
|
||||
*
|
||||
* v1 path: `GET /api/v1/templates`. Same pagination via
|
||||
* `?page=N&perPage=100`. v1 returns the legacy shape — we normalize to
|
||||
* `?page=N&perPage=100`. v1 returns the legacy shape - we normalize to
|
||||
* the same `{ id, name }` summary the UI consumes.
|
||||
*/
|
||||
export async function listTemplates(
|
||||
@@ -891,7 +891,7 @@ export async function getTemplate(templateId: number, portId?: string): Promise<
|
||||
*
|
||||
* 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
|
||||
* 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(
|
||||
@@ -973,7 +973,7 @@ export async function sendReminder(
|
||||
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:
|
||||
// (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.
|
||||
@@ -986,7 +986,7 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
||||
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`,
|
||||
internalMessage: `v2 envelope ${docId} has no envelopeItems - cannot download signed PDF`,
|
||||
});
|
||||
}
|
||||
return downloadEnvelopeItemPdf(itemId, portId, 'signed');
|
||||
@@ -1025,7 +1025,7 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
||||
|
||||
/** Convenience health-check used by the admin "Test connection" button.
|
||||
*
|
||||
* v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` — the old v1
|
||||
* 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
|
||||
@@ -1040,7 +1040,7 @@ export async function checkDocumensoHealth(
|
||||
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,
|
||||
// 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) {
|
||||
@@ -1072,12 +1072,12 @@ export async function checkDocumensoHealth(
|
||||
* 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 }> }
|
||||
* - 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
|
||||
@@ -1098,7 +1098,7 @@ export type DocumensoFieldType =
|
||||
| 'RADIO';
|
||||
|
||||
/**
|
||||
* Typed metadata shapes per field type — surfaces what fieldMeta
|
||||
* 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
|
||||
@@ -1199,26 +1199,36 @@ export async function placeFields(
|
||||
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
||||
|
||||
if (apiVersion === 'v2') {
|
||||
// Documenso v2 field schema (confirmed against live trpc error
|
||||
// responses, 2026-05-22):
|
||||
// - `recipientId` must be a NUMBER (the v1 client returns it
|
||||
// stringified; we coerce here so callers can pass either form)
|
||||
// - the page index field is named `page`, NOT `pageNumber` (v1's
|
||||
// key) - wrong key surfaces as Zod "Required at data[i].page"
|
||||
// - `positionX/Y` + `width/height` carry percent values (0-100)
|
||||
const v2Fields = fields.map((f) => ({
|
||||
recipientId: String(f.recipientId),
|
||||
recipientId: Number(f.recipientId),
|
||||
type: f.type,
|
||||
pageNumber: f.pageNumber,
|
||||
page: 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.
|
||||
// Documenso v2 expects the field array under `data`, not `fields`
|
||||
// (the endpoint is a trpc-style createMany whose input schema wraps
|
||||
// the bulk payload in `{ envelopeId, data: [...] }`). The previous
|
||||
// shape returned 400: "Input validation failed - Required at data"
|
||||
// and surfaced as DOCUMENSO_UPSTREAM_ERROR on every custom-upload
|
||||
// send. Confirmed against the live Documenso 2.x error response.
|
||||
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 }),
|
||||
body: JSON.stringify({ envelopeId: docId, data: v2Fields }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
@@ -1265,7 +1275,7 @@ export async function placeFields(
|
||||
}
|
||||
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.
|
||||
// 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) {
|
||||
@@ -1364,7 +1374,7 @@ export async function voidDocument(docId: string, portId?: string): Promise<void
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an envelope's metadata while it's still in DRAFT or PENDING — title,
|
||||
* 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).
|
||||
*
|
||||
@@ -1391,11 +1401,11 @@ export async function updateEnvelope(
|
||||
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',
|
||||
'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
|
||||
// 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 };
|
||||
|
||||
Reference in New Issue
Block a user