Skip to content

Commit 4d659a7

Browse files
authored
Merge pull request #2546 from mroderick/fix/workshop-invitation-resilience
feat: add database-backed invitation logging and resilient bulk invitations
2 parents 1d58e5d + 01c2dba commit 4d659a7

30 files changed

Lines changed: 1153 additions & 61 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module Admin
2+
class WorkshopInvitationLogsController < Admin::ApplicationController
3+
def index
4+
@workshop = Workshop.find(params[:workshop_id])
5+
authorize @workshop, :show?
6+
logs = InvitationLog.where(loggable: @workshop)
7+
.order(created_at: :desc)
8+
.includes(:initiator, :entries)
9+
@pagy, @logs = pagy(logs)
10+
end
11+
12+
def show
13+
@workshop = Workshop.find(params[:workshop_id])
14+
authorize @workshop, :show?
15+
@log = InvitationLog.find(params[:id])
16+
@entries = @log.entries.order(processed_at: :desc).includes(:member)
17+
end
18+
end
19+
end

app/controllers/admin/workshops_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ def invite
8585
audience = params[:for]
8686

8787
if @workshop.virtual?
88-
InvitationManager.new.send_virtual_workshop_emails(@workshop, audience)
88+
InvitationManager.new.send_virtual_workshop_emails(@workshop, audience, current_user.id)
8989
else
90-
InvitationManager.new.send_workshop_emails(@workshop, audience)
90+
InvitationManager.new.send_workshop_emails(@workshop, audience, current_user.id)
9191
end
9292

9393
redirect_to admin_workshop_path(@workshop), notice: "Invitations to #{audience} are being emailed out."
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class CleanupExpiredInvitationLogsJob < ApplicationJob
2+
queue_as :default
3+
4+
def perform
5+
InvitationLog.destroy_by(expires_at: ..Time.current)
6+
end
7+
end

app/models/concerns/workshop_invitation_manager_concerns.rb

Lines changed: 116 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,63 @@ def send_workshop_attendance_reminders(workshop)
1515
end
1616
handle_asynchronously :send_workshop_attendance_reminders
1717

18-
def send_workshop_emails(workshop, audience)
18+
def send_workshop_emails(workshop, audience, initiator_id = nil)
1919
return 'The workshop is not invitable' unless workshop.invitable?
2020

21-
invite_students_to_workshop(workshop) if audience.in?(%w[students everyone])
22-
invite_coaches_to_workshop(workshop) if audience.in?(%w[coaches everyone])
21+
initiator = initiator_id ? Member.find_by(id: initiator_id) : nil
22+
logger = initiator ? InvitationLogger.new(workshop, initiator, audience, :invite) : nil
23+
24+
if logger
25+
begin
26+
logger.start_batch
27+
rescue ActiveRecord::RecordNotUnique
28+
return 'A batch is already running for this workshop and audience'
29+
end
30+
end
31+
32+
total = 0
33+
begin
34+
if audience.in?(%w[students everyone])
35+
total += invite_students_to_workshop(workshop, logger)
36+
end
37+
if audience.in?(%w[coaches everyone])
38+
total += invite_coaches_to_workshop(workshop, logger)
39+
end
40+
logger&.finish_batch(total)
41+
rescue StandardError => e
42+
logger&.fail_batch(e)
43+
raise
44+
end
2345
end
2446
handle_asynchronously :send_workshop_emails
2547

26-
def send_virtual_workshop_emails(workshop, audience)
48+
def send_virtual_workshop_emails(workshop, audience, initiator_id = nil)
2749
return 'The workshop is not invitable' unless workshop.invitable?
2850

29-
invite_students_to_virtual_workshop(workshop) if audience.in?(%w[students everyone])
30-
invite_coaches_to_virtual_workshop(workshop) if audience.in?(%w[coaches everyone])
51+
initiator = initiator_id ? Member.find_by(id: initiator_id) : nil
52+
logger = initiator ? InvitationLogger.new(workshop, initiator, audience, :invite) : nil
53+
54+
if logger
55+
begin
56+
logger.start_batch
57+
rescue ActiveRecord::RecordNotUnique
58+
return 'A batch is already running for this workshop and audience'
59+
end
60+
end
61+
62+
total = 0
63+
begin
64+
if audience.in?(%w[students everyone])
65+
total += invite_students_to_virtual_workshop(workshop, logger)
66+
end
67+
if audience.in?(%w[coaches everyone])
68+
total += invite_coaches_to_virtual_workshop(workshop, logger)
69+
end
70+
logger&.finish_batch(total)
71+
rescue StandardError => e
72+
logger&.fail_batch(e)
73+
raise
74+
end
3175
end
3276
handle_asynchronously :send_virtual_workshop_emails
3377

@@ -51,35 +95,87 @@ def send_workshop_waiting_list_reminders(workshop)
5195
private
5296

5397
def create_invitation(workshop, member, role)
54-
invitation = WorkshopInvitation.create(workshop: workshop, member: member, role: role)
55-
invitation.persisted? ? invitation : nil
98+
WorkshopInvitation.find_or_create_by(workshop: workshop, member: member, role: role)
99+
rescue StandardError => e
100+
log_invitation_failure(workshop, member, role, e)
101+
nil
56102
end
57103

58-
def invite_coaches_to_virtual_workshop(workshop)
104+
def log_invitation_failure(workshop, member, role, error)
105+
Rails.logger.error(
106+
'[InvitationManager] Failed to create invitation: ' \
107+
"workshop_id=#{workshop.id}, chapter_id=#{workshop.chapter_id}, " \
108+
"member_id=#{member.id}, role=#{role}, " \
109+
"error=#{error.class.name}: #{error.message}"
110+
)
111+
end
112+
113+
def invite_coaches_to_virtual_workshop(workshop, logger = nil)
114+
count = 0
59115
chapter_coaches(workshop.chapter).shuffle.each do |coach|
60-
invitation = create_invitation(workshop, coach, 'Coach') || next
61-
VirtualWorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
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
62123
end
124+
count
63125
end
64126

