fix(audit-wave-11): BullMQ jobId plumbing for natural dedup

concurrency-auditor C-2: every queue.add(...) site previously enqueued
without a stable jobId, so a double-dispatch (webhook retry, double-
click on Send, scheduler tick collision) would create two queue jobs
and the downstream worker would deliver twice. BullMQ rejects a
duplicate jobId while the original is still queued or active, so a
stable per-entity key gives at-most-once semantics naturally.

Added jobIds across all 10 enqueue sites:

- email send-invoice → `send-invoice:<invoiceId>`
- notifications invoice-overdue-notify → keyed per UTC day so dupes
  collapse intra-day but tomorrow's run can re-notify if unpaid
- export gdpr-export → keyed on the exportId (unique per request)
- webhooks deliver (3 sites: dispatch, retry, test) → keyed on the
  webhook_deliveries row UUID
- maintenance expense-dedup-scan → keyed on expenseId
- notifications send-notification-email → keyed on notification id
- email send-inquiry-confirmation → keyed on interestId (1 per
  submission)
- email send-inquiry-sales-notification → keyed on interestId+email
  (1 per recipient per submission)
- reports generate-report → keyed on the generated_reports row id

Pure refactor — no UX impact. Closes the BullMQ dedup gap that was
the second half of the concurrency-auditor's CRITICAL-tier findings.

Test fixture update: gdpr-export integration test now asserts the
jobId option on the queue.add call.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:02:38 +02:00
parent 2496911dc4
commit b4e502fedd
9 changed files with 117 additions and 56 deletions

View File

@@ -98,13 +98,20 @@ export async function requestGdprExport(input: RequestExportInput): Promise<Requ
userAgent: input.userAgent,
});
await getQueue('export').add('gdpr-export', {
exportId: row.id,
portId: input.portId,
clientId: input.clientId,
emailToClient: input.emailToClient,
emailOverride: input.emailOverride ?? null,
});
// Stable jobId: exportId is unique per request — dedup is guaranteed
// because a second enqueue with the same exportId would either be
// rejected (in-flight) or no-op (completed). concurrency-auditor C-2.
await getQueue('export').add(
'gdpr-export',
{
exportId: row.id,
portId: input.portId,
clientId: input.clientId,
emailToClient: input.emailToClient,
emailOverride: input.emailOverride ?? null,
},
{ jobId: `gdpr-export:${row.id}` },
);
return { export: row };
}