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

@@ -320,13 +320,17 @@ export async function redeliverWebhookDelivery(
.returning();
const queue = getQueue('webhooks');
await queue.add('deliver', {
webhookId,
portId,
event: source.eventType,
deliveryId: next!.id,
payload: replayPayload,
});
await queue.add(
'deliver',
{
webhookId,
portId,
event: source.eventType,
deliveryId: next!.id,
payload: replayPayload,
},
{ jobId: `deliver:${next!.id}` },
);
void createAuditLog({
userId: meta.userId,
@@ -371,13 +375,17 @@ export async function sendTestWebhook(portId: string, webhookId: string, eventTy
// Enqueue the job
const queue = getQueue('webhooks');
await queue.add('deliver', {
webhookId,
portId,
event: eventType,
deliveryId: delivery!.id,
payload: delivery!.payload,
});
await queue.add(
'deliver',
{
webhookId,
portId,
event: eventType,
deliveryId: delivery!.id,
payload: delivery!.payload,
},
{ jobId: `deliver:${delivery!.id}` },
);
return { deliveryId: delivery!.id, status: 'queued' };
}