Send recap emails for new feedback (#440)

This commit is contained in:
Riccardo Graziosi
2024-11-19 17:17:05 +01:00
committed by GitHub
parent ace50e1089
commit c0d70186f6
19 changed files with 426 additions and 13 deletions

View File

@@ -64,6 +64,9 @@ gem 'rack-cors', '2.0.2'
# ActiveJob backend
gem 'sidekiq', '7.3.5'
# Cron jobs with sidekiq
gem 'sidekiq-cron', '2.0.1'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]

View File

@@ -85,6 +85,9 @@ GEM
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
cssbundling-rails (1.1.2)
railties (>= 6.0.0)
date (3.4.0)
@@ -96,6 +99,8 @@ GEM
warden (~> 1.2.3)
diff-lcs (1.5.1)
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
execjs (2.10.0)
factory_bot (5.0.2)
activesupport (>= 4.2.0)
@@ -105,6 +110,9 @@ GEM
ffi (1.17.0)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
httparty (0.21.0)
@@ -173,6 +181,7 @@ GEM
nio4r (~> 2.0)
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.10)
rack-attack (6.7.0)
@@ -264,6 +273,11 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.0.1)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
spring (2.1.1)
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
@@ -284,6 +298,7 @@ GEM
turbolinks-source (5.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
@@ -330,6 +345,7 @@ DEPENDENCIES
rswag-specs (= 2.15.0)
selenium-webdriver (= 4.17.0)
sidekiq (= 7.3.5)
sidekiq-cron (= 2.0.1)
spring (= 2.1.1)
spring-watcher-listen (= 2.0.1)
stripe (= 11.2.0)

View File

@@ -23,6 +23,10 @@
padding: 15px;
margin: 0 auto;
label[for=user_notifications_enabled] {
@extend .mb-0;
}
}
.apiKeyGenerateButton { width: 100%; }

View File

@@ -35,7 +35,12 @@ class ApplicationController < ActionController::Base
protected
def configure_devise_permitted_parameters
additional_permitted_parameters = [:full_name, :notifications_enabled, :invitation_token]
additional_permitted_parameters = [
:full_name,
:notifications_enabled,
:recap_notification_frequency,
:invitation_token
]
devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters)
devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters)

View File

@@ -43,7 +43,8 @@ class TenantsController < ApplicationController
email: params[:user][:email],
password: is_o_auth_login ? Devise.friendly_token : params[:user][:password],
has_set_password: !is_o_auth_login,
role: "owner"
role: "owner",
recap_notification_frequency: "daily"
)
if is_o_auth_login

View File

