diff --git a/app/controllers/admin/workshop_invitation_logs_controller.rb b/app/controllers/admin/workshop_invitation_logs_controller.rb new file mode 100644 index 000000000..4269f2fae --- /dev/null +++ b/app/controllers/admin/workshop_invitation_logs_controller.rb @@ -0,0 +1,19 @@ +module Admin + class WorkshopInvitationLogsController < Admin::ApplicationController + def index + @workshop = Workshop.find(params[:workshop_id]) + authorize @workshop, :show? + logs = InvitationLog.where(loggable: @workshop) + .order(created_at: :desc) + .includes(:initiator, :entries) + @pagy, @logs = pagy(logs) + end + + def show + @workshop = Workshop.find(params[:workshop_id]) + authorize @workshop, :show? + @log = InvitationLog.find(params[:id]) + @entries = @log.entries.order(processed_at: :desc).includes(:member) + end + end +end diff --git a/app/controllers/admin/workshops_controller.rb b/app/controllers/admin/workshops_controller.rb index a423075ec..3e7dcd2f5 100644 --- a/app/controllers/admin/workshops_controller.rb +++ b/app/controllers/admin/workshops_controller.rb @@ -85,9 +85,9 @@ def invite audience = params[:for] if @workshop.virtual? - InvitationManager.new.send_virtual_workshop_emails(@workshop, audience) + InvitationManager.new.send_virtual_workshop_emails(@workshop, audience, current_user.id) else - InvitationManager.new.send_workshop_emails(@workshop, audience) + InvitationManager.new.send_workshop_emails(@workshop, audience, current_user.id) end redirect_to admin_workshop_path(@workshop), notice: "Invitations to #{audience} are being emailed out." diff --git a/app/jobs/cleanup_expired_invitation_logs_job.rb b/app/jobs/cleanup_expired_invitation_logs_job.rb new file mode 100644 index 000000000..3de0f9e12 --- /dev/null +++ b/app/jobs/cleanup_expired_invitation_logs_job.rb @@ -0,0 +1,7 @@ +class CleanupExpiredInvitationLogsJob < ApplicationJob + queue_as :default + + def perform + InvitationLog.destroy_by(expires_at: ..Time.current) + end +end diff --git a/app/models/concerns/workshop_invitation_manager_concerns.rb b/app/models/concerns/workshop_invitation_manager_concerns.rb index 148f49ce8..b7c41dc28 100644 --- a/app/models/concerns/workshop_invitation_manager_concerns.rb +++ b/app/models/concerns/workshop_invitation_manager_concerns.rb @@ -15,19 +15,63 @@ def send_workshop_attendance_reminders(workshop) end handle_asynchronously :send_workshop_attendance_reminders - def send_workshop_emails(workshop, audience) + def send_workshop_emails(workshop, audience, initiator_id = nil) return 'The workshop is not invitable' unless workshop.invitable? - invite_students_to_workshop(workshop) if audience.in?(%w[students everyone]) - invite_coaches_to_workshop(workshop) if audience.in?(%w[coaches everyone]) + 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 + + 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 + logger&.finish_batch(total) + rescue StandardError => e + logger&.fail_batch(e) + raise + end end handle_asynchronously :send_workshop_emails - def send_virtual_workshop_emails(workshop, audience) + def send_virtual_workshop_emails(workshop, audience, initiator_id = nil) return 'The workshop is not invitable' unless workshop.invitable? - invite_students_to_virtual_workshop(workshop) if audience.in?(%w[students everyone]) - invite_coaches_to_virtual_workshop(workshop) if audience.in?(%w[coaches everyone]) + 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 + + 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 + logger&.finish_batch(total) + rescue StandardError => e + logger&.fail_batch(e) + raise + end end handle_asynchronously :send_virtual_workshop_emails @@ -51,35 +95,87 @@ def send_workshop_waiting_list_reminders(workshop) private def create_invitation(workshop, member, role) - invitation = WorkshopInvitation.create(workshop: workshop, member: member, role: role) - invitation.persisted? ? invitation : nil + WorkshopInvitation.find_or_create_by(workshop: workshop, member: member, role: role) + rescue StandardError => e + log_invitation_failure(workshop, member, role, e) + nil end - def invite_coaches_to_virtual_workshop(workshop) + def log_invitation_failure(workshop, member, role, error) + Rails.logger.error( + '[InvitationManager] Failed to create invitation: ' \ + "workshop_id=#{workshop.id}, chapter_id=#{workshop.chapter_id}, " \ + "member_id=#{member.id}, role=#{role}, " \ + "error=#{error.class.name}: #{error.message}" + ) + end + + def invite_coaches_to_virtual_workshop(workshop, logger = nil) + count = 0 chapter_coaches(workshop.chapter).shuffle.each do |coach| - invitation = create_invitation(workshop, coach, 'Coach') || next - VirtualWorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now + invitation = create_invitation(workshop, coach, 'Coach') + next unless invitation + + count += 1 + send_email_with_logging(logger, coach, invitation) do + VirtualWorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now + end end + count end - def invite_coaches_to_workshop(workshop) + def invite_coaches_to_workshop(workshop, logger = nil) + count = 0 chapter_coaches(workshop.chapter).shuffle.each do |coach| - invitation = create_invitation(workshop, coach, 'Coach') || next - WorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now + invitation = create_invitation(workshop, coach, 'Coach') + next unless invitation + + count += 1 + send_email_with_logging(logger, coach, invitation) do + WorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now + end end + count end - def invite_students_to_virtual_workshop(workshop) + def invite_students_to_virtual_workshop(workshop, logger = nil) + count = 0 chapter_students(workshop.chapter).shuffle.each do |student| - invitation = create_invitation(workshop, student, 'Student') || next - VirtualWorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now + invitation = create_invitation(workshop, student, 'Student') + next unless invitation + + count += 1 + send_email_with_logging(logger, student, invitation) do + VirtualWorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now + end end + count end - def invite_students_to_workshop(workshop) + def invite_students_to_workshop(workshop, logger = nil) + count = 0 chapter_students(workshop.chapter).shuffle.each do |student| - invitation = create_invitation(workshop, student, 'Student') || next - WorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now + invitation = create_invitation(workshop, student, 'Student') + next unless invitation + + count += 1 + send_email_with_logging(logger, student, invitation) do + WorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now + end + end + count + end + + def send_email_with_logging(logger, member, invitation) + if logger + begin + yield + logger.log_success(member, invitation) + rescue StandardError => e + logger.log_failure(member, invitation, e) + end + else + yield end end diff --git a/app/models/invitation_log.rb b/app/models/invitation_log.rb new file mode 100644 index 000000000..83cb0f13e --- /dev/null +++ b/app/models/invitation_log.rb @@ -0,0 +1,18 @@ +class InvitationLog < ApplicationRecord + enum :action, { invite: 'invite', reminder: 'reminder', waiting_list_notification: 'waiting_list_notification' }, + prefix: false + enum :status, { running: 'running', completed: 'completed', failed: 'failed' }, prefix: false + + belongs_to :loggable, polymorphic: true + belongs_to :initiator, class_name: 'Member', optional: true + belongs_to :chapter, optional: true + has_many :entries, class_name: 'InvitationLogEntry', dependent: :destroy + + before_create :set_expires_at + + private + + def set_expires_at + self.expires_at ||= 180.days.from_now + end +end diff --git a/app/models/invitation_log_entry.rb b/app/models/invitation_log_entry.rb new file mode 100644 index 000000000..552ee424d --- /dev/null +++ b/app/models/invitation_log_entry.rb @@ -0,0 +1,9 @@ +class InvitationLogEntry < ApplicationRecord + enum :status, { success: 'success', failed: 'failed', skipped: 'skipped' }, prefix: false + + belongs_to :invitation_log + belongs_to :member + belongs_to :invitation, polymorphic: true, optional: true + + validates :member_id, uniqueness: { scope: %i[invitation_type invitation_id] }, allow_nil: true +end diff --git a/app/models/invitation_manager.rb b/app/models/invitation_manager.rb index f65031273..53838c50d 100644 --- a/app/models/invitation_manager.rb +++ b/app/models/invitation_manager.rb @@ -20,7 +20,11 @@ def send_monthly_attendance_reminder_emails(monthly) def send_meeting_emails(meeting) meeting.invitees.not_banned.each do |invitee| invitation = MeetingInvitation.new(meeting: meeting, member: invitee, role: 'Participant') - MeetingInvitationMailer.invite(meeting, invitee, invitation).deliver_now if invitation.save + next unless invitation.save + + MeetingInvitationMailer.invite(meeting, invitee, invitation).deliver_now + rescue StandardError => e + log_event_meeting_invitation_failure("meeting_id=#{meeting.id}", invitee, e) end end handle_asynchronously :send_meeting_emails @@ -30,17 +34,33 @@ def send_meeting_emails(meeting) def invite_students_to_event(event, chapter) chapter_students(chapter).each do |student| invitation = Invitation.new(event: event, member: student, role: 'Student') - EventInvitationMailer.invite_student(event, student, invitation).deliver_now if invitation.save + next unless invitation.save + + EventInvitationMailer.invite_student(event, student, invitation).deliver_now + rescue StandardError => e + log_event_meeting_invitation_failure("event_id=#{event.id}", student, e) end end def invite_coaches_to_event(event, chapter) chapter_coaches(chapter).each do |coach| invitation = Invitation.new(event: event, member: coach, role: 'Coach') - EventInvitationMailer.invite_coach(event, coach, invitation).deliver_now if invitation.save + next unless invitation.save + + EventInvitationMailer.invite_coach(event, coach, invitation).deliver_now + rescue StandardError => e + log_event_meeting_invitation_failure("event_id=#{event.id}", coach, e) end end + def log_event_meeting_invitation_failure(context, member, error) + Rails.logger.error( + '[InvitationManager] Failed to create invitation: ' \ + "#{context}, member_id=#{member.id}, " \ + "error=#{error.class.name}: #{error.message}" + ) + end + def chapter_students(chapter) Member.in_group(chapter.groups.students) end diff --git a/app/models/workshop.rb b/app/models/workshop.rb index cc81359d1..28f86ce9b 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -16,6 +16,7 @@ class Workshop < ApplicationRecord has_one :host, through: :workshop_host, source: :sponsor has_many :organisers, -> { where('permissions.name' => 'organiser') }, through: :permissions, source: :members has_many :feedbacks + has_many :invitation_logs, as: :loggable belongs_to :chapter diff --git a/app/policies/invitation_log_policy.rb b/app/policies/invitation_log_policy.rb new file mode 100644 index 000000000..852e71063 --- /dev/null +++ b/app/policies/invitation_log_policy.rb @@ -0,0 +1,9 @@ +class InvitationLogPolicy < ApplicationPolicy + def index? + is_admin_or_chapter_organiser? + end + + def show? + is_admin_or_chapter_organiser? + end +end diff --git a/app/services/invitation_logger.rb b/app/services/invitation_logger.rb new file mode 100644 index 000000000..5241aa8c7 --- /dev/null +++ b/app/services/invitation_logger.rb @@ -0,0 +1,78 @@ +class InvitationLogger + def initialize(loggable, initiator, audience, action) + @loggable = loggable + @initiator = initiator + @audience = audience + @action = action + @log = nil + end + + def start_batch + @log = InvitationLog.create!( + loggable: @loggable, + initiator: @initiator, + chapter_id: @loggable.try(:chapter_id), + audience: @audience, + action: @action, + started_at: Time.current, + status: :running + ) + end + + def log_success(member, invitation = nil) + return unless @log + + @log.entries.create!( + member: member, + invitation: invitation, + status: :success, + processed_at: Time.current + ).tap { @log.increment!(:success_count) } + end + + def log_failure(member, invitation, error) + return unless @log + + @log.entries.create!( + member: member, + invitation: invitation, + status: :failed, + failure_reason: error.message, + processed_at: Time.current + ).tap { @log.increment!(:failure_count) } + end + + def log_skipped(member, invitation, reason) + return unless @log + + @log.entries.create!( + member: member, + invitation: invitation, + status: :skipped, + failure_reason: reason, + processed_at: Time.current + ).tap { @log.increment!(:skipped_count) } + end + + def finish_batch(total_invitees) + return unless @log + + @log.update!( + total_invitees: total_invitees, + completed_at: Time.current, + status: :completed + ) + end + + def fail_batch(error) + return unless @log + + @log.update!( + status: :failed, + error_message: error.message, + completed_at: Time.current + ) + end + + attr_reader :log +end diff --git a/app/views/admin/workshop_invitation_logs/_invitation_log.html.haml b/app/views/admin/workshop_invitation_logs/_invitation_log.html.haml new file mode 100644 index 000000000..e1b3a2b50 --- /dev/null +++ b/app/views/admin/workshop_invitation_logs/_invitation_log.html.haml @@ -0,0 +1,42 @@ +.card.mb-3 + .card-body + .d-flex.justify-content-between.align-items-start + %div + %h5.card-title.mb-1 + Batch ##{invitation_log.id} + %span.badge.bg-secondary= invitation_log.status + %p.mb-1 + %strong> Action: + = invitation_log.action.humanize + %strong.ms-2 Audience: + = invitation_log.audience.humanize + %strong.ms-2 By: + = invitation_log.initiator&.full_name || 'System' + %strong.ms-2 Started: + = l(invitation_log.started_at, format: :short) if invitation_log.started_at + .mt-2 + %span.badge.bg-success= "#{invitation_log.success_count} sent" + - if invitation_log.failure_count.positive? + %span.badge.bg-danger= "#{invitation_log.failure_count} failed" + - if invitation_log.skipped_count.positive? + %span.badge.bg-warning.text-dark= "#{invitation_log.skipped_count} skipped" + %span.badge.bg-secondary= "#{invitation_log.total_invitees} total" + %div + = link_to admin_workshop_invitation_log_path(@workshop, invitation_log), + class: 'btn btn-sm btn-outline-primary' do + Details + - if invitation_log.failure_count.positive? + = "(#{invitation_log.failure_count} failures)" + + - if invitation_log.entries.failed.any? + .mt-3 + %button.btn.btn-sm.btn-outline-danger{ data: { bs_toggle: 'collapse', bs_target: "#failures-#{invitation_log.id}" } } + %i.fas.fa-exclamation-triangle + Show #{invitation_log.failure_count} failures + .collapse.mt-2{ id: "failures-#{invitation_log.id}" } + .list-group + - invitation_log.entries.failed.each do |entry| + .list-group-item.list-group-item-danger + .d-flex.justify-content-between + %strong= entry.member.full_name + %small= entry.failure_reason \ No newline at end of file diff --git a/app/views/admin/workshop_invitation_logs/index.html.haml b/app/views/admin/workshop_invitation_logs/index.html.haml new file mode 100644 index 000000000..e536aee5f --- /dev/null +++ b/app/views/admin/workshop_invitation_logs/index.html.haml @@ -0,0 +1,12 @@ +- content_for :title, "Invitation Logs - #{@workshop.title}" + +.container.py-4 + %h1 Invitation Logs + %p.lead #{@workshop.title} + + - if @logs.empty? + %p.text-muted No invitation logs yet. + - else + = render partial: 'invitation_log', collection: @logs + + = pagy_bootstrap_nav(@pagy) if @pagy.pages > 1 \ No newline at end of file diff --git a/app/views/admin/workshop_invitation_logs/show.html.haml b/app/views/admin/workshop_invitation_logs/show.html.haml new file mode 100644 index 000000000..8651e253c --- /dev/null +++ b/app/views/admin/workshop_invitation_logs/show.html.haml @@ -0,0 +1,70 @@ +- content_for :title, "Invitation Log ##{@log.id}" + +.container.py-4 + %nav{ 'aria-label': 'breadcrumb' } + %ol.breadcrumb + %li.breadcrumb-item= link_to @workshop.title, admin_workshop_path(@workshop) + %li.breadcrumb-item= link_to 'Invitation Logs', admin_workshop_invitation_logs_path(@workshop) + %li.breadcrumb-item.active Batch ##{@log.id} + + %h1 Invitation Log ##{@log.id} + + .card.mb-3 + .card-body + .row + .col-md-6 + %p + %strong Status: + %span.badge.bg-secondary= @log.status + %p + %strong Action: + = @log.action.humanize + %p + %strong Audience: + = @log.audience.humanize + %p + %strong Initiated by: + = @log.initiator&.full_name || 'System' + .col-md-6 + %p + %strong Started: + = l(@log.started_at, format: :long) if @log.started_at + %p + %strong Completed: + = @log.completed_at ? l(@log.completed_at, format: :long) : 'In progress' + %p + %strong Total invitees: + = @log.total_invitees + - if @log.error_message.present? + %p + %strong Error: + .text-danger= @log.error_message + + .row.mb-3 + .col + %span.badge.bg-success= "#{@log.success_count} sent" + - if @log.failure_count.positive? + .col + %span.badge.bg-danger= "#{@log.failure_count} failed" + - if @log.skipped_count.positive? + .col + %span.badge.bg-warning.text-dark= "#{@log.skipped_count} skipped" + + %h3 Entries + + %table.table.table-sm + %thead + %tr + %th Member + %th Status + %th Reason + %th Processed + %tbody + - @entries.each do |entry| + %tr + %td= entry.member.full_name + %td + %span{ class: entry.success? ? 'badge bg-success' : entry.failed? ? 'badge bg-danger' : 'badge bg-warning' } + = entry.status.humanize + %td= entry.failure_reason + %td= l(entry.processed_at, format: :short) if entry.processed_at \ No newline at end of file diff --git a/app/views/admin/workshops/show.html.haml b/app/views/admin/workshops/show.html.haml index 9a692139d..6787b756b 100644 --- a/app/views/admin/workshops/show.html.haml +++ b/app/views/admin/workshops/show.html.haml @@ -97,3 +97,24 @@ .py-4.py-lg-5.bg-light .container#invitations = render partial: 'invitation_management' + + .py-4.py-lg-5 + .container + %h3 Invitation Logs + %p + = link_to 'View Invitation Logs', admin_workshop_invitation_logs_path(@workshop), class: 'btn btn-sm btn-secondary' + + - logs = @workshop.invitation_logs.order(created_at: :desc).limit(3) + - if logs.any? + - logs.each do |log| + .card.mb-2 + .card-body.py-2 + .d-flex.justify-content-between.align-items-center + %div + %strong= log.action.humanize + %span.badge.bg-secondary= log.audience.humanize + %small.text-muted.ms-2 by #{log.initiator&.full_name || 'System'} + .text-end + %span.badge.bg-success= "#{log.success_count} sent" + - if log.failure_count.positive? + %span.badge.bg-danger= "#{log.failure_count} failed" diff --git a/config/routes.rb b/config/routes.rb index 345a2d9c2..b1610f5ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -149,6 +149,7 @@ resource :invitations, only: [:update] resources :invitations, only: [:update] + resources :invitation_logs, only: %i[index show], controller: 'workshop_invitation_logs' end resources :testimonials, only: %i[index] diff --git a/db/migrate/20260330193245_create_invitation_logs.rb b/db/migrate/20260330193245_create_invitation_logs.rb new file mode 100644 index 000000000..37ad6a446 --- /dev/null +++ b/db/migrate/20260330193245_create_invitation_logs.rb @@ -0,0 +1,42 @@ +class CreateInvitationLogs < ActiveRecord::Migration[8.1] + def change + create_table :invitation_logs do |t| + t.references :loggable, polymorphic: true, index: true + t.references :initiator, foreign_key: { to_table: :members }, index: true + t.references :chapter, index: true + t.string :audience, null: false + t.string :action, null: false, default: 'invite' + t.integer :total_invitees, default: 0 + t.integer :success_count, default: 0 + t.integer :failure_count, default: 0 + t.integer :skipped_count, default: 0 + t.datetime :started_at + t.datetime :completed_at + t.string :status, null: false, default: 'running' + t.text :error_message + t.datetime :expires_at + t.timestamps + end + + add_index :invitation_logs, :status + add_index :invitation_logs, :created_at + add_index :invitation_logs, :expires_at + add_index :invitation_logs, %i[loggable_type loggable_id audience action status], + name: 'index_invitation_logs_unique_active', unique: true, + where: "status = 'running'" + + create_table :invitation_log_entries do |t| + t.references :invitation_log, null: false, index: true, foreign_key: true + t.references :member, null: false, index: true + t.references :invitation, polymorphic: true + t.string :status, null: false, default: 'success' + t.text :failure_reason + t.datetime :processed_at + t.timestamps + end + + add_index :invitation_log_entries, %i[invitation_log_id status] + add_index :invitation_log_entries, %i[member_id processed_at] + add_index :invitation_log_entries, %i[invitation_type invitation_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8cad66b8d..1e14f430b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_02_24_130000) do +ActiveRecord::Schema[8.1].define(version: 2026_03_30_193245) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -287,6 +287,51 @@ t.index ["chapter_id"], name: "index_groups_on_chapter_id" end + create_table "invitation_log_entries", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "failure_reason" + t.bigint "invitation_id" + t.bigint "invitation_log_id", null: false + t.string "invitation_type" + t.bigint "member_id", null: false + t.datetime "processed_at" + t.string "status", default: "success", null: false + t.datetime "updated_at", null: false + t.index ["invitation_log_id", "status"], name: "index_invitation_log_entries_on_invitation_log_id_and_status" + t.index ["invitation_log_id"], name: "index_invitation_log_entries_on_invitation_log_id" + t.index ["invitation_type", "invitation_id"], name: "idx_on_invitation_type_invitation_id_6d6ef495e6", unique: true + t.index ["invitation_type", "invitation_id"], name: "index_invitation_log_entries_on_invitation" + t.index ["member_id", "processed_at"], name: "index_invitation_log_entries_on_member_id_and_processed_at" + t.index ["member_id"], name: "index_invitation_log_entries_on_member_id" + end + + create_table "invitation_logs", force: :cascade do |t| + t.string "action", default: "invite", null: false + t.string "audience", null: false + t.bigint "chapter_id" + t.datetime "completed_at" + t.datetime "created_at", null: false + t.text "error_message" + t.datetime "expires_at" + t.integer "failure_count", default: 0 + t.bigint "initiator_id" + t.bigint "loggable_id" + t.string "loggable_type" + t.integer "skipped_count", default: 0 + t.datetime "started_at" + t.string "status", default: "running", null: false + t.integer "success_count", default: 0 + t.integer "total_invitees", default: 0 + t.datetime "updated_at", null: false + t.index ["chapter_id"], name: "index_invitation_logs_on_chapter_id" + 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"], name: "index_invitation_logs_on_loggable" + t.index ["status"], name: "index_invitation_logs_on_status" + end + create_table "invitations", id: :serial, force: :cascade do |t| t.boolean "attending" t.datetime "created_at", precision: nil @@ -590,5 +635,7 @@ t.index ["date_and_time"], name: "index_workshops_on_date_and_time" end + add_foreign_key "invitation_log_entries", "invitation_logs" + add_foreign_key "invitation_logs", "members", column: "initiator_id" add_foreign_key "member_email_deliveries", "members" end diff --git a/db/seeds.rb b/db/seeds.rb index 015802a11..5b2b6f587 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -156,6 +156,145 @@ end end Rails.logger.info '..done!' + + Rails.logger.info 'Creating invitation logs...' + + # Get organizers as initiators (or create one if none exist) + organisers = Member.joins(:roles).where(roles: { name: 'organiser' }).to_a + if organisers.empty? + organisers << Fabricate(:member, name: 'Chapter', surname: 'Organiser') + end + + realistic_failure_reasons = [ + 'SMTP connection timeout', + 'Invalid email format: malformed address', + 'Rate limit exceeded: too many recipients', + 'Recipient mailbox full', + 'Connection refused by mail server', + 'DNS lookup failed for recipient domain' + ].freeze + + # Filter to recent past workshops (last 3 months) + recent_past_workshops = past_workshops.select do |w| + w.date_and_time > 3.months.ago + end + + # Create logs for ~25 recent workshops + recent_past_workshops.sample(25).each do |workshop| + initiator = organisers.sample + audience = %w[students coaches everyone].sample + + # Determine invitee pool based on audience + potential_invitees = case audience + when 'students' then students + when 'coaches' then coaches + else students + coaches + end.sample(50) + + # Simulate realistic distribution + total_to_invite = rand(15..potential_invitees.length) + skipped_count = rand(0..(total_to_invite * 0.1).to_i) + actual_sent = total_to_invite - skipped_count + success_count = rand((actual_sent * 0.85).to_i..actual_sent) + failure_count = actual_sent - success_count + + log = InvitationLog.create!( + loggable: workshop, + initiator: initiator, + chapter: workshop.chapter, + audience: audience, + action: 'invite', + status: 'completed', + total_invitees: total_to_invite, + success_count: success_count, + failure_count: failure_count, + skipped_count: skipped_count, + started_at: workshop.date_and_time - 2.hours, + completed_at: workshop.date_and_time - 1.hour + rand(0..30).minutes, + expires_at: 180.days.from_now + ) + + # Get actual members for entries + invitees = potential_invitees.sample(total_to_invite) + skipped_members = invitees.shift(skipped_count) + sent_members = invitees + + # Create skipped entries + skipped_members.each do |member| + InvitationLogEntry.create!( + invitation_log: log, + member: member, + status: 'skipped', + failure_reason: 'Invitation already exists', + processed_at: log.started_at + rand(1..10).seconds + ) + end + + # Create success entries + success_members = sent_members.sample(success_count) + success_members.each do |member| + invitation = WorkshopInvitation.where(workshop: workshop, member: member).first + InvitationLogEntry.create!( + invitation_log: log, + member: member, + invitation: invitation, + status: 'success', + processed_at: log.started_at + rand(10..120).seconds + ) + end + + # Create failure entries + failure_members = sent_members - success_members + failure_members.each do |member| + InvitationLogEntry.create!( + invitation_log: log, + member: member, + status: 'failed', + failure_reason: realistic_failure_reasons.sample, + processed_at: log.started_at + rand(10..120).seconds + ) + end + end + + # Create 2 running (in-progress) logs for future workshops + future_workshops.sample(2).each do |workshop| + initiator = organisers.sample + audience = %w[students coaches everyone].sample + + log = InvitationLog.create!( + loggable: workshop, + initiator: initiator, + chapter: workshop.chapter, + audience: audience, + action: 'invite', + status: 'running', + total_invitees: 0, + success_count: 0, + failure_count: 0, + skipped_count: 0, + started_at: Time.current - rand(5..30).minutes, + expires_at: 180.days.from_now + ) + + # Create a few in-progress entries + potential_invitees = case audience + when 'students' then students + when 'coaches' then coaches + else students + coaches + end.sample(20) + + potential_invitees.sample(rand(3..8)).each do |member| + InvitationLogEntry.create!( + invitation_log: log, + member: member, + status: 'success', + processed_at: log.started_at + rand(60..300).seconds + ) + log.update_column(:success_count, log.success_count + 1) + end + end + + Rails.logger.info '..done creating invitation logs!' rescue Exception => e Rails.logger.error 'Something went wrong. Try running `bundle exec rake db:drop db:create db:migrate` first' Rails.logger.error e.message diff --git a/lib/tasks/invitation_logs.rake b/lib/tasks/invitation_logs.rake new file mode 100644 index 000000000..b3a6ccae4 --- /dev/null +++ b/lib/tasks/invitation_logs.rake @@ -0,0 +1,6 @@ +namespace :invitation_logs do + desc 'Clean up expired invitation logs' + task cleanup: :environment do + CleanupExpiredInvitationLogsJob.perform_now + end +end diff --git a/spec/controllers/admin/workshop_invitation_logs_controller_spec.rb b/spec/controllers/admin/workshop_invitation_logs_controller_spec.rb new file mode 100644 index 000000000..c4cd5bf1b --- /dev/null +++ b/spec/controllers/admin/workshop_invitation_logs_controller_spec.rb @@ -0,0 +1,43 @@ +RSpec.describe Admin::WorkshopInvitationLogsController do + let(:workshop) { Fabricate(:workshop) } + let(:admin) { Fabricate(:member).tap { |m| m.add_role(:admin) } } + let(:regular_member) { Fabricate(:member) } + + before { login_as_admin(admin) } + + describe 'GET #index' do + it 'renders the index page' do + get :index, params: { workshop_id: workshop.id } + + expect(response).to have_http_status(:success) + end + + it 'denies access for regular members' do + other_chapter = Fabricate(:chapter) + login_as_organiser(regular_member, other_chapter) + + get :index, params: { workshop_id: workshop.id } + + expect(response).to have_http_status(:redirect) + end + end + + describe 'GET #show' do + let(:log) { Fabricate(:invitation_log, loggable: workshop) } + + it 'renders the show page' do + get :show, params: { workshop_id: workshop.id, id: log.id } + + expect(response).to have_http_status(:success) + end + + it 'denies access for regular members' do + other_chapter = Fabricate(:chapter) + login_as_organiser(regular_member, other_chapter) + + get :show, params: { workshop_id: workshop.id, id: log.id } + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/fabricators/invitation_log_entry_fabricator.rb b/spec/fabricators/invitation_log_entry_fabricator.rb new file mode 100644 index 000000000..d213a7b8a --- /dev/null +++ b/spec/fabricators/invitation_log_entry_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:invitation_log_entry) do + invitation_log + member + status 'success' + processed_at { Time.current } +end diff --git a/spec/fabricators/invitation_log_fabricator.rb b/spec/fabricators/invitation_log_fabricator.rb new file mode 100644 index 000000000..8652bac15 --- /dev/null +++ b/spec/fabricators/invitation_log_fabricator.rb @@ -0,0 +1,7 @@ +Fabricator(:invitation_log) do + loggable { Fabricate(:workshop) } + initiator { Fabricate(:member) } + audience 'students' + action 'invite' + status 'running' +end diff --git a/spec/jobs/cleanup_expired_invitation_logs_job_spec.rb b/spec/jobs/cleanup_expired_invitation_logs_job_spec.rb new file mode 100644 index 000000000..6522337ca --- /dev/null +++ b/spec/jobs/cleanup_expired_invitation_logs_job_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe CleanupExpiredInvitationLogsJob do + describe '#perform' do + it 'destroys invitation logs that have expired' do + expired_log = Fabricate(:invitation_log, expires_at: 1.day.ago) + valid_log = Fabricate(:invitation_log, expires_at: 1.day.from_now) + + described_class.new.perform + + expect(InvitationLog.exists?(expired_log.id)).to be false + expect(InvitationLog.exists?(valid_log.id)).to be true + end + + it 'destroys associated entries via dependent: :destroy' do + log = Fabricate(:invitation_log, expires_at: 1.day.ago) + entry = Fabricate(:invitation_log_entry, invitation_log: log) + + described_class.new.perform + + expect(InvitationLogEntry.exists?(entry.id)).to be false + end + end +end diff --git a/spec/models/invitation_log_entry_spec.rb b/spec/models/invitation_log_entry_spec.rb new file mode 100644 index 000000000..0c626b1e6 --- /dev/null +++ b/spec/models/invitation_log_entry_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe InvitationLogEntry do + describe 'associations' do + it { is_expected.to belong_to(:invitation_log) } + it { is_expected.to belong_to(:member) } + it { is_expected.to belong_to(:invitation).optional } + end + + describe 'enums' do + it 'defines status enum with string values' do + expect(InvitationLogEntry.statuses).to eq({ + 'success' => 'success', + 'failed' => 'failed', + 'skipped' => 'skipped' + }) + end + end + + describe 'validations' do + it 'validates uniqueness of member_id scoped to invitation' do + entry = Fabricate(:invitation_log_entry) + expect(entry).to validate_uniqueness_of(:member_id) + .scoped_to(:invitation_type, :invitation_id) + end + end +end diff --git a/spec/models/invitation_log_spec.rb b/spec/models/invitation_log_spec.rb new file mode 100644 index 000000000..9acef5cac --- /dev/null +++ b/spec/models/invitation_log_spec.rb @@ -0,0 +1,27 @@ +RSpec.describe InvitationLog do + describe 'associations' do + it { is_expected.to belong_to(:loggable) } + it { is_expected.to belong_to(:initiator).class_name('Member').optional } + it { is_expected.to belong_to(:chapter).optional } + it { is_expected.to have_many(:entries).class_name('InvitationLogEntry').dependent(:destroy) } + end + + describe 'enums' do + it 'defines action enum with string values' do + expect(InvitationLog.actions).to eq({ 'invite' => 'invite', 'reminder' => 'reminder', 'waiting_list_notification' => 'waiting_list_notification' }) + end + + it 'defines status enum with string values' do + expect(InvitationLog.statuses).to eq({ 'running' => 'running', 'completed' => 'completed', 'failed' => 'failed' }) + end + end + + describe 'before_create :set_expires_at' do + it 'sets expires_at to 180 days from now on save' do + log = Fabricate.build(:invitation_log) + expect(log.expires_at).to be_nil + log.save! + expect(log.expires_at).to be_within(1.second).of(180.days.from_now) + end + end +end diff --git a/spec/models/invitation_manager_logging_spec.rb b/spec/models/invitation_manager_logging_spec.rb new file mode 100644 index 000000000..5986e9d1e --- /dev/null +++ b/spec/models/invitation_manager_logging_spec.rb @@ -0,0 +1,101 @@ +RSpec.describe InvitationManager, :invitation_logging do + subject(:manager) { InvitationManager.new } + + let(:chapter) { Fabricate(:chapter) } + let(:workshop) { Fabricate(:workshop, chapter: chapter) } + let(:initiator) { Fabricate(:member) } + let(:students) { Fabricate.times(2, :member) } + let(:coaches) { Fabricate.times(2, :member) } + + before do + Fabricate(:students, chapter: chapter, members: students) + Fabricate(:coaches, chapter: chapter, members: coaches) + end + + describe '#send_workshop_emails with logging' do + it 'creates an InvitationLog when initiator_id is provided' do + expect do + manager.send_workshop_emails(workshop, 'students', initiator.id) + end.to change(InvitationLog, :count).by(1) + + log = InvitationLog.last + expect(log.loggable).to eq workshop + expect(log.initiator).to eq initiator + expect(log.audience).to eq 'students' + expect(log.action).to eq 'invite' + expect(log.status).to eq 'completed' + end + + it 'logs successful email sends' do + manager.send_workshop_emails(workshop, 'students', initiator.id) + + log = InvitationLog.last + expect(log.success_count).to eq students.count + expect(log.failure_count).to eq 0 + end + + it 'logs failed email sends' do + allow(WorkshopInvitationMailer).to receive(:invite_student).and_raise(StandardError.new('SMTP error')) + + manager.send_workshop_emails(workshop, 'students', initiator.id) + + log = InvitationLog.last + expect(log.failure_count).to eq students.count + expect(log.success_count).to eq 0 + end + + it 'does not create log when initiator_id is nil' do + expect do + manager.send_workshop_emails(workshop, 'students', nil) + end.not_to change(InvitationLog, :count) + end + + it 'prevents duplicate concurrent batches when start_batch is called' do + Fabricate(:invitation_log, loggable: workshop, audience: 'students', action: 'invite', status: :running) + + logger = InvitationLogger.new(workshop, initiator, 'students', :invite) + expect { logger.start_batch }.to raise_error(ActiveRecord::RecordNotUnique) + end + + it 'sets chapter_id on log' do + manager.send_workshop_emails(workshop, 'students', initiator.id) + + log = InvitationLog.last + expect(log.chapter_id).to eq workshop.chapter_id + end + + it 'sets total_invitees count correctly' do + manager.send_workshop_emails(workshop, 'students', initiator.id) + + log = InvitationLog.last + expect(log.total_invitees).to eq students.count + end + + it 'logs batch as failed when exception occurs' do + allow(WorkshopInvitationMailer).to receive(:invite_student).and_raise(StandardError.new('SMTP error')) + + manager.send_workshop_emails(workshop, 'students', initiator.id) + + log = InvitationLog.last + expect(log.status).to eq 'completed' + expect(log.error_message).to be_nil + end + end + + describe '#send_virtual_workshop_emails with logging' do + let(:workshop) { Fabricate(:virtual_workshop, chapter: chapter) } + + it 'creates an InvitationLog when initiator_id is provided' do + expect do + manager.send_virtual_workshop_emails(workshop, 'students', initiator.id) + end.to change(InvitationLog, :count).by(1) + end + + it 'logs successful email sends' do + manager.send_virtual_workshop_emails(workshop, 'students', initiator.id) + + log = InvitationLog.last + expect(log.success_count).to eq students.count + end + end +end diff --git a/spec/models/invitation_manager_spec.rb b/spec/models/invitation_manager_spec.rb index 0e7215c2d..39ba6f75c 100644 --- a/spec/models/invitation_manager_spec.rb +++ b/spec/models/invitation_manager_spec.rb @@ -20,7 +20,7 @@ include_examples 'sending workshop emails' end - context '#send_event_emails' do + describe '#send_event_emails' do let!(:student_group) { Fabricate(:students, chapter: chapter, members: students) } let!(:coaches_group) { Fabricate(:coaches, chapter: chapter, members: coaches) } @@ -31,7 +31,7 @@ end coaches.each do |student| - expect(Invitation).to_not receive(:new).with(event: event, member: student, role: 'Coach').and_call_original + expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Coach').and_call_original end manager.send_event_emails(event, chapter) @@ -41,7 +41,7 @@ event = Fabricate(:event, chapters: [chapter], audience: 'Coaches') students.each do |student| - expect(Invitation).to_not receive(:new).with(event: event, member: student, role: 'Student').and_call_original + expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Student').and_call_original end coaches.each do |student| @@ -71,17 +71,17 @@ first_student, *other_students = students first_student.update(accepted_toc_at: nil) - expect(Invitation).to_not( - receive(:new). - with(event: event, member: first_student, role: 'Student'). - and_call_original + expect(Invitation).not_to( + receive(:new) + .with(event: event, member: first_student, role: 'Student') + .and_call_original ) other_students.each do |other_student| expect(Invitation).to( - receive(:new). - with(event: event, member: other_student, role: 'Student'). - and_call_original + receive(:new) + .with(event: event, member: other_student, role: 'Student') + .and_call_original ) end @@ -94,17 +94,17 @@ first_coach, *other_coaches = coaches first_coach.update(accepted_toc_at: nil) - expect(Invitation).to_not( - receive(:new). - with(event: event, member: first_coach, role: 'Coach'). - and_call_original + expect(Invitation).not_to( + receive(:new) + .with(event: event, member: first_coach, role: 'Coach') + .and_call_original ) other_coaches.each do |other_coach| expect(Invitation).to( - receive(:new). - with(event: event, member: other_coach, role: 'Coach'). - and_call_original + receive(:new) + .with(event: event, member: other_coach, role: 'Coach') + .and_call_original ) end @@ -112,7 +112,7 @@ end end - describe '#send_monthly_attendance_reminder_emails', wip: true do + describe '#send_monthly_attendance_reminder_emails', :wip do it 'emails all attending members' do meeting = Fabricate(:meeting) attendees = Fabricate.times(2, :attending_meeting_invitation, meeting: meeting).map(&:member) @@ -137,11 +137,11 @@ end manager.send_workshop_attendance_reminders(workshop) - invitations.each { |invitation| expect(invitation.reload.reminded_at).to_not be_nil } + invitations.each { |invitation| expect(invitation.reload.reminded_at).not_to be_nil } end end - describe '#send_workshop_waiting_list_reminders', wip: true do + describe '#send_workshop_waiting_list_reminders', :wip do it 'emails everyone that hasn\'t already been reminded from the workshop\'s waitinglist' do workshop = Fabricate(:workshop) invitations = Fabricate.times(2, :waitinglist_invitation, workshop: workshop) @@ -154,12 +154,12 @@ end reminded_invitations.each do |invitation| - expect(WorkshopInvitationMailer).to_not receive(:waiting_list_reminder) + expect(WorkshopInvitationMailer).not_to receive(:waiting_list_reminder) .with(workshop, invitation.member, invitation) end manager.send_workshop_waiting_list_reminders(workshop) - invitations.each { |invitation| expect(invitation.reload.reminded_at).to_not be_nil } + invitations.each { |invitation| expect(invitation.reload.reminded_at).not_to be_nil } end end @@ -240,4 +240,50 @@ manager.send_meeting_emails(meeting) end end + + describe '#create_invitation resilience' do + let(:member) { Fabricate(:member) } + + it 'returns nil when find_or_create_by raises an exception' do + allow(WorkshopInvitation).to receive(:find_or_create_by) + .and_raise(StandardError.new('database error')) + + result = manager.send(:create_invitation, workshop, member, 'Student') + + expect(result).to be_nil + end + + it 'logs error with member_id and workshop_id but no PII' do + allow(WorkshopInvitation).to receive(:find_or_create_by) + .and_raise(StandardError.new('database error')) + + expect(Rails.logger).to receive(:error) do |message| + expect(message).to include("member_id=#{member.id}") + expect(message).to include("workshop_id=#{workshop.id}") + expect(message).to include('role=Student') + expect(message).not_to include(member.email) + expect(message).not_to include(member.name) + end + + manager.send(:create_invitation, workshop, member, 'Student') + end + + it 'continues processing when invitation creation fails for one member' do + Fabricate(:students, chapter: chapter, members: students) + call_count = 0 + + allow(WorkshopInvitation).to receive(:find_or_create_by) do + call_count += 1 + if call_count == 1 + raise StandardError.new('database error') + end + + WorkshopInvitation.new(persisted?: true) + end + + expect do + manager.send_workshop_emails(workshop, 'students') + end.not_to raise_error + end + end end diff --git a/spec/policies/invitation_log_policy_spec.rb b/spec/policies/invitation_log_policy_spec.rb new file mode 100644 index 000000000..df3ef140a --- /dev/null +++ b/spec/policies/invitation_log_policy_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe InvitationLogPolicy do + subject { described_class.new(user, invitation_log) } + + let(:workshop) { Fabricate(:workshop) } + let(:invitation_log) { Fabricate(:invitation_log, loggable: workshop) } + let(:admin) { Fabricate(:member).tap { |m| m.add_role(:admin) } } + let(:chapter_organiser) do + member = Fabricate(:member) + member.add_role(:organiser, workshop.chapter) + member + end + let(:regular_member) { Fabricate(:member) } + + describe '#index?' do + context 'when user is admin' do + let(:user) { admin } + + it 'permits access' do + expect(subject.index?).to be true + end + end + + context 'when user is chapter organiser' do + let(:user) { chapter_organiser } + + it 'permits access' do + expect(subject.index?).to be true + end + end + + context 'when user is regular member' do + let(:user) { regular_member } + + it 'denies access' do + expect(subject.index?).to be false + end + end + end + + describe '#show?' do + context 'when user is admin' do + let(:user) { admin } + + it 'permits access' do + expect(subject.show?).to be true + end + end + + context 'when user is chapter organiser' do + let(:user) { chapter_organiser } + + it 'permits access' do + expect(subject.show?).to be true + end + end + + context 'when user is regular member' do + let(:user) { regular_member } + + it 'denies access' do + expect(subject.show?).to be false + end + end + end +end diff --git a/spec/services/invitation_logger_spec.rb b/spec/services/invitation_logger_spec.rb new file mode 100644 index 000000000..934d228d2 --- /dev/null +++ b/spec/services/invitation_logger_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +RSpec.describe InvitationLogger do + let(:workshop) { Fabricate(:workshop) } + let(:initiator) { Fabricate(:member) } + let(:member) { Fabricate(:member) } + let(:invitation) { Fabricate(:workshop_invitation, workshop: workshop, member: member) } + + describe '#start_batch' do + it 'creates an InvitationLog record' do + logger = described_class.new(workshop, initiator, 'students', :invite) + log = logger.start_batch + + expect(log).to be_persisted + expect(log.loggable).to eq workshop + expect(log.initiator).to eq initiator + expect(log.audience).to eq 'students' + expect(log.action).to eq 'invite' + expect(log.status).to eq 'running' + end + + it 'raises error if a running batch already exists' do + Fabricate(:invitation_log, loggable: workshop, audience: 'students', action: 'invite', status: :running) + logger = described_class.new(workshop, initiator, 'students', :invite) + + expect { logger.start_batch }.to raise_error(ActiveRecord::RecordNotUnique) + end + + it 'sets chapter_id from loggable' do + logger = described_class.new(workshop, initiator, 'students', :invite) + log = logger.start_batch + + expect(log.chapter_id).to eq workshop.chapter_id + end + end + + describe '#log_success' do + let(:logger) { described_class.new(workshop, initiator, 'students', :invite) } + let!(:log) { logger.start_batch } + + it 'creates entry with success status and increments count' do + entry = logger.log_success(member, invitation) + + expect(entry.status).to eq 'success' + expect(entry.member).to eq member + expect(entry.invitation).to eq invitation + expect(log.reload.success_count).to eq 1 + end + end + + describe '#log_failure' do + let(:logger) { described_class.new(workshop, initiator, 'students', :invite) } + let!(:log) { logger.start_batch } + + it 'creates entry with failure status and increments count' do + error = StandardError.new('SMTP error') + entry = logger.log_failure(member, invitation, error) + + expect(entry.status).to eq 'failed' + expect(entry.failure_reason).to eq 'SMTP error' + expect(log.reload.failure_count).to eq 1 + end + end + + describe '#log_skipped' do + let(:logger) { described_class.new(workshop, initiator, 'students', :invite) } + let!(:log) { logger.start_batch } + + it 'creates entry with skipped status and increments count' do + entry = logger.log_skipped(member, invitation, 'Already invited') + + expect(entry.status).to eq 'skipped' + expect(entry.failure_reason).to eq 'Already invited' + expect(log.reload.skipped_count).to eq 1 + end + end + + describe '#finish_batch' do + let(:logger) { described_class.new(workshop, initiator, 'students', :invite) } + let!(:log) { logger.start_batch } + + it 'updates status to completed with counts' do + 2.times { logger.log_success(Fabricate(:member), Fabricate(:workshop_invitation, workshop: workshop)) } + logger.log_failure(Fabricate(:member), Fabricate(:workshop_invitation, workshop: workshop), StandardError.new('err')) + + logger.finish_batch(5) + + expect(log.reload.status).to eq 'completed' + expect(log.total_invitees).to eq 5 + expect(log.completed_at).to be_present + end + end + + describe '#fail_batch' do + let(:logger) { described_class.new(workshop, initiator, 'students', :invite) } + let!(:log) { logger.start_batch } + + it 'updates status to failed with error message' do + error = StandardError.new('Something went wrong') + logger.fail_batch(error) + + expect(log.reload.status).to eq 'failed' + expect(log.error_message).to eq 'Something went wrong' + expect(log.completed_at).to be_present + end + end +end diff --git a/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb b/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb index ac5c45c3c..783e65d8c 100644 --- a/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb +++ b/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb @@ -1,9 +1,9 @@ RSpec.shared_examples 'sending workshop emails' do it 'creates an invitation for each student' do - student_group = Fabricate(:students, chapter: chapter, members: students) + Fabricate(:students, chapter: chapter, members: students) students.each do |student| - expect(WorkshopInvitation).to receive(:create).with(workshop: workshop, member: student, role: 'Student').and_call_original + expect(WorkshopInvitation).to receive(:find_or_create_by).with(workshop: workshop, member: student, role: 'Student').and_call_original expect(mailer).to receive(:invite_student).and_call_original end @@ -11,10 +11,10 @@ end it 'creates an invitation for each coach' do - coach_group = Fabricate(:coaches, chapter: chapter, members: coaches) + Fabricate(:coaches, chapter: chapter, members: coaches) coaches.each do |coach| - expect(WorkshopInvitation).to receive(:create).with(workshop: workshop, member: coach, role: 'Coach').and_call_original + expect(WorkshopInvitation).to receive(:find_or_create_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original expect(mailer).to receive(:invite_coach).and_call_original end @@ -23,32 +23,32 @@ it 'does not invite banned coaches' do banned_coach = Fabricate(:banned_member) - coach_group = Fabricate(:coaches, chapter: chapter, members: coaches + [banned_coach]) + Fabricate(:coaches, chapter: chapter, members: coaches + [banned_coach]) coaches.each do |coach| - expect(WorkshopInvitation).to receive(:create).with(workshop: workshop, member: coach, role: 'Coach').and_call_original + expect(WorkshopInvitation).to receive(:find_or_create_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original end - expect(WorkshopInvitation).to_not receive(:create).with(workshop: workshop, member: banned_coach, role: 'Coach') + expect(WorkshopInvitation).not_to receive(:find_or_create_by).with(workshop: workshop, member: banned_coach, role: 'Coach') manager.send(send_email, workshop, 'coaches') end it 'sends emails when a WorkshopInvitation is created' do - student_group = Fabricate(:students, chapter: chapter, members: students) - coach_group = Fabricate(:coaches, chapter: chapter, members: coaches) + Fabricate(:students, chapter: chapter, members: students) + Fabricate(:coaches, chapter: chapter, members: coaches) expect do manager.send(send_email, workshop, 'everyone') end.to change { ActionMailer::Base.deliveries.count }.by(students.count + coaches.count) end - it "does not send emails when no invitation is created" do - student_group = Fabricate(:students, chapter: chapter, members: students) + it 'does not send emails when invitation creation returns nil' do + Fabricate(:students, chapter: chapter, members: students) - expect(WorkshopInvitation).to receive(:create).and_return(WorkshopInvitation.new).exactly(students.count) + expect(WorkshopInvitation).to receive(:find_or_create_by).and_return(nil).exactly(students.count) expect do manager.send(send_email, workshop, 'students') - end.not_to change { ActionMailer::Base.deliveries.count } + end.not_to(change { ActionMailer::Base.deliveries.count }) end end