Skip to content

Commit 6a73071

Browse files
committed
fix: make bulk invitation creation resilient to individual failures
- Use find_or_create_by instead of create to handle duplicate invitations - Add exception handling to catch database errors and continue processing - Log errors at ERROR level with member_id and context IDs (workshop/event/meeting) - Add tests for exception handling and bulk resilience - Update existing tests to use find_or_create_by This fixes issue #2542 where workshop invitations would fail silently if any individual member's invitation creation raised an exception, causing the entire bulk invitation process to abort and leaving remaining members without invitations.
1 parent aabd403 commit 6a73071

4 files changed

Lines changed: 117 additions & 40 deletions

File tree

app/models/concerns/workshop_invitation_manager_concerns.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,19 @@ def send_workshop_waiting_list_reminders(workshop)
5151
private
5252

5353
def create_invitation(workshop, member, role)
54-
invitation = WorkshopInvitation.create(workshop: workshop, member: member, role: role)
55-
invitation.persisted? ? invitation : nil
54+
WorkshopInvitation.find_or_create_by(workshop: workshop, member: member, role: role)
55+
rescue StandardError => e
56+
log_invitation_failure(workshop, member, role, e)
57+
nil
58+
end
59+
60+
def log_invitation_failure(workshop, member, role, error)
61+
Rails.logger.error(
62+
'[InvitationManager] Failed to create invitation: ' \
63+
"workshop_id=#{workshop.id}, chapter_id=#{workshop.chapter_id}, " \
64+
"member_id=#{member.id}, role=#{role}, " \
65+
"error=#{error.class.name}: #{error.message}"
66+
)
5667
end
5768

5869
def invite_coaches_to_virtual_workshop(workshop)

app/models/invitation_manager.rb

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ def send_monthly_attendance_reminder_emails(monthly)
2020
def send_meeting_emails(meeting)
2121
meeting.invitees.not_banned.each do |invitee|
2222
invitation = MeetingInvitation.new(meeting: meeting, member: invitee, role: 'Participant')
23-
MeetingInvitationMailer.invite(meeting, invitee, invitation).deliver_now if invitation.save
23+
next unless invitation.save
24+
25+
MeetingInvitationMailer.invite(meeting, invitee, invitation).deliver_now
26+
rescue StandardError => e
27+
log_event_meeting_invitation_failure("meeting_id=#{meeting.id}", invitee, e)
2428
end
2529
end
2630
handle_asynchronously :send_meeting_emails
@@ -30,17 +34,33 @@ def send_meeting_emails(meeting)
3034
def invite_students_to_event(event, chapter)
3135
chapter_students(chapter).each do |student|
3236
invitation = Invitation.new(event: event, member: student, role: 'Student')
33-
EventInvitationMailer.invite_student(event, student, invitation).deliver_now if invitation.save
37+
next unless invitation.save
38+
39+
EventInvitationMailer.invite_student(event, student, invitation).deliver_now
40+
rescue StandardError => e
41+
log_event_meeting_invitation_failure("event_id=#{event.id}", student, e)
3442
end
3543
end
3644

3745
def invite_coaches_to_event(event, chapter)
3846
chapter_coaches(chapter).each do |coach|
3947
invitation = Invitation.new(event: event, member: coach, role: 'Coach')
40-
EventInvitationMailer.invite_coach(event, coach, invitation).deliver_now if invitation.save
48+
next unless invitation.save
49+
50+
EventInvitationMailer.invite_coach(event, coach, invitation).deliver_now
51+
rescue StandardError => e
52+
log_event_meeting_invitation_failure("event_id=#{event.id}", coach, e)
4153
end
4254
end
4355

56+
def log_event_meeting_invitation_failure(context, member, error)
57+
Rails.logger.error(
58+
'[InvitationManager] Failed to create invitation: ' \
59+
"#{context}, member_id=#{member.id}, " \
60+
"error=#{error.class.name}: #{error.message}"
61+
)
62+
end
63+
4464
def chapter_students(chapter)
4565
Member.in_group(chapter.groups.students)
4666
end

spec/models/invitation_manager_spec.rb

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
include_examples 'sending workshop emails'
2121
end
2222