@@ -0,0 +1,87 @@
class SendRecapEmails < ActiveJob::Base
queue_as :default
def perform(*args)
# Fix times to 15:00 UTC
time_now = Time.now.utc.change(hour: args[0], min: 0, sec: 0)
one_day_ago = 1.day.ago.utc.change(hour: args[0], min: 0, sec: 0)
one_week_ago = 1.week.ago.utc.change(hour: args[0], min: 0, sec: 0)
one_month_ago = 1.month.ago.utc.change(hour: args[0], min: 0, sec: 0)
# Get tenants with active subscriptions
tbs = TenantBilling.unscoped.all
tbs = tbs.select { |tb| tb.has_active_subscription? }
tenants = Tenant.where(id: tbs.map(&:tenant_id))
# Based on the current date, determine which recap notifications to send
frequencies_to_notify = ['daily']
frequencies_to_notify.push('weekly') if Date.today.monday? # Send weekly recap on Mondays
frequencies_to_notify.push('monthly') if Date.today.day == 1 # Send monthly recap on the 1st of the month
tenants.each do |tenant|
Current.tenant = tenant
I18n.locale = tenant.locale
# Get users with recap notifications enabled
users = tenant.users.where(
role: ['owner', 'admin', 'moderator'],
notifications_enabled: true,
recap_notification_frequency: frequencies_to_notify,
)
# Get the different recap notification frequencies for users
users_recap_notification_frequencies = users.map(&:recap_notification_frequency).flatten.uniq
# Get only needed posts
if users_recap_notification_frequencies.include?('daily')
published_posts_daily = Post.where(approval_status: 'approved', created_at: one_day_ago..time_now).to_a
pending_posts_daily = Post.where(approval_status: 'pending', created_at: one_day_ago..time_now).to_a
end
if frequencies_to_notify.include?('weekly') && users_recap_notification_frequencies.include?('weekly')
published_posts_weekly = Post.where(approval_status: 'approved', created_at: one_week_ago..time_now).to_a
pending_posts_weekly = Post.where(approval_status: 'pending', created_at: one_week_ago..time_now).to_a
end
if frequencies_to_notify.include?('monthly') && users_recap_notification_frequencies.include?('monthly')
published_posts_monthly = Post.where(approval_status: 'approved', created_at: one_month_ago..time_now).to_a
pending_posts_monthly = Post.where(approval_status: 'pending', created_at: one_month_ago..time_now).to_a
end
# Notify each user based on their recap notification frequency
users.each do |user|
# Remove from published_posts the posts published by the user
published_posts_daily_user = published_posts_daily&.select { |post| post.user_id != user.id }
should_send_daily_recap = published_posts_daily_user&.any? || pending_posts_daily&.any?
published_posts_weekly_user = published_posts_weekly&.select { |post| post.user_id != user.id }
should_send_weekly_recap = published_posts_weekly_user&.any? || pending_posts_weekly&.any?
published_posts_monthly_user = published_posts_monthly&.select { |post| post.user_id != user.id }
should_send_monthly_recap = published_posts_monthly_user&.any? || pending_posts_monthly&.any?
# Send recap email
if user.recap_notification_frequency == 'daily' && should_send_daily_recap
UserMailer.recap(
frequency: I18n.t('common.forms.auth.recap_notification_frequency_daily'),
user: user,
published_posts_count: published_posts_daily_user&.count,
pending_posts_count: pending_posts_daily&.count,
).deliver_later
elsif user.recap_notification_frequency == 'weekly' && should_send_weekly_recap
UserMailer.recap(
frequency: I18n.t('common.forms.auth.recap_notification_frequency_weekly'),
user: user,
published_posts_count: published_posts_weekly_user&.count,
pending_posts_count: pending_posts_weekly&.count,
).deliver_later
elsif user.recap_notification_frequency == 'monthly' && should_send_monthly_recap
UserMailer.recap(
frequency: I18n.t('common.forms.auth.recap_notification_frequency_monthly'),
user: user,
published_posts_count: published_posts_monthly_user&.count,
pending_posts_count: pending_posts_monthly&.count,
).deliver_later
end
end
end
end
end

View File

@@ -43,6 +43,21 @@ class UserMailer < ApplicationMailer
)
end
def recap(frequency:, user:, published_posts_count:, pending_posts_count:)
Current.tenant = user.tenant
@frequency = frequency
@user = user
@published_posts_count = published_posts_count
@pending_posts_count = pending_posts_count
mail(
to: user.email,
subject: t('mailers.user.recap.subject', site_name: site_name, frequency: frequency)
)
end
private
def site_name

View File

@@ -15,6 +15,8 @@ class User < ApplicationRecord
enum role: [:user, :moderator, :admin, :owner]
enum status: [:active, :blocked, :deleted]
enum recap_notification_frequency: [:never, :daily, :weekly, :monthly]
after_initialize :set_default_role, if: :new_record?
after_initialize :set_default_status, if: :new_record?

View File

