From 9de564a6d14fd876b1dd857af3c843e4f5a6ac87 Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:48:20 +0000 Subject: [PATCH 01/10] feat: add chapter member contact flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 74 +++++++++ app/mailers/chapter_contact_mailer.rb | 10 ++ app/policies/chapter_policy.rb | 8 + .../admin/chapter_contacts/index.html.haml | 10 ++ .../admin/chapter_contacts/new.html.haml | 15 ++ .../chapter_contact_mailer/contact.html.haml | 1 + app/views/layouts/_member_menu.html.haml | 4 + config/routes.rb | 2 + .../admin/contacting_chapter_members_spec.rb | 142 ++++++++++++++++++ 9 files changed, 266 insertions(+) create mode 100644 app/controllers/admin/chapter_contacts_controller.rb create mode 100644 app/mailers/chapter_contact_mailer.rb create mode 100644 app/views/admin/chapter_contacts/index.html.haml create mode 100644 app/views/admin/chapter_contacts/new.html.haml create mode 100644 app/views/chapter_contact_mailer/contact.html.haml create mode 100644 spec/features/admin/contacting_chapter_members_spec.rb diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb new file mode 100644 index 000000000..f90c246ce --- /dev/null +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -0,0 +1,74 @@ +class Admin::ChapterContactsController < Admin::ApplicationController + before_action :set_chapter, only: %i[new create] + after_action :verify_authorized + + def index + authorize Chapter, :contact_index? + @chapters = accessible_chapters + end + + def new + authorize @chapter, :contact? + @recipient_count = recipients.count + end + + def create + authorize @chapter, :contact? + @subject, @message = contact_params.values_at(:subject, :message) + @recipient_count = recipients.count + + return render_new unless valid_contact? + + send_contact_emails + redirect_to new_admin_chapter_contact_path(@chapter), + notice: "Email sent to #{@recipient_count} chapter members" + end + + private + + def contact_params + params.expect(chapter_contact: %i[subject message]) + end + + def recipients + @chapter.members.distinct + end + + def accessible_chapters + return Chapter.order(:name) if current_user.is_admin? || global_organiser? + + current_user.organised_chapters.order(:name) + end + + def global_organiser? + current_user.roles.where(name: 'organiser', resource_type: nil, resource_id: nil).exists? + end + + def valid_contact? + return true if @subject.present? && @message.present? && @recipient_count.positive? + + flash.now[:alert] = contact_error + false + end + + def contact_error + return 'Subject and message are required.' if @subject.blank? || @message.blank? + + 'There are no current chapter subscribers to email.' + end + + def render_new + render :new, status: :unprocessable_entity + end + + def send_contact_emails + recipients.find_each do |member| + ChapterContactMailer.with(chapter: @chapter, recipient: member, subject: @subject, message: @message) + .contact.deliver_later + end + end + + def set_chapter + @chapter = Chapter.find(params[:chapter_id]) + end +end diff --git a/app/mailers/chapter_contact_mailer.rb b/app/mailers/chapter_contact_mailer.rb new file mode 100644 index 000000000..6a834b96d --- /dev/null +++ b/app/mailers/chapter_contact_mailer.rb @@ -0,0 +1,10 @@ +class ChapterContactMailer < ApplicationMailer + include EmailHeaderHelper + + def contact + @chapter = params.fetch(:chapter) + @message = params.fetch(:message) + + mail_to_member(params.fetch(:recipient), params.fetch(:subject), @chapter.email, &:html) + end +end diff --git a/app/policies/chapter_policy.rb b/app/policies/chapter_policy.rb index c3a798683..fea895cd8 100644 --- a/app/policies/chapter_policy.rb +++ b/app/policies/chapter_policy.rb @@ -23,6 +23,14 @@ def members? admin_or_organiser? end + def contact? + user.is_admin? || user.has_role?(:organiser, record) + end + + def contact_index? + user.is_admin? || user.has_role?(:organiser, :any) + end + private def admin_or_organiser? diff --git a/app/views/admin/chapter_contacts/index.html.haml b/app/views/admin/chapter_contacts/index.html.haml new file mode 100644 index 000000000..0e465e6c8 --- /dev/null +++ b/app/views/admin/chapter_contacts/index.html.haml @@ -0,0 +1,10 @@ +.container.py-4 + %h1 Contact a chapter + - if @chapters.any? + %ul.list-group + - @chapters.each do |chapter| + %li.list-group-item.d-flex.justify-content-between.align-items-center + = chapter.name + = link_to 'Contact members', new_admin_chapter_contact_path(chapter), class: 'btn btn-primary' + - else + %p You are not an organiser for any chapters. diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml new file mode 100644 index 000000000..3500c8f36 --- /dev/null +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -0,0 +1,15 @@ +.container.py-4 + %h1 Contact #{@chapter.name} members + %p= "#{@recipient_count} recipients" + + - if flash[:alert] + .alert.alert-danger= flash[:alert] + + = form_with scope: :chapter_contact, url: admin_chapter_contact_path(@chapter) do |form| + .mb-3 + = form.label :subject, class: 'form-label' + = form.text_field :subject, class: 'form-control', required: true + .mb-3 + = form.label :message, class: 'form-label' + = form.text_area :message, class: 'form-control', rows: 8, required: true + = form.submit 'Send email', class: 'btn btn-primary' diff --git a/app/views/chapter_contact_mailer/contact.html.haml b/app/views/chapter_contact_mailer/contact.html.haml new file mode 100644 index 000000000..a04366554 --- /dev/null +++ b/app/views/chapter_contact_mailer/contact.html.haml @@ -0,0 +1 @@ += simple_format(@message) diff --git a/app/views/layouts/_member_menu.html.haml b/app/views/layouts/_member_menu.html.haml index b99d7c62b..c22fe4fc2 100644 --- a/app/views/layouts/_member_menu.html.haml +++ b/app/views/layouts/_member_menu.html.haml @@ -14,6 +14,10 @@ %li = link_to invitations_path, class: 'dropdown-item' do Invitations + - if current_user.is_admin? || current_user.has_role?(:organiser, :any) + %li + = link_to admin_chapter_contacts_path, class: 'dropdown-item' do + Contact chapter %li = link_to subscriptions_path, class: 'dropdown-item' do Manage subscriptions diff --git a/config/routes.rb b/config/routes.rb index bf3fa5d86..6560e669f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -94,6 +94,7 @@ get '/guide' => 'portal#guide', as: :guide resources :announcements, only: %i[new index create edit update] + resources :chapter_contacts, only: [:index] resources :members, only: %i[show index] do get :search, on: :collection @@ -112,6 +113,7 @@ resources :workshops, only: [:index] resources :feedback, only: [:index], controller: 'chapters/feedback' resources :organisers, only: %i[index create destroy], controller: 'chapters/organisers' + resource :contact, only: %i[new create], controller: 'chapter_contacts' end resources :stats, only: [:index] diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb new file mode 100644 index 000000000..5d9730bf3 --- /dev/null +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.feature 'Contacting chapter members' do + let(:organiser) { Fabricate(:member) } + let(:chapter) { Fabricate(:chapter) } + + context 'when a chapter organiser' do + let(:students) { Fabricate(:group, name: 'Students', chapter:) } + let(:coaches) { Fabricate(:group, name: 'Coaches', chapter:) } + let(:student) { Fabricate(:member) } + let(:coach) { Fabricate(:member) } + let(:both) { Fabricate(:member) } + let(:former_member) { Fabricate(:member) } + let(:other_chapter) { Fabricate(:chapter) } + let(:other_chapter_member) { Fabricate(:member) } + + before do + organiser.add_role(:organiser, chapter) + Fabricate(:subscription, member: student, group: students) + Fabricate(:subscription, member: coach, group: coaches) + Fabricate(:subscription, member: both, group: students) + Fabricate(:subscription, member: both, group: coaches) + Fabricate(:discarded_subscription, member: former_member, group: students) + other_group = Fabricate(:group, name: 'Students', chapter: other_chapter) + Fabricate(:subscription, member: other_chapter_member, group: other_group) + end + + scenario 'emails current chapter subscribers individually' do + login(organiser) + open_contact_menu + expect(page).to have_no_link('Contact members', href: new_admin_chapter_contact_path(other_chapter)) + click_on 'Contact members' + expect(page).to have_text('3 recipients') + fill_in 'Subject', with: 'Workshop update' + fill_in 'Message', with: 'The workshop starts at 6pm.' + ActionMailer::Base.deliveries.clear + + click_on 'Send email' + + expect(page).to have_text('Email sent to 3 chapter members') + expect_email_fanout( + recipients: [student, coach, both], + excluded: [former_member, other_chapter_member] + ) + end + end + + scenario 'admin can access any chapter contact form' do + admin = Fabricate(:member) + Fabricate(:group, name: 'Students', chapter:) + login_as_admin(admin) + + visit root_path + click_on 'Menu' + click_on 'Contact chapter' + click_on 'Contact members' + + expect(page).to have_current_path(new_admin_chapter_contact_path(chapter)) + end + + scenario 'regular members cannot see or directly access chapter contact' do + member = Fabricate(:member) + Fabricate(:group, name: 'Students', chapter:) + login(member) + ActionMailer::Base.deliveries.clear + + visit root_path + click_on 'Menu' + expect(page).to have_no_link('Contact chapter') + + visit new_admin_chapter_contact_path(chapter) + expect(current_url).to eq(root_url) + expect(ActionMailer::Base.deliveries).to be_empty + end + + scenario 'organiser of another chapter cannot access this chapter contact' do + other_chapter = Fabricate(:chapter) + other_organiser = Fabricate(:member) + other_organiser.add_role(:organiser, other_chapter) + login(other_organiser) + + visit new_admin_chapter_contact_path(chapter) + + expect(current_url).to eq(root_url) + end + + scenario 'does not send when the chapter has no current subscribers' do + organiser.add_role(:organiser, chapter) + login(organiser) + visit new_admin_chapter_contact_path(chapter) + fill_in 'Subject', with: 'Workshop update' + fill_in 'Message', with: 'The workshop starts at 6pm.' + ActionMailer::Base.deliveries.clear + + click_on 'Send email' + + expect(page).to have_text('There are no current chapter subscribers to email.') + expect(ActionMailer::Base.deliveries).to be_empty + end + + def open_contact_menu + visit root_path + click_on 'Menu' + click_on 'Contact chapter' + end + + def expect_email_fanout(recipients:, excluded:) + expect_delivered_to(recipients) + expect_no_delivery_to(excluded) + expect_individual_emails(recipients.size) + end + + def expect_delivered_to(recipients) + recipients.each { |member| expect(delivered_emails_to(member).one?).to be(true) } + end + + def expect_no_delivery_to(members) + members.each { |member| expect(delivered_emails_to(member)).to be_empty } + end + + def expect_individual_emails(count) + emails = ActionMailer::Base.deliveries + expect(emails.size).to eq(count) + emails.each { |email| expect_individual_email(email) } + end + + def expect_individual_email(email) + expect_mail_privacy(email) + expect_mail_content(email) + end + + def expect_mail_privacy(email) + expect(email.to.size).to eq(1) + expect(email.cc).to be_empty + expect(email.bcc).to be_empty + end + + def expect_mail_content(email) + expect(email.subject).to eq('Workshop update') + expect(email.body.encoded).to include('The workshop starts at 6pm.') + end +end From 6a5953c457cd3b450b5abb24f601afd84d75a274 Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:24:44 +0000 Subject: [PATCH 02/10] feat: add chapter contact preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 17 +++++++--- .../admin/chapter_contacts/new.html.haml | 8 ++--- .../admin/chapter_contacts/preview.html.haml | 11 +++++++ config/routes.rb | 4 ++- .../admin/contacting_chapter_members_spec.rb | 32 +++++++++++++++++-- 5 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 app/views/admin/chapter_contacts/preview.html.haml diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index f90c246ce..0a0cb12a8 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -1,5 +1,5 @@ class Admin::ChapterContactsController < Admin::ApplicationController - before_action :set_chapter, only: %i[new create] + before_action :set_chapter, only: %i[new preview create] after_action :verify_authorized def index @@ -12,6 +12,17 @@ def new @recipient_count = recipients.count end + def preview + authorize @chapter, :contact? + @subject, @message = contact_params.values_at(:subject, :message) + @recipient_count = recipients.count + + return render_new unless valid_contact? + + @recipients = recipients.order(:name, :surname) + render :preview + end + def create authorize @chapter, :contact? @subject, @message = contact_params.values_at(:subject, :message) @@ -57,9 +68,7 @@ def contact_error 'There are no current chapter subscribers to email.' end - def render_new - render :new, status: :unprocessable_entity - end + def render_new = render :new, status: :unprocessable_entity def send_contact_emails recipients.find_each do |member| diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index 3500c8f36..2cfa0c9f5 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -5,11 +5,11 @@ - if flash[:alert] .alert.alert-danger= flash[:alert] - = form_with scope: :chapter_contact, url: admin_chapter_contact_path(@chapter) do |form| + = form_with scope: :chapter_contact, url: preview_admin_chapter_contact_path(@chapter) do |form| .mb-3 = form.label :subject, class: 'form-label' - = form.text_field :subject, class: 'form-control', required: true + = form.text_field :subject, class: 'form-control', required: true, value: @subject .mb-3 = form.label :message, class: 'form-label' - = form.text_area :message, class: 'form-control', rows: 8, required: true - = form.submit 'Send email', class: 'btn btn-primary' + = form.text_area :message, class: 'form-control', rows: 8, required: true, value: @message + = form.submit 'Preview recipients', class: 'btn btn-primary' diff --git a/app/views/admin/chapter_contacts/preview.html.haml b/app/views/admin/chapter_contacts/preview.html.haml new file mode 100644 index 000000000..e5b534ca2 --- /dev/null +++ b/app/views/admin/chapter_contacts/preview.html.haml @@ -0,0 +1,11 @@ +.container.py-4 + %h1 Confirm email to #{@chapter.name} + %p= "#{@recipient_count} #{'recipient'.pluralize(@recipient_count)} will receive this email" + %ul + - @recipients.each do |recipient| + %li= recipient.full_name + + = form_with scope: :chapter_contact, url: admin_chapter_contact_path(@chapter) do |form| + = form.hidden_field :subject, value: @subject + = form.hidden_field :message, value: @message + = form.submit 'Confirm and send', class: 'btn btn-primary' diff --git a/config/routes.rb b/config/routes.rb index 6560e669f..8c573f137 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,7 +113,9 @@ resources :workshops, only: [:index] resources :feedback, only: [:index], controller: 'chapters/feedback' resources :organisers, only: %i[index create destroy], controller: 'chapters/organisers' - resource :contact, only: %i[new create], controller: 'chapter_contacts' + resource :contact, only: %i[new create], controller: 'chapter_contacts' do + post :preview, on: :collection + end end resources :stats, only: [:index] diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index 5d9730bf3..2e7d963a2 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -25,7 +25,7 @@ Fabricate(:subscription, member: other_chapter_member, group: other_group) end - scenario 'emails current chapter subscribers individually' do + scenario 'previews current subscribers and emails each individually after confirmation' do login(organiser) open_contact_menu expect(page).to have_no_link('Contact members', href: new_admin_chapter_contact_path(other_chapter)) @@ -35,7 +35,10 @@ fill_in 'Message', with: 'The workshop starts at 6pm.' ActionMailer::Base.deliveries.clear - click_on 'Send email' + click_on 'Preview recipients' + + expect_preview([student, coach, both]) + click_on 'Confirm and send' expect(page).to have_text('Email sent to 3 chapter members') expect_email_fanout( @@ -92,7 +95,7 @@ fill_in 'Message', with: 'The workshop starts at 6pm.' ActionMailer::Base.deliveries.clear - click_on 'Send email' + click_on 'Preview recipients' expect(page).to have_text('There are no current chapter subscribers to email.') expect(ActionMailer::Base.deliveries).to be_empty @@ -110,6 +113,29 @@ def expect_email_fanout(recipients:, excluded:) expect_individual_emails(recipients.size) end + def expect_preview(recipients) + expect_preview_count(recipients.size) + expect_recipient_names(recipients) + expect_recipient_addresses_hidden(recipients) + expect_no_preview_delivery + end + + def expect_preview_count(count) + expect(page).to have_text("#{count} recipients will receive this email") + end + + def expect_recipient_names(recipients) + recipients.each { |recipient| expect(page).to have_text(recipient.full_name) } + end + + def expect_recipient_addresses_hidden(recipients) + recipients.each { |recipient| expect(page).to have_no_text(recipient.email) } + end + + def expect_no_preview_delivery + expect(ActionMailer::Base.deliveries).to be_empty + end + def expect_delivered_to(recipients) recipients.each { |member| expect(delivered_emails_to(member).one?).to be(true) } end From 9f5314205b453540c3803abdde1a6145f75f4301 Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:46:40 +0000 Subject: [PATCH 03/10] feat: require chapter contact confirmation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 75 ++++++--- app/models/chapter_contact_confirmation.rb | 41 +++++ .../admin/chapter_contacts/preview.html.haml | 5 +- ...00_create_chapter_contact_confirmations.rb | 18 +++ db/schema.rb | 21 ++- .../admin/chapter_contacts_controller_spec.rb | 143 ++++++++++++++++++ .../admin/contacting_chapter_members_spec.rb | 3 + 7 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 app/models/chapter_contact_confirmation.rb create mode 100644 db/migrate/20261110143000_create_chapter_contact_confirmations.rb create mode 100644 spec/controllers/admin/chapter_contacts_controller_spec.rb diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index 0a0cb12a8..c1addc7b3 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -14,37 +14,71 @@ def new def preview authorize @chapter, :contact? - @subject, @message = contact_params.values_at(:subject, :message) - @recipient_count = recipients.count - - return render_new unless valid_contact? + @subject, @message = preview_params.values_at(:subject, :message) + recipient_ids = recipients.pluck(:id) + return render_new unless valid_contact?(recipient_ids) - @recipients = recipients.order(:name, :surname) + create_confirmation(recipient_ids) render :preview end def create authorize @chapter, :contact? - @subject, @message = contact_params.values_at(:subject, :message) - @recipient_count = recipients.count + token = confirmation_token + return reject_confirmation unless token.is_a?(String) && token.present? - return render_new unless valid_contact? + confirmation = find_confirmation(token) + return reject_confirmation unless confirmation + + return reject_confirmation unless confirmation.consume! { |record| send_contact_emails(record) } - send_contact_emails redirect_to new_admin_chapter_contact_path(@chapter), - notice: "Email sent to #{@recipient_count} chapter members" + notice: "Email sent to #{confirmation.recipient_ids.size} chapter members" end private - def contact_params + def preview_params params.expect(chapter_contact: %i[subject message]) end + def confirmation_token + params[:chapter_contact]&.[](:confirmation_token) + end + def recipients @chapter.members.distinct end + def valid_contact?(recipient_ids) + @recipient_count = recipient_ids.size + return true if @subject.present? && @message.present? && recipient_ids.any? + + flash.now[:alert] = contact_error + false + end + + def create_confirmation(recipient_ids) + confirmation = ChapterContactConfirmation.create_for!( + chapter: @chapter, + sender: current_user, + subject: @subject, + message: @message, + recipient_ids: + ) + @confirmation_token = confirmation.token + @recipient_count = confirmation.recipient_ids.size + @recipients = Member.where(id: confirmation.recipient_ids).order(:name, :surname) + end + + def find_confirmation(token) + ChapterContactConfirmation.find_by( + chapter: @chapter, + sender: current_user, + token_digest: Digest::SHA256.hexdigest(token) + ) + end + def accessible_chapters return Chapter.order(:name) if current_user.is_admin? || global_organiser? @@ -55,24 +89,23 @@ def global_organiser? current_user.roles.where(name: 'organiser', resource_type: nil, resource_id: nil).exists? end - def valid_contact? - return true if @subject.present? && @message.present? && @recipient_count.positive? - - flash.now[:alert] = contact_error - false - end - def contact_error return 'Subject and message are required.' if @subject.blank? || @message.blank? 'There are no current chapter subscribers to email.' end + def reject_confirmation + redirect_to new_admin_chapter_contact_path(@chapter), + alert: 'Email preview expired or already used; please preview again.' + end + def render_new = render :new, status: :unprocessable_entity - def send_contact_emails - recipients.find_each do |member| - ChapterContactMailer.with(chapter: @chapter, recipient: member, subject: @subject, message: @message) + def send_contact_emails(confirmation) + Member.where(id: confirmation.recipient_ids).find_each do |member| + ChapterContactMailer.with(chapter: confirmation.chapter, recipient: member, + subject: confirmation.subject, message: confirmation.message) .contact.deliver_later end end diff --git a/app/models/chapter_contact_confirmation.rb b/app/models/chapter_contact_confirmation.rb new file mode 100644 index 000000000..93f7b2bc0 --- /dev/null +++ b/app/models/chapter_contact_confirmation.rb @@ -0,0 +1,41 @@ +class ChapterContactConfirmation < ApplicationRecord + LIFETIME = 30.minutes + + belongs_to :chapter + belongs_to :sender, class_name: 'Member' + + validates :token_digest, :subject, :message, :expires_at, presence: true + validates :token_digest, uniqueness: true + validates :recipient_ids, presence: true + + attr_accessor :token + + def self.create_for!(chapter:, sender:, subject:, message:, recipient_ids:) + token = SecureRandom.urlsafe_base64(32) + create!( + chapter:, + sender:, + subject:, + message:, + recipient_ids: recipient_ids.uniq, + token_digest: Digest::SHA256.hexdigest(token), + expires_at: LIFETIME.from_now + ).tap { |confirmation| confirmation.token = token } + end + + def consume!(now: Time.current) + consumed = false + with_lock do + if usable_at?(now) + update!(consumed_at: now) + yield self + consumed = true + end + end + consumed + end + + def usable_at?(time) + consumed_at.nil? && expires_at > time + end +end diff --git a/app/views/admin/chapter_contacts/preview.html.haml b/app/views/admin/chapter_contacts/preview.html.haml index e5b534ca2..b4ab03b08 100644 --- a/app/views/admin/chapter_contacts/preview.html.haml +++ b/app/views/admin/chapter_contacts/preview.html.haml @@ -1,11 +1,10 @@ .container.py-4 %h1 Confirm email to #{@chapter.name} - %p= "#{@recipient_count} #{'recipient'.pluralize(@recipient_count)} will receive this email" + %p #{@recipient_count} #{'recipient'.pluralize(@recipient_count)} will receive this email %ul - @recipients.each do |recipient| %li= recipient.full_name = form_with scope: :chapter_contact, url: admin_chapter_contact_path(@chapter) do |form| - = form.hidden_field :subject, value: @subject - = form.hidden_field :message, value: @message + = form.hidden_field :confirmation_token, value: @confirmation_token = form.submit 'Confirm and send', class: 'btn btn-primary' diff --git a/db/migrate/20261110143000_create_chapter_contact_confirmations.rb b/db/migrate/20261110143000_create_chapter_contact_confirmations.rb new file mode 100644 index 000000000..8caa468c9 --- /dev/null +++ b/db/migrate/20261110143000_create_chapter_contact_confirmations.rb @@ -0,0 +1,18 @@ +class CreateChapterContactConfirmations < ActiveRecord::Migration[8.1] + def change + create_table :chapter_contact_confirmations do |t| + t.references :chapter, null: false, foreign_key: true + t.references :sender, null: false, foreign_key: { to_table: :members } + t.string :token_digest, null: false + t.string :subject, null: false + t.text :message, null: false + t.jsonb :recipient_ids, null: false, default: [] + t.datetime :expires_at, null: false + t.datetime :consumed_at + t.timestamps + end + + add_index :chapter_contact_confirmations, :token_digest, unique: true + add_index :chapter_contact_confirmations, :expires_at + end +end diff --git a/db/schema.rb b/db/schema.rb index fa1431a2d..54f976ec1 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_10_13_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_11_10_143000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -92,6 +92,23 @@ t.index ["member_id"], name: "index_bans_on_member_id" end + create_table "chapter_contact_confirmations", force: :cascade do |t| + t.bigint "chapter_id", null: false + t.bigint "sender_id", null: false + t.string "token_digest", null: false + t.string "subject", null: false + t.text "message", null: false + t.jsonb "recipient_ids", default: [], null: false + t.datetime "expires_at", null: false + t.datetime "consumed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["chapter_id"], name: "index_chapter_contact_confirmations_on_chapter_id" + t.index ["expires_at"], name: "index_chapter_contact_confirmations_on_expires_at" + t.index ["sender_id"], name: "index_chapter_contact_confirmations_on_sender_id" + t.index ["token_digest"], name: "index_chapter_contact_confirmations_on_token_digest", unique: true + end + create_table "chapters", id: :serial, force: :cascade do |t| t.boolean "active", default: true t.string "city" @@ -591,6 +608,8 @@ t.index ["updated_at"], name: "index_workshops_on_updated_at" end + add_foreign_key "chapter_contact_confirmations", "chapters" + add_foreign_key "chapter_contact_confirmations", "members", column: "sender_id" add_foreign_key "invitation_log_entries", "invitation_logs" add_foreign_key "invitation_logs", "members", column: "initiator_id" add_foreign_key "member_email_deliveries", "members" diff --git a/spec/controllers/admin/chapter_contacts_controller_spec.rb b/spec/controllers/admin/chapter_contacts_controller_spec.rb new file mode 100644 index 000000000..21a6d6f8d --- /dev/null +++ b/spec/controllers/admin/chapter_contacts_controller_spec.rb @@ -0,0 +1,143 @@ +require 'rails_helper' + +RSpec.describe Admin::ChapterContactsController do + let(:organiser) { Fabricate(:member) } + let(:chapter) { Fabricate(:chapter) } + let(:member) { Fabricate(:member) } + let(:group) { Fabricate(:group, name: 'Students', chapter:) } + + before do + organiser.add_role(:organiser, chapter) + Fabricate(:subscription, member:, group:) + login(organiser) + ActionMailer::Base.deliveries.clear + end + + describe 'POST #preview' do + render_views + + it 'persists a short-lived, sender- and chapter-bound recipient snapshot' do + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { subject: 'Update', message: 'Hello chapter' } + } + + token = confirmation_token + confirmation = ChapterContactConfirmation.last + expect(response).to be_successful + expect(confirmation.chapter).to eq(chapter) + expect(confirmation.sender).to eq(organiser) + expect(confirmation.subject).to eq('Update') + expect(confirmation.message).to eq('Hello chapter') + expect(confirmation.recipient_ids).to eq([member.id]) + expect(confirmation.expires_at).to be_within(1.second).of(30.minutes.from_now) + expect(confirmation.token_digest).to eq(Digest::SHA256.hexdigest(token)) + end + end + + describe 'POST #create' do + render_views + + it 'does not send when confirmation was skipped' do + expect do + post :create, params: { + chapter_id: chapter.id, + chapter_contact: { subject: 'Direct', message: 'Bypass preview' } + } + end.not_to change(ActionMailer::Base.deliveries, :count) + + expect(response).to redirect_to(new_admin_chapter_contact_path(chapter)) + expect(flash[:alert]).to include('preview') + end + + it 'sends the stored snapshot once and rejects replay' do + outsider = Fabricate(:member) + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { subject: 'Previewed subject', message: 'Previewed message' } + } + token = confirmation_token + + expect { confirm(token, subject: 'Tampered subject', message: 'Tampered message') } + .to change(ActionMailer::Base.deliveries, :count).by(1) + + email = ActionMailer::Base.deliveries.last + expect(email.to).to eq([member.email]) + expect(email.subject).to eq('Previewed subject') + expect(email.body.encoded).to include('Previewed message') + + expect { confirm(token) }.not_to change(ActionMailer::Base.deliveries, :count) + expect(delivered_emails_to(outsider)).to be_empty + expect(flash[:alert]).to include('expired or already used') + end + + it 'rejects an expired confirmation' do + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { subject: 'Update', message: 'Hello chapter' } + } + confirmation = ChapterContactConfirmation.last + token = confirmation_token + confirmation.update!(expires_at: 1.second.ago) + + expect do + post :create, params: { + chapter_id: chapter.id, + chapter_contact: { confirmation_token: token } + } + end.not_to change(ActionMailer::Base.deliveries, :count) + + expect(response).to redirect_to(new_admin_chapter_contact_path(chapter)) + expect(flash[:alert]).to include('expired or already used') + end + + it 'rejects a confirmation belonging to another sender' do + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { subject: 'Update', message: 'Hello chapter' } + } + token = confirmation_token + another_organiser = Fabricate(:member) + another_organiser.add_role(:organiser, chapter) + login(another_organiser) + + expect do + post :create, params: { + chapter_id: chapter.id, + chapter_contact: { confirmation_token: token } + } + end.not_to change(ActionMailer::Base.deliveries, :count) + expect(response).to redirect_to(new_admin_chapter_contact_path(chapter)) + expect(flash[:alert]).to include('expired or already used') + end + + it 'rejects a confirmation for a different chapter' do + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { subject: 'Update', message: 'Hello chapter' } + } + token = confirmation_token + other_chapter = Fabricate(:chapter) + login_as_admin(Fabricate(:member)) + + expect do + post :create, params: { + chapter_id: other_chapter.id, + chapter_contact: { confirmation_token: token } + } + end.not_to change(ActionMailer::Base.deliveries, :count) + expect(response).to redirect_to(new_admin_chapter_contact_path(other_chapter)) + end + end + + def confirmation_token + Nokogiri::HTML(response.body).at_css('input[name="chapter_contact[confirmation_token]"]')['value'] + end + + def confirm(token, **attributes) + post :create, params: { + chapter_id: chapter.id, + chapter_contact: { confirmation_token: token }.merge(attributes) + } + end +end diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index 2e7d963a2..1beddf22b 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -38,6 +38,9 @@ click_on 'Preview recipients' expect_preview([student, coach, both]) + expect(page).to have_field('chapter_contact[confirmation_token]', type: 'hidden') + expect(page).to have_no_field('chapter_contact[subject]') + expect(page).to have_no_field('chapter_contact[message]') click_on 'Confirm and send' expect(page).to have_text('Email sent to 3 chapter members') From cc8ea8985147d0c0796833712aed0dfee8b3abbd Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:49:54 +0000 Subject: [PATCH 04/10] feat: add chapter organiser audience Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 24 ++++++++--- .../admin/chapter_contacts/new.html.haml | 5 ++- .../admin/contacting_chapter_members_spec.rb | 43 +++++++++++++++---- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index c1addc7b3..31657e72c 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -9,13 +9,16 @@ def index def new authorize @chapter, :contact? - @recipient_count = recipients.count + @audience = 'all_members' + @recipient_count = recipients(@audience).count end def preview authorize @chapter, :contact? - @subject, @message = preview_params.values_at(:subject, :message) - recipient_ids = recipients.pluck(:id) + attributes = preview_params + @subject, @message = attributes.values_at(:subject, :message) + @audience = attributes[:audience].presence || 'all_members' + recipient_ids = recipients(@audience).pluck(:id) return render_new unless valid_contact?(recipient_ids) create_confirmation(recipient_ids) @@ -39,20 +42,25 @@ def create private def preview_params - params.expect(chapter_contact: %i[subject message]) + params.expect(chapter_contact: %i[subject message audience]) end def confirmation_token params[:chapter_contact]&.[](:confirmation_token) end - def recipients - @chapter.members.distinct + def recipients(audience) + case audience + when 'all_members' then @chapter.members.distinct + when 'chapter_organisers' then @chapter.organisers + else Member.none + end end def valid_contact?(recipient_ids) @recipient_count = recipient_ids.size - return true if @subject.present? && @message.present? && recipient_ids.any? + return true if @audience.in?(%w[all_members chapter_organisers]) && + @subject.present? && @message.present? && recipient_ids.any? flash.now[:alert] = contact_error false @@ -91,6 +99,8 @@ def global_organiser? def contact_error return 'Subject and message are required.' if @subject.blank? || @message.blank? + return 'Choose a valid audience.' unless @audience.in?(%w[all_members chapter_organisers]) + return 'There are no chapter organisers to email.' if @audience == 'chapter_organisers' 'There are no current chapter subscribers to email.' end diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index 2cfa0c9f5..a89d0384e 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -1,11 +1,14 @@ .container.py-4 %h1 Contact #{@chapter.name} members - %p= "#{@recipient_count} recipients" + %p #{@recipient_count} recipients - if flash[:alert] .alert.alert-danger= flash[:alert] = form_with scope: :chapter_contact, url: preview_admin_chapter_contact_path(@chapter) do |form| + .mb-3 + = form.label :audience, 'Audience', class: 'form-label' + = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers']], @audience), {}, class: 'form-select' .mb-3 = form.label :subject, class: 'form-label' = form.text_field :subject, class: 'form-control', required: true, value: @subject diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index 1beddf22b..75bbdbfbe 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -49,6 +49,30 @@ excluded: [former_member, other_chapter_member] ) end + + scenario 'previews and emails chapter organisers when leadership is selected' do + another_organiser = Fabricate(:member) + another_organiser.add_role(:organiser, chapter) + login(organiser) + visit new_admin_chapter_contact_path(chapter) + select 'Chapter organisers', from: 'Audience' + fill_in 'Subject', with: 'Leadership update' + fill_in 'Message', with: 'A message for chapter organisers.' + ActionMailer::Base.deliveries.clear + + click_on 'Preview recipients' + + expect_preview([organiser, another_organiser]) + click_on 'Confirm and send' + + expect(page).to have_text('Email sent to 2 chapter members') + expect_email_fanout( + recipients: [organiser, another_organiser], + excluded: [student, coach, both, former_member, other_chapter_member], + subject: 'Leadership update', + message: 'A message for chapter organisers.' + ) + end end scenario 'admin can access any chapter contact form' do @@ -110,10 +134,11 @@ def open_contact_menu click_on 'Contact chapter' end - def expect_email_fanout(recipients:, excluded:) + def expect_email_fanout(recipients:, excluded:, subject: 'Workshop update', + message: 'The workshop starts at 6pm.') expect_delivered_to(recipients) expect_no_delivery_to(excluded) - expect_individual_emails(recipients.size) + expect_individual_emails(recipients.size, subject:, message:) end def expect_preview(recipients) @@ -147,15 +172,15 @@ def expect_no_delivery_to(members) members.each { |member| expect(delivered_emails_to(member)).to be_empty } end - def expect_individual_emails(count) + def expect_individual_emails(count, subject:, message:) emails = ActionMailer::Base.deliveries expect(emails.size).to eq(count) - emails.each { |email| expect_individual_email(email) } + emails.each { |email| expect_individual_email(email, subject:, message:) } end - def expect_individual_email(email) + def expect_individual_email(email, subject:, message:) expect_mail_privacy(email) - expect_mail_content(email) + expect_mail_content(email, subject:, message:) end def expect_mail_privacy(email) @@ -164,8 +189,8 @@ def expect_mail_privacy(email) expect(email.bcc).to be_empty end - def expect_mail_content(email) - expect(email.subject).to eq('Workshop update') - expect(email.body.encoded).to include('The workshop starts at 6pm.') + def expect_mail_content(email, subject:, message:) + expect(email.subject).to eq(subject) + expect(email.body.encoded).to include(message) end end From 7d7c5a4f361c2550eca1298be933f5241248b034 Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:54:08 +0000 Subject: [PATCH 05/10] feat: add custom chapter contact audience Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 23 +++++------ app/models/chapter.rb | 13 +++++++ .../admin/chapter_contacts/new.html.haml | 9 ++++- .../admin/chapter_contacts_controller_spec.rb | 16 ++++++++ .../admin/contacting_chapter_members_spec.rb | 38 ++++++++++++++++++- 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index 31657e72c..fadf01940 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -9,8 +9,10 @@ def index def new authorize @chapter, :contact? + @chapter_members = @chapter.contact_members @audience = 'all_members' - @recipient_count = recipients(@audience).count + @recipient_count = @chapter.contact_recipients(@audience).count + @selected_member_ids = [] end def preview @@ -18,7 +20,9 @@ def preview attributes = preview_params @subject, @message = attributes.values_at(:subject, :message) @audience = attributes[:audience].presence || 'all_members' - recipient_ids = recipients(@audience).pluck(:id) + @selected_member_ids = Array(attributes[:member_ids]) + @chapter_members = @chapter.contact_members + recipient_ids = @chapter.contact_recipients(@audience, @selected_member_ids).pluck(:id) return render_new unless valid_contact?(recipient_ids) create_confirmation(recipient_ids) @@ -42,24 +46,16 @@ def create private def preview_params - params.expect(chapter_contact: %i[subject message audience]) + params.expect(chapter_contact: [:subject, :message, :audience, { member_ids: [] }]) end def confirmation_token params[:chapter_contact]&.[](:confirmation_token) end - def recipients(audience) - case audience - when 'all_members' then @chapter.members.distinct - when 'chapter_organisers' then @chapter.organisers - else Member.none - end - end - def valid_contact?(recipient_ids) @recipient_count = recipient_ids.size - return true if @audience.in?(%w[all_members chapter_organisers]) && + return true if @audience.in?(%w[all_members chapter_organisers custom_list]) && @subject.present? && @message.present? && recipient_ids.any? flash.now[:alert] = contact_error @@ -99,8 +95,9 @@ def global_organiser? def contact_error return 'Subject and message are required.' if @subject.blank? || @message.blank? - return 'Choose a valid audience.' unless @audience.in?(%w[all_members chapter_organisers]) + return 'Choose a valid audience.' unless @audience.in?(%w[all_members chapter_organisers custom_list]) return 'There are no chapter organisers to email.' if @audience == 'chapter_organisers' + return 'Select at least one current chapter member.' if @audience == 'custom_list' 'There are no current chapter subscribers to email.' end diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 70822e516..55b4f1394 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -34,6 +34,19 @@ def organisers @organisers ||= Member.with_role(:organiser, self) end + def contact_members + members.distinct.order(:name, :surname) + end + + def contact_recipients(audience, member_ids = []) + case audience + when 'all_members' then members.distinct + when 'chapter_organisers' then organisers + when 'custom_list' then members.where(id: member_ids).distinct + else Member.none + end + end + # All members subscribed to this chapter's Students group, regardless of ban or TOC status. # Use #eligible_students when building invitation lists. def students diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index a89d0384e..311788ac1 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -8,7 +8,14 @@ = form_with scope: :chapter_contact, url: preview_admin_chapter_contact_path(@chapter) do |form| .mb-3 = form.label :audience, 'Audience', class: 'form-label' - = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers']], @audience), {}, class: 'form-select' + = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list']], @audience), {}, class: 'form-select' + .mb-3 + %fieldset + %legend.fs-6 Select members for a custom list + = form.collection_check_boxes :member_ids, @chapter_members, :id, :full_name do |checkbox| + .form-check + = checkbox.check_box(class: 'form-check-input') + = checkbox.label(class: 'form-check-label') .mb-3 = form.label :subject, class: 'form-label' = form.text_field :subject, class: 'form-control', required: true, value: @subject diff --git a/spec/controllers/admin/chapter_contacts_controller_spec.rb b/spec/controllers/admin/chapter_contacts_controller_spec.rb index 21a6d6f8d..fb5893e92 100644 --- a/spec/controllers/admin/chapter_contacts_controller_spec.rb +++ b/spec/controllers/admin/chapter_contacts_controller_spec.rb @@ -33,6 +33,22 @@ expect(confirmation.expires_at).to be_within(1.second).of(30.minutes.from_now) expect(confirmation.token_digest).to eq(Digest::SHA256.hexdigest(token)) end + + it 'restricts a custom list to current members of the chapter' do + outsider = Fabricate(:member) + + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { + audience: 'custom_list', + member_ids: [member.id, outsider.id], + subject: 'Update', + message: 'Hello selected members' + } + } + + expect(ChapterContactConfirmation.last.recipient_ids).to eq([member.id]) + end end describe 'POST #create' do diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index 75bbdbfbe..6f5341100 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -73,6 +73,42 @@ message: 'A message for chapter organisers.' ) end + + scenario 'previews and emails only selected members for a custom list' do + login(organiser) + visit new_admin_chapter_contact_path(chapter) + select 'Custom list', from: 'Audience' + check coach.full_name + fill_in 'Subject', with: 'Coach update' + fill_in 'Message', with: 'A message for one selected coach.' + ActionMailer::Base.deliveries.clear + + click_on 'Preview recipients' + + expect_preview([coach]) + click_on 'Confirm and send' + + expect(page).to have_text('Email sent to 1 chapter members') + expect_email_fanout( + recipients: [coach], + excluded: [student, both, former_member, other_chapter_member], + subject: 'Coach update', + message: 'A message for one selected coach.' + ) + end + + scenario 'requires at least one member for a custom list' do + login(organiser) + visit new_admin_chapter_contact_path(chapter) + select 'Custom list', from: 'Audience' + fill_in 'Subject', with: 'Coach update' + fill_in 'Message', with: 'A message for selected members.' + + click_on 'Preview recipients' + + expect(page).to have_text('Select at least one current chapter member.') + expect(ActionMailer::Base.deliveries).to be_empty + end end scenario 'admin can access any chapter contact form' do @@ -149,7 +185,7 @@ def expect_preview(recipients) end def expect_preview_count(count) - expect(page).to have_text("#{count} recipients will receive this email") + expect(page).to have_text("#{count} #{'recipient'.pluralize(count)} will receive this email") end def expect_recipient_names(recipients) From 1cd05b496d45301656f04cc02c4969a362339c94 Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:58:44 +0000 Subject: [PATCH 06/10] feat: add member activity contact audiences Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 19 +++--- app/models/chapter.rb | 15 +++-- app/models/member.rb | 8 +++ .../admin/chapter_contacts/new.html.haml | 2 +- .../admin/contacting_chapter_members_spec.rb | 58 +++++++++++++++++++ 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index fadf01940..abeda7625 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -23,7 +23,7 @@ def preview @selected_member_ids = Array(attributes[:member_ids]) @chapter_members = @chapter.contact_members recipient_ids = @chapter.contact_recipients(@audience, @selected_member_ids).pluck(:id) - return render_new unless valid_contact?(recipient_ids) + return render(:new, status: :unprocessable_entity) unless valid_contact?(recipient_ids) create_confirmation(recipient_ids) render :preview @@ -55,7 +55,7 @@ def confirmation_token def valid_contact?(recipient_ids) @recipient_count = recipient_ids.size - return true if @audience.in?(%w[all_members chapter_organisers custom_list]) && + return true if @audience.in?(Chapter::CONTACT_AUDIENCES) && @subject.present? && @message.present? && recipient_ids.any? flash.now[:alert] = contact_error @@ -95,11 +95,14 @@ def global_organiser? def contact_error return 'Subject and message are required.' if @subject.blank? || @message.blank? - return 'Choose a valid audience.' unless @audience.in?(%w[all_members chapter_organisers custom_list]) - return 'There are no chapter organisers to email.' if @audience == 'chapter_organisers' - return 'Select at least one current chapter member.' if @audience == 'custom_list' - - 'There are no current chapter subscribers to email.' + return 'Choose a valid audience.' unless @audience.in?(Chapter::CONTACT_AUDIENCES) + + { + 'chapter_organisers' => 'There are no chapter organisers to email.', + 'custom_list' => 'Select at least one current chapter member.', + 'active_last_month' => 'There are no members active in the last month to email.', + 'inactive_last_month' => 'There are no members inactive for the last month to email.' + }.fetch(@audience, 'There are no current chapter subscribers to email.') end def reject_confirmation @@ -107,8 +110,6 @@ def reject_confirmation alert: 'Email preview expired or already used; please preview again.' end - def render_new = render :new, status: :unprocessable_entity - def send_contact_emails(confirmation) Member.where(id: confirmation.recipient_ids).find_each do |member| ChapterContactMailer.with(chapter: confirmation.chapter, recipient: member, diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 55b4f1394..97181b615 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -1,4 +1,6 @@ class Chapter < ApplicationRecord + CONTACT_AUDIENCES = %w[all_members chapter_organisers custom_list active_last_month inactive_last_month].freeze + resourcify :permissions, role_cname: 'Permission', role_table_name: :permission validates :name, :email, uniqueness: true, presence: true @@ -39,12 +41,15 @@ def contact_members end def contact_recipients(audience, member_ids = []) - case audience - when 'all_members' then members.distinct - when 'chapter_organisers' then organisers - when 'custom_list' then members.where(id: member_ids).distinct - else Member.none + recipients = case audience + when 'all_members' then members + when 'chapter_organisers' then organisers + when 'custom_list' then members.where(id: member_ids) + when 'active_last_month' then members.with_activity_since(1.month.ago) + when 'inactive_last_month' then members.without_activity_since(1.month.ago) + else Member.none end + recipients.distinct end # All members subscribed to this chapter's Students group, regardless of ban or TOC status. diff --git a/app/models/member.rb b/app/models/member.rb index 5e87558b2..63742b9de 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -46,6 +46,14 @@ class Member < ApplicationRecord scope :accepted_toc, -> { where.not(accepted_toc_at: nil) } scope :order_by_email, -> { order(:email) } scope :subscribers, -> { joins(:subscriptions).order('created_at desc').uniq } + scope :with_activity_since, lambda { |time| + where(id: PublicActivity::Activity.where(owner_type: 'Member').where(created_at: time..) + .where.not(owner_id: nil).select(:owner_id)) + } + scope :without_activity_since, lambda { |time| + where.not(id: PublicActivity::Activity.where(owner_type: 'Member').where(created_at: time..) + .where.not(owner_id: nil).select(:owner_id)) + } scope :not_banned, lambda { joins('LEFT OUTER JOIN bans ON members.id = bans.member_id') .where('bans.id is NULL or bans.expires_at < CURRENT_DATE') diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index 311788ac1..10e7d0d2c 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -8,7 +8,7 @@ = form_with scope: :chapter_contact, url: preview_admin_chapter_contact_path(@chapter) do |form| .mb-3 = form.label :audience, 'Audience', class: 'form-label' - = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list']], @audience), {}, class: 'form-select' + = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list'], ['Active in the last month', 'active_last_month'], ['Inactive for the last month', 'inactive_last_month']], @audience), {}, class: 'form-select' .mb-3 %fieldset %legend.fs-6 Select members for a custom list diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index 6f5341100..54dbc81a4 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -109,6 +109,54 @@ expect(page).to have_text('Select at least one current chapter member.') expect(ActionMailer::Base.deliveries).to be_empty end + + scenario 'previews and emails members active in the last month' do + record_activity(coach, 2.weeks.ago) + record_activity(both, 2.months.ago) + login(organiser) + visit new_admin_chapter_contact_path(chapter) + select 'Active in the last month', from: 'Audience' + fill_in 'Subject', with: 'Active member update' + fill_in 'Message', with: 'A message for recently active members.' + ActionMailer::Base.deliveries.clear + + click_on 'Preview recipients' + + expect_preview([coach]) + click_on 'Confirm and send' + + expect(page).to have_text('Email sent to 1 chapter member') + expect_email_fanout( + recipients: [coach], + excluded: [student, both, former_member, other_chapter_member], + subject: 'Active member update', + message: 'A message for recently active members.' + ) + end + + scenario 'previews and emails members inactive for the last month' do + record_activity(coach, 2.weeks.ago) + record_activity(both, 2.months.ago) + login(organiser) + visit new_admin_chapter_contact_path(chapter) + select 'Inactive for the last month', from: 'Audience' + fill_in 'Subject', with: 'Inactive member update' + fill_in 'Message', with: 'A message for members with no recent activity.' + ActionMailer::Base.deliveries.clear + + click_on 'Preview recipients' + + expect_preview([student, both]) + click_on 'Confirm and send' + + expect(page).to have_text('Email sent to 2 chapter members') + expect_email_fanout( + recipients: [student, both], + excluded: [coach, former_member, other_chapter_member], + subject: 'Inactive member update', + message: 'A message for members with no recent activity.' + ) + end end scenario 'admin can access any chapter contact form' do @@ -170,6 +218,16 @@ def open_contact_menu click_on 'Contact chapter' end + def record_activity(member, created_at) + PublicActivity::Activity.create!( + owner: member, + key: 'event_invitation.rsvp', + trackable: member, + created_at:, + updated_at: created_at + ) + end + def expect_email_fanout(recipients:, excluded:, subject: 'Workshop update', message: 'The workshop starts at 6pm.') expect_delivered_to(recipients) From 5e858e9df8dbff6db31b8a660b829f83084f7c6b Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:06:27 +0000 Subject: [PATCH 07/10] feat: add event RSVP contact audience Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 55 ++++++------ app/models/chapter.rb | 22 ++++- app/models/chapter_contact_confirmation.rb | 4 + .../admin/chapter_contacts/new.html.haml | 8 +- .../admin/chapter_contacts_controller_spec.rb | 20 +++++ .../admin/contacting_chapter_members_spec.rb | 87 ++++++++++++++++++- 6 files changed, 164 insertions(+), 32 deletions(-) diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index abeda7625..b66c81583 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -10,6 +10,7 @@ def index def new authorize @chapter, :contact? @chapter_members = @chapter.contact_members + @chapter_events = @chapter.contact_events @audience = 'all_members' @recipient_count = @chapter.contact_recipients(@audience).count @selected_member_ids = [] @@ -17,12 +18,7 @@ def new def preview authorize @chapter, :contact? - attributes = preview_params - @subject, @message = attributes.values_at(:subject, :message) - @audience = attributes[:audience].presence || 'all_members' - @selected_member_ids = Array(attributes[:member_ids]) - @chapter_members = @chapter.contact_members - recipient_ids = @chapter.contact_recipients(@audience, @selected_member_ids).pluck(:id) + recipient_ids = preview_recipient_ids return render(:new, status: :unprocessable_entity) unless valid_contact?(recipient_ids) create_confirmation(recipient_ids) @@ -32,25 +28,38 @@ def preview def create authorize @chapter, :contact? token = confirmation_token - return reject_confirmation unless token.is_a?(String) && token.present? + return reject_confirmation unless token - confirmation = find_confirmation(token) - return reject_confirmation unless confirmation - - return reject_confirmation unless confirmation.consume! { |record| send_contact_emails(record) } + confirmation = ChapterContactConfirmation.find_for(chapter: @chapter, sender: current_user, token:) + return reject_confirmation unless confirmation&.consume! { |record| send_contact_emails(record) } + count = confirmation.recipient_ids.size redirect_to new_admin_chapter_contact_path(@chapter), - notice: "Email sent to #{confirmation.recipient_ids.size} chapter members" + notice: "Email sent to #{count} chapter #{'member'.pluralize(count)}" end private def preview_params - params.expect(chapter_contact: [:subject, :message, :audience, { member_ids: [] }]) + params.expect(chapter_contact: [:subject, :message, :audience, :event_id, :rsvp_status, { member_ids: [] }]) end def confirmation_token - params[:chapter_contact]&.[](:confirmation_token) + token = params[:chapter_contact]&.[](:confirmation_token) + token if token.is_a?(String) && token.present? + end + + def preview_recipient_ids + attributes = preview_params + @subject, @message = attributes.values_at(:subject, :message) + @audience = attributes[:audience].presence || 'all_members' + @selected_member_ids = Array(attributes[:member_ids]) + @event_id, @rsvp_status = attributes.values_at(:event_id, :rsvp_status) + @chapter_members = @chapter.contact_members + @chapter_events = @chapter.contact_events + @chapter.contact_recipients( + @audience, @selected_member_ids, event_id: @event_id, rsvp_status: @rsvp_status + ).pluck(:id) end def valid_contact?(recipient_ids) @@ -75,24 +84,13 @@ def create_confirmation(recipient_ids) @recipients = Member.where(id: confirmation.recipient_ids).order(:name, :surname) end - def find_confirmation(token) - ChapterContactConfirmation.find_by( - chapter: @chapter, - sender: current_user, - token_digest: Digest::SHA256.hexdigest(token) - ) - end - def accessible_chapters - return Chapter.order(:name) if current_user.is_admin? || global_organiser? + global_organiser = current_user.roles.where(name: 'organiser', resource_type: nil, resource_id: nil).exists? + return Chapter.order(:name) if current_user.is_admin? || global_organiser current_user.organised_chapters.order(:name) end - def global_organiser? - current_user.roles.where(name: 'organiser', resource_type: nil, resource_id: nil).exists? - end - def contact_error return 'Subject and message are required.' if @subject.blank? || @message.blank? return 'Choose a valid audience.' unless @audience.in?(Chapter::CONTACT_AUDIENCES) @@ -101,7 +99,8 @@ def contact_error 'chapter_organisers' => 'There are no chapter organisers to email.', 'custom_list' => 'Select at least one current chapter member.', 'active_last_month' => 'There are no members active in the last month to email.', - 'inactive_last_month' => 'There are no members inactive for the last month to email.' + 'inactive_last_month' => 'There are no members inactive for the last month to email.', + 'event_rsvp' => 'No current chapter members match that event RSVP status.' }.fetch(@audience, 'There are no current chapter subscribers to email.') end diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 97181b615..8afb0e733 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -1,5 +1,8 @@ class Chapter < ApplicationRecord - CONTACT_AUDIENCES = %w[all_members chapter_organisers custom_list active_last_month inactive_last_month].freeze + CONTACT_AUDIENCES = %w[ + all_members chapter_organisers custom_list active_last_month inactive_last_month event_rsvp + ].freeze + CONTACT_RSVP_STATUSES = %w[attending declined unanswered].freeze resourcify :permissions, role_cname: 'Permission', role_table_name: :permission @@ -40,13 +43,18 @@ def contact_members members.distinct.order(:name, :surname) end - def contact_recipients(audience, member_ids = []) + def contact_events + events.order(date_and_time: :desc) + end + + def contact_recipients(audience, member_ids = [], event_id: nil, rsvp_status: nil) recipients = case audience when 'all_members' then members when 'chapter_organisers' then organisers when 'custom_list' then members.where(id: member_ids) when 'active_last_month' then members.with_activity_since(1.month.ago) when 'inactive_last_month' then members.without_activity_since(1.month.ago) + when 'event_rsvp' then recipients_by_event_rsvp(event_id, rsvp_status) else Member.none end recipients.distinct @@ -76,6 +84,16 @@ def eligible_coaches private + def recipients_by_event_rsvp(event_id, rsvp_status) + return Member.none unless CONTACT_RSVP_STATUSES.include?(rsvp_status) + + event = contact_events.find_by(id: event_id) + return Member.none unless event + + attending = { 'attending' => true, 'declined' => false, 'unanswered' => nil }.fetch(rsvp_status) + members.where(id: event.invitations.where(attending:).select(:member_id)) + end + def members_for_group(name) members.where(groups: { name: }).distinct end diff --git a/app/models/chapter_contact_confirmation.rb b/app/models/chapter_contact_confirmation.rb index 93f7b2bc0..359c8cd98 100644 --- a/app/models/chapter_contact_confirmation.rb +++ b/app/models/chapter_contact_confirmation.rb @@ -10,6 +10,10 @@ class ChapterContactConfirmation < ApplicationRecord attr_accessor :token + def self.find_for(chapter:, sender:, token:) + find_by(chapter:, sender:, token_digest: Digest::SHA256.hexdigest(token)) + end + def self.create_for!(chapter:, sender:, subject:, message:, recipient_ids:) token = SecureRandom.urlsafe_base64(32) create!( diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index 10e7d0d2c..c5ab97bd3 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -8,7 +8,7 @@ = form_with scope: :chapter_contact, url: preview_admin_chapter_contact_path(@chapter) do |form| .mb-3 = form.label :audience, 'Audience', class: 'form-label' - = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list'], ['Active in the last month', 'active_last_month'], ['Inactive for the last month', 'inactive_last_month']], @audience), {}, class: 'form-select' + = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list'], ['Active in the last month', 'active_last_month'], ['Inactive for the last month', 'inactive_last_month'], ['Event RSVP status', 'event_rsvp']], @audience), {}, class: 'form-select' .mb-3 %fieldset %legend.fs-6 Select members for a custom list @@ -16,6 +16,12 @@ .form-check = checkbox.check_box(class: 'form-check-input') = checkbox.label(class: 'form-check-label') + .mb-3 + = form.label :event_id, 'Event', class: 'form-label' + = form.select :event_id, options_from_collection_for_select(@chapter_events, :id, :name, @event_id), { include_blank: 'Select an event' }, class: 'form-select' + .mb-3 + = form.label :rsvp_status, 'RSVP status', class: 'form-label' + = form.select :rsvp_status, options_for_select([['Attending', 'attending'], ['Declined', 'declined'], ['No response', 'unanswered']], @rsvp_status), { include_blank: 'Select an RSVP status' }, class: 'form-select' .mb-3 = form.label :subject, class: 'form-label' = form.text_field :subject, class: 'form-control', required: true, value: @subject diff --git a/spec/controllers/admin/chapter_contacts_controller_spec.rb b/spec/controllers/admin/chapter_contacts_controller_spec.rb index fb5893e92..725c74f7c 100644 --- a/spec/controllers/admin/chapter_contacts_controller_spec.rb +++ b/spec/controllers/admin/chapter_contacts_controller_spec.rb @@ -49,6 +49,26 @@ expect(ChapterContactConfirmation.last.recipient_ids).to eq([member.id]) end + + it 'rejects RSVP audiences for events outside the chapter' do + other_event = Fabricate(:event) + Fabricate(:invitation, event: other_event, member:, attending: true) + + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { + audience: 'event_rsvp', + event_id: other_event.id, + rsvp_status: 'attending', + subject: 'Update', + message: 'Hello event attendees' + } + } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('No current chapter members match that event RSVP status.') + expect(ChapterContactConfirmation.count).to eq(0) + end end describe 'POST #create' do diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index 54dbc81a4..01df6f2a8 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -88,7 +88,7 @@ expect_preview([coach]) click_on 'Confirm and send' - expect(page).to have_text('Email sent to 1 chapter members') + expect(page).to have_text('Email sent to 1 chapter member') expect_email_fanout( recipients: [coach], excluded: [student, both, former_member, other_chapter_member], @@ -157,6 +157,57 @@ message: 'A message for members with no recent activity.' ) end + + scenario 'previews and emails current chapter members attending a selected event' do + event = event_with_rsvps + login(organiser) + visit new_admin_chapter_contact_path(chapter) + select 'Event RSVP status', from: 'Audience' + select event.name, from: 'Event' + select 'Attending', from: 'RSVP status' + fill_in 'Subject', with: 'Attendee update' + fill_in 'Message', with: 'A message for event attendees.' + ActionMailer::Base.deliveries.clear + + click_on 'Preview recipients' + + expect_preview([student]) + click_on 'Confirm and send' + + expect(page).to have_text('Email sent to 1 chapter member') + expect_email_fanout( + recipients: [student], + excluded: [coach, both, former_member, other_chapter_member], + subject: 'Attendee update', + message: 'A message for event attendees.' + ) + end + + scenario 'selects declined RSVPs for a chapter event' do + event = event_with_rsvps + login(organiser) + expect_rsvp_campaign( + event:, + selection: { + status: 'Declined', recipient: coach, subject: 'Declined RSVP update', + message: 'A message for members who declined.' + }, + excluded: [student, both, former_member, other_chapter_member] + ) + end + + scenario 'selects chapter members with no response to an event' do + event = event_with_rsvps + login(organiser) + expect_rsvp_campaign( + event:, + selection: { + status: 'No response', recipient: both, subject: 'Unanswered RSVP update', + message: 'A message for members who have not responded.' + }, + excluded: [student, coach, former_member, other_chapter_member] + ) + end end scenario 'admin can access any chapter contact form' do @@ -228,6 +279,40 @@ def record_activity(member, created_at) ) end + def event_with_rsvps + event = Fabricate(:event, chapters: [chapter]) + Fabricate(:invitation, member: student, event:, attending: true) + Fabricate(:invitation, member: coach, event:, attending: false) + Fabricate(:invitation, member: both, event:, attending: nil) + Fabricate(:invitation, member: former_member, event:, attending: true) + Fabricate(:invitation, member: other_chapter_member, event:, attending: true) + event + end + + def expect_rsvp_campaign(event:, selection:, excluded:) + status, recipient, subject, message = selection.values_at(:status, :recipient, :subject, :message) + prepare_rsvp_contact(event:, status:) + fill_contact_form(subject:, message:) + click_on 'Preview recipients' + expect_preview([recipient]) + click_on 'Confirm and send' + expect(page).to have_text('Email sent to 1 chapter member') + expect_email_fanout(recipients: [recipient], excluded:, subject:, message:) + end + + def prepare_rsvp_contact(event:, status:) + visit new_admin_chapter_contact_path(chapter) + select 'Event RSVP status', from: 'Audience' + select event.name, from: 'Event' + select status, from: 'RSVP status' + end + + def fill_contact_form(subject:, message:) + fill_in 'Subject', with: subject + fill_in 'Message', with: message + ActionMailer::Base.deliveries.clear + end + def expect_email_fanout(recipients:, excluded:, subject: 'Workshop update', message: 'The workshop starts at 6pm.') expect_delivered_to(recipients) From 91f873f8cb09986139dfa9f1a3956ce3a6016d0e Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:17:59 +0000 Subject: [PATCH 08/10] feat: make member activity period configurable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../admin/chapter_contacts_controller.rb | 57 +++++++++---------- app/models/chapter.rb | 25 +++++++- app/models/chapter_contact_confirmation.rb | 2 + .../admin/chapter_contacts/new.html.haml | 3 + .../admin/chapter_contacts_controller_spec.rb | 16 ++++++ .../admin/contacting_chapter_members_spec.rb | 25 ++++++++ 6 files changed, 94 insertions(+), 34 deletions(-) diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index b66c81583..20ab50f76 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -9,8 +9,7 @@ def index def new authorize @chapter, :contact? - @chapter_members = @chapter.contact_members - @chapter_events = @chapter.contact_events + load_form_collections @audience = 'all_members' @recipient_count = @chapter.contact_recipients(@audience).count @selected_member_ids = [] @@ -27,10 +26,9 @@ def preview def create authorize @chapter, :contact? - token = confirmation_token - return reject_confirmation unless token - - confirmation = ChapterContactConfirmation.find_for(chapter: @chapter, sender: current_user, token:) + confirmation = ChapterContactConfirmation.find_for( + chapter: @chapter, sender: current_user, token: params[:chapter_contact]&.[](:confirmation_token) + ) return reject_confirmation unless confirmation&.consume! { |record| send_contact_emails(record) } count = confirmation.recipient_ids.size @@ -40,32 +38,28 @@ def create private - def preview_params - params.expect(chapter_contact: [:subject, :message, :audience, :event_id, :rsvp_status, { member_ids: [] }]) - end - - def confirmation_token - token = params[:chapter_contact]&.[](:confirmation_token) - token if token.is_a?(String) && token.present? - end - - def preview_recipient_ids - attributes = preview_params - @subject, @message = attributes.values_at(:subject, :message) - @audience = attributes[:audience].presence || 'all_members' + def preview_recipient_ids # rubocop:disable Metrics/MethodLength + attributes = params.expect( + chapter_contact: %i[subject message audience event_id rsvp_status activity_months] + [{ member_ids: [] }] + ) + @subject, @message, @audience, @event_id, @rsvp_status = attributes.values_at( + :subject, :message, :audience, :event_id, :rsvp_status + ) + @audience ||= 'all_members' @selected_member_ids = Array(attributes[:member_ids]) - @event_id, @rsvp_status = attributes.values_at(:event_id, :rsvp_status) - @chapter_members = @chapter.contact_members - @chapter_events = @chapter.contact_events + @activity_months = attributes[:activity_months].presence || '1' + load_form_collections @chapter.contact_recipients( - @audience, @selected_member_ids, event_id: @event_id, rsvp_status: @rsvp_status + @audience, @selected_member_ids, + event_id: @event_id, rsvp_status: @rsvp_status, activity_months: @activity_months ).pluck(:id) end def valid_contact?(recipient_ids) @recipient_count = recipient_ids.size return true if @audience.in?(Chapter::CONTACT_AUDIENCES) && - @subject.present? && @message.present? && recipient_ids.any? + @subject.present? && @message.present? && + Chapter.valid_contact_activity_months?(@audience, @activity_months) && recipient_ids.any? flash.now[:alert] = contact_error false @@ -94,14 +88,15 @@ def accessible_chapters def contact_error return 'Subject and message are required.' if @subject.blank? || @message.blank? return 'Choose a valid audience.' unless @audience.in?(Chapter::CONTACT_AUDIENCES) + return 'Choose a valid activity period.' unless + Chapter.valid_contact_activity_months?(@audience, @activity_months) - { - 'chapter_organisers' => 'There are no chapter organisers to email.', - 'custom_list' => 'Select at least one current chapter member.', - 'active_last_month' => 'There are no members active in the last month to email.', - 'inactive_last_month' => 'There are no members inactive for the last month to email.', - 'event_rsvp' => 'No current chapter members match that event RSVP status.' - }.fetch(@audience, 'There are no current chapter subscribers to email.') + Chapter::CONTACT_AUDIENCE_ERRORS.fetch(@audience, 'There are no current chapter subscribers to email.') + end + + def load_form_collections + @chapter_members = @chapter.contact_members + @chapter_events = @chapter.contact_events end def reject_confirmation diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 8afb0e733..915f13d50 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -3,6 +3,14 @@ class Chapter < ApplicationRecord all_members chapter_organisers custom_list active_last_month inactive_last_month event_rsvp ].freeze CONTACT_RSVP_STATUSES = %w[attending declined unanswered].freeze + CONTACT_ACTIVITY_MONTHS = %w[1 3 6 12].freeze + CONTACT_AUDIENCE_ERRORS = { + 'chapter_organisers' => 'There are no chapter organisers to email.', + 'custom_list' => 'Select at least one current chapter member.', + 'active_last_month' => 'There are no members active in the selected period.', + 'inactive_last_month' => 'There are no members inactive for the selected period.', + 'event_rsvp' => 'No current chapter members match that event RSVP status.' + }.freeze resourcify :permissions, role_cname: 'Permission', role_table_name: :permission @@ -47,19 +55,23 @@ def contact_events events.order(date_and_time: :desc) end - def contact_recipients(audience, member_ids = [], event_id: nil, rsvp_status: nil) + def contact_recipients(audience, member_ids = [], event_id: nil, rsvp_status: nil, activity_months: '1') recipients = case audience when 'all_members' then members when 'chapter_organisers' then organisers when 'custom_list' then members.where(id: member_ids) - when 'active_last_month' then members.with_activity_since(1.month.ago) - when 'inactive_last_month' then members.without_activity_since(1.month.ago) + when 'active_last_month' then activity_recipients(:active, activity_months) + when 'inactive_last_month' then activity_recipients(:inactive, activity_months) when 'event_rsvp' then recipients_by_event_rsvp(event_id, rsvp_status) else Member.none end recipients.distinct end + def self.valid_contact_activity_months?(audience, months) + !audience.in?(%w[active_last_month inactive_last_month]) || CONTACT_ACTIVITY_MONTHS.include?(months) + end + # All members subscribed to this chapter's Students group, regardless of ban or TOC status. # Use #eligible_students when building invitation lists. def students @@ -84,6 +96,13 @@ def eligible_coaches private + def activity_recipients(state, months) + return Member.none if CONTACT_ACTIVITY_MONTHS.exclude?(months) + + cutoff = months.to_i.months.ago + state == :active ? members.with_activity_since(cutoff) : members.without_activity_since(cutoff) + end + def recipients_by_event_rsvp(event_id, rsvp_status) return Member.none unless CONTACT_RSVP_STATUSES.include?(rsvp_status) diff --git a/app/models/chapter_contact_confirmation.rb b/app/models/chapter_contact_confirmation.rb index 359c8cd98..c8b6239cd 100644 --- a/app/models/chapter_contact_confirmation.rb +++ b/app/models/chapter_contact_confirmation.rb @@ -11,6 +11,8 @@ class ChapterContactConfirmation < ApplicationRecord attr_accessor :token def self.find_for(chapter:, sender:, token:) + return unless token.is_a?(String) && token.present? + find_by(chapter:, sender:, token_digest: Digest::SHA256.hexdigest(token)) end diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index c5ab97bd3..0834e24cf 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -22,6 +22,9 @@ .mb-3 = form.label :rsvp_status, 'RSVP status', class: 'form-label' = form.select :rsvp_status, options_for_select([['Attending', 'attending'], ['Declined', 'declined'], ['No response', 'unanswered']], @rsvp_status), { include_blank: 'Select an RSVP status' }, class: 'form-select' + .mb-3 + = form.label :activity_months, 'Activity period', class: 'form-label' + = form.select :activity_months, options_for_select([['1 month', '1'], ['3 months', '3'], ['6 months', '6'], ['12 months', '12']], @activity_months), {}, class: 'form-select' .mb-3 = form.label :subject, class: 'form-label' = form.text_field :subject, class: 'form-control', required: true, value: @subject diff --git a/spec/controllers/admin/chapter_contacts_controller_spec.rb b/spec/controllers/admin/chapter_contacts_controller_spec.rb index 725c74f7c..382f58528 100644 --- a/spec/controllers/admin/chapter_contacts_controller_spec.rb +++ b/spec/controllers/admin/chapter_contacts_controller_spec.rb @@ -69,6 +69,22 @@ expect(response.body).to include('No current chapter members match that event RSVP status.') expect(ChapterContactConfirmation.count).to eq(0) end + + it 'rejects unsupported activity periods' do + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { + audience: 'active_last_month', + activity_months: '9', + subject: 'Update', + message: 'Hello recently active members' + } + } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Choose a valid activity period.') + expect(ChapterContactConfirmation.count).to eq(0) + end end describe 'POST #create' do diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index 01df6f2a8..f409a1c8a 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -158,6 +158,31 @@ ) end + scenario 'uses the selected three-month activity window' do + record_activity(coach, 2.weeks.ago) + record_activity(both, 2.months.ago) + login(organiser) + visit new_admin_chapter_contact_path(chapter) + select 'Active in the last month', from: 'Audience' + select '3 months', from: 'Activity period' + fill_in 'Subject', with: 'Recent activity update' + fill_in 'Message', with: 'A message for members active in the last three months.' + ActionMailer::Base.deliveries.clear + + click_on 'Preview recipients' + + expect_preview([coach, both]) + click_on 'Confirm and send' + + expect(page).to have_text('Email sent to 2 chapter members') + expect_email_fanout( + recipients: [coach, both], + excluded: [student, former_member, other_chapter_member], + subject: 'Recent activity update', + message: 'A message for members active in the last three months.' + ) + end + scenario 'previews and emails current chapter members attending a selected event' do event = event_with_rsvps login(organiser) From dd57fc4e3d570b424056c8593284d82966f81eb2 Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:02:14 +0000 Subject: [PATCH 09/10] feat: show audience-specific contact fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/assets/javascripts/application.js | 5 ++-- .../javascripts/chapter-contact-audience.js | 23 ++++++++++++++ .../admin/chapter_contacts/new.html.haml | 19 ++++++------ .../admin/contacting_chapter_members_spec.rb | 30 +++++++++++++++++++ 4 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 app/assets/javascripts/chapter-contact-audience.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 80e690f64..023abd5a7 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -18,6 +18,7 @@ //= require chosen.jquery //= require feedback-rating //= require subscriptions-toggle +//= require chapter-contact-audience //= require add-all-chapters //= require invitations //= require rsvp-close-time @@ -35,7 +36,7 @@ $(function() { // This fix places "the original input behind the Chosen input, matching the // height and width so that the warning appears in the correct position." // https://github.com/harvesthq/chosen/issues/515#issuecomment-474588057 - $('select').not('#member_lookup_id, #meeting_invitations_member, #meeting_organisers').on('chosen:ready', function () { + $('select').not('#member_lookup_id, #meeting_invitations_member, #meeting_organisers, #chapter_contact_audience').on('chosen:ready', function () { var height = $(this).next('.chosen-container').height(); var width = $(this).next('.chosen-container').width(); @@ -47,7 +48,7 @@ $(function() { }).show(); }); - $('select').not('#member_lookup_id, #meeting_invitations_member, #meeting_organisers').chosen({ + $('select').not('#member_lookup_id, #meeting_invitations_member, #meeting_organisers, #chapter_contact_audience').chosen({ allow_single_deselect: true, no_results_text: 'No results matched' }); diff --git a/app/assets/javascripts/chapter-contact-audience.js b/app/assets/javascripts/chapter-contact-audience.js new file mode 100644 index 000000000..d1cb70de2 --- /dev/null +++ b/app/assets/javascripts/chapter-contact-audience.js @@ -0,0 +1,23 @@ +/* global $ */ + +$(() => { + const selector = '[data-chapter-contact-audience]'; + const fieldAudiences = { + custom_list: ['custom_list'], + event_rsvp: ['event_rsvp'], + activity_period: ['active_last_month', 'inactive_last_month'] + }; + + function updateAudienceFields() { + const $form = $(this).closest('form'); + const audience = $(this).val(); + + $form.find('[data-contact-audience-fields]').each(function () { + const field = this.getAttribute('data-contact-audience-fields'); + $(this).toggleClass('d-none', !fieldAudiences[field].includes(audience)); + }); + } + + $(document).on('change', selector, updateAudienceFields); + $(selector).each(updateAudienceFields); +}); diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index 0834e24cf..308568729 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -8,21 +8,22 @@ = form_with scope: :chapter_contact, url: preview_admin_chapter_contact_path(@chapter) do |form| .mb-3 = form.label :audience, 'Audience', class: 'form-label' - = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list'], ['Active in the last month', 'active_last_month'], ['Inactive for the last month', 'inactive_last_month'], ['Event RSVP status', 'event_rsvp']], @audience), {}, class: 'form-select' - .mb-3 + = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list'], ['Active in the last month', 'active_last_month'], ['Inactive for the last month', 'inactive_last_month'], ['Event RSVP status', 'event_rsvp']], @audience), {}, class: 'form-select', data: { chapter_contact_audience: true } + .mb-3{ data: { contact_audience_fields: 'custom_list' } } %fieldset %legend.fs-6 Select members for a custom list = form.collection_check_boxes :member_ids, @chapter_members, :id, :full_name do |checkbox| .form-check = checkbox.check_box(class: 'form-check-input') = checkbox.label(class: 'form-check-label') - .mb-3 - = form.label :event_id, 'Event', class: 'form-label' - = form.select :event_id, options_from_collection_for_select(@chapter_events, :id, :name, @event_id), { include_blank: 'Select an event' }, class: 'form-select' - .mb-3 - = form.label :rsvp_status, 'RSVP status', class: 'form-label' - = form.select :rsvp_status, options_for_select([['Attending', 'attending'], ['Declined', 'declined'], ['No response', 'unanswered']], @rsvp_status), { include_blank: 'Select an RSVP status' }, class: 'form-select' - .mb-3 + .mb-3{ data: { contact_audience_fields: 'event_rsvp' } } + .mb-3 + = form.label :event_id, 'Event', class: 'form-label' + = form.select :event_id, options_from_collection_for_select(@chapter_events, :id, :name, @event_id), { include_blank: 'Select an event' }, class: 'form-select' + .mb-3 + = form.label :rsvp_status, 'RSVP status', class: 'form-label' + = form.select :rsvp_status, options_for_select([['Attending', 'attending'], ['Declined', 'declined'], ['No response', 'unanswered']], @rsvp_status), { include_blank: 'Select an RSVP status' }, class: 'form-select' + .mb-3{ data: { contact_audience_fields: 'activity_period' } } = form.label :activity_months, 'Activity period', class: 'form-label' = form.select :activity_months, options_for_select([['1 month', '1'], ['3 months', '3'], ['6 months', '6'], ['12 months', '12']], @activity_months), {}, class: 'form-select' .mb-3 diff --git a/spec/features/admin/contacting_chapter_members_spec.rb b/spec/features/admin/contacting_chapter_members_spec.rb index f409a1c8a..fff93f4ba 100644 --- a/spec/features/admin/contacting_chapter_members_spec.rb +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -25,6 +25,25 @@ Fabricate(:subscription, member: other_chapter_member, group: other_group) end + scenario 'shows only controls for the selected audience', :js do + login(organiser) + visit new_admin_chapter_contact_path(chapter) + + expect_audience_fields(custom_list: false, event_rsvp: false, activity_period: false) + + select 'Custom list', from: 'Audience' + expect_audience_fields(custom_list: true, event_rsvp: false, activity_period: false) + + select 'Active in the last month', from: 'Audience' + expect_audience_fields(custom_list: false, event_rsvp: false, activity_period: true) + + select 'Inactive for the last month', from: 'Audience' + expect_audience_fields(custom_list: false, event_rsvp: false, activity_period: true) + + select 'Event RSVP status', from: 'Audience' + expect_audience_fields(custom_list: false, event_rsvp: true, activity_period: false) + end + scenario 'previews current subscribers and emails each individually after confirmation' do login(organiser) open_contact_menu @@ -294,6 +313,17 @@ def open_contact_menu click_on 'Contact chapter' end + def expect_audience_fields(custom_list:, event_rsvp:, activity_period:) + { custom_list:, event_rsvp:, activity_period: }.each do |audience, shown| + selector = "[data-contact-audience-fields='#{audience}']" + if shown + expect(page).to have_no_css("#{selector}.d-none", visible: :all) + else + expect(page).to have_css("#{selector}.d-none", visible: :all) + end + end + end + def record_activity(member, created_at) PublicActivity::Activity.create!( owner: member, From 0722733af8ce6f5d1ecceead7e395f270c3cbd7f Mon Sep 17 00:00:00 2001 From: David M <207745043+davidmillen50@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:09:23 +0000 Subject: [PATCH 10/10] fix: centralize chapter contact audience handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../javascripts/chapter-contact-audience.js | 8 +--- .../admin/chapter_contacts_controller.rb | 18 ++++---- app/models/chapter.rb | 45 +++++++------------ app/models/chapter_contact_audience.rb | 34 ++++++++++++++ .../admin/chapter_contacts/new.html.haml | 6 +-- .../admin/chapter_contacts_controller_spec.rb | 26 +++++++++++ 6 files changed, 92 insertions(+), 45 deletions(-) create mode 100644 app/models/chapter_contact_audience.rb diff --git a/app/assets/javascripts/chapter-contact-audience.js b/app/assets/javascripts/chapter-contact-audience.js index d1cb70de2..91397ff35 100644 --- a/app/assets/javascripts/chapter-contact-audience.js +++ b/app/assets/javascripts/chapter-contact-audience.js @@ -2,19 +2,15 @@ $(() => { const selector = '[data-chapter-contact-audience]'; - const fieldAudiences = { - custom_list: ['custom_list'], - event_rsvp: ['event_rsvp'], - activity_period: ['active_last_month', 'inactive_last_month'] - }; function updateAudienceFields() { const $form = $(this).closest('form'); const audience = $(this).val(); + const fieldAudiences = JSON.parse(this.getAttribute('data-contact-audience-field-map')); $form.find('[data-contact-audience-fields]').each(function () { const field = this.getAttribute('data-contact-audience-fields'); - $(this).toggleClass('d-none', !fieldAudiences[field].includes(audience)); + $(this).toggleClass('d-none', !fieldAudiences[field]?.includes(audience)); }); } diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb index 20ab50f76..1797311c2 100644 --- a/app/controllers/admin/chapter_contacts_controller.rb +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -57,9 +57,9 @@ def preview_recipient_ids # rubocop:disable Metrics/MethodLength def valid_contact?(recipient_ids) @recipient_count = recipient_ids.size - return true if @audience.in?(Chapter::CONTACT_AUDIENCES) && + return true if @audience.in?(ChapterContactAudience::AUDIENCES) && @subject.present? && @message.present? && - Chapter.valid_contact_activity_months?(@audience, @activity_months) && recipient_ids.any? + ChapterContactAudience.valid_activity_months?(@audience, @activity_months) && recipient_ids.any? flash.now[:alert] = contact_error false @@ -87,11 +87,12 @@ def accessible_chapters def contact_error return 'Subject and message are required.' if @subject.blank? || @message.blank? - return 'Choose a valid audience.' unless @audience.in?(Chapter::CONTACT_AUDIENCES) + return 'Choose a valid audience.' unless @audience.in?(ChapterContactAudience::AUDIENCES) return 'Choose a valid activity period.' unless - Chapter.valid_contact_activity_months?(@audience, @activity_months) + ChapterContactAudience.valid_activity_months?(@audience, @activity_months) - Chapter::CONTACT_AUDIENCE_ERRORS.fetch(@audience, 'There are no current chapter subscribers to email.') + ChapterContactAudience::CONFIG.dig(@audience, :error) || + 'There are no current chapter subscribers to email.' end def load_form_collections @@ -106,9 +107,10 @@ def reject_confirmation def send_contact_emails(confirmation) Member.where(id: confirmation.recipient_ids).find_each do |member| - ChapterContactMailer.with(chapter: confirmation.chapter, recipient: member, - subject: confirmation.subject, message: confirmation.message) - .contact.deliver_later + delivery = ChapterContactMailer.with( + chapter: confirmation.chapter, recipient: member, subject: confirmation.subject, message: confirmation.message + ).contact.deliver_later + raise ActiveJob::EnqueueError, 'Chapter contact email could not be enqueued' unless delivery end end diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 915f13d50..140b05a7e 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -1,17 +1,4 @@ class Chapter < ApplicationRecord - CONTACT_AUDIENCES = %w[ - all_members chapter_organisers custom_list active_last_month inactive_last_month event_rsvp - ].freeze - CONTACT_RSVP_STATUSES = %w[attending declined unanswered].freeze - CONTACT_ACTIVITY_MONTHS = %w[1 3 6 12].freeze - CONTACT_AUDIENCE_ERRORS = { - 'chapter_organisers' => 'There are no chapter organisers to email.', - 'custom_list' => 'Select at least one current chapter member.', - 'active_last_month' => 'There are no members active in the selected period.', - 'inactive_last_month' => 'There are no members inactive for the selected period.', - 'event_rsvp' => 'No current chapter members match that event RSVP status.' - }.freeze - resourcify :permissions, role_cname: 'Permission', role_table_name: :permission validates :name, :email, uniqueness: true, presence: true @@ -56,20 +43,10 @@ def contact_events end def contact_recipients(audience, member_ids = [], event_id: nil, rsvp_status: nil, activity_months: '1') - recipients = case audience - when 'all_members' then members - when 'chapter_organisers' then organisers - when 'custom_list' then members.where(id: member_ids) - when 'active_last_month' then activity_recipients(:active, activity_months) - when 'inactive_last_month' then activity_recipients(:inactive, activity_months) - when 'event_rsvp' then recipients_by_event_rsvp(event_id, rsvp_status) - else Member.none - end - recipients.distinct - end + config = ChapterContactAudience::CONFIG[audience] + return Member.none unless config - def self.valid_contact_activity_months?(audience, months) - !audience.in?(%w[active_last_month inactive_last_month]) || CONTACT_ACTIVITY_MONTHS.include?(months) + contact_recipient_scope(config[:query], member_ids, event_id, rsvp_status, activity_months).distinct end # All members subscribed to this chapter's Students group, regardless of ban or TOC status. @@ -96,15 +73,27 @@ def eligible_coaches private + def contact_recipient_scope(query, member_ids, event_id, rsvp_status, activity_months) + case query + when :members then members + when :organisers then organisers + when :selected_members then members.where(id: member_ids) + when :active_members then activity_recipients(:active, activity_months) + when :inactive_members then activity_recipients(:inactive, activity_months) + when :event_rsvp then recipients_by_event_rsvp(event_id, rsvp_status) + else Member.none + end + end + def activity_recipients(state, months) - return Member.none if CONTACT_ACTIVITY_MONTHS.exclude?(months) + return Member.none if ChapterContactAudience::ACTIVITY_MONTHS.exclude?(months) cutoff = months.to_i.months.ago state == :active ? members.with_activity_since(cutoff) : members.without_activity_since(cutoff) end def recipients_by_event_rsvp(event_id, rsvp_status) - return Member.none unless CONTACT_RSVP_STATUSES.include?(rsvp_status) + return Member.none unless ChapterContactAudience::RSVP_STATUSES.key?(rsvp_status) event = contact_events.find_by(id: event_id) return Member.none unless event diff --git a/app/models/chapter_contact_audience.rb b/app/models/chapter_contact_audience.rb new file mode 100644 index 000000000..fd34a916d --- /dev/null +++ b/app/models/chapter_contact_audience.rb @@ -0,0 +1,34 @@ +module ChapterContactAudience + CONFIG = { + 'all_members' => { label: 'All chapter members', fields: [], query: :members }, + 'chapter_organisers' => { label: 'Chapter organisers', fields: [], query: :organisers, + error: 'There are no chapter organisers to email.' }, + 'custom_list' => { label: 'Custom list', fields: %w[custom_list], query: :selected_members, + error: 'Select at least one current chapter member.' }, + 'active_last_month' => { label: 'Active in the last month', fields: %w[activity_period], query: :active_members, + error: 'There are no members active in the selected period.' }, + 'inactive_last_month' => { label: 'Inactive for the last month', fields: %w[activity_period], + query: :inactive_members, + error: 'There are no members inactive for the selected period.' }, + 'event_rsvp' => { label: 'Event RSVP status', fields: %w[event_rsvp], query: :event_rsvp, + error: 'No current chapter members match that event RSVP status.' } + }.freeze + AUDIENCES = CONFIG.keys.freeze + ACTIVITY_MONTHS = %w[1 3 6 12].freeze + RSVP_STATUSES = { 'attending' => 'Attending', 'declined' => 'Declined', 'unanswered' => 'No response' }.freeze + FIELD_AUDIENCES = CONFIG.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(audience, config), fields| + config[:fields].each { |field| fields[field] << audience } + end.transform_values(&:freeze).freeze + + def self.options + CONFIG.map { |value, config| [config[:label], value] } + end + + def self.activity_period_options + ACTIVITY_MONTHS.map { |months| ["#{months} #{months == '1' ? 'month' : 'months'}", months] } + end + + def self.valid_activity_months?(audience, months) + !FIELD_AUDIENCES.fetch('activity_period').include?(audience) || ACTIVITY_MONTHS.include?(months) + end +end diff --git a/app/views/admin/chapter_contacts/new.html.haml b/app/views/admin/chapter_contacts/new.html.haml index 308568729..6e8e190ae 100644 --- a/app/views/admin/chapter_contacts/new.html.haml +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -8,7 +8,7 @@ = form_with scope: :chapter_contact, url: preview_admin_chapter_contact_path(@chapter) do |form| .mb-3 = form.label :audience, 'Audience', class: 'form-label' - = form.select :audience, options_for_select([['All chapter members', 'all_members'], ['Chapter organisers', 'chapter_organisers'], ['Custom list', 'custom_list'], ['Active in the last month', 'active_last_month'], ['Inactive for the last month', 'inactive_last_month'], ['Event RSVP status', 'event_rsvp']], @audience), {}, class: 'form-select', data: { chapter_contact_audience: true } + = form.select :audience, options_for_select(ChapterContactAudience.options, @audience), {}, class: 'form-select', data: { chapter_contact_audience: true, contact_audience_field_map: ChapterContactAudience::FIELD_AUDIENCES.to_json } .mb-3{ data: { contact_audience_fields: 'custom_list' } } %fieldset %legend.fs-6 Select members for a custom list @@ -22,10 +22,10 @@ = form.select :event_id, options_from_collection_for_select(@chapter_events, :id, :name, @event_id), { include_blank: 'Select an event' }, class: 'form-select' .mb-3 = form.label :rsvp_status, 'RSVP status', class: 'form-label' - = form.select :rsvp_status, options_for_select([['Attending', 'attending'], ['Declined', 'declined'], ['No response', 'unanswered']], @rsvp_status), { include_blank: 'Select an RSVP status' }, class: 'form-select' + = form.select :rsvp_status, options_for_select(ChapterContactAudience::RSVP_STATUSES.map { |value, label| [label, value] }, @rsvp_status), { include_blank: 'Select an RSVP status' }, class: 'form-select' .mb-3{ data: { contact_audience_fields: 'activity_period' } } = form.label :activity_months, 'Activity period', class: 'form-label' - = form.select :activity_months, options_for_select([['1 month', '1'], ['3 months', '3'], ['6 months', '6'], ['12 months', '12']], @activity_months), {}, class: 'form-select' + = form.select :activity_months, options_for_select(ChapterContactAudience.activity_period_options, @activity_months), {}, class: 'form-select' .mb-3 = form.label :subject, class: 'form-label' = form.text_field :subject, class: 'form-control', required: true, value: @subject diff --git a/spec/controllers/admin/chapter_contacts_controller_spec.rb b/spec/controllers/admin/chapter_contacts_controller_spec.rb index 382f58528..811a70fef 100644 --- a/spec/controllers/admin/chapter_contacts_controller_spec.rb +++ b/spec/controllers/admin/chapter_contacts_controller_spec.rb @@ -123,6 +123,32 @@ expect(flash[:alert]).to include('expired or already used') end + it 'rolls back queued emails and keeps the confirmation reusable if enqueuing fails' do + another_member = Fabricate(:member) + Fabricate(:subscription, member: another_member, group:) + post :preview, params: { + chapter_id: chapter.id, + chapter_contact: { subject: 'Update', message: 'Hello chapter' } + } + token = confirmation_token + original_delay_jobs = Delayed::Worker.delay_jobs + Delayed::Worker.delay_jobs = true + enqueue_count = 0 + allow(Delayed::Job).to receive(:enqueue).and_wrap_original do |original, *args| + enqueue_count += 1 + raise ActiveJob::EnqueueError if enqueue_count == 2 + + original.call(*args) + end + queued_jobs = Delayed::Job.count + + expect { confirm(token) }.to raise_error(ActiveJob::EnqueueError, 'Chapter contact email could not be enqueued') + expect(Delayed::Job.count).to eq(queued_jobs) + expect(ChapterContactConfirmation.last.reload.consumed_at).to be_nil + ensure + Delayed::Worker.delay_jobs = original_delay_jobs + end + it 'rejects an expired confirmation' do post :preview, params: { chapter_id: chapter.id,