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