@@ -34,15 +34,6 @@
</div>
<% end %>
<div class="form-group">
<%= f.label :notifications_enabled, t('common.forms.auth.notifications_enabled') %>
&nbsp;
<%= f.check_box :notifications_enabled, style: "transform: scale(1.5)" %>
<small id="notificationsHelp" class="form-text text-muted">
<%= t('common.forms.auth.notifications_enabled_help') %>
</small>
</div>
<div class="form-group">
<%= f.label :password, t('common.forms.auth.password') %>
<%= f.password_field :password, autocomplete: "new-password", class: "form-control" %>
@@ -58,6 +49,39 @@
<hr />
<h3><%= t('common.forms.auth.notifications') %></h3>
<br />
<div class="form-group">
<%= f.label :notifications_enabled, t('activerecord.attributes.user.notifications_enabled') %>
&nbsp;
<%= f.check_box :notifications_enabled, style: "transform: scale(1.5)" %>
<small id="notificationsHelp" class="form-text text-muted">
<%= t('common.forms.auth.notifications_enabled_help') %>
</small>
</div>
<% if Rails.application.sidekiq_enabled? %>
<div class="form-group">
<%= f.label :recap_notification_frequency, t('activerecord.attributes.user.recap_notification_frequency') %>
<%= f.select :recap_notification_frequency,
[
[t('common.forms.auth.recap_notification_frequency_never'), "never"],
[t('common.forms.auth.recap_notification_frequency_daily'), "daily"],
[t('common.forms.auth.recap_notification_frequency_weekly'), "weekly"],
[t('common.forms.auth.recap_notification_frequency_monthly'), "monthly"]
],
{ include_blank: false },
class: "form-control" %>
<small id="recapNotificationFrequencyHelp" class="form-text text-muted">
<%= t('common.forms.auth.recap_notification_frequency_help') %>
</small>
</div>
<% else %>
<p>You have to <a href="https://docs.astuto.io/deploy-with-sidekiq">enable Sidekiq</a> to receive recap notifications.</p>
<% end %>
<hr />
<div class="form-group">
<%= f.label :current_password, t('common.forms.auth.current_password') %>
<%= f.password_field :current_password, autocomplete: "current-password", required: true, class: "form-control" %>

View File

@@ -0,0 +1,18 @@
<%= render 'user_mailer/opening', user_name: @user.full_name_or_email %>
<p>
<%= t('mailers.user.recap.body_html', frequency: @frequency.downcase) %>:
</p>
<ul>
<li><%= t('mailers.user.recap.published_posts_count_html', count: @published_posts_count) %></li>
<li><%= t('mailers.user.recap.pending_posts_count_html', count: @pending_posts_count) %></li>
</ul>
<p>
<%= link_to t('mailers.user.learn_more'), get_url_for(method(:root_url)) %>
</p>
<%= render 'user_mailer/closing' %>
<%= render 'user_mailer/unsubscribe_from_site' %>

View File

@@ -67,5 +67,9 @@ module App
def stripe_yearly_lookup_key
ENV["STRIPE_YEARLY_LOOKUP_KEY"]
end
def sidekiq_enabled?
ENV["ACTIVE_JOB_BACKEND"] == "sidekiq"
end
end
end

View File

@@ -0,0 +1,12 @@
Sidekiq::Cron.configure do |config|
config.cron_schedule_file = 'config/sidekiq_cron_schedule.yml'
config.cron_poll_interval = 30
config.cron_history_size = 50
config.default_namespace = 'default'
config.natural_cron_parsing_mode = :strict
# Handles the case when the Sidekiq process was down for a while and the cron job should have run (set to 10 minutes, i.e. 600 seconds)
# This could happen during the deployment of a new version of the application
config.reschedule_grace_period = 600
end

View File

@@ -50,6 +50,11 @@ en:
notify_follower_of_post_status_change:
subject: '[%{site_name}] Status change on post "%{post}"'
body_html: 'There is a status update on the post you are following <b>%{post}</b>'
recap:
subject: '[%{site_name}] %{frequency} recap of feedback space activity'
body_html: 'Here is the %{frequency} recap of activities in your feedback space'
published_posts_count_html: 'New published feedback: %{count}'
pending_posts_count_html: 'New feedback pending approval: %{count}'
activerecord:
models:
board:
@@ -148,6 +153,7 @@ en:
password_confirmation: 'Password confirmation'
role: 'Role'
notifications_enabled: 'Notifications enabled'
recap_notification_frequency: 'Recap notification frequency'
errors:
messages:
invalid: 'is invalid'

View File

@@ -19,8 +19,13 @@ en:
new_password: 'New password'
new_password_confirmation: 'New password confirmation'
current_password: 'Current password'
notifications_enabled: 'Notifications enabled'
notifications: 'Notifications'
notifications_enabled_help: "if disabled, you won't receive any notification"
recap_notification_frequency_never: 'Never'
recap_notification_frequency_daily: 'Daily'
recap_notification_frequency_weekly: 'Weekly'
recap_notification_frequency_monthly: 'Monthly'
recap_notification_frequency_help: 'recap notifications let you know if new feedback has been submitted or is waiting for your approval'
waiting_confirmation: 'Currently waiting confirmation for %{email}'
no_password_set: 'You must set a password to update your profile'
set_password: 'Set password'

