Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/admin/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def invite
authorize @event

@event.chapters.each do |chapter|
InvitationManager.new.send_event_emails(@event, chapter)
InvitationManager.new.send_event_emails(@event, chapter, current_user.id)
end

redirect_to admin_event_path(@event), notice: 'Invitations will be emailed out soon.'
Expand Down
9 changes: 7 additions & 2 deletions app/services/invitation_logger.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
class InvitationLogger
def initialize(loggable, initiator, audience, action)
def initialize(loggable, initiator, audience, action, chapter_id: nil)
@loggable = loggable
@initiator = initiator
@audience = audience
@action = action
@chapter_id = chapter_id
@log = nil
end

def start_batch
@log = InvitationLog.create!(
loggable: @loggable,
initiator: @initiator,
chapter_id: @loggable.try(:chapter_id),
chapter_id: resolved_chapter_id,
audience: @audience,
action: @action,
started_at: Time.current,
Expand Down Expand Up @@ -77,6 +78,10 @@ def fail_batch(error)

private

def resolved_chapter_id
@chapter_id || @loggable.try(:chapter_id) || raise(ArgumentError, 'chapter_id is required')
end

def find_or_build_entry(member, invitation, status)
@log.entries.find_or_create_by(member: member, invitation: invitation) do |entry|
entry.status = status
Expand Down
159 changes: 98 additions & 61 deletions app/services/invitation_manager.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
class InvitationManager
def send_event_emails(event, chapter)
def send_event_emails(event, chapter, initiator_id = nil) # rubocop:disable Metrics/AbcSize
return 'The event is not invitable' unless event.invitable?

invite_coaches_to_event(event, chapter) unless event.audience.eql?('Students')
invite_students_to_event(event, chapter) unless event.audience.eql?('Coaches')
rescue StandardError => e
Rollbar.error(e, event_id: event.id, chapter_id: chapter.id)
raise
audience = event_audience(event)
logger = invitation_logger(event, initiator_id, audience, chapter.id)

result = start_invitation_batch(logger)
return result if result.is_a?(String)

total = 0
begin
total += invite_coaches_to_event(event, chapter, logger) unless event.audience.eql?('Students')
total += invite_students_to_event(event, chapter, logger) unless event.audience.eql?('Coaches')
logger&.finish_batch(total)
rescue StandardError => e
Rollbar.error(e, event_id: event.id, chapter_id: chapter.id)
logger&.fail_batch(e)
raise
end
end
handle_asynchronously :send_event_emails

Expand Down Expand Up @@ -42,25 +53,14 @@ def send_workshop_attendance_reminders(workshop)
def send_workshop_emails(workshop, audience, initiator_id = nil)
return 'The workshop is not invitable' unless workshop.invitable?

initiator = initiator_id ? Member.find_by(id: initiator_id) : nil
logger = initiator ? InvitationLogger.new(workshop, initiator, audience, :invite) : nil

if logger
begin
logger.start_batch
rescue ActiveRecord::RecordNotUnique
return 'A batch is already running for this workshop and audience'
end
end
logger = invitation_logger(workshop, initiator_id, audience, workshop.chapter_id)
result = start_invitation_batch(logger)
return result if result.is_a?(String)

total = 0
begin
if audience.in?(%w[students everyone])
total += invite_students_to_workshop(workshop, logger)
end
if audience.in?(%w[coaches everyone])
total += invite_coaches_to_workshop(workshop, logger)
end
total += invite_students_to_workshop(workshop, logger) if audience.in?(%w[students everyone])
total += invite_coaches_to_workshop(workshop, logger) if audience.in?(%w[coaches everyone])
logger&.finish_batch(total)
rescue StandardError => e
logger&.fail_batch(e)
Expand All @@ -72,25 +72,14 @@ def send_workshop_emails(workshop, audience, initiator_id = nil)
def send_virtual_workshop_emails(workshop, audience, initiator_id = nil)
return 'The workshop is not invitable' unless workshop.invitable?

initiator = initiator_id ? Member.find_by(id: initiator_id) : nil
logger = initiator ? InvitationLogger.new(workshop, initiator, audience, :invite) : nil

if logger
begin
logger.start_batch
rescue ActiveRecord::RecordNotUnique
return 'A batch is already running for this workshop and audience'
end
end
logger = invitation_logger(workshop, initiator_id, audience, workshop.chapter_id)
result = start_invitation_batch(logger)
return result if result.is_a?(String)

total = 0
begin
if audience.in?(%w[students everyone])
total += invite_students_to_virtual_workshop(workshop, logger)
end
if audience.in?(%w[coaches everyone])
total += invite_coaches_to_virtual_workshop(workshop, logger)
end
total += invite_students_to_virtual_workshop(workshop, logger) if audience.in?(%w[students everyone])
total += invite_coaches_to_virtual_workshop(workshop, logger) if audience.in?(%w[coaches everyone])
logger&.finish_batch(total)
rescue StandardError => e
logger&.fail_batch(e)
Expand All @@ -99,14 +88,6 @@ def send_virtual_workshop_emails(workshop, audience, initiator_id = nil)
end
handle_asynchronously :send_virtual_workshop_emails

def send_waiting_list_emails(workshop)
workshop = WorkshopPresenter.decorate(workshop)

retrieve_and_notify_waitlisted(workshop, role: 'Coach') if workshop.coach_spaces?
retrieve_and_notify_waitlisted(workshop, role: 'Student') if workshop.student_spaces?
end
handle_asynchronously :send_waiting_list_emails

def send_workshop_waiting_list_reminders(workshop)
workshop_mailer = workshop.virtual? ? VirtualWorkshopInvitationMailer : WorkshopInvitationMailer
workshop.invitations.on_waiting_list.not_reminded.each do |invitation|
Expand All @@ -116,28 +97,54 @@ def send_workshop_waiting_list_reminders(workshop)
end
handle_asynchronously :send_workshop_waiting_list_reminders

def send_waiting_list_emails(workshop)
workshop = WorkshopPresenter.decorate(workshop)

retrieve_and_notify_waitlisted(workshop, role: 'Coach') if workshop.coach_spaces?
retrieve_and_notify_waitlisted(workshop, role: 'Student') if workshop.student_spaces?
end
handle_asynchronously :send_waiting_list_emails

private

def invite_students_to_event(event, chapter)
def invite_students_to_event(event, chapter, logger = nil)
count = 0
chapter_students(chapter).each do |student|
invitation = Invitation.new(event: event, member: student, role: 'Student')
next unless invitation.save
invitation = create_event_invitation(event, student, 'Student')
next unless invitation

EventInvitationMailer.invite_student(event, student, invitation).deliver_later
if invitation.previously_new_record?
count += 1
send_email_with_logging(logger, student, invitation) do
EventInvitationMailer.invite_student(event, student, invitation).deliver_later
end
else
logger&.log_skipped(student, invitation, 'Already invited to this event')
end
rescue StandardError => e
log_event_meeting_invitation_failure("event_id=#{event.id}", student, e)
end
count
end

def invite_coaches_to_event(event, chapter)
def invite_coaches_to_event(event, chapter, logger = nil)
count = 0
chapter_coaches(chapter).each do |coach|
invitation = Invitation.new(event: event, member: coach, role: 'Coach')
next unless invitation.save
invitation = create_event_invitation(event, coach, 'Coach')
next unless invitation

EventInvitationMailer.invite_coach(event, coach, invitation).deliver_later
if invitation.previously_new_record?
count += 1
send_email_with_logging(logger, coach, invitation) do
EventInvitationMailer.invite_coach(event, coach, invitation).deliver_later
end
else
logger&.log_skipped(coach, invitation, 'Already invited to this event')
end
rescue StandardError => e
log_event_meeting_invitation_failure("event_id=#{event.id}", coach, e)
end
count
end

def log_event_meeting_invitation_failure(context, member, error)
Expand All @@ -157,14 +164,19 @@ def chapter_coaches(chapter)
end

def create_invitation(workshop, member, role)
invitation = WorkshopInvitation.find_or_initialize_by(workshop: workshop, member: member, role: role)
invitation.save! if invitation.new_record?
invitation
WorkshopInvitation.find_or_create_by!(workshop: workshop, member: member, role: role)
rescue StandardError => e
log_invitation_failure(workshop, member, role, e)
nil
end

def create_event_invitation(event, member, role)
Invitation.find_or_create_by!(event: event, member: member, role: role)
rescue StandardError => e
log_event_meeting_invitation_failure("event_id=#{event.id}", member, e)
nil
end

def log_invitation_failure(workshop, member, role, error)
Rails.logger.error(
'[InvitationManager] Failed to create invitation: ' \
Expand Down Expand Up @@ -216,6 +228,13 @@ def invite_members(workshop, logger, members, role = 'Coach')
count
end

def retrieve_and_notify_waitlisted(workshop, role:)
WaitingList.by_workshop(workshop).where_role(role).each do |waiting_list|
WorkshopInvitationMailer.notify_waiting_list(waiting_list.invitation).deliver_later
waiting_list.destroy
end
end

def send_email_with_logging(logger, member, invitation)
if logger
begin
Expand All @@ -229,10 +248,28 @@ def send_email_with_logging(logger, member, invitation)
end
end

def retrieve_and_notify_waitlisted(workshop, role:)
WaitingList.by_workshop(workshop).where_role(role).each do |waiting_list|
WorkshopInvitationMailer.notify_waiting_list(waiting_list.invitation).deliver_later
waiting_list.destroy
def event_audience(event)
case event.audience
when 'Students' then 'students'
when 'Coaches' then 'coaches'
else 'everyone'
end
end

def invitation_logger(loggable, initiator_id, audience, chapter_id)
initiator = Member.find_by(id: initiator_id)
return nil unless initiator

InvitationLogger.new(loggable, initiator, audience, :invite, chapter_id: chapter_id)
end

def start_invitation_batch(logger)
return unless logger

begin
logger.start_batch
rescue ActiveRecord::RecordNotUnique
'A batch is already running for this loggable and audience'
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class AddChapterIdToInvitationLogsUniqueActiveIndex < ActiveRecord::Migration[8.1]
disable_ddl_transaction!

def up
remove_index :invitation_logs,
name: 'index_invitation_logs_unique_active',
algorithm: :concurrently

add_index :invitation_logs,
%i[loggable_type loggable_id chapter_id audience action status],
unique: true,
where: "status = 'running'",
name: 'index_invitation_logs_unique_active',
algorithm: :concurrently
end

def down
remove_index :invitation_logs,
name: 'index_invitation_logs_unique_active',
algorithm: :concurrently

add_index :invitation_logs,
%i[loggable_type loggable_id audience action status],
unique: true,
where: "status = 'running'",
name: 'index_invitation_logs_unique_active',
algorithm: :concurrently
end
end
10 changes: 8 additions & 2 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_07_02_102700) do
ActiveRecord::Schema[8.1].define(version: 2026_07_29_151201) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"

Expand Down Expand Up @@ -201,6 +201,7 @@
create_table "events", id: :serial, force: :cascade do |t|
t.boolean "announce_only"
t.string "audience"
t.string "check_in_code"
t.text "coach_description"
t.string "coach_questionnaire"
t.integer "coach_spaces"
Expand Down Expand Up @@ -228,6 +229,7 @@
t.string "url"
t.integer "venue_id"
t.boolean "virtual", default: false, null: false
t.index ["check_in_code"], name: "index_events_on_check_in_code", unique: true
t.index ["date_and_time"], name: "index_events_on_date_and_time"
t.index ["slug"], name: "index_events_on_slug", unique: true
t.index ["venue_id"], name: "index_events_on_venue_id"
Expand Down Expand Up @@ -327,7 +329,7 @@
t.index ["created_at"], name: "index_invitation_logs_on_created_at"
t.index ["expires_at"], name: "index_invitation_logs_on_expires_at"
t.index ["initiator_id"], name: "index_invitation_logs_on_initiator_id"
t.index ["loggable_type", "loggable_id", "audience", "action", "status"], name: "index_invitation_logs_unique_active", unique: true, where: "((status)::text = 'running'::text)"
t.index ["loggable_type", "loggable_id", "chapter_id", "audience", "action", "status"], name: "index_invitation_logs_unique_active", unique: true, where: "((status)::text = 'running'::text)"
t.index ["loggable_type", "loggable_id"], name: "index_invitation_logs_on_loggable"
t.index ["status"], name: "index_invitation_logs_on_status"
end
Expand All @@ -339,6 +341,7 @@
t.integer "member_id"
t.text "note"
t.string "role"
t.string "source"
t.string "token"
t.datetime "updated_at", precision: nil
t.boolean "verified"
Expand Down Expand Up @@ -592,6 +595,7 @@
t.datetime "reminded_at", precision: nil
t.string "role"
t.datetime "rsvp_time", precision: nil
t.string "source"
t.string "token"
t.text "tutorial"
t.datetime "updated_at", precision: nil
Expand All @@ -616,6 +620,7 @@

create_table "workshops", id: :serial, force: :cascade do |t|
t.integer "chapter_id"
t.string "check_in_code"
t.integer "coach_spaces", default: 0
t.datetime "created_at", precision: nil
t.datetime "date_and_time", precision: nil
Expand All @@ -632,6 +637,7 @@
t.datetime "updated_at", precision: nil
t.boolean "virtual", default: false
t.index ["chapter_id"], name: "index_workshops_on_chapter_id"
t.index ["check_in_code"], name: "index_workshops_on_check_in_code", unique: true
t.index ["date_and_time"], name: "index_workshops_on_date_and_time"
end

Expand Down
Loading