A hub restart kills sendEventCampaignAsync's in-memory loop mid-flight,
leaving a campaign stuck at status='sending' with recipients still
'pending' — this happened live today and required a manual server-side
fix. Building a proper resume path instead of relying on that:
- resumeCampaignSend(campaignId): re-reads channel/from/event_id from the
campaign's own params (nothing to re-enter), refuses if nothing is
pending or a send is already active, and re-invokes the same worker,
which skips already-'sent' rows — safe to call on a healthy campaign too.
- Anti-double-send guard (activeEventSends / tryReserveSend): reservation
now happens synchronously at the scheduling point (massSend/reminderSend/
resumeCampaignSend), not inside the deferred setImmediate callback —
closes a race where two near-simultaneous calls could both slip through
before either worker actually started.
- New route: POST /events/campaign-resume {campaign_id}.
- CampaignDetailPage.vue: "Reprendre l'envoi" button, shown only for
event-type campaigns stuck at status='sending' with pending recipients.
Verified live: used the new endpoint to resume the real in-progress "On
fête nos 20 ans" campaign after this exact deploy interrupted it — resumed
cleanly with zero duplicate sends.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>