diff --git a/app/controllers/check_ins_controller.rb b/app/controllers/check_ins_controller.rb index 323b007a4..7b59e4304 100644 --- a/app/controllers/check_ins_controller.rb +++ b/app/controllers/check_ins_controller.rb @@ -102,9 +102,9 @@ def find_invitation def find_or_create_invitation(role) if @check_in_target.is_a?(Event) - Invitation.create_or_find_by(event: @check_in_target, member: current_user, role: role) + Invitation.create_or_find_by(event: @check_in_target, member: current_user, role:) else - WorkshopInvitation.create_or_find_by(workshop: @check_in_target, member: current_user, role: role) + WorkshopInvitation.create_or_find_by(workshop: @check_in_target, member: current_user, role:) end end 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..7c77f946d 100644 --- a/app/mailers/member_mailer.rb +++ b/app/mailers/member_mailer.rb @@ -2,7 +2,27 @@ 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] + + # Placeholder copy — replace with Kimberley's copy (issue #2384) before merge. + def signup_nudge + @member = params[:member] + subject = 'Pick a codebar chapter' + + mail_to_member(@member, subject, 'hello@codebar.io') do |format| + format.html { render 'signup_nudge' } + end + end + + # Placeholder copy — replace with Kimberley's copy (issue #2384) before merge. + def signup_nudge_followup + @member = params[:member] + subject = 'Still thinking about codebar?' + + mail_to_member(@member, subject, 'hello@codebar.io') do |format| + format.html { render 'signup_nudge_followup' } + 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..8d79c3dd6 --- /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 + 14.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..74aba7bc1 --- /dev/null +++ b/app/views/member_mailer/signup_nudge.html.haml @@ -0,0 +1,13 @@ +/ Placeholder copy — replace with Kimberley's copy (issue #2384) before merge. +%h1 Hi #{@member.name}, + +%p + Thanks for signing up to codebar! You’ve created your account, but you haven’t picked a chapter yet. + +%p + Head to your dashboard, choose the chapter closest to you, and subscribe as a student or a coach — then we can start inviting you to workshops. + +%p + #{"-- "} +%br +The codebar team diff --git a/app/views/member_mailer/signup_nudge_followup.html.haml b/app/views/member_mailer/signup_nudge_followup.html.haml new file mode 100644 index 000000000..ddb50448b --- /dev/null +++ b/app/views/member_mailer/signup_nudge_followup.html.haml @@ -0,0 +1,13 @@ +/ Placeholder copy — replace with Kimberley's copy (issue #2384) before merge. +%h1 Hi #{@member.name}, + +%p + A little while ago we nudged you about picking a codebar chapter — you still haven’t subscribed, so we wanted to check in once more. + +%p + It only takes a minute: choose your nearest chapter, subscribe, and you’ll hear about every upcoming workshop there. + +%p + #{"-- "} +%br +The codebar team diff --git a/docs/plans/2026-08-31-signup-nudge-email-sequence-plan.md b/docs/plans/2026-08-31-signup-nudge-email-sequence-plan.md new file mode 100644 index 000000000..f03f0f1f7 --- /dev/null +++ b/docs/plans/2026-08-31-signup-nudge-email-sequence-plan.md @@ -0,0 +1,81 @@ +--- +type: feat +origin: none +issue: codebar/planner#2384 +artifact_readiness: implementation-ready +--- + +# Signup nudge email sequence (issue #2384) + +Automated emails to members who signed up via the website but never subscribed to a chapter: a nudge 7–14 days after signup and a follow-up one month later, then the sequence ends. Built on the typed `member_email_deliveries` log shipped in #2832 (`email_type` column + unique index on `(member_id, email_type)`). + +## Key Technical Decisions + +- **Shared typed log as sequence state** — `session-settled: user-approved` (class: user-approved; rejected alternative: dedicated `signup_nudges` table; also rejected: boolean flags on `members`). Stage eligibility is derived from `member_email_deliveries` rows; no new state store. +- **Stage 1 window 7–14 days after signup** — `session-settled: user-approved` (class: user-approved; rejected alternatives: strict one-day window, rolling `<= 7 days` window). Wide enough to absorb scheduler gaps, narrow enough to exclude the historical never-subscribed backlog from the day of deploy. +- **Stage 2 anchored to stage 1's send time** — `session-settled: user-approved` (class: user-approved; rejected alternative: fixed member age). Follow-up lands exactly one month after the nudge regardless of when a run picked up the member. +- **Audience includes abandoned signups** — `session-settled: user-directed` (class: user-directed; rejected alternative: `accepted_toc`-only). Members who never completed signup receive the nudge. +- **Dedupe is delivery-confirmed** — rows are written by the `EmailDelivery` concern after delivery; there is no pre-enqueue stamping. Rejected alternative: stamp-before-enqueue (at-most-once) — loses the "lost nudge" self-healing within the stage-1 window. +- **No `member_email_deliveries` exclusion coupling** — the chaser's exclusion is already scoped to `email_type: 'chaser'` (#2832); nudge rows cannot suppress chaser emails. + +## Implementation Units + +### U1 — Mailer actions and views + +- **Goal:** two member-facing emails that opt into the typed delivery log. +- **Files:** + - Modify `app/mailers/member_mailer.rb` + - Create `app/views/member_mailer/signup_nudge.html.haml` + - Create `app/views/member_mailer/signup_nudge_followup.html.haml` +- **Approach:** + - `def signup_nudge` — subject and copy TBD (placeholder until copy arrives from Kimberley); `mail_to_member(member, subject, 'hello@codebar.io')`; rendered via `signup_nudge.html.haml`. + - `def signup_nudge_followup` — same shape, second subject/copy. + - `after_deliver :log_sent_email, only: [:chaser, :signup_nudge, :signup_nudge_followup]`. +- **Test scenarios:** + - Each action renders headers (to/from) and non-empty body containing its distinctive copy. + - Delivery creates a `MemberEmailDelivery` row with the action's `email_type`. + +### U2 — Sequence service + +- **Goal:** one service computes both stages' eligible members per daily run and sends. +- **Files:** + - Create `app/services/signup_nudge_email_service.rb` +- **Approach:** + - `def self.send_nudges` + - Stage 1 (nudge): `Member.not_banned` where `created_at` falls on the 7..14-days-ago window, `where.not(id: Subscription.select(:member_id))`, `where.not(id: MemberEmailDelivery.where(email_type: 'signup_nudge').select(:member_id))`; `MemberMailer.with(member:).signup_nudge.deliver_later` per member. + - Stage 2 (follow-up): `Member.not_banned` with a `signup_nudge` row `created_at <= 1.month.ago`, no `signup_nudge_followup` row, `where.not(id: Subscription.select(:member_id))`; `MemberMailer.with(member:).signup_nudge_followup.deliver_later`. + - No `accepted_toc` filter (abandoned signups included). No `unsubscribed` filter (nothing sets it; the chaser ignores it too). +- **Test scenarios:** + - Sends nudge to a member created 10 days ago with no subscription; follow-up to a member whose nudge row is 6 weeks old. + - Skips: subscribed members, banned members, members with the stage's row already present, members younger than 7 days, members older than 14 days without a nudge row, follow-up for a member whose nudge row is 3 weeks old, follow-up for a member with no nudge row. + - Terminal state: a member with both rows receives nothing from either stage. + +### U3 — Job and rake task + +- **Goal:** the daily entry point, mirroring the chaser's wiring. +- **Files:** + - Create `app/jobs/send_signup_nudge_email_job.rb` + - Modify `lib/tasks/chaser.rake` +- **Approach:** `SendSignupNudgeEmailJob` (queue `:default`) calls `SignupNudgeEmailService.send_nudges`; rake task `chaser:signup_nudges` enqueues the job; the namespace description is updated to cover both emails. +- **Test scenarios:** + - The rake task enqueues the job (existing chaser rake-task spec pattern). + +### U4 — Ops handoff + +- **Goal:** the sequence actually runs daily in production. +- **Files:** none (dashboard-side). +- **Approach:** PR description carries an ops note: add a Heroku Scheduler entry `rake chaser:signup_nudges` (daily), same as the existing `rake chaser:three_months` entry; without it the feature ships dark. +- **Test scenarios:** none. + +## Verification + +- `make test` (parallel RSpec) green; new specs for U1–U3; existing `three_month_email_service` specs unaffected (chaser exclusion already typed). +- Manual letter-opener check of both emails in development. +- After merge: confirm the first scheduled run creates `signup_nudge` rows for the current 7–14-day cohort. Owner: Morgan. + +## Risks & Dependencies + +- **Copy dependency:** both subjects/bodies are placeholders until Kimberley provides copy via Slack (per the issue). The PR can be reviewed and held; copy lands as a follow-up commit before merge. +- **Volume:** cohorts are 0–3 members/day (verified against the production dump). A stage-1 catch-up run is bounded by the 8 daily cohorts in the 7–14-day window (≤ ~24 emails); a stage-2 catch-up after a long outage is bounded by the accumulated `signup_nudge` population rather than the two-week window. +- **Deploy-time boundary:** members older than 14 days at merge never enter the sequence — accepted, bounded. +- **Edge cases accepted:** a member who subscribes and unsubscribes within the month re-enters stage 2; a member whose stage-1 send failed (e.g. invalid email → `SkippedEmail`) gets no stage-1 row and therefore no stage 2 (under-send bias). 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/controllers/check_ins_controller_spec.rb b/spec/controllers/check_ins_controller_spec.rb index 2bf85549c..0a79402da 100644 --- a/spec/controllers/check_ins_controller_spec.rb +++ b/spec/controllers/check_ins_controller_spec.rb @@ -35,7 +35,7 @@ it 'creates an invitation with source=check_in' do post :create, params: { code: event.check_in_code, role: 'Student' } - invitation = Invitation.find_by(event: event, member: member) + invitation = Invitation.find_by(event:, member:) expect(invitation.source).to eq(InvitationConcerns::SOURCE_CHECK_IN) expect(invitation.attending).to be true expect(invitation.verified).to be true @@ -56,20 +56,20 @@ it 'ignores unpermitted parameters' do post :create, params: { code: event.check_in_code, role: 'Student', hacker_field: 'malicious' } - invitation = Invitation.find_by(event: event, member: member) + invitation = Invitation.find_by(event:, member:) expect(invitation.source).to eq(InvitationConcerns::SOURCE_CHECK_IN) expect(response).to redirect_to(check_in_e_confirm_path(code: event.check_in_code)) end it 'redirects to confirm when already checked in' do - invitation = Fabricate(:invitation, event: event, member: member, role: 'Student', attending: true, verified: true) + invitation = Fabricate(:invitation, event:, member:, role: 'Student', attending: true, verified: true) post :create, params: { code: event.check_in_code, role: 'Student' } expect(response).to redirect_to(check_in_e_confirm_path(code: event.check_in_code)) expect(Invitation.find(invitation.id).verified).to be true end it 'rejects selecting a different role than the existing invitation' do - Fabricate(:invitation, event: event, member: member, role: 'Student') + Fabricate(:invitation, event:, member:, role: 'Student') post :create, params: { code: event.check_in_code, role: 'Coach' } expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) expect(flash[:alert]).to include('Student') @@ -77,7 +77,7 @@ it 'rejects check-in when the role is at capacity' do event.update!(student_spaces: 1) - Fabricate(:invitation, event: event, member: Fabricate(:member), role: 'Student', + Fabricate(:invitation, event:, member: Fabricate(:member), role: 'Student', attending: true, verified: true) post :create, params: { code: event.check_in_code, role: 'Student' } expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) @@ -89,7 +89,7 @@ date_and_time: Time.zone.now - 30.minutes, ends_at: Time.zone.now + 30.minutes) post :create, params: { code: workshop.check_in_code, role: 'Student' } - invitation = WorkshopInvitation.find_by(workshop: workshop, member: member) + invitation = WorkshopInvitation.find_by(workshop:, member:) expect(invitation.source).to eq(InvitationConcerns::SOURCE_CHECK_IN) expect(invitation.attending).to be true expect(invitation.attended).to be true @@ -101,7 +101,7 @@ workshop = Fabricate(:workshop, date_and_time: Time.zone.now - 30.minutes, ends_at: Time.zone.now + 30.minutes) - invitation = Fabricate(:workshop_invitation, workshop: workshop, member: member, role: 'Student') + invitation = Fabricate(:workshop_invitation, workshop:, member:, role: 'Student') WaitingList.add(invitation) post :create, params: { code: workshop.check_in_code, role: 'Student' } expect(response).to redirect_to(check_in_w_path(code: workshop.check_in_code)) 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..aa86b6c6d 100644 --- a/spec/mailers/member_mailer_spec.rb +++ b/spec/mailers/member_mailer_spec.rb @@ -196,4 +196,42 @@ 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('Pick a codebar chapter') + 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('Head to your dashboard') + 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('Still thinking about 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('we nudged you') + 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..0f3145362 --- /dev/null +++ b/spec/services/signup_nudge_email_service_spec.rb @@ -0,0 +1,93 @@ +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: 3.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 '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 14 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