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
9 changes: 9 additions & 0 deletions app/jobs/send_coach_milestone_email_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class SendCoachMilestoneEmailJob < ApplicationJob
queue_as :default

def perform
CoachMilestoneEmailService.send_milestone_emails
end
end
6 changes: 5 additions & 1 deletion app/mailers/concerns/email_delivery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ def log_sent_email
return unless member
return unless @_mail_was_called

MemberEmailDelivery.find_or_create_by!(member:, email_type: action_name) do |delivery|
MemberEmailDelivery.find_or_create_by!(member:, email_type: email_type_for_log) do |delivery|
delivery.subject = mail.subject
delivery.body = mail.html_part ? mail.html_part.body.to_s : mail.body.to_s
delivery.to = Array(mail.to)
delivery.cc = Array(mail.cc)
delivery.bcc = Array(mail.bcc)
end
end

def email_type_for_log
action_name
end
end
21 changes: 20 additions & 1 deletion app/mailers/member_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class MemberMailer < ApplicationMailer
include EmailHeaderHelper
include EmailDelivery

after_deliver :log_sent_email, only: %i[chaser signup_nudge signup_nudge_followup]
after_deliver :log_sent_email, only: %i[chaser signup_nudge signup_nudge_followup coach_milestone]

def signup_nudge
@member = params[:member]
Expand Down Expand Up @@ -31,6 +31,15 @@ def chaser
end
end

def coach_milestone
@member = params[:member]
@milestone = params[:milestone]
subject = "You’ve attended #{@milestone} codebar workshops — amazing 🎉"
mail_to_member(@member, subject, 'hello@codebar.io', 'hello@codebar.io') do |format|
format.html { render 'coach_milestone' }
end
end

def welcome(member)
if member.student?
welcome_student(member)
Expand Down Expand Up @@ -99,4 +108,14 @@ def ban(member, ban)
format.html { render 'ban' }
end.deliver
end

private

# One action serves every milestone, so the delivery log is keyed per
# milestone rather than per action (see EmailDelivery#email_type_for_log).
def email_type_for_log
return super unless action_name == 'coach_milestone'

"coach_milestone_#{params[:milestone]}"
end
end
58 changes: 58 additions & 0 deletions app/services/coach_milestone_email_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

# Thanks coaches once per attendance milestone (issue #2386). Run daily by
# chaser:coach_milestones. Only the highest milestone reached is sent, so a
# coach already past several milestones at launch gets one email rather than
# one per milestone; the delivery log then keeps every milestone send-once.
class CoachMilestoneEmailService
MILESTONES = [5].freeze
EMAIL_TYPE_PREFIX = 'coach_milestone_'

def self.send_milestone_emails
due = milestones_due
return if due.empty?

Member.not_banned.accepted_toc.where(id: due.keys).find_each do |member|
MemberMailer.with(member:, milestone: due[member.id]).coach_milestone.deliver_later
end
end

# { member_id => milestone } for coaches owed an email: the highest milestone
# at or below their count, unless that milestone or a higher one was already sent.
def self.milestones_due
sent = sent_milestones
attended_counts.each_with_object({}) do |(member_id, count), due|
milestone = milestone_for(count)
next unless milestone
next if sent.fetch(member_id, []).any? { |previous| previous >= milestone }

due[member_id] = milestone
end
end

# Attended past workshops per coach. The WHERE matches the partial index
# index_workshop_invitations_coach_attended_on_member_id.
def self.attended_counts
WorkshopInvitation.to_coaches.attended
.joins(:workshop)
.where('workshops.date_and_time < ?', Time.zone.now)
.group(:member_id)
.having('COUNT(*) >= ?', MILESTONES.min)
.count
end

def self.milestone_for(count)
MILESTONES.select { |milestone| milestone <= count }.max
end

# { member_id => [milestones already emailed] }
def self.sent_milestones
MemberEmailDelivery.where('email_type LIKE ?', "#{EMAIL_TYPE_PREFIX}%")
.where.not(member_id: nil)
.pluck(:member_id, :email_type)
.group_by(&:first)
.transform_values { |rows| rows.map { |(_, type)| type.delete_prefix(EMAIL_TYPE_PREFIX).to_i } }
end

