Skip to content

fix(invitations): prevent duplicate workshop invitation emails#2577

Merged
till merged 1 commit intocodebar:masterfrom
mroderick:fix/duplicate-workshop-invitations
Apr 16, 2026
Merged

fix(invitations): prevent duplicate workshop invitation emails#2577
till merged 1 commit intocodebar:masterfrom
mroderick:fix/duplicate-workshop-invitations

Conversation

@mroderick
Copy link
Copy Markdown
Collaborator

Summary

Fixes a bug where re-running workshop invitation batches would send duplicate emails to members who had already been invited.

Problem Analysis

What Was Happening

When invitation batches were triggered multiple times for the same workshop:

  1. WorkshopInvitation.find_or_create_by would find existing invitations (not create new ones)
  2. The code would proceed to send another email anyway
  3. The system logged these as "success" instead of "skipped"
  4. logger.log_skipped existed in the codebase but was never called

Real-World Evidence

Brighton Chapter - Workshop #3679 (April 21, 2026):

Batch Date Audience Invited Success Skipped
Log 36 Apr 15 everyone 867 867 0
Log 38 Apr 16 students 594 593 0

The Issue:

  • All 594 members in the second batch were already invited in the first batch
  • 0 were marked as skipped (should have been 594)
  • 593 duplicate emails were sent (1 failed due to invalid email)

Root Cause

The code pattern in all 4 invitation methods:

invitation = create_invitation(workshop, member, role)  # finds existing
next unless invitation                                    # never nil

count += 1                                                # counts existing!
send_email_with_logging(...)                              # sends duplicate!

When an invitation already existed, find_or_create_by returned it, and the code treated it the same as a newly created invitation.

Solution

1. Track New vs Existing Invitations

Changed create_invitation from find_or_create_by to find_or_initialize_by + save!:

def create_invitation(workshop, member, role)
  invitation = WorkshopInvitation.find_or_initialize_by(...)
  invitation.save! if invitation.new_record?
  invitation  # Now we can check previously_new_record?
end

2. Detect Duplicates and Skip Emails

Added previously_new_record? checks in all invitation methods:

if invitation.previously_new_record?
  count += 1
  send_email_with_logging(...) { yield }
else
  logger&.log_skipped(member, invitation, 'Already invited')
end

3. DRY Refactor

Extracted common logic to invite_members helper:

  • Reduced code duplication (4 nearly-identical blocks)
  • File size: 207 → 177 lines (-30 lines)
  • Each invitation method now just 3 lines with block syntax

Changes

Files Modified

  • app/models/concerns/workshop_invitation_manager_concerns.rb

    • Refactored 4 invitation methods to use invite_members helper
    • Added previously_new_record? detection
    • Added log_skipped calls for existing invitations
  • spec/models/invitation_manager_spec.rb

    • Added #create_invitation unit tests for new vs existing behavior
    • Added "duplicate invitation prevention" test suite
    • Added test for only inviting newly eligible members on re-run
  • spec/support/shared_examples/behaves_like_sending_workshop_emails.rb

    • Updated mocks from find_or_create_by to find_or_initialize_by
    • Added shared test: "does not send duplicate emails when members are already invited"

Testing

All tests pass:

  • 34 examples, 0 failures (added 7 new tests)
  • New tests verify:
    • New invitations return with previously_new_record? = true
    • Existing invitations return with previously_new_record? = false
    • Re-running batch only sends emails to new members
    • Skipped members are properly logged

Impact

Immediate Benefits

  • Re-running invitation batches now only emails new members
  • Proper tracking of skipped members in invitation_logs
  • 593 fewer duplicate emails per re-run (Brighton-sized chapter)

Future Benefits

  • Organizers can safely re-run batches to catch new registrations
  • Clear visibility into who's being skipped vs invited
  • Cleaner, more maintainable code through DRY refactor

Verification

To verify this fix works:

  1. Run invitation batch for a workshop
  2. Check invitation_log shows success_count = N, skipped_count = 0
  3. Re-run invitation batch for same workshop
  4. Check new invitation_log shows success_count = 0, skipped_count = N
  5. Only members who joined since first batch should receive emails

Related

  • Fixes silent duplicate email issue discovered during Brighton chapter investigation
  • Addresses the skipped_count = 0 anomaly identified in production logs

@mroderick mroderick force-pushed the fix/duplicate-workshop-invitations branch from 971c460 to 77323a2 Compare April 16, 2026 19:03
@mroderick mroderick marked this pull request as ready for review April 16, 2026 19:03
@mroderick mroderick requested review from olleolleolle and till April 16, 2026 19:04
@till till merged commit 1d0031a into codebar:master Apr 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants