feat(documenso-phase-2): webhook handler enhancement — cascade + completion fan-out

Closes the silence after the first signing invitation. Three real
improvements on top of the existing webhook plumbing, all aligned with
the Documenso v1.32 + v2 webhook payload shape (verified against the
official OpenAPI spec + Context7 docs):

1. Cascading "your turn" emails — when DOCUMENT_SIGNED / DOCUMENT_
   RECIPIENT_COMPLETED / RECIPIENT_SIGNED fires for a recipient,
   handleRecipientSigned now resolves the next pending signer in
   signing order and sends them the branded sendSigningInvitation()
   email with the embedded-host-wrapped URL. Stamps invitedAt so a
   duplicate webhook retry doesn't re-send.

2. On-completion PDF distribution — handleDocumentCompleted now re-
   reads the just-committed signedFileId, resolves all signers, and
   fires sendSigningCompleted() to every recipient with the signed
   PDF attached. resolveAttachments in lib/email already pulls bytes
   through getStorageBackend() so this works under both the s3/minio
   and filesystem backends without changes. Failures fall through to
   logger.error rather than throwing — the document is already marked
   completed and the admin can re-trigger manually.

3. Token-based recipient matching — Documenso v1 + v2 webhook recipients
   carry a `token` field (per the OpenAPI spec); same token appears in
   the document-create response. Captured at send time into the existing
   document_signers.signing_token column (already in schema from Phase 1)
   and used by handleRecipientSigned + handleDocumentOpened before
   falling back to email match. Robust against the case where one email
   serves multiple roles on a contract — which is the documented gap in
   the legacy nocodb-based handler.

Supporting changes:
- New helper module lib/services/documenso-signers.ts with
  extractSigningToken() (URL-tail fallback), DOC_TYPE_LABEL map, and
  nextPendingSigner() picker. 11 unit tests cover the token-regex,
  the helper picks the lowest pending signing-order, and rejects
  declined/signed correctly.
- documenso-client normalizeDocument now reads `token` from both
  `recipients[]` and the legacy capital-R `Recipient[]` array Documenso
  v1.32 sometimes ships in webhooks.
- documents.service signer-update at send time prefers the explicit
  token field, falling back to extractSigningToken(signingUrl) for any
  v2 deployment whose distribute response omits it.

Out of scope for Phase 2 (per the build plan):
- Custom-doc upload-to-Documenso path (Phase 3)
- Recipient + field-placement UI (Phase 4)
- DNS-rebinding hardening + circuit-breaker (deferred-refactor list)
- Auto-reminder cron — manual "Send reminder" button + auto-reminder
  toggle remain manual until Phase 6 polish

Tests: 1315/1315 vitest  + 11 new tests for documenso-signers ;
tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:47:33 +02:00
parent ebdd8408bf
commit 3dc4c6ff14
5 changed files with 389 additions and 21 deletions

View File

@@ -120,7 +120,13 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
const r = (raw ?? {}) as Record<string, unknown>;
const id = String(r.documentId ?? r.id ?? '');
const status = String(r.status ?? 'PENDING');
const recipientsRaw = (r.recipients as Array<Record<string, unknown>> | undefined) ?? [];
// v1.32+ payloads carry a `Recipient` (capital R) array as a legacy
// 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) ??
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
[];
const recipients = recipientsRaw.map((rec) => ({
id: String(rec.recipientId ?? rec.id ?? ''),
name: String(rec.name ?? ''),
@@ -130,6 +136,11 @@ 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,
// 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.
token: typeof rec.token === 'string' ? rec.token : undefined,
}));
return { id, status, recipients };
}
@@ -153,6 +164,11 @@ export interface DocumensoDocument {
status: string;
signingUrl?: string;
embeddedUrl?: string;
/** v1 + v2 recipient token. Used to populate
* `document_signers.signing_token` so the webhook handler can
* match recipients without leaning on email (which may be reused
* across roles). */
token?: string;
}>;
}