fix(audit): documenso — M2 (reservation EOI-milestone pollution), L11 (v2 numericId GET fallback), L12 (API URL normalize/validate), L13 (event dedup)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:59:07 +02:00
parent 37ffb2c3b4
commit 4084029962
4 changed files with 156 additions and 36 deletions

View File

@@ -845,34 +845,50 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Update interest if linked
if (interest) {
// Always link the Documenso envelope to the interest, regardless of
// document type. The EOI-specific milestone columns + eoi_sent rule are
// gated below so a reservation_agreement send doesn't pollute EOI funnel
// data (dateEoiSent / eoiStatus / eoiDocStatus / eoi stage advance).
await db
.update(interests)
.set({
documensoId: documensoDoc.id,
dateEoiSent: new Date(),
eoiStatus: 'waiting_for_signatures',
updatedAt: new Date(),
})
.where(eq(interests.id, interest.id));
// Trigger berth rules
void evaluateRule('eoi_sent', interest.id, portId, meta);
// EOI documents only: stamp EOI milestones, fire the eoi_sent berth
// rule, and advance to the `eoi` stage. A reservation_agreement is not
// an EOI and must not touch these columns.
if (doc.documentType === 'eoi') {
await db
.update(interests)
.set({
dateEoiSent: new Date(),
eoiStatus: 'waiting_for_signatures',
updatedAt: new Date(),
})
.where(eq(interests.id, interest.id));
// Advance pipeline stage to eoi (no-op if already further along).
// Doc sub-status is set by the webhook receiver when Documenso confirms;
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
void advanceStageIfBehindGated(
interest.id,
portId,
'eoi',
meta,
'EOI sent for signing',
'eoi_sent',
);
await db
.update(interests)
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
// Trigger berth rules
void evaluateRule('eoi_sent', interest.id, portId, meta);
// Advance pipeline stage to eoi (no-op if already further along).
// Doc sub-status is set by the webhook receiver when Documenso confirms;
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
void advanceStageIfBehindGated(
interest.id,
portId,
'eoi',
meta,
'EOI sent for signing',
'eoi_sent',
);
await db
.update(interests)
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
}
// Reservation agreements drive the reservation stage; the contract
// pathway uses its own send call and stamps contractDocStatus.
@@ -1752,11 +1768,21 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
);
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'completed',
eventData: { documensoId: eventData.documentId },
});
// Idempotent insert: DOCUMENT_COMPLETED is retried by Documenso on
// receiver 5xx and re-reconciled by the poll worker. There is exactly one
// logical `completed` event per document, so use a deterministic
// per-document hash as the dedup key — the `idx_de_dedup` partial unique
// index on (documentId, signatureHash) + onConflictDoNothing collapses
// retries into a single timeline row instead of accumulating duplicates.
await db
.insert(documentEvents)
.values({
documentId: doc.id,
eventType: 'completed',
signatureHash: `completed:${doc.id}`,
eventData: { documensoId: eventData.documentId },
})
.onConflictDoNothing();
emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id });
@@ -1915,6 +1941,11 @@ export async function handleDocumentOpened(eventData: {
documentId: doc.id,
eventType: 'viewed',
signerId: signer?.id ?? null,
// recipient_email is the key for idx_de_per_recipient_dedup (partial
// unique on (documentId, recipientEmail, eventType) WHERE
// recipientEmail IS NOT NULL). Without it the index never engages and
// v2 multi-delivery RECIPIENT_VIEWED opens can't dedup.
recipientEmail: eventData.recipientEmail ?? null,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail },
})