Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inactive user detection and email notification #865

Merged
merged 9 commits into from
Jun 13, 2021
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
EmCousin marked this conversation as resolved.
Show resolved Hide resolved
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) %>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I replaced this in order to remove the nested controllers in Match and SlotAlert controllers.

<%= 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