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..91397ff35 --- /dev/null +++ b/app/assets/javascripts/chapter-contact-audience.js @@ -0,0 +1,19 @@ +/* global $ */ + +$(() => { + const selector = '[data-chapter-contact-audience]'; + + 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)); + }); + } + + $(document).on('change', selector, updateAudienceFields); + $(selector).each(updateAudienceFields); +}); diff --git a/app/controllers/admin/chapter_contacts_controller.rb b/app/controllers/admin/chapter_contacts_controller.rb new file mode 100644 index 000000000..1797311c2 --- /dev/null +++ b/app/controllers/admin/chapter_contacts_controller.rb @@ -0,0 +1,120 @@ +class Admin::ChapterContactsController < Admin::ApplicationController + before_action :set_chapter, only: %i[new preview create] + after_action :verify_authorized + + def index + authorize Chapter, :contact_index? + @chapters = accessible_chapters + end + + def new + authorize @chapter, :contact? + load_form_collections + @audience = 'all_members' + @recipient_count = @chapter.contact_recipients(@audience).count + @selected_member_ids = [] + end + + def preview + authorize @chapter, :contact? + recipient_ids = preview_recipient_ids + return render(:new, status: :unprocessable_entity) unless valid_contact?(recipient_ids) + + create_confirmation(recipient_ids) + render :preview + end + + def create + authorize @chapter, :contact? + 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 + redirect_to new_admin_chapter_contact_path(@chapter), + notice: "Email sent to #{count} chapter #{'member'.pluralize(count)}" + end + + private + + 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]) + @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, activity_months: @activity_months + ).pluck(:id) + end + + def valid_contact?(recipient_ids) + @recipient_count = recipient_ids.size + return true if @audience.in?(ChapterContactAudience::AUDIENCES) && + @subject.present? && @message.present? && + ChapterContactAudience.valid_activity_months?(@audience, @activity_months) && 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 accessible_chapters + 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 contact_error + return 'Subject and message are required.' if @subject.blank? || @message.blank? + return 'Choose a valid audience.' unless @audience.in?(ChapterContactAudience::AUDIENCES) + return 'Choose a valid activity period.' unless + ChapterContactAudience.valid_activity_months?(@audience, @activity_months) + + ChapterContactAudience::CONFIG.dig(@audience, :error) || + '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 + redirect_to new_admin_chapter_contact_path(@chapter), + alert: 'Email preview expired or already used; please preview again.' + end + + def send_contact_emails(confirmation) + Member.where(id: confirmation.recipient_ids).find_each do |member| + 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 + + 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/models/chapter.rb b/app/models/chapter.rb index 70822e516..140b05a7e 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -34,6 +34,21 @@ def organisers @organisers ||= Member.with_role(:organiser, self) end + def contact_members + members.distinct.order(:name, :surname) + end + + def contact_events + events.order(date_and_time: :desc) + end + + def contact_recipients(audience, member_ids = [], event_id: nil, rsvp_status: nil, activity_months: '1') + config = ChapterContactAudience::CONFIG[audience] + return Member.none unless config + + 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. # Use #eligible_students when building invitation lists. def students @@ -58,6 +73,35 @@ 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 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 ChapterContactAudience::RSVP_STATUSES.key?(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_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/models/chapter_contact_confirmation.rb b/app/models/chapter_contact_confirmation.rb new file mode 100644 index 000000000..c8b6239cd --- /dev/null +++ b/app/models/chapter_contact_confirmation.rb @@ -0,0 +1,47 @@ +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.find_for(chapter:, sender:, token:) + return unless token.is_a?(String) && token.present? + + 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!( + 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/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/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..6e8e190ae --- /dev/null +++ b/app/views/admin/chapter_contacts/new.html.haml @@ -0,0 +1,35 @@ +.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: preview_admin_chapter_contact_path(@chapter) do |form| + .mb-3 + = form.label :audience, 'Audience', class: 'form-label' + = 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 + = 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{ 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(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(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 + .mb-3 + = form.label :message, class: 'form-label' + = 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..b4ab03b08 --- /dev/null +++ b/app/views/admin/chapter_contacts/preview.html.haml @@ -0,0 +1,10 @@ +.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 :confirmation_token, value: @confirmation_token + = form.submit 'Confirm and send', 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..8c573f137 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,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' do + post :preview, on: :collection + end end resources :stats, only: [:index] 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..811a70fef --- /dev/null +++ b/spec/controllers/admin/chapter_contacts_controller_spec.rb @@ -0,0 +1,221 @@ +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 + + 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 + + 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 + + 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 + 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 '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, + 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 new file mode 100644 index 000000000..fff93f4ba --- /dev/null +++ b/spec/features/admin/contacting_chapter_members_spec.rb @@ -0,0 +1,430 @@ +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 '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 + 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 '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') + expect_email_fanout( + recipients: [student, coach, both], + 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 + + 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 member') + 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 + + 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 + + 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) + 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 + 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 'Preview recipients' + + 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_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, + key: 'event_invitation.rsvp', + trackable: member, + created_at:, + updated_at: 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) + expect_no_delivery_to(excluded) + expect_individual_emails(recipients.size, subject:, message:) + 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} #{'recipient'.pluralize(count)} 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 + + def expect_no_delivery_to(members) + members.each { |member| expect(delivered_emails_to(member)).to be_empty } + end + + def expect_individual_emails(count, subject:, message:) + emails = ActionMailer::Base.deliveries + expect(emails.size).to eq(count) + emails.each { |email| expect_individual_email(email, subject:, message:) } + end + + def expect_individual_email(email, subject:, message:) + expect_mail_privacy(email) + expect_mail_content(email, subject:, message:) + 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, subject:, message:) + expect(email.subject).to eq(subject) + expect(email.body.encoded).to include(message) + end +end