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:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -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 };