View File

@@ -0,0 +1,9 @@
# For crontab syntax, see https://crontab.guru/
send_recap_emails:
cron: "0 15 * * *" # At 15:00 every day
# cron: "*/30 * * * * *" # Execute every 30 seconds (for testing purposes)
class: "SendRecapEmails"
queue: default
args:
hour: 15 # This should be in sync with the "cron" time

View File

@@ -0,0 +1,5 @@
class AddRecapNotificationFrequencyToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :recap_notification_frequency, :integer, default: 0, null: false
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2024_10_04_170520) do
ActiveRecord::Schema.define(version: 2024_11_18_082824) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -239,6 +239,7 @@ ActiveRecord::Schema.define(version: 2024_10_04_170520) do
t.datetime "last_sign_in_at"
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.integer "recap_notification_frequency", default: 0, null: false
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email", "tenant_id"], name: "index_users_on_email_and_tenant_id", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View File

@@ -0,0 +1,187 @@
require 'rails_helper'
include ActiveSupport::Testing::TimeHelpers
RSpec.describe SendRecapEmails, type: :job do
before do
@hour_of_execution = 15
@admin = FactoryBot.create(:user, role: 'admin', notifications_enabled: true)
allow(UserMailer).to receive(:recap).and_call_original
end
it 'sends a daily recap email with published posts count and pending posts count' do
@admin.recap_notification_frequency = 'daily'
@admin.save
travel_to Time.now.utc.change(hour: @hour_of_execution) do
# Published posts
FactoryBot.create(:post, approval_status: 'approved', created_at: 23.hours.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 16.hours.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 30.hours.ago) # Should not be included in recap
# Pending posts
FactoryBot.create(:post, approval_status: 'pending', created_at: 23.hours.ago)
FactoryBot.create(:post, approval_status: 'pending', created_at: 30.hours.ago) # Should not be included in recap
SendRecapEmails.perform_now(@hour_of_execution)
expect(UserMailer).to have_received(:recap).with(
frequency: 'Daily',
user: @admin,
published_posts_count: 2,
pending_posts_count: 1
).once
end
end
it 'does not count posts published by the user receiving the recap email' do
@admin.recap_notification_frequency = 'daily'
@admin.save
travel_to Time.now.utc.change(hour: @hour_of_execution) do
# Published posts
FactoryBot.create(:post, approval_status: 'approved', created_at: 23.hours.ago, user: @admin) # Should not be included in recap
FactoryBot.create(:post, approval_status: 'approved', created_at: 16.hours.ago)
# Pending posts
FactoryBot.create(:post, approval_status: 'pending', created_at: 23.hours.ago)
SendRecapEmails.perform_now(@hour_of_execution)
expect(UserMailer).to have_received(:recap).with(
frequency: 'Daily',
user: @admin,
published_posts_count: 1,
pending_posts_count: 1
).once
end
end
it 'sends a recap email for every owner/admin/mod with notifications enabled and recap_notification_frequency set' do
@admin.recap_notification_frequency = 'daily'
@admin.save
owner = FactoryBot.create(:user, role: 'owner', notifications_enabled: true, recap_notification_frequency: 'daily')
mod = FactoryBot.create(:user, role: 'moderator', notifications_enabled: false, recap_notification_frequency: 'daily') # Should not receive recap
travel_to Time.now.utc.change(hour: @hour_of_execution) do
# Published posts
FactoryBot.create(:post, approval_status: 'approved', created_at: 23.hours.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 16.hours.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 30.hours.ago) # Should not be included in recap
# Pending posts
FactoryBot.create(:post, approval_status: 'pending', created_at: 23.hours.ago)
FactoryBot.create(:post, approval_status: 'pending', created_at: 30.hours.ago) # Should not be included in recap
SendRecapEmails.perform_now(@hour_of_execution)
expect(UserMailer).to have_received(:recap).with(
frequency: 'Daily',
user: @admin,
published_posts_count: 2,
pending_posts_count: 1
).once
expect(UserMailer).to have_received(:recap).with(
frequency: 'Daily',
user: owner,
published_posts_count: 2,
pending_posts_count: 1
).once
expect(UserMailer).not_to have_received(:recap).with(
frequency: 'Daily',
user: mod,
published_posts_count: 2,
pending_posts_count: 1
)
end
end
it 'sends a weekly recap email with published posts count and pending posts count on Monday' do
@admin.recap_notification_frequency = 'weekly'
@admin.save
travel_to Time.zone.local(2024, 11, 18, @hour_of_execution, 0, 0) do # Monday
# Published posts
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 6.days.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 10.days.ago) # Should not be included in recap
# Pending posts
FactoryBot.create(:post, approval_status: 'pending', created_at: 1.minute.ago)
FactoryBot.create(:post, approval_status: 'pending', created_at: 6.days.ago)
FactoryBot.create(:post, approval_status: 'pending', created_at: 10.days.ago) # Should not be included in recap
SendRecapEmails.perform_now(@hour_of_execution)
expect(UserMailer).to have_received(:recap).with(
frequency: 'Weekly',
user: @admin,
published_posts_count: 2,
pending_posts_count: 2
).once
end
end
it 'does not send a weekly recap email on days other than Monday' do
@admin.recap_notification_frequency = 'weekly'
@admin.save
travel_to Time.zone.local(2024, 11, 19, @hour_of_execution, 0, 0) do # Tuesday
SendRecapEmails.perform_now(@hour_of_execution)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
expect(UserMailer).not_to have_received(:recap)
end
travel_to Time.zone.local(2024, 11, 20, @hour_of_execution, 0, 0) do # Wednesday
SendRecapEmails.perform_now(@hour_of_execution)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
expect(UserMailer).not_to have_received(:recap)
end
travel_to Time.zone.local(2024, 11, 21, @hour_of_execution, 0, 0) do # Thursday
SendRecapEmails.perform_now(@hour_of_execution)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
expect(UserMailer).not_to have_received(:recap)
end
travel_to Time.zone.local(2024, 11, 22, @hour_of_execution, 0, 0) do # Friday
SendRecapEmails.perform_now(@hour_of_execution)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
expect(UserMailer).not_to have_received(:recap)
end
travel_to Time.zone.local(2024, 11, 23, @hour_of_execution, 0, 0) do # Saturday
SendRecapEmails.perform_now(@hour_of_execution)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
expect(UserMailer).not_to have_received(:recap)
end
travel_to Time.zone.local(2024, 11, 24, @hour_of_execution, 0, 0) do # Sunday
SendRecapEmails.perform_now(@hour_of_execution)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
expect(UserMailer).not_to have_received(:recap)
end
end
it 'sends a monthly recap email with published posts count and pending posts count on the first day of the month' do
@admin.recap_notification_frequency = 'monthly'
@admin.save
travel_to Time.zone.local(2024, 11, 1, @hour_of_execution, 0, 0) do # First day of the month
# Published posts
FactoryBot.create(:post, approval_status: 'approved', created_at: 1.hour.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.days.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 3.weeks.ago)
FactoryBot.create(:post, approval_status: 'approved', created_at: 2.months.ago) # Should not be included in recap
# Pending posts
FactoryBot.create(:post, approval_status: 'pending', created_at: 2.days.ago)
FactoryBot.create(:post, approval_status: 'pending', created_at: 2.months.ago) # Should not be included in recap
SendRecapEmails.perform_now(@hour_of_execution)
expect(UserMailer).to have_received(:recap).with(
frequency: 'Monthly',
user: @admin,
published_posts_count: 3,
pending_posts_count: 1
).once
end
end
end

View File

@@ -33,4 +33,13 @@ class UserMailerPreview < ActionMailer::Preview
follower = comment.post.follows.first.user
UserMailer.notify_follower_of_post_status_change(post: Post.first, follower: follower)
end
def recap
UserMailer.recap(
frequency: I18n.t('common.forms.auth.recap_notification_frequency_daily'),
user: User.first,
published_posts_count: 3,
pending_posts_count: 2,
)
end
end