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:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user