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
4 changes: 2 additions & 2 deletions app/controllers/check_ins_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions app/jobs/send_signup_nudge_email_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class SendSignupNudgeEmailJob < ApplicationJob
queue_as :default

def perform
SignupNudgeEmailService.send_nudges
end
end
22 changes: 21 additions & 1 deletion app/mailers/member_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
42 changes: 42 additions & 0 deletions app/services/signup_nudge_email_service.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/views/member_mailer/signup_nudge.html.haml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/views/member_mailer/signup_nudge_followup.html.haml
Original file line number Diff line number Diff line change
@@ -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
81 changes: 81 additions & 0 deletions docs/plans/2026-08-31-signup-nudge-email-sequence-plan.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 5 additions & 1 deletion lib/tasks/chaser.rake
Original file line number Diff line number Diff line change
@@ -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
14 changes: 7 additions & 7 deletions spec/controllers/check_ins_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,28 +56,28 @@

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')
end

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))
Expand All @@ -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
Expand All @@ -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))
Expand Down
13 changes: 13 additions & 0 deletions spec/lib/tasks/chaser_rake_spec.rb
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions spec/mailers/member_mailer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading