Skip to content

Commit

Permalink
Send recap emails for new feedback (#440)
Browse files Browse the repository at this point in the history
  • Loading branch information
riggraz authored Nov 19, 2024
1 parent ace50e1 commit c0d7018
Show file tree
Hide file tree
Showing 19 changed files with 426 additions and 13 deletions.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
16 changes: 16 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/assets/stylesheets/common/_form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@

padding: 15px;
margin: 0 auto;

label[for=user_notifications_enabled] {
@extend .mb-0;
}
}

.apiKeyGenerateButton { width: 100%; }
Expand Down
7 changes: 6 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ def after_sign_out_path_for(resource_or_scope)
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)
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/tenants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def create
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
Expand Down
87 changes: 87 additions & 0 deletions app/jobs/send_recap_emails.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ def notify_follower_of_post_status_change(post:, follower:)
)
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
Expand Down
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
42 changes: 33 additions & 9 deletions app/views/devise/registrations/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
Expand All @@ -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" %>
Expand Down
18 changes: 18 additions & 0 deletions app/views/user_mailer/recap.html.erb
Original file line number Diff line number Diff line change
@@ -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' %>
4 changes: 4 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,9 @@ def stripe_monthly_lookup_key
def stripe_yearly_lookup_key
ENV["STRIPE_YEARLY_LOOKUP_KEY"]
end

def sidekiq_enabled?
ENV["ACTIVE_JOB_BACKEND"] == "sidekiq"
end
end
end
12 changes: 12 additions & 0 deletions config/initializers/sidekiq_cron.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions config/locales/backend/backend.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 9 additions & 0 deletions config/sidekiq_cron_schedule.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -239,6 +239,7 @@
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
Expand Down
Loading

0 comments on commit c0d7018

Please sign in to comment.