65-
def invite_coaches_to_workshop(workshop)
127+
def invite_coaches_to_workshop(workshop, logger = nil)
128+
count = 0
66129
chapter_coaches(workshop.chapter).shuffle.each do |coach|
67-
invitation = create_invitation(workshop, coach, 'Coach') || next
68-
WorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
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
69137
end
138+
count
70139
end
71140

72-
def invite_students_to_virtual_workshop(workshop)
141+
def invite_students_to_virtual_workshop(workshop, logger = nil)
142+
count = 0
73143
chapter_students(workshop.chapter).shuffle.each do |student|
74-
invitation = create_invitation(workshop, student, 'Student') || next
75-
VirtualWorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now
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
76151
end
152+
count
77153
end
78154

79-
def invite_students_to_workshop(workshop)
155+
def invite_students_to_workshop(workshop, logger = nil)
156+
count = 0
80157
chapter_students(workshop.chapter).shuffle.each do |student|
81-
invitation = create_invitation(workshop, student, 'Student') || next
82-
WorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now
158+
invitation = create_invitation(workshop, student, 'Student')
159+
next unless invitation
160+
161+
count += 1
162+
send_email_with_logging(logger, student, invitation) do
163+
WorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now
164+
end
165+
end
166+
count
167+
end
168+
169+
def send_email_with_logging(logger, member, invitation)
170+
if logger
171+
begin
172+
yield
173+
logger.log_success(member, invitation)
174+
rescue StandardError => e
175+
logger.log_failure(member, invitation, e)
176+
end
177+
else
178+
yield
83179
end
84180
end
85181

app/models/invitation_log.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class InvitationLog < ApplicationRecord
2+
enum :action, { invite: 'invite', reminder: 'reminder', waiting_list_notification: 'waiting_list_notification' },
3+
prefix: false
4+
enum :status, { running: 'running', completed: 'completed', failed: 'failed' }, prefix: false
5+
6+
belongs_to :loggable, polymorphic: true
7+
belongs_to :initiator, class_name: 'Member', optional: true
8+
belongs_to :chapter, optional: true
9+
has_many :entries, class_name: 'InvitationLogEntry', dependent: :destroy
10+
11+
before_create :set_expires_at
12+
13+
private
14+
15+
def set_expires_at
16+
self.expires_at ||= 180.days.from_now
17+
end
18+
end

app/models/invitation_log_entry.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class InvitationLogEntry < ApplicationRecord
2+
enum :status, { success: 'success', failed: 'failed', skipped: 'skipped' }, prefix: false
3+
4+
belongs_to :invitation_log
5+
belongs_to :member
6+
belongs_to :invitation, polymorphic: true, optional: true
7+
8+
validates :member_id, uniqueness: { scope: %i[invitation_type invitation_id] }, allow_nil: true
9+
end

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

app/models/workshop.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Workshop < ApplicationRecord
1616
has_one :host, through: :workshop_host, source: :sponsor
1717
has_many :organisers, -> { where('permissions.name' => 'organiser') }, through: :permissions, source: :members
1818
has_many :feedbacks
19+
has_many :invitation_logs, as: :loggable
1920

2021
belongs_to :chapter
2122

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class InvitationLogPolicy < ApplicationPolicy
2+
def index?
3+
is_admin_or_chapter_organiser?
4+
end
5+
6+
def show?
7+
is_admin_or_chapter_organiser?
8+
end
9+
end

app/services/invitation_logger.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
class InvitationLogger
2+
def initialize(loggable, initiator, audience, action)
3+
@loggable = loggable
4+
@initiator = initiator
5+
@audience = audience
6+
@action = action
7+
@log = nil
8+
end
9+
10+
def start_batch
11+
@log = InvitationLog.create!(
12+
loggable: @loggable,
13+
initiator: @initiator,
14+
chapter_id: @loggable.try(:chapter_id),
15+
audience: @audience,
16+
action: @action,
17+
started_at: Time.current,
18+
status: :running
19+
)
20+
end
21+
22+
def log_success(member, invitation = nil)
23+
return unless @log
24+
25+
@log.entries.create!(
26+
member: member,
27+
invitation: invitation,
28+
status: :success,
29+
processed_at: Time.current
30+
).tap { @log.increment!(:success_count) }
31+
end
32+
33+
def log_failure(member, invitation, error)
34+
return unless @log
35+
36+
@log.entries.create!(
37+
member: member,
38+
invitation: invitation,
39+
status: :failed,
40+
failure_reason: error.message,
41+
processed_at: Time.current
42+
).tap { @log.increment!(:failure_count) }
43+
end
44+
45+
def log_skipped(member, invitation, reason)
46+
return unless @log
47+
48+
@log.entries.create!(
49+
member: member,
50+
invitation: invitation,
51+
status: :skipped,
52+
failure_reason: reason,
53+
processed_at: Time.current
54+
).tap { @log.increment!(:skipped_count) }
55+
end
56+
57+
def finish_batch(total_invitees)
58+
return unless @log
59+
60+
@log.update!(
61+
total_invitees: total_invitees,
62+
completed_at: Time.current,
63+
status: :completed
64+
)
65+
end
66+
67+
def fail_batch(error)
68+
return unless @log
69+
70+
@log.update!(
71+
status: :failed,
72+
error_message: error.message,
73+
completed_at: Time.current
74+
)
75+
end
76+
77+
attr_reader :log
78+
end

0 commit comments

Comments
 (0)