From 0ef297135b681cf18bdd55ac7d9179202d2c6d65 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 10 Sep 2026 12:38:25 +0200 Subject: [PATCH 1/2] feat(signup-nudge): two-stage email sequence for members without a chapter Members who sign up but never subscribe to a chapter get at most two emails, recorded in member_email_deliveries (typed log from #2832): 1. Nudge 7 days after signup to members created in the last 30 days who have no subscription and are not banned. 2. One follow-up a month after the nudge if they still have none. Stage eligibility derives from the typed log rows; delivery-confirmed writes via the EmailDelivery concern (email_type signup_nudge / signup_nudge_followup) make a lost send retry within the window while the (member_id, email_type) unique index prevents duplicates. Members who subscribe exit the sequence at any point. Daily entry point: rake chaser:signup_nudges (add to Heroku Scheduler after merge). Both emails currently use the copy from Kimberley's first-email draft; dedicated follow-up copy is pending (issue #2384). --- app/jobs/send_signup_nudge_email_job.rb | 9 ++ app/mailers/member_mailer.rb | 21 +++- app/services/signup_nudge_email_service.rb | 42 ++++++++ .../member_mailer/signup_nudge.html.haml | 25 +++++ lib/tasks/chaser.rake | 6 +- spec/lib/tasks/chaser_rake_spec.rb | 13 +++ spec/mailers/member_mailer_spec.rb | 39 +++++++ .../signup_nudge_email_service_spec.rb | 100 ++++++++++++++++++ 8 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 app/jobs/send_signup_nudge_email_job.rb create mode 100644 app/services/signup_nudge_email_service.rb create mode 100644 app/views/member_mailer/signup_nudge.html.haml create mode 100644 spec/lib/tasks/chaser_rake_spec.rb create mode 100644 spec/services/signup_nudge_email_service_spec.rb diff --git a/app/jobs/send_signup_nudge_email_job.rb b/app/jobs/send_signup_nudge_email_job.rb new file mode 100644 index 000000000..4b6a804cc --- /dev/null +++ b/app/jobs/send_signup_nudge_email_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SendSignupNudgeEmailJob < ApplicationJob + queue_as :default + + def perform + SignupNudgeEmailService.send_nudges + end +end diff --git a/app/mailers/member_mailer.rb b/app/mailers/member_mailer.rb index 323a68ef5..e463639e0 100644 --- a/app/mailers/member_mailer.rb +++ b/app/mailers/member_mailer.rb @@ -2,7 +2,26 @@ class MemberMailer < ApplicationMailer include EmailHeaderHelper include EmailDelivery - after_deliver :log_sent_email, only: [:chaser] + after_deliver :log_sent_email, only: %i[chaser signup_nudge signup_nudge_followup] + + def signup_nudge + @member = params[:member] + subject = 'Let’s get you connected with codebar!' + + mail_to_member(@member, subject, 'hello@codebar.io') do |format| + format.html { render 'signup_nudge' } + end + end + + # Same copy as the nudge until Kimberley supplies follow-up copy (issue #2384). + def signup_nudge_followup + @member = params[:member] + subject = 'Let’s get you connected with codebar!' + + mail_to_member(@member, subject, 'hello@codebar.io') do |format| + format.html { render 'signup_nudge' } + end + end def chaser @member = params[:member] diff --git a/app/services/signup_nudge_email_service.rb b/app/services/signup_nudge_email_service.rb new file mode 100644 index 000000000..547501024 --- /dev/null +++ b/app/services/signup_nudge_email_service.rb @@ -0,0 +1,42 @@ +class SignupNudgeEmailService + NUDGE = 'signup_nudge'.freeze + FOLLOWUP = 'signup_nudge_followup'.freeze + + def self.send_nudges + send_signup_nudge + send_signup_nudge_followup + end + + def self.send_signup_nudge + Member.not_banned + .where(created_at: nudge_window) + .merge(unemailed(NUDGE)) + .merge(never_subscribed) + .find_each { |member| MemberMailer.with(member:).signup_nudge.deliver_later } + end + + def self.send_signup_nudge_followup + Member.not_banned + .joins(:member_email_deliveries) + .where(member_email_deliveries: { email_type: NUDGE, created_at: ..1.month.ago }) + .merge(unemailed(FOLLOWUP)) + .merge(never_subscribed) + .distinct + .find_each { |member| MemberMailer.with(member:).signup_nudge_followup.deliver_later } + end + + def self.unemailed(email_type) + Member.where.not(id: MemberEmailDelivery.where(email_type:).select(:member_id)) + end + + def self.never_subscribed + Member.where.not(id: Subscription.select(:member_id)) + end + + def self.nudge_window + 30.days.ago.beginning_of_day..7.days.ago.end_of_day + end + + private_class_method :send_signup_nudge, :send_signup_nudge_followup, :unemailed, :never_subscribed, + :nudge_window +end diff --git a/app/views/member_mailer/signup_nudge.html.haml b/app/views/member_mailer/signup_nudge.html.haml new file mode 100644 index 000000000..70672f74b --- /dev/null +++ b/app/views/member_mailer/signup_nudge.html.haml @@ -0,0 +1,25 @@ +%h1 Hi #{@member.name}, + +%p + Thanks for signing up to codebar, we can’t wait to see you! + +%p + We noticed you haven’t joined a chapter yet, and we’d love to help you get started. + +%p + Codebar chapters are where the magic happens, you can meet fellow members whether they’re students or coaches, and of course attend our workshops and events. + +%p + 👉 #{link_to 'Find your local chapter and join today', subscriptions_url} + +%p + If you’re not sure which chapter is right for you, or you’d like tips on getting started, we’re happy to help, just hit reply to this email and we’ll guide you. + +%p + We can’t wait to see you at a workshop or event very soon! + +%p + #{"-- "} +%br +Warmly, +The codebar Team diff --git a/lib/tasks/chaser.rake b/lib/tasks/chaser.rake index d06134aa5..e79edd4e3 100644 --- a/lib/tasks/chaser.rake +++ b/lib/tasks/chaser.rake @@ -1,7 +1,11 @@ namespace :chaser do desc "Send emails to users who've not attended in a while" - task three_months: :environment do SendThreeMonthEmailJob.perform_later end + + desc 'Send emails to new members who have not subscribed to a chapter' + task signup_nudges: :environment do + SendSignupNudgeEmailJob.perform_later + end end diff --git a/spec/lib/tasks/chaser_rake_spec.rb b/spec/lib/tasks/chaser_rake_spec.rb new file mode 100644 index 000000000..67ff1cae0 --- /dev/null +++ b/spec/lib/tasks/chaser_rake_spec.rb @@ -0,0 +1,13 @@ +RSpec.describe 'rake chaser:signup_nudges', type: :task do + it 'preloads the Rails environment' do + expect(task.prerequisites).to include 'environment' + end + + it 'enqueues the signup nudge email job' do + allow(SendSignupNudgeEmailJob).to receive(:perform_later) + + task.invoke + + expect(SendSignupNudgeEmailJob).to have_received(:perform_later) + end +end diff --git a/spec/mailers/member_mailer_spec.rb b/spec/mailers/member_mailer_spec.rb index 3ba201809..985e4236e 100644 --- a/spec/mailers/member_mailer_spec.rb +++ b/spec/mailers/member_mailer_spec.rb @@ -196,4 +196,43 @@ end.to change(MemberEmailDelivery, :count).by(1) end end + + describe 'signup nudge' do + let(:mail) { described_class.with(member:).signup_nudge.deliver_now } + + it 'renders the headers' do + expect(mail.subject).to eq('Let’s get you connected with codebar!') + 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('Find your local chapter') + expect(mail.body.encoded).to match(subscriptions_url) + end + + it 'logs with its own email_type' do + expect { mail } + .to change { MemberEmailDelivery.where(member:, email_type: 'signup_nudge').count }.by(1) + end + end + + describe 'signup nudge follow-up' do + let(:mail) { described_class.with(member:).signup_nudge_followup.deliver_now } + + it 'renders the headers' do + expect(mail.subject).to eq('Let’s get you connected with codebar!') + expect(mail.to).to eq([member.email]) + expect(mail.from).to eq(['hello@codebar.io']) + end + + it 'renders the same body as the nudge' do + expect(mail.body.encoded).to match('Find your local chapter') + end + + it 'logs with its own email_type' do + expect { mail } + .to change { MemberEmailDelivery.where(member:, email_type: 'signup_nudge_followup').count }.by(1) + end + end end diff --git a/spec/services/signup_nudge_email_service_spec.rb b/spec/services/signup_nudge_email_service_spec.rb new file mode 100644 index 000000000..341551661 --- /dev/null +++ b/spec/services/signup_nudge_email_service_spec.rb @@ -0,0 +1,100 @@ +RSpec.describe SignupNudgeEmailService, type: :service do + describe '#send_nudges' do + subject(:call) { described_class.send_nudges } + + 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!(:nudge_eligible) { Fabricate(:member, created_at: 10.days.ago) } + let!(:followup_eligible) { Fabricate(:member, created_at: 6.weeks.ago) } + let!(:subscribed_in_window) { Fabricate(:member, created_at: 10.days.ago) } + let!(:banned_in_window) { Fabricate(:banned_member, created_at: 10.days.ago) } + let!(:too_young) { Fabricate(:member, created_at: 2.days.ago) } + let!(:too_old) { Fabricate(:member, created_at: 5.weeks.ago) } + let!(:recently_nudged) { Fabricate(:member, created_at: 10.days.ago) } + let!(:completed_sequence) { Fabricate(:member, created_at: 7.weeks.ago) } + let!(:subscribed_after_nudge) { Fabricate(:member, created_at: 6.weeks.ago) } + + before do + Fabricate(:subscription, member: subscribed_in_window) + Fabricate(:member_email_delivery, member: followup_eligible, email_type: 'signup_nudge', + created_at: 5.weeks.ago) + Fabricate(:member_email_delivery, member: recently_nudged, email_type: 'signup_nudge') + Fabricate(:member_email_delivery, member: completed_sequence, email_type: 'signup_nudge', + created_at: 6.weeks.ago) + Fabricate(:member_email_delivery, member: completed_sequence, email_type: 'signup_nudge_followup', + created_at: 5.weeks.ago) + Fabricate(:member_email_delivery, member: subscribed_after_nudge, email_type: 'signup_nudge', + created_at: 5.weeks.ago) + Fabricate(:subscription, member: subscribed_after_nudge) + end + + it 'nudges members created 7-14 days ago who have no subscription' do + expect { perform_enqueued_jobs { call } } + .to change { MemberEmailDelivery.where(member: nudge_eligible, email_type: 'signup_nudge').count } + .by(1) + end + + it 'sends the follow-up to members nudged more than a month ago' do + expect { perform_enqueued_jobs { call } } + .to change { + MemberEmailDelivery.where(member: followup_eligible, email_type: 'signup_nudge_followup').count + } + .by(1) + end + + it 'does not nudge subscribed members' do + expect { perform_enqueued_jobs { call } } + .not_to(change { MemberEmailDelivery.where(member: subscribed_in_window).count }) + end + + it 'does not nudge banned members' do + expect { perform_enqueued_jobs { call } } + .not_to(change { MemberEmailDelivery.where(member: banned_in_window).count }) + end + + it 'nudges members created 15-30 days ago who have no subscription' do + older_eligible = Fabricate(:member, created_at: 20.days.ago) + expect { perform_enqueued_jobs { call } } + .to change { MemberEmailDelivery.where(member: older_eligible, email_type: 'signup_nudge').count } + .by(1) + end + + it 'does not nudge members younger than 7 days' do + expect { perform_enqueued_jobs { call } } + .not_to(change { MemberEmailDelivery.where(member: too_young).count }) + end + + it 'does not nudge members older than 30 days without a nudge row' do + expect { perform_enqueued_jobs { call } } + .not_to(change { MemberEmailDelivery.where(member: too_old).count }) + end + + it 'does not re-nudge a member already nudged' do + expect { perform_enqueued_jobs { call } } + .not_to(change { MemberEmailDelivery.where(member: recently_nudged, email_type: 'signup_nudge').count }) + end + + it 'does not send a follow-up while the nudge is less than a month old' do + expect { perform_enqueued_jobs { call } } + .not_to(change { MemberEmailDelivery.where(member: recently_nudged, email_type: 'signup_nudge_followup').count }) + end + + it 'sends nothing further to members who completed the sequence' do + expect { perform_enqueued_jobs { call } } + .not_to(change { MemberEmailDelivery.where(member: completed_sequence).count }) + end + + it 'does not send a follow-up to a member who has since subscribed' do + expect { perform_enqueued_jobs { call } } + .not_to(change do + MemberEmailDelivery.where(member: subscribed_after_nudge, email_type: 'signup_nudge_followup').count + end) + end + end +end From bbbaeb461222dec8e46db0c0d7e36630d331c0ff Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 10 Sep 2026 13:40:26 +0200 Subject: [PATCH 2/2] fix(signup-nudge): NULL-safe anti-subqueries; require rails_helper in specs Addresses review feedback from #2835: - never_subscribed and unemailed: both member_id columns are nullable in the DB, and a single NULL row would make NOT IN exclude every member, silently disabling the daily job. Filter NULLs inside the subquery. The query stays an uncorrelated subquery (hash anti-join), which is what the reviewer suggested. - regression test: rows with NULL member_id must not block sends - require rails_helper at the top of the two new spec files --- app/services/signup_nudge_email_service.rb | 5 +++-- spec/lib/tasks/chaser_rake_spec.rb | 2 ++ spec/services/signup_nudge_email_service_spec.rb | 12 ++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/services/signup_nudge_email_service.rb b/app/services/signup_nudge_email_service.rb index 547501024..6e5885112 100644 --- a/app/services/signup_nudge_email_service.rb +++ b/app/services/signup_nudge_email_service.rb @@ -25,12 +25,13 @@ def self.send_signup_nudge_followup .find_each { |member| MemberMailer.with(member:).signup_nudge_followup.deliver_later } end + # NULL-safe NOT IN: a NULL member_id in the subquery would exclude every member def self.unemailed(email_type) - Member.where.not(id: MemberEmailDelivery.where(email_type:).select(:member_id)) + Member.where.not(id: MemberEmailDelivery.where(email_type:).where.not(member_id: nil).select(:member_id)) end def self.never_subscribed - Member.where.not(id: Subscription.select(:member_id)) + Member.where.not(id: Subscription.where.not(member_id: nil).select(:member_id)) end def self.nudge_window diff --git a/spec/lib/tasks/chaser_rake_spec.rb b/spec/lib/tasks/chaser_rake_spec.rb index 67ff1cae0..379ac4e94 100644 --- a/spec/lib/tasks/chaser_rake_spec.rb +++ b/spec/lib/tasks/chaser_rake_spec.rb @@ -1,3 +1,5 @@ +require 'rails_helper' + RSpec.describe 'rake chaser:signup_nudges', type: :task do it 'preloads the Rails environment' do expect(task.prerequisites).to include 'environment' diff --git a/spec/services/signup_nudge_email_service_spec.rb b/spec/services/signup_nudge_email_service_spec.rb index 341551661..0856a1948 100644 --- a/spec/services/signup_nudge_email_service_spec.rb +++ b/spec/services/signup_nudge_email_service_spec.rb @@ -1,3 +1,5 @@ +require 'rails_helper' + RSpec.describe SignupNudgeEmailService, type: :service do describe '#send_nudges' do subject(:call) { described_class.send_nudges } @@ -96,5 +98,15 @@ MemberEmailDelivery.where(member: subscribed_after_nudge, email_type: 'signup_nudge_followup').count end) end + + # DB allows NULL member_id on both tables; one such row would make NOT IN exclude everyone + it 'still sends nudges when log or subscription rows have no member_id' do + Fabricate(:member_email_delivery, email_type: 'signup_nudge').update_column(:member_id, nil) + Fabricate(:subscription).update_column(:member_id, nil) + + expect { perform_enqueued_jobs { call } } + .to change { MemberEmailDelivery.where(member: nudge_eligible, email_type: 'signup_nudge').count } + .by(1) + end end end