Skip to content
Merged
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
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
21 changes: 20 additions & 1 deletion app/mailers/member_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
43 changes: 43 additions & 0 deletions app/services/signup_nudge_email_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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

# 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:).where.not(member_id: nil).select(:member_id))
end

def self.never_subscribed
Member.where.not(id: Subscription.where.not(member_id: nil).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
25 changes: 25 additions & 0 deletions app/views/member_mailer/signup_nudge.html.haml
Original file line number Diff line number Diff line change
@@ -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
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
15 changes: 15 additions & 0 deletions spec/lib/tasks/chaser_rake_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
require 'rails_helper'

RSpec.describe 'rake chaser:signup_nudges', type: :task do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require the rails_helper - at the top of the file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added require 'rails_helper' at the top.

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
39 changes: 39 additions & 0 deletions spec/mailers/member_mailer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,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
112 changes: 112 additions & 0 deletions spec/services/signup_nudge_email_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
require 'rails_helper'

RSpec.describe SignupNudgeEmailService, type: :service do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Require.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added require 'rails_helper' at the top.

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

# 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