Skip to content

Commit

Permalink
Inactive user detection and email notification (#865)
Browse files Browse the repository at this point in the history
* Create an inactive user email with a job to trigger them

* Try to fix standardrb with open Range and TargetRubyVersion

* Add a script to generate volume in order to test the inactive user query

* Add a rake task to trigger the inactive users emails

* Throttle inactive user emails

* Account as refused expired matches too

* Consider refused matches as activity

* Update email template

* Clean-up controllers that were used for anonymizing the user
  • Loading branch information
nicoolas25 authored Jun 13, 2021
1 parent 2f71c5f commit 007e499
Show file tree
Hide file tree
Showing 26 changed files with 494 additions and 176 deletions.
1 change: 1 addition & 0 deletions .standard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ruby_version: 2.7.3
36 changes: 0 additions & 36 deletions app/controllers/matches/users_controller.rb

This file was deleted.

36 changes: 36 additions & 0 deletions app/controllers/redirects_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This controller holds temporary redirection logic.
# After a while, it would be fine removing each action as it shouldn't be used anymore.
class RedirectsController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :redirect_to_root_with_message
rescue_from ArgumentError, with: :redirect_to_root_with_message

def confirm_destroy_from_match
redirect_to confirm_destroy_path(
Match.find_by!(match_confirmation_token: params[:match_confirmation_token]).user
)
end

def confirm_destroy_from_slot_alert
redirect_to confirm_destroy_path(
SlotAlert.find_by!(token: params[:token]).user
)
end

private

def skip_pundit?
true
end

def redirect_to_root_with_message
flash[:error] = "Désolé, ce lien n’est plus valide."
redirect_to root_path
end

def confirm_destroy_path(user)
raise ArgumentError if user.anonymized_at

token = user.signed_id(purpose: "users.destroy", expires_in: 1.minute)
confirm_destroy_profile_path(authentication_token: token)
end
end
33 changes: 0 additions & 33 deletions app/controllers/slot_alerts/users_controller.rb

This file was deleted.

57 changes: 57 additions & 0 deletions app/jobs/send_inactive_user_emails_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
class SendInactiveUserEmailsJob < ApplicationJob
queue_as :low

DEFAULT_MIN_UNANSWERED_MATCHES = 2 # At least two refused OR unanswered matches
DEFAULT_AGE_RANGE = 0..200 # Covers all ages (except unborns)
DEFAULT_SIGNED_UP_RANGE = nil..nil # Covers all dates

def perform(*args)
self.class.inactive_user_ids(*args).each do |user_id|
UserMailer
.with(user_id: user_id)
.send_inactive_user_unsubscription_request
.deliver_later
end
end

def self.inactive_user_ids(min_unanswered_matches = DEFAULT_MIN_UNANSWERED_MATCHES, age_range = DEFAULT_AGE_RANGE, signed_up_date_range = DEFAULT_SIGNED_UP_RANGE)
sql = <<~SQL.squish
with target_users as (
select id
from users
where anonymized_at is null
and birthdate <= :max_birthdate
and birthdate >= :min_birthdate
and created_at <= :max_created_at
and created_at >= :min_created_at
), match_stats_per_user as (
select
user_id
, count(*) filter (where confirmed_at is not null) as confirmed_matches_count
, count(*) filter (where refused_at is not null) as refused_matches_count
, count(*) filter (where expires_at < now() and confirmed_at is null and refused_at is null) as unanswered_matches_count
, count(*) filter (where expires_at >= now() and confirmed_at is null and refused_at is null) as pending_matches_count
from matches
group by user_id
)
select u.id
from target_users as u
join match_stats_per_user as s
on s.user_id = u.id
and s.unanswered_matches_count >= :min_unanswered_matches
and s.refused_matches_count = 0
and s.pending_matches_count = 0
and s.confirmed_matches_count = 0
SQL
params = {
min_unanswered_matches: min_unanswered_matches,
min_birthdate: (age_range.end || 200).years.ago.to_date,
max_birthdate: (age_range.begin || 0).years.ago.to_date,
min_created_at: signed_up_date_range.begin || 200.years.ago,
max_created_at: signed_up_date_range.end || Date.current.end_of_day
}
User.connection.select_values(
ActiveRecord::Base.send(:sanitize_sql_array, [sql, params])
)
end
end
19 changes: 19 additions & 0 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class UserMailer < ApplicationMailer
default from: "Covidliste <[email protected]>"

MIN_SEND_INTERVAL = 7.days

def send_inactive_user_unsubscription_request
@user = User.find(params[:user_id])

return if @user.email.blank?
return if @user.last_inactive_user_email_sent_at && @user.last_inactive_user_email_sent_at >= MIN_SEND_INTERVAL.ago

@user.touch(:last_inactive_user_email_sent_at)

mail(
to: @user.email,
subject: "Attendez-vous toujours une dose de vaccin ?"
)
end
end
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ def anonymize!(reason = nil)
self.birthdate = nil
self.grid_i = nil
self.grid_j = nil
self.last_inactive_user_email_sent_at = nil
self.anonymized_at = Time.now.utc
self.anonymized_reason = reason
save(validate: false)
Expand Down
5 changes: 3 additions & 2 deletions app/views/match_mailer/match_confirmation_instructions.mjml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<% authentication_token = @match.user.signed_id(purpose: "users.destroy", expires_in: 7.days) %>
<mj-section padding-top="15px" padding-bottom="15px">
<mj-column>
<mj-text padding-bottom="0px">
Expand All @@ -16,13 +17,13 @@
<mj-text padding-bottom="0px">
<strong>Vous ne souhaitez plus être informé de doses disponibles ?</strong>
</mj-text>
<mj-button href="<%= edit_matches_users_url(match_confirmation_token: @match_confirmation_token) %>" padding-bottom="0px">
<mj-button href="<%= confirm_destroy_profile_url(authentication_token: authentication_token) %>" padding-bottom="0px">
Supprimer mon compte
</mj-button>
<mj-text>
<strong>Si le lien ne fonctionne pas</strong>, copiez et collez l’adresse suivante dans votre navigateur :
<br />
<%= edit_matches_users_url(match_confirmation_token: @match_confirmation_token) %>
<%= confirm_destroy_profile_url(authentication_token: authentication_token) %>
</mj-text>
<%= render partial: "mailer/social_networks", formats: [:html] %>
</mj-column>
Expand Down
16 changes: 0 additions & 16 deletions app/views/matches/users/edit.html.erb

This file was deleted.

3 changes: 2 additions & 1 deletion app/views/slot_alert_mailer/follow_up.mjml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<% authentication_token = @alert.user.signed_id(purpose: "users.destroy", expires_in: 7.days) %>
<mj-section padding-top="15px" padding-bottom="15px">
<mj-column>
<mj-text padding-bottom="0px">
Expand All @@ -9,7 +10,7 @@
<br />
Si c'est le cas nous vous invitons à supprimer votre compte Covidliste.
</mj-text>
<mj-button href="<%= edit_slot_alerts_users_url(token: @alert_token) %>" padding-bottom="20px">
<mj-button href="<%= confirm_destroy_profile_url(authentication_token: authentication_token) %>" padding-bottom="20px">
Supprimer mon compte
</mj-button>
<mj-text>
Expand Down
3 changes: 2 additions & 1 deletion app/views/slot_alert_mailer/notify.mjml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<%- distance = distance_delta({lat: @alert.user.lat, lon: @alert.user.lon}, {lat: @slot.latitude, lon: @slot.longitude}) %>
<% authentication_token = @alert.user.signed_id(purpose: "users.destroy", expires_in: 7.days) %>
<mj-section padding-top="15px" padding-bottom="0px">
<mj-column>
<mj-text padding-bottom="0px">
Expand Down Expand Up @@ -65,7 +66,7 @@
<h1>Vous ne souhaitez plus être informé de doses disponibles ?</h1>
<br />
<strong>
<%= link_to "Supprimer mon compte", edit_slot_alerts_users_url(token: @alert_token) %>
<%= link_to "Supprimer mon compte", confirm_destroy_profile_url(authentication_token: authentication_token) %>
</strong>
<br />
<br />
Expand Down
1 change: 0 additions & 1 deletion app/views/slot_alerts/users/edit.html.erb

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<% token = @user.signed_id(purpose: "users.destroy", expires_in: 7.days) %>
<mj-section padding-top="15px" padding-bottom="15px">
<mj-column>
<mj-text padding-bottom="0px">
<h1>Avez-vous toujours besoin d'une dose de vaccin ?</h1>
<br />
<strong>Si vous ne souhaitez plus être informé de doses disponibles,</strong>,
laissez votre place à quelqu'un d'autre en supprimant votre compte :
</mj-text>
<mj-button href="<%= confirm_destroy_profile_url(authentication_token: token) %>" padding-bottom="0px">
Supprimer mon compte
</mj-button>
<mj-text>
<strong>Si le lien ne fonctionne pas</strong>, copiez et collez l’adresse suivante dans votre navigateur :
<br />
<%= confirm_destroy_profile_url(authentication_token: token) %>
</mj-text>
<%= render partial: "mailer/social_networks", formats: [:html] %>
</mj-column>
</mj-section>
60 changes: 37 additions & 23 deletions app/views/users/_confirm_destroy_message.html.erb
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
<div class="container">
<div class="d-flex align-items-center flex-column my-4 my-lg-5">
<h4 class="text-center">
Pourquoi souhaitez vous supprimer votre compte ?
</h4>
<% token = user.signed_id(purpose: "users.destroy", expires_in: 1.hour) %>
<% if user.matches.confirmed.any? %>
<div class="container">
<div class="d-flex align-items-center flex-column my-4 my-lg-5">
<h4 class="text-center mb-4">
Vous avez confirmé votre rendez-vous.
</h4>
<p class="alert alert-info">
Vous ne pouvez pas supprimer vos informations actuellement car vous avez confirmé un rendez-vous de vaccination.
<br>
Votre profil sera anonymisé quelques jours après le RDV.
</p>
</div>
</div>
<% else %>
<div class="container">
<div class="d-flex align-items-center flex-column my-4 my-lg-5">
<h4 class="text-center">
Pourquoi souhaitez vous supprimer votre compte ?
</h4>
<% token = user.signed_id(purpose: "users.destroy", expires_in: 1.hour) %>

<p class="mt-4 text-center">
<strong>
Cette information est nécessaire pour nous améliorer notre service. Prenez le temps d'y répondre sérieusement.
Par ailleurs, cette information n'est pas associée à votre compte.
</strong>
</p>

<%= simple_form_for "", url: profile_path(authentication_token: token), method: :delete do |f| %>
<div class="mt-4">
<%= f.input :reason, required: true, label: '', class: 'mt-4', collection: User::ANONYMIZED_REASONS.map{|k, v| [v, k]}.shuffle, as: :radio_buttons, item_label_class: 'mt-y' %>
<%= f.submit "Supprimer mon compte !",
id: dom_id(user, :delete),
class: "btn btn-outline-danger btn-lg mt-4",
data: {confirm: "En confirmant, votre compte ainsi que toutes les données associées seront supprimées de nos serveurs. Êtes-vous sûr(e) ?"} %>
</div>
<% end %>
<p class="mt-4 text-center">
<strong>
Cette information est nécessaire pour nous améliorer notre service. Prenez le temps d'y répondre sérieusement.
Par ailleurs, cette information n'est pas associée à votre compte.
</strong>
</p>
<%= simple_form_for "", url: profile_path(authentication_token: token), method: :delete do |f| %>
<div class="mt-4">
<%= f.input :reason, required: true, label: '', class: 'mt-4', collection: User::ANONYMIZED_REASONS.map{|k, v| [v, k]}.shuffle, as: :radio_buttons, item_label_class: 'mt-y' %>
<%= f.submit "Supprimer mon compte !",
id: dom_id(user, :delete),
class: "btn btn-outline-danger btn-lg mt-4",
data: {confirm: "En confirmant, votre compte ainsi que toutes les données associées seront supprimées de nos serveurs. Êtes-vous sûr(e) ?"} %>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
9 changes: 1 addition & 8 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,11 @@
resources :partner_external_accounts, only: [:destroy]
end

## matches
namespace :matches do
resource :users, only: [:edit]
end

# slot alerts
get "/s/:token" => "slot_alerts#show", :as => :slot_alert
patch "/s/:token" => "slot_alerts#update"
namespace :slot_alerts do
resource :users, only: [:edit]
end

# Matches
get "/m/:match_confirmation_token(/:source)" => "matches#show", :as => :match
patch "/m/:match_confirmation_token(/:source)" => "matches#update"
delete "/m/:match_confirmation_token(/:source)" => "matches#destroy"
Expand Down
3 changes: 2 additions & 1 deletion config/routes/redirects.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Handle old routes redirections (before devise users path migration)

get "/login", to: redirect("/users/login", status: 302), as: :legacy_new_user_session
post "/login", to: redirect("/users/login", status: 302), as: :legacy_user_session
delete "/logout", to: redirect("/users/logout", status: 302), as: :legacy_destroy_user_session
get "/profile", to: redirect("/users/profile", status: 302), as: :legacy_profile
get "/confirmation/new", to: redirect("/users/confirmation/new", status: 302), as: :legacy_new_user_confirmation
get "/confirmation", to: redirect { |_, request| "/users/confirmation#{request.params.present? ? "?" + request.params.to_query : ""}" }, as: :legacy_user_confirmation
get "/matches/users/edit", controller: :redirects, action: :confirm_destroy_from_match, as: :legacy_edit_matches_users
get "/slot_alerts/users/edit", controller: :redirects, action: :confirm_destroy_from_slot_alert, as: :legacy_edit_slot_alerts_users
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddLastInactiveUserEmailSentAt < ActiveRecord::Migration[6.1]
def change
add_column :users, :last_inactive_user_email_sent_at, :datetime
end
end
1 change: 1 addition & 0 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 007e499

Please sign in to comment.