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