Skip to content

Commit 1d0031a

Browse files
authored
Merge pull request #2577 from mroderick/fix/duplicate-workshop-invitations
fix(invitations): prevent duplicate workshop invitation emails
2 parents 914a01f + 77323a2 commit 1d0031a

4 files changed

Lines changed: 136 additions & 55 deletions

File tree

app/models/concerns/workshop_invitation_manager_concerns.rb

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ def send_workshop_waiting_list_reminders(workshop)
9595
private
9696

9797
def create_invitation(workshop, member, role)
98-
WorkshopInvitation.find_or_create_by(workshop: workshop, member: member, role: role)
98+
invitation = WorkshopInvitation.find_or_initialize_by(workshop: workshop, member: member, role: role)
99+
invitation.save! if invitation.new_record?
100+
invitation
99101
rescue StandardError => e
100102
log_invitation_failure(workshop, member, role, e)
101103
nil
@@ -111,56 +113,42 @@ def log_invitation_failure(workshop, member, role, error)
111113
end
112114

113115
def invite_coaches_to_virtual_workshop(workshop, logger = nil)
114-
count = 0
115-
chapter_coaches(workshop.chapter).shuffle.each do |coach|
116-
invitation = create_invitation(workshop, coach, 'Coach')
117-
next unless invitation
118-
119-
count += 1
120-
send_email_with_logging(logger, coach, invitation) do
121-
VirtualWorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
122-
end
116+
invite_members(workshop, logger, chapter_coaches(workshop.chapter)) do |coach, invitation|
117+
VirtualWorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
123118
end
124-
count
125119
end
126120

127121
def invite_coaches_to_workshop(workshop, logger = nil)
128-
count = 0
129-
chapter_coaches(workshop.chapter).shuffle.each do |coach|
130-
invitation = create_invitation(workshop, coach, 'Coach')
131-
next unless invitation
132-
133-
count += 1
134-
send_email_with_logging(logger, coach, invitation) do
135-
WorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
136-
end
122+
invite_members(workshop, logger, chapter_coaches(workshop.chapter)) do |coach, invitation|
123+
WorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
137124
end
138-
count
139125
end
140126

141127
def invite_students_to_virtual_workshop(workshop, logger = nil)
142-
count = 0
143-
chapter_students(workshop.chapter).shuffle.each do |student|
144-
invitation = create_invitation(workshop, student, 'Student')
145-
next unless invitation
146-
147-
count += 1
148-
send_email_with_logging(logger, student, invitation) do
149-
VirtualWorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now
150-
end
128+
invite_members(workshop, logger, chapter_students(workshop.chapter), 'Student') do |student, invitation|
129+
VirtualWorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now
151130
end
152-
count
153131
end
154132

155133
def invite_students_to_workshop(workshop, logger = nil)
134+
invite_members(workshop, logger, chapter_students(workshop.chapter), 'Student') do |member, invitation|
135+
WorkshopInvitationMailer.invite_student(workshop, member, invitation).deliver_now
136+
end
137+
end
138+
139+
def invite_members(workshop, logger, members, role = 'Coach')
156140
count = 0
157-
chapter_students(workshop.chapter).shuffle.each do |student|
158-
invitation = create_invitation(workshop, student, 'Student')
141+
members.shuffle.each do |member|
142+
invitation = create_invitation(workshop, member, role)
159143
next unless invitation
160144

161-
count += 1
162-
send_email_with_logging(logger, student, invitation) do
163-
WorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now
145+
if invitation.previously_new_record?
146+
count += 1
147+
send_email_with_logging(logger, member, invitation) do
148+
yield member, invitation
149+
end
150+
else
151+
logger&.log_skipped(member, invitation, 'Already invited to this workshop')
164152
end
165153
end
166154
count

spec/models/invitation_manager_deduplication_spec.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,18 @@
5252
coaches_group.members << member_in_both_groups
5353
end
5454

55-
it 'sends one invitation per role when audience is everyone' do
56-
expect(WorkshopInvitation).to receive(:find_or_create_by)
57-
.with(workshop: workshop, member: member_in_both_groups, role: 'Student')
58-
.and_call_original
59-
.once
55+
it 'creates one invitation per role when audience is everyone' do
56+
expect do
57+
manager.send_workshop_emails(workshop, 'everyone')
58+
end.to change(WorkshopInvitation, :count).by(2)
6059