private_class_method :milestones_due, :attended_counts, :milestone_for, :sent_milestones
end
16 changes: 16 additions & 0 deletions app/views/member_mailer/coach_milestone.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
%h1 Hi #{@member.name},

%p
You’ve now attended #{@milestone} codebar workshops as a coach 🎉

%p
This is just a very quick email to say a huge thank you. Without our wonderful coaches our workshops would not happen, so the difference you’re making is huge!

%p
So the biggest thank you for being part of codebar and we’re excited to keep seeing you at our workshops 💜

%p
#{'-- '}
%br
The biggest thank you,
The codebar Team
5 changes: 5 additions & 0 deletions lib/tasks/chaser.rake
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ namespace :chaser do
task signup_nudges: :environment do
SendSignupNudgeEmailJob.perform_later
end

desc 'Send thank-you emails to coaches who reached an attendance milestone'
task coach_milestones: :environment do
SendCoachMilestoneEmailJob.perform_later
end
end
13 changes: 13 additions & 0 deletions spec/jobs/send_coach_milestone_email_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
require 'rails_helper'

RSpec.describe SendCoachMilestoneEmailJob do
describe '#perform' do
it 'delegates to the coach milestone email service' do
allow(CoachMilestoneEmailService).to receive(:send_milestone_emails)

described_class.new.perform

expect(CoachMilestoneEmailService).to have_received(:send_milestone_emails)
end
end
end
15 changes: 15 additions & 0 deletions spec/lib/tasks/chaser_coach_milestones_rake_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
require 'rails_helper'

RSpec.describe 'rake chaser:coach_milestones' do
it 'preloads the Rails environment' do
expect(task.prerequisites).to include 'environment'
end

it 'enqueues the coach milestone email job' do
allow(SendCoachMilestoneEmailJob).to receive(:perform_later)

task.invoke

expect(SendCoachMilestoneEmailJob).to have_received(:perform_later)
end
end
34 changes: 34 additions & 0 deletions spec/mailers/member_mailer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,38 @@
.to change { MemberEmailDelivery.where(member:, email_type: 'signup_nudge_followup').count }.by(1)
end
end

describe '#coach_milestone' do
let(:mail) { described_class.with(member:, milestone: 5).coach_milestone.deliver_now }

it 'renders the headers' do
expect(mail.subject).to eq('You’ve attended 5 codebar workshops — amazing 🎉')
expect(mail.to).to eq([member.email])
expect(mail.from).to eq(['hello@codebar.io'])
end

it 'renders the body' do
expect(mail.body.encoded).to match("Hi #{member.name}")
expect(mail.body.encoded).to match('attended 5 codebar workshops as a coach')
end

it 'logs with the milestone in the email_type' do
expect { mail }
.to change { MemberEmailDelivery.where(member:, email_type: 'coach_milestone_5').count }.by(1)
end

it 'logs each milestone separately' do
described_class.with(member:, milestone: 5).coach_milestone.deliver_now
described_class.with(member:, milestone: 10).coach_milestone.deliver_now

expect(MemberEmailDelivery.where(member:).pluck(:email_type)).to contain_exactly('coach_milestone_5', 'coach_milestone_10')
end

it 'logs one row per milestone even if the delivery is performed twice' do
expect do
described_class.with(member:, milestone: 5).coach_milestone.deliver_now
described_class.with(member:, milestone: 5).coach_milestone.deliver_now
end.to change(MemberEmailDelivery, :count).by(1)
end
end
end
132 changes: 132 additions & 0 deletions spec/services/coach_milestone_email_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
require 'rails_helper'

RSpec.describe CoachMilestoneEmailService do
describe '.send_milestone_emails' do
subject(:call) { described_class.send_milestone_emails }

around do |example|
original_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :test
example.run
ensure
ActiveJob::Base.queue_adapter = original_adapter
end

