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

@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { db } from '@/lib/db';
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
import { extractSigningToken } from '@/lib/services/documenso-signers';
import {
handleRecipientSigned,
handleDocumentCompleted,
@@ -69,11 +70,33 @@ function isKnownEvent(event: string): event is KnownDocumensoEvent {
return KNOWN_DOCUMENSO_EVENTS.has(event as KnownDocumensoEvent);
}
/**
* Pull the recipient's signing token out of a Documenso webhook
* payload. v1.13 emits `recipients[].token`; some 2.x payloads use
* `signingToken`; both versions always carry a `signingUrl` whose tail
* IS the token. Prefer the explicit fields, fall back to URL extraction
* so the cascade still works when Documenso reshapes its payload.
*/
function resolveRecipientToken(r: DocumensoRecipient): string | null {
if (r.token) return r.token;
if (r.signingToken) return r.signingToken;
if (r.signingUrl) return extractSigningToken(r.signingUrl);
return null;
}
type DocumensoRecipient = {
email: string;
signingStatus?: string;
readStatus?: string;
signedAt?: string | null;
/** Per-recipient signing token Documenso uses as the URL tail.
* Present on both v1.13 and v2 payloads under varied field names —
* we coalesce them below. Phase 2: passed through to the handlers
* so they can match against `document_signers.signing_token`
* instead of email. */
token?: string | null;
signingToken?: string | null;
signingUrl?: string | null;
};
type DocumensoWebhookBody = {
@@ -214,6 +237,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
await handleRecipientSigned({
documentId: documensoId,
recipientEmail: r.email,
recipientToken: resolveRecipientToken(r),
signatureHash: `${signatureHash}:signed:${r.email}`,
...portScope,
});
@@ -239,6 +263,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
await handleDocumentOpened({
documentId: documensoId,
recipientEmail: r.email,
recipientToken: resolveRecipientToken(r),
signatureHash: `${signatureHash}:opened:${r.email}`,
...portScope,
});