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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user