From e3e0e69c04f10154e3ba07b6dbf5c018245f1b5a Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sat, 2 May 2026 23:00:58 +0200 Subject: [PATCH] fix(documenso): expired event, real signer emails, query invalidation, double-fire - Wire the `DOCUMENT_EXPIRED` webhook event to `handleDocumentExpired`. Previously the handler existed but was never called, leaving expired EOIs stuck in `sent` / `partially_signed` forever. - `sendForSigning` now resolves real port-configured signer emails via `getPortEoiSigners(portId)` instead of fabricating `developer@{slug}.com` / `sales@{slug}.com`. The Documenso-template pathway was already using these; the upload-PDF pathway now matches. - `handleRecipientSigned` logs a warning when the email match returns zero rows so a misconfigured signer isn't a silent no-op. - `handleDocumentCompleted` skips berth-rule re-evaluation when the interest is already at or past `eoi_signed`, preventing a double-fire when `DOCUMENT_SIGNED` and `DOCUMENT_COMPLETED` arrive close together. - EOI generate dialog now invalidates by predicate (any queryKey starting with `'documents'`) so the Documents tab and hub counts refresh after generation, instead of missing because the actual query key shape didn't match the targeted invalidation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/webhooks/documenso/route.ts | 5 ++ .../documents/eoi-generate-dialog.tsx | 8 ++- src/lib/services/documents.service.ts | 57 ++++++++++++++++--- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index 6d24c68..57f26ef 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook'; import { handleRecipientSigned, handleDocumentCompleted, + handleDocumentExpired, handleDocumentOpened, handleDocumentRejected, handleDocumentCancelled, @@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise { await handleDocumentCancelled({ documentId: documensoId, signatureHash }); break; + case 'DOCUMENT_EXPIRED': + await handleDocumentExpired({ documentId: documensoId }); + break; + default: logger.info({ event }, 'Unhandled Documenso webhook event type'); } diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index e658ed2..1ebd889 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -109,7 +109,13 @@ export function EoiGenerateDialog({ }, }); - queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] }); + // Invalidate all document list queries (hub counts + per-interest lists). + // The DocumentList component uses ['documents', { interestId, clientId }] + // and the hub uses ['documents', 'hub', ...] / ['documents', 'hub-counts']. + // Using a predicate avoids key-shape drift between callers. + queryClient.invalidateQueries({ + predicate: (q) => q.queryKey[0] === 'documents', + }); onOpenChange(false); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate EOI'); diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 8d6ab7a..90225e6 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -24,6 +24,7 @@ import { minioClient, buildStoragePath } from '@/lib/minio'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { evaluateRule } from '@/lib/services/berth-rules-engine'; +import { PIPELINE_STAGES } from '@/lib/constants'; import { advanceStageIfBehind } from '@/lib/services/interests.service'; import { createDocument as documensoCreate, @@ -31,6 +32,7 @@ import { downloadSignedPdf, voidDocument as documensoVoid, } from '@/lib/services/documenso-client'; +import { getPortEoiSigners } from '@/lib/services/documenso-payload'; import type { CreateDocumentInput, UpdateDocumentInput, @@ -506,6 +508,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); + // Resolve port-configured signer emails from system settings; fall back to + // legacy defaults only if the setting is absent. Fabricated slug-based + // addresses (developer@{slug}.com) are no longer used here because they + // never match real port users and cause silent no-ops in handleRecipientSigned. + const eoiSigners = await getPortEoiSigners(portId); + // BR-021: Create 3 signers — client (1), developer (2), sales/approver (3) const signerRecords = await db .insert(documentSigners) @@ -520,16 +528,16 @@ export async function sendForSigning(documentId: string, portId: string, meta: A }, { documentId, - signerName: port.name, - signerEmail: `developer@${port.slug}.com`, + signerName: eoiSigners.developer.name, + signerEmail: eoiSigners.developer.email, signerRole: 'developer', signingOrder: 2, status: 'pending', }, { documentId, - signerName: `${port.name} Sales`, - signerEmail: `sales@${port.slug}.com`, + signerName: eoiSigners.approver.name, + signerEmail: eoiSigners.approver.email, signerRole: 'approver', signingOrder: 3, status: 'pending', @@ -552,10 +560,15 @@ export async function sendForSigning(documentId: string, portId: string, meta: A // Create document in Documenso + send const documensoDoc = await documensoCreate(doc.title, pdfBase64, [ { name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 }, - { name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 }, { - name: `${port.name} Sales`, - email: `sales@${port.slug}.com`, + name: eoiSigners.developer.name, + email: eoiSigners.developer.email, + role: 'SIGNER', + signingOrder: 2, + }, + { + name: eoiSigners.approver.name, + email: eoiSigners.approver.email, role: 'SIGNER', signingOrder: 3, }, @@ -788,6 +801,22 @@ export async function handleRecipientSigned(eventData: { ) .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. + logger.warn( + { + documensoId: eventData.documentId, + documentId: doc.id, + recipientEmail: eventData.recipientEmail, + }, + 'handleRecipientSigned: no matching signer row for recipient email — ' + + 'check eoi_signers system setting for this port', + ); + } + // Update document to partially_signed if eoi type if (doc.documentType === 'eoi' && doc.status === 'sent') { await db @@ -896,7 +925,19 @@ export async function handleDocumentCompleted(eventData: { documentId: string }) ipAddress: '0.0.0.0', userAgent: 'webhook', }; - void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta); + + // Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple times + // (webhook retries) or follow a DOCUMENT_SIGNED that already advanced the + // stage. advanceStageIfBehind handles the pipeline guard internally, but + // evaluateRule has no idempotency — skip it if the interest is already at + // eoi_signed or beyond to prevent duplicate berth-rule side effects. + const currentStageIdx = PIPELINE_STAGES.indexOf( + interest.pipelineStage as (typeof PIPELINE_STAGES)[number], + ); + const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed'); + if (currentStageIdx < eoiSignedIdx) { + void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta); + } // Advance to eoi_signed (no-op if interest already past it). void advanceStageIfBehind(