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/mailers/concerns/email_delivery.rb b/app/mailers/concerns/email_delivery.rb new file mode 100644 index 000000000..f39bf581a --- /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: mail.to, + cc: mail.cc, + bcc: mail.bcc + ) + end +end diff --git a/app/mailers/member_mailer.rb b/app/mailers/member_mailer.rb index ebc5e4850..fa1c8425f 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_email, 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.rb b/app/models/member.rb index b6be0e8a3..13e36fd70 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -22,6 +22,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? diff --git a/app/models/member_email_delivery.rb b/app/models/member_email_delivery.rb new file mode 100644 index 000000000..f6d836129 --- /dev/null +++ b/app/models/member_email_delivery.rb @@ -0,0 +1,3 @@ +class MemberEmailDelivery < ApplicationRecord + belongs_to :member, polymorphic: true, optional: true +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..20d3c1585 --- /dev/null +++ b/app/services/three_month_email_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ThreeMonthEmailService + def self.send_chaser + 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 >= ?', 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) + .merge(Group.students) + .left_joins(:member_email_deliveries) + .where(member_email_deliveries: { id: nil }) + .where.not(id: recent_attendee_ids) + .where(id: past_year_attendee_ids) + .distinct + return if members.empty? + + members.find_each do |member| + MemberMailer.with(member: member).chaser.deliver_later + end + end +end 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 diff --git a/config/application.rb b/config/application.rb index a8af5e442..e84652355 100644 --- a/config/application.rb +++ b/config/application.rb @@ -33,6 +33,9 @@ class Application < Rails::Application config.active_record.belongs_to_required_by_default = true + # let's start using Active Job! + config.active_job.queue_adapter = :delayed_job + if ENV["RAILS_LOG_TO_STDOUT"].present? $stdout.sync = true config.rails_semantic_logger.add_file_appender = false diff --git a/config/environments/test.rb b/config/environments/test.rb index 7b18fa6e0..3afe8b20f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,5 +1,6 @@ require "active_support/core_ext/integer/time" require "timecop" +require "active_job/test_helper" # The test environment is used exclusively to run your application's # test suite. You never need to work with it otherwise. Remember that @@ -55,6 +56,8 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + config.active_job.queue_adapter = :test + # Fake omniauth for testing OmniAuth.config.test_mode = true 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 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/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 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..4117bb819 --- /dev/null +++ b/spec/services/three_month_email_service_spec.rb @@ -0,0 +1,200 @@ +RSpec.describe ThreeMonthEmailService, type: :service do + describe "#send_chaser" do + subject(:call) { described_class.send_chaser } + + let(:chapter) { Fabricate(:chapter) } + let(:students_group) { Fabricate(:group, name: "Students", chapter: chapter) } + let(:coaches_group) { Fabricate(:group, name: "Coaches", chapter: chapter) } + + 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 + + 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: member, + workshop: Fabricate(:workshop, chapter: chapter, date_and_time: 1.month.ago), + role: "Student", + attended: true + ) + member + end + + let!(:student_with_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: 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 + + 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) + + 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 "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) + + 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( + :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 } + + 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: 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 "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, + 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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3ee48ccc4..ce9f6ee41 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -57,6 +57,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 @@ -95,6 +96,9 @@ def self.branch_coverage? to_return(status: 200, body: '{"status":"active","segments":[]}', headers: { 'Content-Type' => 'application/json' }) DatabaseCleaner.strategy = :transaction + + clear_enqueued_jobs + clear_performed_jobs end # Driver is using an external browser with an app