23-
context '#send_event_emails' do
23+
describe '#send_event_emails' do
2424
let!(:student_group) { Fabricate(:students, chapter: chapter, members: students) }
2525
let!(:coaches_group) { Fabricate(:coaches, chapter: chapter, members: coaches) }
2626

@@ -31,7 +31,7 @@
3131
end
3232

3333
coaches.each do |student|
34-
expect(Invitation).to_not receive(:new).with(event: event, member: student, role: 'Coach').and_call_original
34+
expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Coach').and_call_original
3535
end
3636

3737
manager.send_event_emails(event, chapter)
@@ -41,7 +41,7 @@
4141
event = Fabricate(:event, chapters: [chapter], audience: 'Coaches')
4242

4343
students.each do |student|
44-
expect(Invitation).to_not receive(:new).with(event: event, member: student, role: 'Student').and_call_original
44+
expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Student').and_call_original
4545
end
4646

4747
coaches.each do |student|
@@ -71,17 +71,17 @@
7171
first_student, *other_students = students
7272
first_student.update(accepted_toc_at: nil)
7373

74-
expect(Invitation).to_not(
75-
receive(:new).
76-
with(event: event, member: first_student, role: 'Student').
77-
and_call_original
74+
expect(Invitation).not_to(
75+
receive(:new)
76+
.with(event: event, member: first_student, role: 'Student')
77+
.and_call_original
7878
)
7979

8080
other_students.each do |other_student|
8181
expect(Invitation).to(
82-
receive(:new).
83-
with(event: event, member: other_student, role: 'Student').
84-
and_call_original
82+
receive(:new)
83+
.with(event: event, member: other_student, role: 'Student')
84+
.and_call_original
8585
)
8686
end
8787

@@ -94,25 +94,25 @@
9494
first_coach, *other_coaches = coaches
9595
first_coach.update(accepted_toc_at: nil)
9696

97-
expect(Invitation).to_not(
98-
receive(:new).
99-
with(event: event, member: first_coach, role: 'Coach').
100-
and_call_original
97+
expect(Invitation).not_to(
98+
receive(:new)
99+
.with(event: event, member: first_coach, role: 'Coach')
100+
.and_call_original
101101
)
102102

103103
other_coaches.each do |other_coach|
104104
expect(Invitation).to(
105-
receive(:new).
106-
with(event: event, member: other_coach, role: 'Coach').
107-
and_call_original
105+
receive(:new)
106+
.with(event: event, member: other_coach, role: 'Coach')
107+
.and_call_original
108108
)
109109
end
110110

111111
manager.send_event_emails(event, chapter)
112112
end
113113
end
114114

115-
describe '#send_monthly_attendance_reminder_emails', wip: true do
115+
describe '#send_monthly_attendance_reminder_emails', :wip do
116116
it 'emails all attending members' do
117117
meeting = Fabricate(:meeting)
118118
attendees = Fabricate.times(2, :attending_meeting_invitation, meeting: meeting).map(&:member)
@@ -137,11 +137,11 @@
137137
end
138138

139139
manager.send_workshop_attendance_reminders(workshop)
140-
invitations.each { |invitation| expect(invitation.reload.reminded_at).to_not be_nil }
140+
invitations.each { |invitation| expect(invitation.reload.reminded_at).not_to be_nil }
141141
end
142142
end
143143

144-
describe '#send_workshop_waiting_list_reminders', wip: true do
144+
describe '#send_workshop_waiting_list_reminders', :wip do
145145
it 'emails everyone that hasn\'t already been reminded from the workshop\'s waitinglist' do
146146
workshop = Fabricate(:workshop)
147147
invitations = Fabricate.times(2, :waitinglist_invitation, workshop: workshop)
@@ -154,12 +154,12 @@
154154
end
155155

156156
reminded_invitations.each do |invitation|
157-
expect(WorkshopInvitationMailer).to_not receive(:waiting_list_reminder)
157+
expect(WorkshopInvitationMailer).not_to receive(:waiting_list_reminder)
158158
.with(workshop, invitation.member, invitation)
159159
end
160160

161161
manager.send_workshop_waiting_list_reminders(workshop)
162-
invitations.each { |invitation| expect(invitation.reload.reminded_at).to_not be_nil }
162+
invitations.each { |invitation| expect(invitation.reload.reminded_at).not_to be_nil }
163163
end
164164
end
165165

@@ -240,4 +240,50 @@
240240
manager.send_meeting_emails(meeting)
241241
end
242242
end
243+
244+
describe '#create_invitation resilience' do
245+
let(:member) { Fabricate(:member) }
246+
247+
it 'returns nil when find_or_create_by raises an exception' do
248+
allow(WorkshopInvitation).to receive(:find_or_create_by)
249+
.and_raise(StandardError.new('database error'))
250+
251+
result = manager.send(:create_invitation, workshop, member, 'Student')
252+
253+
expect(result).to be_nil
254+
end
255+
256+
it 'logs error with member_id and workshop_id but no PII' do
257+
allow(WorkshopInvitation).to receive(:find_or_create_by)
258+
.and_raise(StandardError.new('database error'))
259+
260+
expect(Rails.logger).to receive(:error) do |message|
261+
expect(message).to include("member_id=#{member.id}")
262+
expect(message).to include("workshop_id=#{workshop.id}")
263+
expect(message).to include('role=Student')
264+
expect(message).not_to include(member.email)
265+
expect(message).not_to include(member.name)
266+
end
267+
268+
manager.send(:create_invitation, workshop, member, 'Student')
269+
end
270+
271+
it 'continues processing when invitation creation fails for one member' do
272+
Fabricate(:students, chapter: chapter, members: students)
273+
call_count = 0
274+
275+
allow(WorkshopInvitation).to receive(:find_or_create_by) do
276+
call_count += 1
277+
if call_count == 1
278+
raise StandardError.new('database error')
279+
end
280+
281+
WorkshopInvitation.new(persisted?: true)
282+
end
283+
284+
expect do
285+
manager.send_workshop_emails(workshop, 'students')
286+
end.not_to raise_error
287+
end
288+
end
243289
end
Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
RSpec.shared_examples 'sending workshop emails' do
22
it 'creates an invitation for each student' do
3-
student_group = Fabricate(:students, chapter: chapter, members: students)
3+
Fabricate(:students, chapter: chapter, members: students)
44

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

1010
manager.send(send_email, workshop, 'students')
1111
end
1212

1313
it 'creates an invitation for each coach' do
14-
coach_group = Fabricate(:coaches, chapter: chapter, members: coaches)
14+
Fabricate(:coaches, chapter: chapter, members: coaches)
1515

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

@@ -23,32 +23,32 @@
2323

2424
it 'does not invite banned coaches' do
2525
banned_coach = Fabricate(:banned_member)
26-
coach_group = Fabricate(:coaches, chapter: chapter, members: coaches + [banned_coach])
26+
Fabricate(:coaches, chapter: chapter, members: coaches + [banned_coach])
2727

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

3333
manager.send(send_email, workshop, 'coaches')
3434
end
3535

3636
it 'sends emails when a WorkshopInvitation is created' do
37-
student_group = Fabricate(:students, chapter: chapter, members: students)
38-
coach_group = Fabricate(:coaches, chapter: chapter, members: coaches)
37+
Fabricate(:students, chapter: chapter, members: students)
38+
Fabricate(:coaches, chapter: chapter, members: coaches)
3939

4040
expect do
4141
manager.send(send_email, workshop, 'everyone')
4242
end.to change { ActionMailer::Base.deliveries.count }.by(students.count + coaches.count)
4343
end
4444

45-
it "does not send emails when no invitation is created" do
46-
student_group = Fabricate(:students, chapter: chapter, members: students)
45+
it 'does not send emails when invitation creation returns nil' do
46+
Fabricate(:students, chapter: chapter, members: students)
4747

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

5050
expect do
5151
manager.send(send_email, workshop, 'students')
52-
end.not_to change { ActionMailer::Base.deliveries.count }
52+
end.not_to(change { ActionMailer::Base.deliveries.count })
5353
end
5454
end

0 commit comments

Comments
 (0)