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

@@ -35,6 +35,16 @@ import {
} from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import {
DOC_TYPE_LABEL,
extractSigningToken,
nextPendingSigner,
} from '@/lib/services/documenso-signers';
import {
sendSigningInvitation,
sendSigningCompleted,
type SignerRole,
} from '@/lib/services/document-signing-emails.service';
import {
listTree,
collectDescendantIds,
@@ -734,7 +744,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
await documensoSend(documensoDoc.id, portId);
// Update signer records with signing URLs from Documenso response
// Update signer records with signing URLs + tokens from the Documenso
// response. The signingToken column powers the webhook recipient-match
// path (more robust than email match — same email can serve multiple
// roles on a contract). Documenso's recipient response carries `token`
// explicitly per the OpenAPI spec; we keep the URL-extraction fallback
// for any v2 deployment whose distribute response omits the field.
for (const docSigner of documensoDoc.recipients) {
const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email);
if (localSigner) {
@@ -743,6 +758,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
.set({
signingUrl: docSigner.signingUrl ?? null,
embeddedUrl: docSigner.embeddedUrl ?? null,
signingToken: docSigner.token ?? extractSigningToken(docSigner.signingUrl),
})
.where(eq(documentSigners.id, localSigner.id));
}
@@ -998,36 +1014,49 @@ async function resolveWebhookDocument(
export async function handleRecipientSigned(eventData: {
documentId: string;
recipientEmail: string;
/** Optional Documenso recipient token — when supplied (webhook
* payload exposes it on v1.13 + 2.x), preferred over the email
* match because a single email can serve multiple roles on one
* document. Falls back to email match when null. */
recipientToken?: string | null;
signatureHash?: string;
portId?: string;
}) {
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
if (!doc) return;
// Update signer status
// Token-match first, fall back to email match. Phase 2: webhook
// payloads carry `recipients[].token` which we captured at send-time
// via extractSigningToken — that's the authoritative identifier.
const signerWhere = eventData.recipientToken
? and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signingToken, eventData.recipientToken),
)
: and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signerEmail, eventData.recipientEmail),
);
const [signer] = await db
.update(documentSigners)
.set({ status: 'signed', signedAt: new Date() })
.where(
and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signerEmail, eventData.recipientEmail),
),
)
.where(signerWhere)
.returning();
if (!signer) {
// Email mismatch: the address Documenso has on the recipient doesn't match
// any row in documentSigners. This happens when the local signers were
// created with fabricated / stale addresses. Log a warning so operators can
// investigate and fix the port's eoi_signers system setting.
// Mismatch: neither token nor email matched. This happens when the
// local signers were created with fabricated / stale addresses or
// the document was created out-of-band. Log a warning so operators
// can investigate and fix the port's eoi_signers system setting.
logger.warn(
{
documensoId: eventData.documentId,
documentId: doc.id,
recipientEmail: eventData.recipientEmail,
hadToken: Boolean(eventData.recipientToken),
},
'handleRecipientSigned: no matching signer row for recipient email - ' +
'handleRecipientSigned: no matching signer row for recipient - ' +
'check eoi_signers system setting for this port',
);
}
@@ -1052,6 +1081,76 @@ export async function handleRecipientSigned(eventData: {
documentId: doc.id,
signerEmail: eventData.recipientEmail,
});
// Phase 2 cascade: now that this signer is done, fire the branded
// "your turn" invitation to the next pending signer in signing order.
// The webhook may fire multiple times per document (one per recipient
// sign event); the `invitedAt` guard prevents duplicate invites.
if (signer) {
await sendCascadingInviteForNextSigner(doc).catch((err) => {
// Cascading-invite failure is non-fatal — the webhook itself
// succeeded. The rep can manually click "Send invitation" if the
// email worker is down.
logger.error(
{ err, documentId: doc.id, justSignedSigner: signer.id },
'cascading "your turn" invite failed after recipient signed',
);
});
}
}
/**
* Phase 2 — cascading invite logic extracted so the
* `handleRecipientSigned` handler stays readable and so the same path
* can be exercised by a dedicated unit test. Finds the next pending
* signer (lowest signing order), sends them a branded invitation, and
* stamps `invitedAt` so a duplicate webhook delivery doesn't re-send.
*/
async function sendCascadingInviteForNextSigner(doc: {
id: string;
portId: string;
documentType: string;
title: string;
}): Promise<void> {
const signers = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.documentId, doc.id))
.orderBy(documentSigners.signingOrder);
const next = nextPendingSigner(signers);
if (!next) return;
if (next.invitedAt) {
// We've already invited them — either via the auto-send wiring at
// document creation (first signer) or via an earlier cascade. Do
// nothing rather than spam them with a second copy.
return;
}
if (!next.signingUrl) {
logger.warn(
{ documentId: doc.id, signerId: next.id },
'cascading invite skipped: signer has no signing URL',
);
return;
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, doc.portId) });
const docCfg = await getPortDocumensoConfig(doc.portId);
await sendSigningInvitation({
portId: doc.portId,
portName: port?.name ?? 'Port Nimara',
recipient: { name: next.signerName, email: next.signerEmail },
documensoSigningUrl: next.signingUrl,
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
signerRole: (next.signerRole as SignerRole) ?? 'other',
senderName: docCfg.developerName ?? null,
});
await db
.update(documentSigners)
.set({ invitedAt: new Date() })
.where(eq(documentSigners.id, next.id));
}
// ─── Owner-wins resolution ────────────────────────────────────────────────────
@@ -1390,6 +1489,63 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id });
// Phase 2: distribute the fully-signed PDF to every recipient via a
// branded "all signed" email. Re-read the document so we see the
// signedFileId the transaction above just committed.
const completedDoc = await db.query.documents.findFirst({
where: eq(documents.id, doc.id),
columns: { signedFileId: true },
});
if (completedDoc?.signedFileId) {
const signers = await db
.select({
name: documentSigners.signerName,
email: documentSigners.signerEmail,
})
.from(documentSigners)
.where(eq(documentSigners.documentId, doc.id));
if (signers.length > 0) {
const portRow = await db.query.ports.findFirst({
where: eq(ports.id, doc.portId),
columns: { name: true },
});
// Resolve the deal's primary client name for the salutation —
// falls back to the document title when the owner chain doesn't
// surface a client.
let clientName = doc.title;
const owner = await resolveDocumentOwner(doc.portId, doc);
if (owner?.entityType === 'client') {
const client = await db.query.clients.findFirst({
where: eq(clients.id, owner.entityId),
columns: { fullName: true },
});
if (client?.fullName) clientName = client.fullName;
}
await sendSigningCompleted({
portId: doc.portId,
portName: portRow?.name ?? 'Port Nimara',
recipients: signers,
clientName,
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
completedAt: new Date(),
signedPdfFileId: completedDoc.signedFileId,
signedPdfFilename: `signed-${doc.id}.pdf`,
}).catch((err) => {
// Don't let a downstream email failure undo the completion —
// the signed PDF is already stored and the document row is
// marked completed. Log + emit so admins can re-trigger via
// the manual "Send copy" flow.
logger.error(
{ err, documentId: doc.id },
'sendSigningCompleted fan-out failed after document completed',
);
});
}
}
// Notify the document creator about completion
if (doc.createdBy && doc.createdBy !== 'system') {
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
@@ -1436,21 +1592,36 @@ export async function handleDocumentExpired(eventData: { documentId: string; por
export async function handleDocumentOpened(eventData: {
documentId: string;
recipientEmail: string;
/** Optional Documenso recipient token — preferred over email match
* (same email may serve multiple roles on one document). */
recipientToken?: string | null;
signatureHash?: string;
portId?: string;
}) {
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
if (!doc) return;
const [signer] = await db
.select()
.from(documentSigners)
.where(
and(
const signerWhere = eventData.recipientToken
? and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signingToken, eventData.recipientToken),
)
: and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signerEmail, eventData.recipientEmail),
),
);
);
const [signer] = await db.select().from(documentSigners).where(signerWhere);
// Stamp openedAt the first time we see a viewed event for this signer.
// Re-deliveries (v2 can fire RECIPIENT_VIEWED multiple times per visit)
// hit the idempotent UPDATE without overwriting the original timestamp.
if (signer && !signer.openedAt) {
await db
.update(documentSigners)
.set({ openedAt: new Date() })
.where(eq(documentSigners.id, signer.id));
}
await db.insert(documentEvents).values({
documentId: doc.id,