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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Expand All @@ -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'
});
Expand Down
19 changes: 19 additions & 0 deletions app/assets/javascripts/chapter-contact-audience.js
Original file line number Diff line number Diff line change
@@ -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);
});
120 changes: 120 additions & 0 deletions app/controllers/admin/chapter_contacts_controller.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions app/mailers/chapter_contact_mailer.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions app/models/chapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions app/models/chapter_contact_audience.rb
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions app/models/chapter_contact_confirmation.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions app/models/member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 8 additions & 0 deletions app/policies/chapter_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
10 changes: 10 additions & 0 deletions app/views/admin/chapter_contacts/index.html.haml
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions app/views/admin/chapter_contacts/new.html.haml
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading