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 { db } from '@/lib/db';
|
||||||
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
||||||
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
|
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
|
||||||
|
import { extractSigningToken } from '@/lib/services/documenso-signers';
|
||||||
import {
|
import {
|
||||||
handleRecipientSigned,
|
handleRecipientSigned,
|
||||||
handleDocumentCompleted,
|
handleDocumentCompleted,
|
||||||
@@ -69,11 +70,33 @@ function isKnownEvent(event: string): event is KnownDocumensoEvent {
|
|||||||
return KNOWN_DOCUMENSO_EVENTS.has(event as 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 = {
|
type DocumensoRecipient = {
|
||||||
email: string;
|
email: string;
|
||||||
signingStatus?: string;
|
signingStatus?: string;
|
||||||
readStatus?: string;
|
readStatus?: string;
|
||||||
signedAt?: string | null;
|
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 = {
|
type DocumensoWebhookBody = {
|
||||||
@@ -214,6 +237,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
|
|||||||
await handleRecipientSigned({
|
await handleRecipientSigned({
|
||||||
documentId: documensoId,
|
documentId: documensoId,
|
||||||
recipientEmail: r.email,
|
recipientEmail: r.email,
|
||||||
|
recipientToken: resolveRecipientToken(r),
|
||||||
signatureHash: `${signatureHash}:signed:${r.email}`,
|
signatureHash: `${signatureHash}:signed:${r.email}`,
|
||||||
...portScope,
|
...portScope,
|
||||||
});
|
});
|
||||||
@@ -239,6 +263,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
|
|||||||
await handleDocumentOpened({
|
await handleDocumentOpened({
|
||||||
documentId: documensoId,
|
documentId: documensoId,
|
||||||
recipientEmail: r.email,
|
recipientEmail: r.email,
|
||||||
|
recipientToken: resolveRecipientToken(r),
|
||||||
signatureHash: `${signatureHash}:opened:${r.email}`,
|
signatureHash: `${signatureHash}:opened:${r.email}`,
|
||||||
...portScope,
|
...portScope,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
|||||||
const r = (raw ?? {}) as Record<string, unknown>;
|
const r = (raw ?? {}) as Record<string, unknown>;
|
||||||
const id = String(r.documentId ?? r.id ?? '');
|
const id = String(r.documentId ?? r.id ?? '');
|
||||||
const status = String(r.status ?? 'PENDING');
|
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) => ({
|
const recipients = recipientsRaw.map((rec) => ({
|
||||||
id: String(rec.recipientId ?? rec.id ?? ''),
|
id: String(rec.recipientId ?? rec.id ?? ''),
|
||||||
name: String(rec.name ?? ''),
|
name: String(rec.name ?? ''),
|
||||||
@@ -130,6 +136,11 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
|||||||
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
|
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
|
||||||
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
|
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
|
||||||
embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : 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 };
|
return { id, status, recipients };
|
||||||
}
|
}
|
||||||
@@ -153,6 +164,11 @@ export interface DocumensoDocument {
|
|||||||
status: string;
|
status: string;
|
||||||
signingUrl?: string;
|
signingUrl?: string;
|
||||||
embeddedUrl?: 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;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
src/lib/services/documenso-signers.ts
Normal file
61
src/lib/services/documenso-signers.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Small helpers shared between the document-signing flows and the
|
||||||
|
* Documenso webhook handlers. Kept in their own file so the two
|
||||||
|
* heavyweight callers (`documents.service.ts` + the webhook handler
|
||||||
|
* routes) don't have to depend on each other for a 30-line utility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocumentLabel } from '@/lib/services/document-signing-emails.service';
|
||||||
|
import type { DocumentSigner } from '@/lib/db/schema/documents';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the per-recipient signing token out of a Documenso signing URL.
|
||||||
|
* Both v1.13 and v2 emit URLs of the form
|
||||||
|
* `https://signatures.example.com/sign/<token>`
|
||||||
|
* so the token is the last non-empty path segment. Returns null when
|
||||||
|
* the URL is empty / unparseable / doesn't carry a token-shaped tail.
|
||||||
|
*
|
||||||
|
* Used at document-send time to populate `document_signers.signing_token`
|
||||||
|
* so subsequent webhook deliveries can match recipients by token
|
||||||
|
* (more robust than email match when one address serves multiple roles).
|
||||||
|
*/
|
||||||
|
export function extractSigningToken(url: string | null | undefined): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segments = parsed.pathname.split('/').filter(Boolean);
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (!last) return null;
|
||||||
|
// A token must be at least 8 chars and contain only URL-safe chars —
|
||||||
|
// discriminates real tokens from generic words like "sign" or "embed"
|
||||||
|
// that some Documenso 2.x deployments append.
|
||||||
|
if (last.length < 8) return null;
|
||||||
|
if (!/^[A-Za-z0-9_-]+$/.test(last)) return null;
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of internal documentType to the customer-facing label used in
|
||||||
|
* email subjects + body copy. Duplicated in send-invitation/route.ts
|
||||||
|
* but consolidated here so the webhook cascade picks up the same
|
||||||
|
* label without re-defining the map. */
|
||||||
|
export const DOC_TYPE_LABEL: Record<string, DocumentLabel> = {
|
||||||
|
eoi: 'Expression of Interest',
|
||||||
|
contract: 'Sales Contract',
|
||||||
|
reservation_agreement: 'Reservation Agreement',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pick the next pending signer in signing order. Returns null when
|
||||||
|
* every signer in the list has signed (or declined). */
|
||||||
|
export function nextPendingSigner<T extends Pick<DocumentSigner, 'status' | 'signingOrder'>>(
|
||||||
|
signers: T[],
|
||||||
|
): T | null {
|
||||||
|
// signers list is assumed pre-sorted asc by signingOrder; we
|
||||||
|
// defensively pick the lowest-order pending row regardless.
|
||||||
|
const pending = signers.filter((s) => s.status === 'pending');
|
||||||
|
if (pending.length === 0) return null;
|
||||||
|
return pending.reduce((lo, s) => (s.signingOrder < lo.signingOrder ? s : lo));
|
||||||
|
}
|
||||||
@@ -35,6 +35,16 @@ import {
|
|||||||
} from '@/lib/services/documenso-client';
|
} from '@/lib/services/documenso-client';
|
||||||
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
||||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
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 {
|
import {
|
||||||
listTree,
|
listTree,
|
||||||
collectDescendantIds,
|
collectDescendantIds,
|
||||||
@@ -734,7 +744,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
|||||||
|
|
||||||
await documensoSend(documensoDoc.id, portId);
|
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) {
|
for (const docSigner of documensoDoc.recipients) {
|
||||||
const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email);
|
const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email);
|
||||||
if (localSigner) {
|
if (localSigner) {
|
||||||
@@ -743,6 +758,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
|||||||
.set({
|
.set({
|
||||||
signingUrl: docSigner.signingUrl ?? null,
|
signingUrl: docSigner.signingUrl ?? null,
|
||||||
embeddedUrl: docSigner.embeddedUrl ?? null,
|
embeddedUrl: docSigner.embeddedUrl ?? null,
|
||||||
|
signingToken: docSigner.token ?? extractSigningToken(docSigner.signingUrl),
|
||||||
})
|
})
|
||||||
.where(eq(documentSigners.id, localSigner.id));
|
.where(eq(documentSigners.id, localSigner.id));
|
||||||
}
|
}
|
||||||
@@ -998,36 +1014,49 @@ async function resolveWebhookDocument(
|
|||||||
export async function handleRecipientSigned(eventData: {
|
export async function handleRecipientSigned(eventData: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
recipientEmail: 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;
|
signatureHash?: string;
|
||||||
portId?: string;
|
portId?: string;
|
||||||
}) {
|
}) {
|
||||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||||
if (!doc) return;
|
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
|
const [signer] = await db
|
||||||
.update(documentSigners)
|
.update(documentSigners)
|
||||||
.set({ status: 'signed', signedAt: new Date() })
|
.set({ status: 'signed', signedAt: new Date() })
|
||||||
.where(
|
.where(signerWhere)
|
||||||
and(
|
|
||||||
eq(documentSigners.documentId, doc.id),
|
|
||||||
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!signer) {
|
if (!signer) {
|
||||||
// Email mismatch: the address Documenso has on the recipient doesn't match
|
// Mismatch: neither token nor email matched. This happens when the
|
||||||
// any row in documentSigners. This happens when the local signers were
|
// local signers were created with fabricated / stale addresses or
|
||||||
// created with fabricated / stale addresses. Log a warning so operators can
|
// the document was created out-of-band. Log a warning so operators
|
||||||
// investigate and fix the port's eoi_signers system setting.
|
// can investigate and fix the port's eoi_signers system setting.
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
documensoId: eventData.documentId,
|
documensoId: eventData.documentId,
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
recipientEmail: eventData.recipientEmail,
|
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',
|
'check eoi_signers system setting for this port',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1081,76 @@ export async function handleRecipientSigned(eventData: {
|
|||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
signerEmail: eventData.recipientEmail,
|
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 ────────────────────────────────────────────────────
|
// ─── Owner-wins resolution ────────────────────────────────────────────────────
|
||||||
@@ -1390,6 +1489,63 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
|||||||
|
|
||||||
emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id });
|
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
|
// Notify the document creator about completion
|
||||||
if (doc.createdBy && doc.createdBy !== 'system') {
|
if (doc.createdBy && doc.createdBy !== 'system') {
|
||||||
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
|
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: {
|
export async function handleDocumentOpened(eventData: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
recipientEmail: 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;
|
signatureHash?: string;
|
||||||
portId?: string;
|
portId?: string;
|
||||||
}) {
|
}) {
|
||||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
||||||
const [signer] = await db
|
const signerWhere = eventData.recipientToken
|
||||||
.select()
|
? and(
|
||||||
.from(documentSigners)
|
eq(documentSigners.documentId, doc.id),
|
||||||
.where(
|
eq(documentSigners.signingToken, eventData.recipientToken),
|
||||||
and(
|
)
|
||||||
|
: and(
|
||||||
eq(documentSigners.documentId, doc.id),
|
eq(documentSigners.documentId, doc.id),
|
||||||
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
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({
|
await db.insert(documentEvents).values({
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
|
|||||||
95
tests/unit/services/documenso-signers.test.ts
Normal file
95
tests/unit/services/documenso-signers.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DOC_TYPE_LABEL,
|
||||||
|
extractSigningToken,
|
||||||
|
nextPendingSigner,
|
||||||
|
} from '@/lib/services/documenso-signers';
|
||||||
|
|
||||||
|
describe('extractSigningToken', () => {
|
||||||
|
it('pulls the last path segment when it looks token-shaped', () => {
|
||||||
|
expect(extractSigningToken('https://sig.example.com/sign/vbT8hi3jKQmrFP_LN1WcS')).toBe(
|
||||||
|
'vbT8hi3jKQmrFP_LN1WcS',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles trailing slash gracefully', () => {
|
||||||
|
expect(extractSigningToken('https://sig.example.com/sign/HkrptwS42ZBXdRKj1TyUo/')).toBe(
|
||||||
|
'HkrptwS42ZBXdRKj1TyUo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-URL input', () => {
|
||||||
|
expect(extractSigningToken('not a url at all')).toBeNull();
|
||||||
|
expect(extractSigningToken('')).toBeNull();
|
||||||
|
expect(extractSigningToken(null)).toBeNull();
|
||||||
|
expect(extractSigningToken(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects too-short tails (defends against generic "sign" / "embed" terminators)', () => {
|
||||||
|
expect(extractSigningToken('https://example.com/sign')).toBeNull();
|
||||||
|
expect(extractSigningToken('https://example.com/abc')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path tails with disallowed characters', () => {
|
||||||
|
// Real tokens are URL-safe base64 — no spaces, no punctuation
|
||||||
|
expect(extractSigningToken('https://example.com/sign/has%20space')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles real v1.32 + v2 token shapes', () => {
|
||||||
|
// Documenso 1.32 token (21-char URL-safe alphabet from real webhook docs)
|
||||||
|
expect(extractSigningToken('https://sig.documenso.com/sign/vbT8hi3jKQmrFP_LN1WcS')).toBe(
|
||||||
|
'vbT8hi3jKQmrFP_LN1WcS',
|
||||||
|
);
|
||||||
|
// Mixed-case + underscores + dashes
|
||||||
|
expect(extractSigningToken('https://app.example.com/sign/Aa_-Zz09Aa_-Zz09')).toBe(
|
||||||
|
'Aa_-Zz09Aa_-Zz09',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DOC_TYPE_LABEL', () => {
|
||||||
|
it('maps the three known document types to customer-facing labels', () => {
|
||||||
|
expect(DOC_TYPE_LABEL.eoi).toBe('Expression of Interest');
|
||||||
|
expect(DOC_TYPE_LABEL.contract).toBe('Sales Contract');
|
||||||
|
expect(DOC_TYPE_LABEL.reservation_agreement).toBe('Reservation Agreement');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextPendingSigner', () => {
|
||||||
|
it('picks the lowest signingOrder pending signer', () => {
|
||||||
|
const signers = [
|
||||||
|
{ status: 'signed', signingOrder: 1 },
|
||||||
|
{ status: 'pending', signingOrder: 2 },
|
||||||
|
{ status: 'pending', signingOrder: 3 },
|
||||||
|
];
|
||||||
|
expect(nextPendingSigner(signers)).toEqual({ status: 'pending', signingOrder: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when every signer has signed', () => {
|
||||||
|
expect(
|
||||||
|
nextPendingSigner([
|
||||||
|
{ status: 'signed', signingOrder: 1 },
|
||||||
|
{ status: 'signed', signingOrder: 2 },
|
||||||
|
]),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats declined the same as no longer pending', () => {
|
||||||
|
expect(
|
||||||
|
nextPendingSigner([
|
||||||
|
{ status: 'declined', signingOrder: 1 },
|
||||||
|
{ status: 'signed', signingOrder: 2 },
|
||||||
|
]),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unordered input by signingOrder', () => {
|
||||||
|
const signers = [
|
||||||
|
{ status: 'pending', signingOrder: 5 },
|
||||||
|
{ status: 'signed', signingOrder: 1 },
|
||||||
|
{ status: 'pending', signingOrder: 2 },
|
||||||
|
];
|
||||||
|
expect(nextPendingSigner(signers)).toEqual({ status: 'pending', signingOrder: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user