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 {
handleRecipientSigned,
handleDocumentCompleted,
handleDocumentExpired,
handleDocumentOpened,
handleDocumentRejected,
handleDocumentCancelled,
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
break;
case 'DOCUMENT_EXPIRED':
await handleDocumentExpired({ documentId: documensoId });
break;
default:
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);
} catch (err) {
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 { 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',
};
// 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(