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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-02 23:00:58 +02:00
parent 6af2ac9680
commit e3e0e69c04
3 changed files with 61 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import { import {
handleRecipientSigned, handleRecipientSigned,
handleDocumentCompleted, handleDocumentCompleted,
handleDocumentExpired,
handleDocumentOpened, handleDocumentOpened,
handleDocumentRejected, handleDocumentRejected,
handleDocumentCancelled, handleDocumentCancelled,
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
await handleDocumentCancelled({ documentId: documensoId, signatureHash }); await handleDocumentCancelled({ documentId: documensoId, signatureHash });
break; break;
case 'DOCUMENT_EXPIRED':
await handleDocumentExpired({ documentId: documensoId });
break;
default: default:
logger.info({ event }, 'Unhandled Documenso webhook event type'); logger.info({ event }, 'Unhandled Documenso webhook event type');
} }

View File

@@ -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); onOpenChange(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI'); setError(err instanceof Error ? err.message : 'Failed to generate EOI');

View File

@@ -24,6 +24,7 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { evaluateRule } from '@/lib/services/berth-rules-engine'; import { evaluateRule } from '@/lib/services/berth-rules-engine';
import { PIPELINE_STAGES } from '@/lib/constants';
import { advanceStageIfBehind } from '@/lib/services/interests.service'; import { advanceStageIfBehind } from '@/lib/services/interests.service';
import { import {
createDocument as documensoCreate, createDocument as documensoCreate,
@@ -31,6 +32,7 @@ import {
downloadSignedPdf, downloadSignedPdf,
voidDocument as documensoVoid, voidDocument as documensoVoid,
} from '@/lib/services/documenso-client'; } from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import type { import type {
CreateDocumentInput, CreateDocumentInput,
UpdateDocumentInput, 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) }); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('Port'); 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) // BR-021: Create 3 signers — client (1), developer (2), sales/approver (3)
const signerRecords = await db const signerRecords = await db
.insert(documentSigners) .insert(documentSigners)
@@ -520,16 +528,16 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
}, },
{ {
documentId, documentId,
signerName: port.name, signerName: eoiSigners.developer.name,
signerEmail: `developer@${port.slug}.com`, signerEmail: eoiSigners.developer.email,
signerRole: 'developer', signerRole: 'developer',
signingOrder: 2, signingOrder: 2,
status: 'pending', status: 'pending',
}, },
{ {
documentId, documentId,
signerName: `${port.name} Sales`, signerName: eoiSigners.approver.name,
signerEmail: `sales@${port.slug}.com`, signerEmail: eoiSigners.approver.email,
signerRole: 'approver', signerRole: 'approver',
signingOrder: 3, signingOrder: 3,
status: 'pending', status: 'pending',
@@ -552,10 +560,15 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Create document in Documenso + send // Create document in Documenso + send
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [ const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 }, { 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`, name: eoiSigners.developer.name,
email: `sales@${port.slug}.com`, email: eoiSigners.developer.email,
role: 'SIGNER',
signingOrder: 2,
},
{
name: eoiSigners.approver.name,
email: eoiSigners.approver.email,
role: 'SIGNER', role: 'SIGNER',
signingOrder: 3, signingOrder: 3,
}, },
@@ -788,6 +801,22 @@ export async function handleRecipientSigned(eventData: {
) )
.returning(); .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 // Update document to partially_signed if eoi type
if (doc.documentType === 'eoi' && doc.status === 'sent') { if (doc.documentType === 'eoi' && doc.status === 'sent') {
await db await db
@@ -896,7 +925,19 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
ipAddress: '0.0.0.0', ipAddress: '0.0.0.0',
userAgent: 'webhook', 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). // Advance to eoi_signed (no-op if interest already past it).
void advanceStageIfBehind( void advanceStageIfBehind(