From f53c52a0af37880dcd368a20fc2d3a257c8d4413 Mon Sep 17 00:00:00 2001 From: Paolo Fabbri Date: Sun, 4 Oct 2026 01:11:18 +0100 Subject: [PATCH 1/5] feat(mailers): make the delivery log email type overridable --- app/mailers/concerns/email_delivery.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/mailers/concerns/email_delivery.rb b/app/mailers/concerns/email_delivery.rb index 048916fd3..d964a30bb 100644 --- a/app/mailers/concerns/email_delivery.rb +++ b/app/mailers/concerns/email_delivery.rb @@ -11,7 +11,7 @@ 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) @@ -19,4 +19,8 @@ def log_sent_email delivery.bcc = Array(mail.bcc) end end + + def email_type_for_log + action_name + end end From 031253646005be33137998221218f002d118d211 Mon Sep 17 00:00:00 2001 From: Paolo Fabbri Date: Sun, 4 Oct 2026 01:19:36 +0100 Subject: [PATCH 2/5] feat(mailers): add the coach milestone thank-you email One action serves every milestone; the delivery log is keyed as coach_milestone_ so each milestone is sent once per member. --- app/mailers/member_mailer.rb | 21 ++++++++++++++++++- .../member_mailer/coach_milestone.html.haml | 16 ++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 app/views/member_mailer/coach_milestone.html.haml diff --git a/app/mailers/member_mailer.rb b/app/mailers/member_mailer.rb index e463639e0..6b7954a30 100644 --- a/app/mailers/member_mailer.rb +++ b/app/mailers/member_mailer.rb @@ -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] @@ -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) @@ -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 diff --git a/app/views/member_mailer/coach_milestone.html.haml b/app/views/member_mailer/coach_milestone.html.haml new file mode 100644 index 000000000..a14839559 --- /dev/null +++ b/app/views/member_mailer/coach_milestone.html.haml @@ -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 From 8ef94706e9f7485ad3e99b11be38ec6c39a5490a Mon Sep 17 00:00:00 2001 From: Paolo Fabbri Date: Sun, 4 Oct 2026 01:30:58 +0100 Subject: [PATCH 3/5] feat(mailers): add the coach milestone email service Picks coaches at or past a milestone in one grouped query, sends only the highest milestone reached, and skips anyone already logged for it. --- app/services/coach_milestone_email_service.rb | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 app/services/coach_milestone_email_service.rb diff --git a/app/services/coach_milestone_email_service.rb b/app/services/coach_milestone_email_service.rb new file mode 100644 index 000000000..1c42a31b4 --- /dev/null +++ b/app/services/coach_milestone_email_service.rb @@ -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 From 5b76ff8b23d5d1aefe682f99b0264bcd19de7388 Mon Sep 17 00:00:00 2001 From: Paolo Fabbri Date: Sun, 4 Oct 2026 01:31:55 +0100 Subject: [PATCH 4/5] feat(mailers): add the coach milestone job and chaser:coach_milestones task --- app/jobs/send_coach_milestone_email_job.rb | 9 +++++++++ lib/tasks/chaser.rake | 5 +++++ 2 files changed, 14 insertions(+) create mode 100644 app/jobs/send_coach_milestone_email_job.rb diff --git a/app/jobs/send_coach_milestone_email_job.rb b/app/jobs/send_coach_milestone_email_job.rb new file mode 100644 index 000000000..dea8560ed --- /dev/null +++ b/app/jobs/send_coach_milestone_email_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SendCoachMilestoneEmailJob < ApplicationJob + queue_as :default + + def perform + CoachMilestoneEmailService.send_milestone_emails + end +end diff --git a/lib/tasks/chaser.rake b/lib/tasks/chaser.rake index e79edd4e3..eb7576e52 100644 --- a/lib/tasks/chaser.rake +++ b/lib/tasks/chaser.rake @@ -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 From f284e0ae073c87a5e6ae0cf801b8edd8963c351a Mon Sep 17 00:00:00 2001 From: Paolo Fabbri Date: Sun, 4 Oct 2026 01:39:30 +0100 Subject: [PATCH 5/5] feat(mailers): spec the coach milestone email, service, job and task --- .../send_coach_milestone_email_job_spec.rb | 13 ++ .../chaser_coach_milestones_rake_spec.rb | 15 ++ spec/mailers/member_mailer_spec.rb | 34 +++++ .../coach_milestone_email_service_spec.rb | 132 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 spec/jobs/send_coach_milestone_email_job_spec.rb create mode 100644 spec/lib/tasks/chaser_coach_milestones_rake_spec.rb create mode 100644 spec/services/coach_milestone_email_service_spec.rb diff --git a/spec/jobs/send_coach_milestone_email_job_spec.rb b/spec/jobs/send_coach_milestone_email_job_spec.rb new file mode 100644 index 000000000..bc9b789dd --- /dev/null +++ b/spec/jobs/send_coach_milestone_email_job_spec.rb @@ -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 diff --git a/spec/lib/tasks/chaser_coach_milestones_rake_spec.rb b/spec/lib/tasks/chaser_coach_milestones_rake_spec.rb new file mode 100644 index 000000000..d5c194b83 --- /dev/null +++ b/spec/lib/tasks/chaser_coach_milestones_rake_spec.rb @@ -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 diff --git a/spec/mailers/member_mailer_spec.rb b/spec/mailers/member_mailer_spec.rb index 1b3d9cb3e..9f3dcdcd2 100644 --- a/spec/mailers/member_mailer_spec.rb +++ b/spec/mailers/member_mailer_spec.rb @@ -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 diff --git a/spec/services/coach_milestone_email_service_spec.rb b/spec/services/coach_milestone_email_service_spec.rb new file mode 100644 index 000000000..4b9ac56f8 --- /dev/null +++ b/spec/services/coach_milestone_email_service_spec.rb @@ -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