def attend(member, count, role: 'Coach', weeks_ago: 1)
count.times do |i|
workshop = Fabricate(:workshop, chapter:, date_and_time: (weeks_ago + i).weeks.ago)
Fabricate(:workshop_invitation, member:, workshop:, role:, attended: true)
end
end

let(:chapter) { Fabricate(:chapter) }

let!(:coach_at_five) { Fabricate(:member).tap { |m| attend(m, 5) } }
let!(:coach_at_seven) { Fabricate(:member).tap { |m| attend(m, 7) } }
let!(:coach_at_four) { Fabricate(:member).tap { |m| attend(m, 4) } }
let!(:coach_with_future_fifth) do
Fabricate(:member).tap do |m|
attend(m, 4)
Fabricate(:workshop_invitation, member: m, workshop: Fabricate(:workshop, chapter:), role: 'Coach',
attended: true)
end
end
let!(:coach_invited_not_attended) do
Fabricate(:member).tap do |m|
5.times do |i|
Fabricate(:workshop_invitation, member: m, role: 'Coach', attending: true, attended: nil,
workshop: Fabricate(:workshop, chapter:, date_and_time: (i + 1).weeks.ago))
end
end
end
let!(:student_at_five) { Fabricate(:member).tap { |m| attend(m, 5, role: 'Student') } }
let!(:coach_already_thanked) do
Fabricate(:member).tap do |m|
attend(m, 5)
Fabricate(:member_email_delivery, member: m, email_type: 'coach_milestone_5')
end
end
let!(:coach_with_other_email_logged) do
Fabricate(:member).tap do |m|
attend(m, 5)
Fabricate(:member_email_delivery, member: m, email_type: 'chaser')
end
end
let!(:coach_thanked_for_higher_milestone) do
Fabricate(:member).tap do |m|
attend(m, 6)
Fabricate(:member_email_delivery, member: m, email_type: 'coach_milestone_10')
end
end
let!(:banned_coach) { Fabricate(:banned_member).tap { |m| attend(m, 5) } }
let!(:coach_without_toc) { Fabricate(:member_without_toc).tap { |m| attend(m, 5) } }

it 'thanks coaches who attended at least five past workshops and were not thanked before' do
expect { perform_enqueued_jobs { call } }.to change(MemberEmailDelivery, :count).by(3)

expect(MemberEmailDelivery.where(member: coach_at_five, email_type: 'coach_milestone_5')).to exist
expect(MemberEmailDelivery.where(member: coach_at_seven, email_type: 'coach_milestone_5')).to exist
expect(MemberEmailDelivery.where(member: coach_with_other_email_logged, email_type: 'coach_milestone_5'))
.to exist
end

it 'does not thank a coach below the milestone' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: coach_at_four).count })
end

it 'does not count workshops that have not taken place yet' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: coach_with_future_fifth).count })
end

it 'does not count invitations that were accepted but not attended' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: coach_invited_not_attended).count })
end

it 'does not count workshops attended as a student' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: student_at_five).count })
end

it 'does not thank a coach twice for the same milestone' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: coach_already_thanked).count })
end

it 'does not thank a coach already thanked for a higher milestone' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: coach_thanked_for_higher_milestone).count })
end

it 'does not thank banned coaches' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: banned_coach).count })
end

it 'does not thank coaches who have not accepted the terms of conduct' do
expect { perform_enqueued_jobs { call } }
.not_to(change { MemberEmailDelivery.where(member: coach_without_toc).count })
end

it 'is idempotent across runs' do
perform_enqueued_jobs { described_class.send_milestone_emails }

expect { perform_enqueued_jobs { described_class.send_milestone_emails } }
.not_to change(MemberEmailDelivery, :count)
end

context 'when a higher milestone is configured' do
before { stub_const("#{described_class}::MILESTONES", [5, 10]) }

let!(:coach_at_twelve) { Fabricate(:member).tap { |m| attend(m, 12) } }

it 'sends only the highest milestone reached' do
perform_enqueued_jobs { call }

expect(MemberEmailDelivery.where(member: coach_at_twelve).pluck(:email_type)).to eq(['coach_milestone_10'])
end
end
end
end
Loading