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:
@@ -589,7 +589,16 @@ export async function sendInvoice(id: string, portId: string, meta: AuditMeta) {
|
||||
// remains intact; downstream consumers can decide whether to render
|
||||
// an external document, link to the in-app view, or wait for the
|
||||
// admin-uploaded AcroForm-fill feature to ship.
|
||||
await getQueue('email').add('send-invoice', { invoiceId: id, portId });
|
||||
// Stable jobId for natural dedup: a double-click on the Send button
|
||||
// or a webhook retry on the upstream caller can fire this twice. BullMQ
|
||||
// rejects a duplicate `jobId` while the original is still queued or
|
||||
// active, so we get at-most-once email per invoice-send action.
|
||||
// concurrency-auditor C-2.
|
||||
await getQueue('email').add(
|
||||
'send-invoice',
|
||||
{ invoiceId: id, portId },
|
||||
{ jobId: `send-invoice:${id}` },
|
||||
);
|
||||
|
||||
// Update status to 'sent'
|
||||
const [updated] = await db
|
||||
@@ -718,10 +727,17 @@ export async function detectOverdue(portId: string) {
|
||||
daysPastDue,
|
||||
});
|
||||
|
||||
await getQueue('notifications').add('invoice-overdue-notify', {
|
||||
invoiceId: inv.id,
|
||||
portId,
|
||||
});
|
||||
// Stable jobId: detectOverdue runs daily; if it fires twice in
|
||||
// the same UTC day (e.g. a manual re-trigger after a worker
|
||||
// restart) we don't want duplicate overdue emails. Per-day key
|
||||
// gives idempotency for the daily fire while letting tomorrow's
|
||||
// run re-notify if the invoice still hasn't been paid.
|
||||
const dayKey = new Date().toISOString().slice(0, 10);
|
||||
await getQueue('notifications').add(
|
||||
'invoice-overdue-notify',
|
||||
{ invoiceId: inv.id, portId },
|
||||
{ jobId: `invoice-overdue-notify:${inv.id}:${dayKey}` },
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{ invoiceId: inv.id, invoiceNumber: inv.invoiceNumber, portId },
|
||||
|
||||
Reference in New Issue
Block a user