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