From 97ca9b5bb7d27c2f287c7a7024d69b01e5f92fb7 Mon Sep 17 00:00:00 2001 From: "jonathan.kerr" <3410350+jonodrew@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:21:21 +0000 Subject: [PATCH 1/9] Add chaser email copy I've taken the copy from Kimberly and put it into HTML, as we do with the rest of our emails Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com> --- .../three_month_chaser.html.haml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 app/views/member_mailer/three_month_chaser.html.haml diff --git a/app/views/member_mailer/three_month_chaser.html.haml b/app/views/member_mailer/three_month_chaser.html.haml new file mode 100644 index 000000000..93fcfde68 --- /dev/null +++ b/app/views/member_mailer/three_month_chaser.html.haml @@ -0,0 +1,21 @@ +%h1 Hi #{@member.name}, + +%p + We’ve noticed you haven’t been to a codebar workshop in a little while, and we just wanted to check in. We know life gets busy, but we’d love to understand how things are going for you and whether there’s anything we can do to make it easier or more valuable for you to join again. +%p + If you have a minute, could you please share your thoughts in this short form? 👉 https://forms.gle/tEETvC3zYP9mcLar7 + +%p + Or, if you’re thinking about coming back soon, we’ve got some great upcoming workshops and events you might like to join 👉https://codebar.io/events/ + +%p + Your feedback really helps us make codebar more welcoming and useful for everyone in our community 💜 + +%p + We’d love to see you again soon! + +%p + #{"-- "} +%br +Warmly, +The Codebar Team From 25a4d3be4de3454ee7eb613af534466a4c4c599f Mon Sep 17 00:00:00 2001 From: "jonathan.kerr" <3410350+jonodrew@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:01:49 +0000 Subject: [PATCH 2/9] Add EmailDelivery concern and test This adds an additional method to the MemberMailer class. It's currently called #chaser and it sends...the chaser email. In the next commit I'll call it from a Job, or maybe a Service that's called from a Job Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com> --- app/mailers/concerns/email_delivery.rb | 19 +++++++++++++++++++ app/mailers/member_mailer.rb | 11 +++++++++++ app/models/member_email_delivery.rb | 3 +++ spec/mailers/member_mailer_spec.rb | 17 +++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 app/mailers/concerns/email_delivery.rb create mode 100644 app/models/member_email_delivery.rb diff --git a/app/mailers/concerns/email_delivery.rb b/app/mailers/concerns/email_delivery.rb new file mode 100644 index 000000000..0032c224d --- /dev/null +++ b/app/mailers/concerns/email_delivery.rb @@ -0,0 +1,19 @@ +module EmailDelivery + extend ActiveSupport::Concern + + private + + def log_sent_email + member = params[:member] + return unless member + + MemberEmailDelivery.create!( + member: member, + subject: mail.subject, + body: mail.body.to_s, + to: Array(mail.to), + cc: Array(mail.cc), + bcc: Array(mail.bcc) + ) + end +end diff --git a/app/mailers/member_mailer.rb b/app/mailers/member_mailer.rb index ebc5e4850..eca12b3b4 100644 --- a/app/mailers/member_mailer.rb +++ b/app/mailers/member_mailer.rb @@ -1,5 +1,16 @@ class MemberMailer < ApplicationMailer include EmailHeaderHelper + include EmailDelivery + + after_deliver :log_sent_mail , only: [:chaser] + + def chaser + @member = params[:member] + subject = "It’s been a while, how are you doing? ♥️" + mail mail_args(@member, subject, 'hello@codebar.io', 'hello@codebar.io') do |format| + format.html {render 'three_month_chaser'} + end + end def welcome(member) if member.student? diff --git a/app/models/member_email_delivery.rb b/app/models/member_email_delivery.rb new file mode 100644 index 000000000..e478f6fba --- /dev/null +++ b/app/models/member_email_delivery.rb @@ -0,0 +1,3 @@ +class MemberEmailDelivery < ApplicationRecord + belongs_to :member +end diff --git a/spec/mailers/member_mailer_spec.rb b/spec/mailers/member_mailer_spec.rb index b09ed32f6..77c6566ff 100644 --- a/spec/mailers/member_mailer_spec.rb +++ b/spec/mailers/member_mailer_spec.rb @@ -115,4 +115,21 @@ end.to change { ActionMailer::Base.deliveries.count }.by 1 end end + + describe "#chaser" do + it "logs the sent email" do + expect do + MemberMailer + .with(member: member) + .chaser + .deliver_now + end.to change(MemberEmailDelivery, :count).by(1) + + log = MemberEmailDelivery.last! + + expect(log.member).to eq(member) + expect(log.subject).to eq("It’s been a while, how are you doing? ♥️") + expect(log.to).to eq([member.email]) + end + end end From 8e843c9d7a86d7c10fcee51921fa818c60fee033 Mon Sep 17 00:00:00 2001 From: "jonathan.kerr" <3410350+jonodrew@users.noreply.github.com> Date: Thu, 21 May 2026 10:48:12 +0100 Subject: [PATCH 3/9] Update test runner to use the queue --- spec/spec_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ca4b95fab..d23c3b9be 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -59,6 +59,7 @@ def self.branch_coverage? ActiveRecord::Migration.check_all_pending! if defined?(ActiveRecord::Migration) RSpec.configure do |config| + config.include ActiveJob::TestHelper config.include ApplicationHelper config.include LoginHelpers config.include ActiveSupport::Testing::TimeHelpers From ee512b606ffc162932c6488c7a5d4e01e019593d Mon Sep 17 00:00:00 2001 From: "jonathan.kerr" <3410350+jonodrew@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:04:05 +0000 Subject: [PATCH 4/9] Add :member_email_deliveries relation to Member This was an oversight when creating the related table, and fixes it Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com> --- app/models/member.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/member.rb b/app/models/member.rb index da811ac15..675a68a21 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -23,6 +23,7 @@ class Member < ApplicationRecord has_many :chapters, -> { distinct }, through: :groups has_many :announcements, -> { distinct }, through: :groups has_many :meeting_invitations + has_many :member_email_deliveries validates :auth_services, presence: true validates :name, :surname, :email, :about_you, presence: true, if: :can_log_in? From 305a6e63a4d72813178b95286e70327cdd79d95a Mon Sep 17 00:00:00 2001 From: "jonathan.kerr" <3410350+jonodrew@users.noreply.github.com> Date: Thu, 21 May 2026 10:49:54 +0100 Subject: [PATCH 5/9] Create Job and Service for sending the chaser --- app/jobs/send_three_month_email_job.rb | 9 ++++ app/services/three_month_email_service.rb | 25 +++++++++ .../member_email_delivery_fabricator.rb | 6 +++ .../three_month_email_service_spec.rb | 51 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 app/jobs/send_three_month_email_job.rb create mode 100644 app/services/three_month_email_service.rb create mode 100644 spec/fabricators/member_email_delivery_fabricator.rb create mode 100644 spec/services/three_month_email_service_spec.rb diff --git a/app/jobs/send_three_month_email_job.rb b/app/jobs/send_three_month_email_job.rb new file mode 100644 index 000000000..29c250500 --- /dev/null +++ b/app/jobs/send_three_month_email_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SendThreeMonthEmailJob < ApplicationJob + queue_as :default + + def perform + ThreeMonthEmailService.send_chaser + end +end diff --git a/app/services/three_month_email_service.rb b/app/services/three_month_email_service.rb new file mode 100644 index 000000000..f6971072a --- /dev/null +++ b/app/services/three_month_email_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ThreeMonthEmailService + def self.send_chaser +recent_attendees = Member.joins(:workshop_invitations) + .merge( + WorkshopInvitation.attended.to_students + .joins(:workshop) + .where('workshops.date_and_time >= ?', 3.months.ago.beginning_of_day) + ) + .distinct + +members = Member.not_banned + .accepted_toc + .joins(:groups) + .merge(Group.students) + .left_joins(:member_email_deliveries) + .where(member_email_deliveries: { id: nil }) + .where.not(id: recent_attendees.select(:id)) + .distinct + members.find_each do |member| + MemberMailer.with(member: member).chaser.deliver_later + end + end +end diff --git a/spec/fabricators/member_email_delivery_fabricator.rb b/spec/fabricators/member_email_delivery_fabricator.rb new file mode 100644 index 000000000..f633b7a35 --- /dev/null +++ b/spec/fabricators/member_email_delivery_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:member_email_delivery) do + member(fabricator: :member) + subject("Chaser") + body("Lorem ipsum") + to(["test_email@address"]) +end diff --git a/spec/services/three_month_email_service_spec.rb b/spec/services/three_month_email_service_spec.rb new file mode 100644 index 000000000..fff8ae210 --- /dev/null +++ b/spec/services/three_month_email_service_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe ThreeMonthEmailService, type: :service do + describe "#send_chaser" do + subject(:call) { described_class.send_chaser } + + around do |example| + original_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + example.run + ensure + ActiveJob::Base.queue_adapter = original_adapter + end + + let!(:eligible_member) { Fabricate(:member) } + let!(:emailed_member) { Fabricate(:member) } + let!(:old_invite_member) { Fabricate(:member) } + + before do + # Eligible: recent invite, no email delivery + Fabricate( + :workshop_invitation, + member: eligible_member, + created_at: 3.months.ago, + attended: false + ) + + # Already emailed: recent invite, but has email delivery + Fabricate( + :workshop_invitation, + member: emailed_member, + created_at: 2.months.ago + ) + Fabricate( + :member_email_delivery, + member: emailed_member + ) + + # Old invite: more than 3 months ago + Fabricate( + :workshop_invitation, + member: old_invite_member, + created_at: 4.months.ago + ) + end + + it "enqueues chaser emails only for eligible members" do + expect { + call + }.to have_enqueued_mail(MemberMailer, :chaser).once + end + end +end From 71996304a7045a5c0767ac971a644c50eda173ee Mon Sep 17 00:00:00 2001 From: "jonathan.kerr" <3410350+jonodrew@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:31:40 +0000 Subject: [PATCH 6/9] Create rake task for running this Job To note - the first time we run this, there's likely to be a massive spike in emails. To set this in Heroku, we'll need to log into the interface and call it as `rake chaser:three_months` Signed-off-by: jonathan.kerr <3410350+jonodrew@users.noreply.github.com> --- lib/tasks/chaser.rake | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 lib/tasks/chaser.rake diff --git a/lib/tasks/chaser.rake b/lib/tasks/chaser.rake new file mode 100644 index 000000000..d06134aa5 --- /dev/null +++ b/lib/tasks/chaser.rake @@ -0,0 +1,7 @@ +namespace :chaser do + desc "Send emails to users who've not attended in a while" + + task three_months: :environment do + SendThreeMonthEmailJob.perform_later + end +end From 086133b7500399484c0c37468568d73d28d3885a Mon Sep 17 00:00:00 2001 From: Michelle Steele Date: Tue, 10 Mar 2026 18:04:44 +0000 Subject: [PATCH 7/9] Fix ThreeMonthEmailService query to check member selection for chaser emails and pass spec --- app/mailers/member_mailer.rb | 2 +- app/services/three_month_email_service.rb | 30 +++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/mailers/member_mailer.rb b/app/mailers/member_mailer.rb index eca12b3b4..fa1c8425f 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_mail , only: [:chaser] + after_deliver :log_sent_email, only: [:chaser] def chaser @member = params[:member] diff --git a/app/services/three_month_email_service.rb b/app/services/three_month_email_service.rb index f6971072a..2edb67d66 100644 --- a/app/services/three_month_email_service.rb +++ b/app/services/three_month_email_service.rb @@ -2,22 +2,22 @@ class ThreeMonthEmailService def self.send_chaser -recent_attendees = Member.joins(:workshop_invitations) - .merge( - WorkshopInvitation.attended.to_students - .joins(:workshop) - .where('workshops.date_and_time >= ?', 3.months.ago.beginning_of_day) - ) - .distinct + cutoff = 3.months.ago.beginning_of_day + recent_attendee_ids = WorkshopInvitation.to_students + .attended + .joins(:workshop) + .where('workshops.date_and_time >= ?', cutoff) + .select(:member_id) + + members = Member.not_banned + .accepted_toc + .joins(:groups) + .merge(Group.students) + .left_joins(:member_email_deliveries) + .where(member_email_deliveries: { id: nil }) + .where.not(id: recent_attendee_ids) + .distinct -members = Member.not_banned - .accepted_toc - .joins(:groups) - .merge(Group.students) - .left_joins(:member_email_deliveries) - .where(member_email_deliveries: { id: nil }) - .where.not(id: recent_attendees.select(:id)) - .distinct members.find_each do |member| MemberMailer.with(member: member).chaser.deliver_later end From da53ef935aec1d3a1755685d89db558a23bc3b47 Mon Sep 17 00:00:00 2001 From: Michelle Steele Date: Tue, 10 Mar 2026 18:34:47 +0000 Subject: [PATCH 8/9] Add more chaser scenarios to the member selection --- .../three_month_email_service_spec.rb | 164 +++++++++++++++--- 1 file changed, 144 insertions(+), 20 deletions(-) diff --git a/spec/services/three_month_email_service_spec.rb b/spec/services/three_month_email_service_spec.rb index fff8ae210..85515c2d1 100644 --- a/spec/services/three_month_email_service_spec.rb +++ b/spec/services/three_month_email_service_spec.rb @@ -10,42 +10,166 @@ ActiveJob::Base.queue_adapter = original_adapter end - let!(:eligible_member) { Fabricate(:member) } - let!(:emailed_member) { Fabricate(:member) } - let!(:old_invite_member) { Fabricate(:member) } + let(:chapter) { Fabricate(:chapter) } + let(:students_group) { Fabricate(:group, name: "Students", chapter: chapter) } + let(:coaches_group) { Fabricate(:group, name: "Coaches", chapter: chapter) } - before do - # Eligible: recent invite, no email delivery + let!(:eligible_student) do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: students_group) + member + end + + let!(:already_emailed_student) do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: students_group) + Fabricate(:member_email_delivery, member: member) + member + end + + let!(:student_with_recent_attendance) do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: students_group) Fabricate( :workshop_invitation, - member: eligible_member, - created_at: 3.months.ago, - attended: false + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 1.month.ago), + role: "Student", + attended: true ) + member + end - # Already emailed: recent invite, but has email delivery + let!(:student_with_old_attendance) do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: students_group) Fabricate( :workshop_invitation, - member: emailed_member, - created_at: 2.months.ago + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 4.months.ago), + role: "Student", + attended: true ) + member + end + + let!(:coach_member) do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: coaches_group) + member + end + + let!(:unsubscribed_member) { Fabricate(:member) } + let!(:banned_student) do + member = Fabricate(:banned_member) + Fabricate(:subscription, member: member, group: students_group) + member + end + let!(:student_without_toc) do + member = Fabricate(:member_without_toc) + Fabricate(:subscription, member: member, group: students_group) + member + end + + it "emails only students who have not attended in the last 3 months and were not emailed before" do + expect { perform_enqueued_jobs { call } }.to change(MemberEmailDelivery, :count).by(2) + + expect(MemberEmailDelivery.where(member: eligible_student)).to exist + expect(MemberEmailDelivery.where(member: student_with_old_attendance)).to exist + end + + it "does not email a member already present in member_email_deliveries" do + expect { perform_enqueued_jobs { call } } + .not_to change { MemberEmailDelivery.where(member: already_emailed_student).count } + end + + it "does not email students with a recent attended workshop" do + expect { perform_enqueued_jobs { call } } + .not_to change { MemberEmailDelivery.where(member: student_with_recent_attendance).count } + end + + it "does not email members without a student subscription" do + perform_enqueued_jobs { call } + + expect(MemberEmailDelivery.where(member: coach_member)).to be_empty + expect(MemberEmailDelivery.where(member: unsubscribed_member)).to be_empty + end + + it "does not email banned students or students without accepted terms" do + perform_enqueued_jobs { call } + + expect(MemberEmailDelivery.where(member: banned_student)).to be_empty + expect(MemberEmailDelivery.where(member: student_without_toc)).to be_empty + end + + it "sends only one chaser for a member with multiple student subscriptions" do + member = Fabricate(:member) + other_chapter = Fabricate(:chapter) + other_students_group = Fabricate(:group, name: "Students", chapter: other_chapter) + Fabricate(:subscription, member: member, group: students_group) + Fabricate(:subscription, member: member, group: other_students_group) + + perform_enqueued_jobs { call } + + expect(MemberEmailDelivery.where(member: member).count).to eq(1) + end + + it "sends only one chaser for a member with multiple qualifying old attendances" do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: students_group) Fabricate( - :member_email_delivery, - member: emailed_member + :workshop_invitation, + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 5.months.ago), + role: "Student", + attended: true ) + Fabricate( + :workshop_invitation, + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 4.months.ago), + role: "Student", + attended: true + ) + + perform_enqueued_jobs { call } - # Old invite: more than 3 months ago + expect(MemberEmailDelivery.where(member: member).count).to eq(1) + end + + it "does not send chasers when there are no eligible members" do + Fabricate( + :workshop_invitation, + member: eligible_student, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 1.month.ago), + role: "Student", + attended: true + ) Fabricate( :workshop_invitation, - member: old_invite_member, - created_at: 4.months.ago + member: student_with_old_attendance, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 1.month.ago), + role: "Student", + attended: true ) + + expect { perform_enqueued_jobs { call } }.not_to change(MemberEmailDelivery, :count) end - it "enqueues chaser emails only for eligible members" do - expect { - call - }.to have_enqueued_mail(MemberMailer, :chaser).once + it "emails a student member who has recent attendance only as a coach" do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: students_group) + Fabricate( + :workshop_invitation, + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 1.month.ago), + role: "Coach", + attended: true + ) + + perform_enqueued_jobs { call } + + expect(MemberEmailDelivery.where(member: member)).to exist end end end From 93cca4efd06d936bd34b402da29201d7d5e43c41 Mon Sep 17 00:00:00 2001 From: Michelle Steele Date: Tue, 17 Mar 2026 18:11:06 +0000 Subject: [PATCH 9/9] Exclude students with no attendance in the past year from chaser email Narrows the chaser target to members who attended between 3 months and 1 year ago, filtering out long-inactive students who are unlikely to re-engage. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/services/three_month_email_service.rb | 13 +++++- .../three_month_email_service_spec.rb | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/services/three_month_email_service.rb b/app/services/three_month_email_service.rb index 2edb67d66..fab281c60 100644 --- a/app/services/three_month_email_service.rb +++ b/app/services/three_month_email_service.rb @@ -2,13 +2,21 @@ class ThreeMonthEmailService def self.send_chaser - cutoff = 3.months.ago.beginning_of_day + three_month_cutoff = 3.months.ago.beginning_of_day + one_year_cutoff = 1.year.ago.beginning_of_day + recent_attendee_ids = WorkshopInvitation.to_students .attended .joins(:workshop) - .where('workshops.date_and_time >= ?', cutoff) + .where('workshops.date_and_time >= ?', three_month_cutoff) .select(:member_id) + past_year_attendee_ids = WorkshopInvitation.to_students + .attended + .joins(:workshop) + .where('workshops.date_and_time >= ?', one_year_cutoff) + .select(:member_id) + members = Member.not_banned .accepted_toc .joins(:groups) @@ -16,6 +24,7 @@ def self.send_chaser .left_joins(:member_email_deliveries) .where(member_email_deliveries: { id: nil }) .where.not(id: recent_attendee_ids) + .where(id: past_year_attendee_ids) .distinct members.find_each do |member| diff --git a/spec/services/three_month_email_service_spec.rb b/spec/services/three_month_email_service_spec.rb index 85515c2d1..bd17f28a5 100644 --- a/spec/services/three_month_email_service_spec.rb +++ b/spec/services/three_month_email_service_spec.rb @@ -17,6 +17,13 @@ let!(:eligible_student) do member = Fabricate(:member) Fabricate(:subscription, member: member, group: students_group) + Fabricate( + :workshop_invitation, + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 6.months.ago), + role: "Student", + attended: true + ) member end @@ -71,6 +78,19 @@ member end + let!(:student_with_very_old_attendance) do + member = Fabricate(:member) + Fabricate(:subscription, member: member, group: students_group) + Fabricate( + :workshop_invitation, + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 14.months.ago), + role: "Student", + attended: true + ) + member + end + it "emails only students who have not attended in the last 3 months and were not emailed before" do expect { perform_enqueued_jobs { call } }.to change(MemberEmailDelivery, :count).by(2) @@ -102,12 +122,25 @@ expect(MemberEmailDelivery.where(member: student_without_toc)).to be_empty end + it "does not email students who have not attended in the past year" do + perform_enqueued_jobs { call } + + expect(MemberEmailDelivery.where(member: student_with_very_old_attendance)).to be_empty + end + it "sends only one chaser for a member with multiple student subscriptions" do member = Fabricate(:member) other_chapter = Fabricate(:chapter) other_students_group = Fabricate(:group, name: "Students", chapter: other_chapter) Fabricate(:subscription, member: member, group: students_group) Fabricate(:subscription, member: member, group: other_students_group) + Fabricate( + :workshop_invitation, + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 6.months.ago), + role: "Student", + attended: true + ) perform_enqueued_jobs { call } @@ -159,6 +192,13 @@ it "emails a student member who has recent attendance only as a coach" do member = Fabricate(:member) Fabricate(:subscription, member: member, group: students_group) + Fabricate( + :workshop_invitation, + member: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 6.months.ago), + role: "Student", + attended: true + ) Fabricate( :workshop_invitation, member: member,