61-
expect(WorkshopInvitation).to receive(:find_or_create_by)
62-
.with(workshop: workshop, member: member_in_both_groups, role: 'Coach')
63-
.and_call_original
64-
.once
60+
# Verify the member has one invitation per role
61+
student_invitation = WorkshopInvitation.find_by(workshop: workshop, member: member_in_both_groups, role: 'Student')
62+
coach_invitation = WorkshopInvitation.find_by(workshop: workshop, member: member_in_both_groups, role: 'Coach')
6563

66-
manager.send_workshop_emails(workshop, 'everyone')
64+
expect(student_invitation).to be_present
65+
expect(coach_invitation).to be_present
66+
expect(student_invitation.id).not_to eq(coach_invitation.id)
6767
end
6868
end
6969
end

spec/models/invitation_manager_spec.rb

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,44 @@
241241
end
242242
end
243243

244+
describe '#create_invitation' do
245+
let(:member) { Fabricate(:member) }
246+
247+
it 'creates a new invitation and returns it with previously_new_record? as true' do
248+
invitation = manager.send(:create_invitation, workshop, member, 'Student')
249+
250+
expect(invitation).to be_a(WorkshopInvitation)
251+
expect(invitation).to be_persisted
252+
expect(invitation.previously_new_record?).to be true
253+
expect(invitation.workshop).to eq(workshop)
254+
expect(invitation.member).to eq(member)
255+
expect(invitation.role).to eq('Student')
256+
end
257+
258+
it 'returns existing invitation with previously_new_record? as false on duplicate call' do
259+
# First call creates the invitation
260+
invitation1 = manager.send(:create_invitation, workshop, member, 'Student')
261+
expect(invitation1.previously_new_record?).to be true
262+
263+
# Second call finds the existing invitation
264+
invitation2 = manager.send(:create_invitation, workshop, member, 'Student')
265+
expect(invitation2.previously_new_record?).to be false
266+
expect(invitation2.id).to eq(invitation1.id)
267+
end
268+
269+
it 'does not create duplicate records in database' do
270+
expect do
271+
# Multiple calls should not create duplicates
272+
3.times { manager.send(:create_invitation, workshop, member, 'Student') }
273+
end.to change(WorkshopInvitation, :count).by(1)
274+
end
275+
end
276+
244277
describe '#create_invitation resilience' do
245278
let(:member) { Fabricate(:member) }
246279

247-
it 'returns nil when find_or_create_by raises an exception' do
248-
allow(WorkshopInvitation).to receive(:find_or_create_by)
280+
it 'returns nil when find_or_initialize_by raises an exception' do
281+
allow(WorkshopInvitation).to receive(:find_or_initialize_by)
249282
.and_raise(StandardError.new('database error'))
250283

251284
result = manager.send(:create_invitation, workshop, member, 'Student')
@@ -254,7 +287,7 @@
254287
end
255288

256289
it 'logs error with member_id and workshop_id but no PII' do
257-
allow(WorkshopInvitation).to receive(:find_or_create_by)
290+
allow(WorkshopInvitation).to receive(:find_or_initialize_by)
258291
.and_raise(StandardError.new('database error'))
259292

260293
expect(Rails.logger).to receive(:error) do |message|
@@ -272,7 +305,7 @@
272305
Fabricate(:students, chapter: chapter, members: students)
273306
call_count = 0
274307

