diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index aa9f3c4c..d7c80c3a 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -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 { 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 { await handleDocumentOpened({ documentId: documensoId, recipientEmail: r.email, + recipientToken: resolveRecipientToken(r), signatureHash: `${signatureHash}:opened:${r.email}`, ...portScope, }); diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index faf3a9c3..3e2fe7ac 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -120,7 +120,13 @@ function normalizeDocument(raw: unknown): DocumensoDocument { const r = (raw ?? {}) as Record; const id = String(r.documentId ?? r.id ?? ''); const status = String(r.status ?? 'PENDING'); - const recipientsRaw = (r.recipients as Array> | 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> | undefined) ?? + (r.Recipient as Array> | 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/`) 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; }>; } diff --git a/src/lib/services/documenso-signers.ts b/src/lib/services/documenso-signers.ts new file mode 100644 index 00000000..878be513 --- /dev/null +++ b/src/lib/services/documenso-signers.ts @@ -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/` + * 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 = { + 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>( + 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)); +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 08b6d058..9b02c6e5 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -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 { + 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, diff --git a/tests/unit/services/documenso-signers.test.ts b/tests/unit/services/documenso-signers.test.ts new file mode 100644 index 00000000..9733f214 --- /dev/null +++ b/tests/unit/services/documenso-signers.test.ts @@ -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 }); + }); +});