275-
allow(WorkshopInvitation).to receive(:find_or_create_by) do
308+
allow(WorkshopInvitation).to receive(:find_or_initialize_by) do
276309
call_count += 1
277310
if call_count == 1
278311
raise StandardError.new('database error')
@@ -286,4 +319,49 @@
286319
end.not_to raise_error
287320
end
288321
end
322+
323+
describe 'duplicate invitation prevention' do
324+
let(:initiator) { Fabricate(:member) }
325+
326+
before do
327+
Fabricate(:students, chapter: chapter, members: students)
328+
end
329+
330+
it 'logs skipped entries for already invited members when re-running batch' do
331+
# First invitation round
332+
manager.send_workshop_emails(workshop, 'students', initiator.id)
333+
334+
# Get the first log
335+
first_log = InvitationLog.where(loggable: workshop, audience: 'students').order(:created_at).first
336+
expect(first_log.success_count).to eq(students.count)
337+
expect(first_log.skipped_count).to eq(0)
338+
339+
# Second invitation round
340+
manager.send_workshop_emails(workshop, 'students', initiator.id)
341+
342+
# Get the second log
343+
second_log = InvitationLog.where(loggable: workshop, audience: 'students').order(:created_at).last
344+
expect(second_log.success_count).to eq(0)
345+
expect(second_log.skipped_count).to eq(students.count)
346+
end
347+
348+
it 'only sends emails to newly eligible members when batch is re-run' do
349+
# First invitation round
350+
manager.send_workshop_emails(workshop, 'students', initiator.id)
351+
ActionMailer::Base.deliveries.clear
352+
353+
# Add a new student
354+
new_student = Fabricate(:member)
355+
Fabricate(:students, chapter: chapter, members: [new_student])
356+
357+
# Second invitation round - should only email the new student
358+
expect do
359+
manager.send_workshop_emails(workshop, 'students', initiator.id)
360+
end.to change { ActionMailer::Base.deliveries.count }.by(1)
361+
362+
log = InvitationLog.where(loggable: workshop, audience: 'students').order(:created_at).last
363+
expect(log.success_count).to eq(1)
364+
expect(log.skipped_count).to eq(students.count)
365+
end
366+
end
289367
end

spec/support/shared_examples/behaves_like_sending_workshop_emails.rb

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Fabricate(:students, chapter: chapter, members: students)
44

55
students.each do |student|
6-
expect(WorkshopInvitation).to receive(:find_or_create_by).with(workshop: workshop, member: student, role: 'Student').and_call_original
6+
expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: student, role: 'Student').and_call_original
77
expect(mailer).to receive(:invite_student).and_call_original
88
end
99

@@ -14,7 +14,7 @@
1414
Fabricate(:coaches, chapter: chapter, members: coaches)
1515

1616
coaches.each do |coach|
17-
expect(WorkshopInvitation).to receive(:find_or_create_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original
17+
expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original
1818
expect(mailer).to receive(:invite_coach).and_call_original
1919
end
2020

@@ -26,9 +26,9 @@
2626
Fabricate(:coaches, chapter: chapter, members: coaches + [banned_coach])
2727

2828
coaches.each do |coach|
29-
expect(WorkshopInvitation).to receive(:find_or_create_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original
29+
expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original
3030
end
31-
expect(WorkshopInvitation).not_to receive(:find_or_create_by).with(workshop: workshop, member: banned_coach, role: 'Coach')
31+
expect(WorkshopInvitation).not_to receive(:find_or_initialize_by).with(workshop: workshop, member: banned_coach, role: 'Coach')
3232

3333
manager.send(send_email, workshop, 'coaches')
3434
end
@@ -45,10 +45,25 @@
4545
it 'does not send emails when invitation creation returns nil' do
4646
Fabricate(:students, chapter: chapter, members: students)
4747

48-
expect(WorkshopInvitation).to receive(:find_or_create_by).and_return(nil).exactly(students.count)
48+
expect(WorkshopInvitation).to receive(:find_or_initialize_by).and_return(nil).exactly(students.count)
4949

5050
expect do
5151
manager.send(send_email, workshop, 'students')
5252
end.not_to(change { ActionMailer::Base.deliveries.count })
5353
end
54+
55+
it 'does not send duplicate emails when members are already invited' do
56+
Fabricate(:students, chapter: chapter, members: students)
57+
58+
# First invitation round - creates invitations and sends emails
59+
manager.send(send_email, workshop, 'students')
60+
61+
# Clear deliveries to track second round
62+
ActionMailer::Base.deliveries.clear
63+
64+
# Second invitation round - should not send duplicate emails
65+
expect do
66+
manager.send(send_email, workshop, 'students')
67+
end.not_to(change { ActionMailer::Base.deliveries.count })
68+
end
5469
end

0 commit comments

Comments
 (0)