From 007e499ee5eb588ce37210c816842b6a1d694d0b Mon Sep 17 00:00:00 2001 From: Nicolas Zermati Date: Sun, 13 Jun 2021 11:17:26 +0200 Subject: [PATCH] Inactive user detection and email notification (#865) * 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 --- .standard.yml | 1 + app/controllers/matches/users_controller.rb | 36 ------- app/controllers/redirects_controller.rb | 36 +++++++ .../slot_alerts/users_controller.rb | 33 ------- app/jobs/send_inactive_user_emails_job.rb | 57 +++++++++++ app/mailers/user_mailer.rb | 19 ++++ app/models/user.rb | 1 + .../match_confirmation_instructions.mjml | 5 +- app/views/matches/users/edit.html.erb | 16 ---- app/views/slot_alert_mailer/follow_up.mjml | 3 +- app/views/slot_alert_mailer/notify.mjml | 3 +- app/views/slot_alerts/users/edit.html.erb | 1 - ..._inactive_user_unsubscription_request.mjml | 20 ++++ .../users/_confirm_destroy_message.html.erb | 60 +++++++----- config/routes.rb | 9 +- config/routes/redirects.rb | 3 +- ...20_add_last_inactive_user_email_sent_at.rb | 5 + db/schema.rb | 1 + lib/tasks/emails.rake | 44 +++++++++ lib/tasks/populate.rake | 62 ++++++++++++ spec/factories/match_factory.rb | 12 +++ spec/factories/user_factory.rb | 14 +++ .../send_inactive_user_emails_job_spec.rb | 95 +++++++++++++++++++ spec/mailers/user_mailer_spec.rb | 27 ++++++ spec/requests/redirections_spec.rb | 53 +++++++++++ .../matches/destroy_user_account_spec.rb | 54 ----------- 26 files changed, 494 insertions(+), 176 deletions(-) create mode 100644 .standard.yml delete mode 100644 app/controllers/matches/users_controller.rb create mode 100644 app/controllers/redirects_controller.rb delete mode 100644 app/controllers/slot_alerts/users_controller.rb create mode 100644 app/jobs/send_inactive_user_emails_job.rb create mode 100644 app/mailers/user_mailer.rb delete mode 100644 app/views/matches/users/edit.html.erb delete mode 100644 app/views/slot_alerts/users/edit.html.erb create mode 100644 app/views/user_mailer/send_inactive_user_unsubscription_request.mjml create mode 100644 db/migrate/20210530071920_add_last_inactive_user_email_sent_at.rb create mode 100644 lib/tasks/emails.rake create mode 100644 spec/jobs/send_inactive_user_emails_job_spec.rb create mode 100644 spec/mailers/user_mailer_spec.rb create mode 100644 spec/requests/redirections_spec.rb delete mode 100644 spec/system/matches/destroy_user_account_spec.rb diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 000000000..da47404c8 --- /dev/null +++ b/.standard.yml @@ -0,0 +1 @@ +ruby_version: 2.7.3 diff --git a/app/controllers/matches/users_controller.rb b/app/controllers/matches/users_controller.rb deleted file mode 100644 index dcfe6c49d..000000000 --- a/app/controllers/matches/users_controller.rb +++ /dev/null @@ -1,36 +0,0 @@ -module Matches - class UsersController < ApplicationController - before_action :set_match, only: [:edit] - rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized - - def pundit_user - @match.user - end - - def edit - end - - private - - def set_match - @match = Match.find_by(match_confirmation_token: params[:match_confirmation_token]) - - if @match.blank? - flash[:error] = "Désolé, ce lien d’invitation n’est pas valide." - return redirect_to root_path - elsif @match.user.blank? - flash[:error] = "Désolé, ce lien d’invitation n’est plus valide. L’utilisateur a été supprimé." - return redirect_to root_path - end - authorize @match - end - - def user_not_authorized(exception) - policy_name = exception - .policy.class.to_s.underscore - message = exception.message || (t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default) - flash[:error] = message - redirect_back(fallback_location: root_path) - end - end -end diff --git a/app/controllers/redirects_controller.rb b/app/controllers/redirects_controller.rb new file mode 100644 index 000000000..bfcb2cc47 --- /dev/null +++ b/app/controllers/redirects_controller.rb @@ -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 diff --git a/app/controllers/slot_alerts/users_controller.rb b/app/controllers/slot_alerts/users_controller.rb deleted file mode 100644 index 733d6bb39..000000000 --- a/app/controllers/slot_alerts/users_controller.rb +++ /dev/null @@ -1,33 +0,0 @@ -module SlotAlerts - class UsersController < ApplicationController - before_action :set_alert, only: [:edit] - rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized - - def pundit_user - @alert.user - end - - def edit - end - - private - - def set_alert - @alert = SlotAlert.find_by(token: params[:token]) - - if @alert.user.blank? - flash[:error] = "Désolé, ce lien n’est plus valide." - return redirect_to root_path - end - authorize @alert - end - - def user_not_authorized(exception) - policy_name = exception - .policy.class.to_s.underscore - message = exception.message || (t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default) - flash[:error] = message - redirect_back(fallback_location: root_path) - end - end -end diff --git a/app/jobs/send_inactive_user_emails_job.rb b/app/jobs/send_inactive_user_emails_job.rb new file mode 100644 index 000000000..2d731ed27 --- /dev/null +++ b/app/jobs/send_inactive_user_emails_job.rb @@ -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 diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 000000000..fcbe645ca --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,19 @@ +class UserMailer < ApplicationMailer + default from: "Covidliste " + + 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 diff --git a/app/models/user.rb b/app/models/user.rb index bd9e7d843..ee0d781ca 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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) diff --git a/app/views/match_mailer/match_confirmation_instructions.mjml b/app/views/match_mailer/match_confirmation_instructions.mjml index 570db5480..2c4780b68 100644 --- a/app/views/match_mailer/match_confirmation_instructions.mjml +++ b/app/views/match_mailer/match_confirmation_instructions.mjml @@ -1,3 +1,4 @@ +<% authentication_token = @match.user.signed_id(purpose: "users.destroy", expires_in: 7.days) %> @@ -16,13 +17,13 @@ Vous ne souhaitez plus être informé de doses disponibles ? - + Supprimer mon compte Si le lien ne fonctionne pas, copiez et collez l’adresse suivante dans votre navigateur :
- <%= edit_matches_users_url(match_confirmation_token: @match_confirmation_token) %> + <%= confirm_destroy_profile_url(authentication_token: authentication_token) %>
<%= render partial: "mailer/social_networks", formats: [:html] %>
diff --git a/app/views/matches/users/edit.html.erb b/app/views/matches/users/edit.html.erb deleted file mode 100644 index 8506c9cc1..000000000 --- a/app/views/matches/users/edit.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -<% if @match.confirmed? %> -
-
-

- Vous avez confirmé votre rendez-vous. -

-

- Vous ne pouvez pas supprimer vos informations actuellement car vous avez confirmé un rendez-vous de vaccination. -
- Votre profil sera anonymisé quelques jours après le RDV. -

-
-
-<% else %> - <%= render partial: "users/confirm_destroy_message", locals: { user: @match.user } %> -<% end %> diff --git a/app/views/slot_alert_mailer/follow_up.mjml b/app/views/slot_alert_mailer/follow_up.mjml index 25138f53c..e61f058ce 100644 --- a/app/views/slot_alert_mailer/follow_up.mjml +++ b/app/views/slot_alert_mailer/follow_up.mjml @@ -1,3 +1,4 @@ +<% authentication_token = @alert.user.signed_id(purpose: "users.destroy", expires_in: 7.days) %> @@ -9,7 +10,7 @@
Si c'est le cas nous vous invitons à supprimer votre compte Covidliste.
- + Supprimer mon compte diff --git a/app/views/slot_alert_mailer/notify.mjml b/app/views/slot_alert_mailer/notify.mjml index 089faa40a..69e325f1f 100644 --- a/app/views/slot_alert_mailer/notify.mjml +++ b/app/views/slot_alert_mailer/notify.mjml @@ -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) %> @@ -65,7 +66,7 @@

Vous ne souhaitez plus être informé de doses disponibles ?


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

diff --git a/app/views/slot_alerts/users/edit.html.erb b/app/views/slot_alerts/users/edit.html.erb deleted file mode 100644 index f00afd837..000000000 --- a/app/views/slot_alerts/users/edit.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render partial: "users/confirm_destroy_message", locals: { user: @alert.user } %> diff --git a/app/views/user_mailer/send_inactive_user_unsubscription_request.mjml b/app/views/user_mailer/send_inactive_user_unsubscription_request.mjml new file mode 100644 index 000000000..b47860625 --- /dev/null +++ b/app/views/user_mailer/send_inactive_user_unsubscription_request.mjml @@ -0,0 +1,20 @@ +<% token = @user.signed_id(purpose: "users.destroy", expires_in: 7.days) %> + + + +

Avez-vous toujours besoin d'une dose de vaccin ?

+
+ Si vous ne souhaitez plus être informé de doses disponibles,, + laissez votre place à quelqu'un d'autre en supprimant votre compte : +
+ + Supprimer mon compte + + + Si le lien ne fonctionne pas, copiez et collez l’adresse suivante dans votre navigateur : +
+ <%= confirm_destroy_profile_url(authentication_token: token) %> +
+ <%= render partial: "mailer/social_networks", formats: [:html] %> +
+
diff --git a/app/views/users/_confirm_destroy_message.html.erb b/app/views/users/_confirm_destroy_message.html.erb index 9028e7ff5..9ab1a4770 100644 --- a/app/views/users/_confirm_destroy_message.html.erb +++ b/app/views/users/_confirm_destroy_message.html.erb @@ -1,25 +1,39 @@ -
-
-

- Pourquoi souhaitez vous supprimer votre compte ? -

- <% token = user.signed_id(purpose: "users.destroy", expires_in: 1.hour) %> +<% if user.matches.confirmed.any? %> +
+
+

+ Vous avez confirmé votre rendez-vous. +

+

+ Vous ne pouvez pas supprimer vos informations actuellement car vous avez confirmé un rendez-vous de vaccination. +
+ Votre profil sera anonymisé quelques jours après le RDV. +

+
+
+<% else %> +
+
+

+ Pourquoi souhaitez vous supprimer votre compte ? +

+ <% token = user.signed_id(purpose: "users.destroy", expires_in: 1.hour) %> -

- - 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. - -

- - <%= simple_form_for "", url: profile_path(authentication_token: token), method: :delete do |f| %> -
- <%= 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) ?"} %> -
- <% end %> +

+ + 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. + +

+ <%= simple_form_for "", url: profile_path(authentication_token: token), method: :delete do |f| %> +
+ <%= 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) ?"} %> +
+ <% end %> +
-
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 96e187c45..1df3f8c8b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/config/routes/redirects.rb b/config/routes/redirects.rb index 782333b8b..4bc124c56 100644 --- a/config/routes/redirects.rb +++ b/config/routes/redirects.rb @@ -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 diff --git a/db/migrate/20210530071920_add_last_inactive_user_email_sent_at.rb b/db/migrate/20210530071920_add_last_inactive_user_email_sent_at.rb new file mode 100644 index 000000000..9e09688e5 --- /dev/null +++ b/db/migrate/20210530071920_add_last_inactive_user_email_sent_at.rb @@ -0,0 +1,5 @@ +class AddLastInactiveUserEmailSentAt < ActiveRecord::Migration[6.1] + def change + add_column :users, :last_inactive_user_email_sent_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f7ed7941..6187335c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -308,6 +308,7 @@ t.integer "alerting_intensity" t.integer "matches_count", default: 0 t.datetime "match_confirmed_at" + t.datetime "last_inactive_user_email_sent_at" t.index ["alerting_intensity"], name: "index_users_on_alerting_intensity" t.index ["alerting_optin_at"], name: "index_users_on_alerting_optin_at" t.index ["anonymized_at"], name: "index_users_on_anonymized_at" diff --git a/lib/tasks/emails.rake b/lib/tasks/emails.rake new file mode 100644 index 000000000..a95b75b01 --- /dev/null +++ b/lib/tasks/emails.rake @@ -0,0 +1,44 @@ +namespace :emails do + desc "Send inactive users email to ask them to delete their profile" + task inactive_users: :environment do |_t, args| + tz = ActiveSupport::TimeZone["Europe/Paris"] + + min_unanswered_matches = ENV["MIN_REFUSED_MATCHES"]&.to_i || 2 + + min_age_range = ENV["MIN_AGE_RANGE"]&.to_i || 0 + max_age_range = ENV["MAX_AGE_RANGE"]&.to_i || 200 + age_range = min_age_range..max_age_range + + min_signed_up_date_range = ENV["MIN_SIGN_UP_DATE_RANGE"].try { |dt| tz.parse(dt) } || 200.years.ago + max_signed_up_date_range = ENV["MAX_SIGN_UP_DATE_RANGE"].try { |dt| tz.parse(dt) } || Date.current.end_of_day + signed_up_date_range = min_signed_up_date_range..max_signed_up_date_range + + puts "The following filter will apply:" + puts "- Refused Matches >= #{min_unanswered_matches}" + puts "- #{min_age_range} <= Age <= #{max_age_range}" + puts "- #{min_signed_up_date_range} <= Sign-up Date <= #{max_signed_up_date_range}" + puts "" + puts "Number of emails to send: ##{SendInactiveUserEmailsJob.inactive_user_ids(min_unanswered_matches, age_range, signed_up_date_range).size}" + puts "" + + if ENV["NO_HELP"].nil? + puts "To customize this, use environment variables:" + puts "- MIN_REFUSED_MATCHES: int default 2" + puts "- MIN_AGE_RANGE: integer; default: 0" + puts "- MAX_AGE_RANGE: integer; default: 200" + puts "- MIN_SIGN_UP_DATE_RANGE: datetime; default: 200 years ago; format: YYYY-MM-DD hh:mm:ss" + puts "- MAX_SIGN_UP_DATE_RANGE: datetime; default: end of today; format: YYYY-MM-DD hh:mm:ss" + puts "" + end + + if ENV["NO_PROMPT"].nil? + puts "Is that okay? Enter to continue / Ctrl + C to abort" + puts ">" + gets + end + + SendInactiveUserEmailsJob.perform_later(min_unanswered_matches, age_range, signed_up_date_range) + + puts "Emails are enqueued!" + end +end diff --git a/lib/tasks/populate.rake b/lib/tasks/populate.rake index 414edd042..08d1714f5 100644 --- a/lib/tasks/populate.rake +++ b/lib/tasks/populate.rake @@ -27,6 +27,68 @@ namespace :populate do puts "Done." end + desc "Generate lot of data: this was designed at testing inactive user query" + task generate_many_users_and_matches: :environment do + params = { + users_count: 2_000_000, + min_users_id: 5_000_000, + matches_count: 500_000, + min_matches_id: 5_000_000, + matches_per_user: 30, + matches_cycle: 500_000 / 30 + } + + query = ActiveRecord::Base.send(:sanitize_sql_array, [<<~SQL, params]) + begin; + delete from matches where id > :min_matches_id; + delete from users where id > :min_users_id; + commit; + SQL + User.connection.execute(query) + + # + # Users + # + query = ActiveRecord::Base.send(:sanitize_sql_array, [<<~SQL, params]) + insert into users(id, birthdate, created_at, anonymized_at, updated_at) + select + :min_users_id + c.i as id + , now() - (mod(c.i, 80) + 17) * interval '1 year' as birthdate + , now() - mod(c.i, 300) * interval '1 minute' as created_at + , NULL as anonymized_at + , now() as updated_at + from generate_series(1, :users_count) c(i) + SQL + User.connection.execute(query) + + # + # Matches + # + query = ActiveRecord::Base.send(:sanitize_sql_array, [<<~SQL, params]) + insert into matches(id, user_id, confirmed_at, refused_at, expires_at, created_at, updated_at) + select + :min_matches_id + c.i as id + , :min_users_id + mod(c.i, :matches_cycle) + 1as user_id + , case + when mod(c.i, 1000) < 1 then -- 1/1000 confirmed + now() - mod(c.i, 100) * interval '10 minutes' + else + null + end as confirmed_at + , case + when mod(c.i, 100) >= 10 then -- 90% refused + now() - mod(c.i, 100) * interval '10 minutes' + else + null + end as refused_at + , now() - (mod(c.i, 100) - 50) * interval '10 minutes' as expires_at + , now() as created_at + , now() as updated_at + from generate_series(1, :matches_count) c(i) + SQL + User.connection.execute(query) + end + desc "Create a new validated Vaccination center with a new partner" task create_vaccination_center_with_partner_and_validate: :environment do partner = Partner.new( diff --git a/spec/factories/match_factory.rb b/spec/factories/match_factory.rb index 3866598bd..9d4c4afb0 100644 --- a/spec/factories/match_factory.rb +++ b/spec/factories/match_factory.rb @@ -8,8 +8,20 @@ confirmed_at { Time.zone.now } end + trait :refused do + refused_at { Time.zone.now } + end + trait :available do expires_at { 1.hours.from_now } end + + trait :pending do + expires_at { 1.hours.from_now } + end + + trait :expired do + expires_at { 1.hour.ago } + end end end diff --git a/spec/factories/user_factory.rb b/spec/factories/user_factory.rb index f7969ad09..661fba398 100644 --- a/spec/factories/user_factory.rb +++ b/spec/factories/user_factory.rb @@ -1,5 +1,12 @@ FactoryBot.define do factory :user do + transient do + confirmed_matches_count { 0 } + refused_matches_count { 0 } + unanswered_matches_count { 0 } + pending_matches_count { 0 } + end + address { generate(:french_address) } lat { 48.1 } lon { 2.3 } @@ -43,5 +50,12 @@ user.add_role(:support_member) end end + + after(:create) do |user, evaluator| + create_list(:match, evaluator.confirmed_matches_count, :confirmed, user: user) + create_list(:match, evaluator.refused_matches_count, :refused, user: user) + create_list(:match, evaluator.unanswered_matches_count, :expired, user: user) + create_list(:match, evaluator.pending_matches_count, :pending, user: user) + end end end diff --git a/spec/jobs/send_inactive_user_emails_job_spec.rb b/spec/jobs/send_inactive_user_emails_job_spec.rb new file mode 100644 index 000000000..dead81719 --- /dev/null +++ b/spec/jobs/send_inactive_user_emails_job_spec.rb @@ -0,0 +1,95 @@ +require "rails_helper" + +RSpec.describe SendInactiveUserEmailsJob do + describe ".inactive_user_ids" do + subject(:inactive_user_ids) { described_class.inactive_user_ids(min_unanswered_matches, age_range, signed_up_date_range) } + + let(:min_unanswered_matches) { described_class::DEFAULT_MIN_UNANSWERED_MATCHES } + let(:age_range) { described_class::DEFAULT_AGE_RANGE } + let(:signed_up_date_range) { described_class::DEFAULT_SIGNED_UP_RANGE } + + let!(:matching_user) do + create(:user, { + birthdate: Date.new(1990, 1, 1), + created_at: 20.days.ago, + confirmed_matches_count: confirmed_matches_count, + refused_matches_count: refused_matches_count, + pending_matches_count: pending_matches_count, + unanswered_matches_count: unanswered_matches_count + }) + end + + let(:confirmed_matches_count) { 0 } + let(:refused_matches_count) { 0 } + let(:unanswered_matches_count) { 2 } + let(:pending_matches_count) { 0 } + + it "returns the ids of users having 2 or more unanswered matches" do + expect(inactive_user_ids).to include(matching_user.id) + end + + context "when a user have at least a pending match" do + let(:pending_matches_count) { 1 } + + it "excludes users having pending matches" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + + context "when a user have at least a confirmed match" do + let(:confirmed_matches_count) { 1 } + + it "excludes users having confirmed matches" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + + context "when a user have at least a refused match" do + let(:refused_matches_count) { 1 } + + it "excludes users having refused matches, they are considered active users" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + + context "when a user refused too few unanswered matches" do + let(:min_unanswered_matches) { 3 } + + it "excludes users having unanswered too few matches to be considered as inactive" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + + context "when a user is too young" do + let(:age_range) { 55.. } + + it "excludes too young users" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + + context "when a user is too old" do + let(:age_range) { ..20 } + + it "excludes too old users" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + + context "when a user was created too recently" do + let(:signed_up_date_range) { ..1.month.ago } + + it "excludes users creted too recently" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + + context "when a user was created too far in the past" do + let(:signed_up_date_range) { 10.days.ago.. } + + it "excludes users created too far in the past" do + expect(inactive_user_ids).not_to include(matching_user.id) + end + end + end +end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb new file mode 100644 index 000000000..025423340 --- /dev/null +++ b/spec/mailers/user_mailer_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +RSpec.describe UserMailer, type: :mailer do + describe "#send_inactive_user_unsubscription_request" do + let(:mail) { described_class.with(user_id: user.id).send_inactive_user_unsubscription_request } + let(:user) { create(:user) } + + it "renders the headers" do + expect(mail.subject).to eq("Attendez-vous toujours une dose de vaccin ?") + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(["inscription@covidliste.com"]) + end + + it "updates the last_inactive_user_email_sent_at" do + expect { mail.deliver_now } + .to change { user.reload.last_inactive_user_email_sent_at } + .from(nil) + .to(be_within(1.minute).of(Time.zone.now)) + end + + it "includes a signed link to the confirm_destroy_profile URL" do + match_data = mail.body.encoded.match(%r{/users/profile/confirm_destroy\?authentication_token=([^"]+)"}) + token = CGI.unescape(match_data.captures.first) + expect(User.find_signed(token, purpose: "users.destroy")).to eq(user) + end + end +end diff --git a/spec/requests/redirections_spec.rb b/spec/requests/redirections_spec.rb new file mode 100644 index 000000000..6899f19fe --- /dev/null +++ b/spec/requests/redirections_spec.rb @@ -0,0 +1,53 @@ +require "rails_helper" + +RSpec.describe "Redirects", type: :request do + describe "From a matches email to delete the account" do + it "redirects to the confirm_destroy_profile_path" do + match = create(:match) + + get legacy_edit_matches_users_path(match_confirmation_token: match.match_confirmation_token) + expect(response).to redirect_to(%r{#{confirm_destroy_profile_path}}) + + auth_token = Rack::Utils.parse_query(URI.parse(response.location).query).fetch("authentication_token") + expect(User.find_signed(auth_token, purpose: "users.destroy")).to eq(match.user) + end + + it "redirects to the root_path when the token is wrong" do + match = create(:match) + get legacy_edit_matches_users_path(match_confirmation_token: match.match_confirmation_token + "foo") + expect(response).to redirect_to(root_path) + end + + it "redirects to the root_path when the user is anynomized" do + match = create(:match) + match.user.anonymize! + get legacy_edit_matches_users_path(match_confirmation_token: match.match_confirmation_token) + expect(response).to redirect_to(root_path) + end + end + + describe "From a slot alert email to delete the account" do + it "redirects to the confirm_destroy_profile_path" do + slot_alert = create(:slot_alert) + + get legacy_edit_slot_alerts_users_path(token: slot_alert.token) + expect(response).to redirect_to(%r{#{confirm_destroy_profile_path}}) + + auth_token = Rack::Utils.parse_query(URI.parse(response.location).query).fetch("authentication_token") + expect(User.find_signed(auth_token, purpose: "users.destroy")).to eq(slot_alert.user) + end + + it "redirects to the root_path when the token is wrong" do + slot_alert = create(:slot_alert) + get legacy_edit_slot_alerts_users_path(token: slot_alert.token + "foo") + expect(response).to redirect_to(root_path) + end + + it "redirects to the root_path when the user is anynomized" do + slot_alert = create(:slot_alert) + slot_alert.user.anonymize! + get legacy_edit_slot_alerts_users_path(token: slot_alert.token) + expect(response).to redirect_to(root_path) + end + end +end diff --git a/spec/system/matches/destroy_user_account_spec.rb b/spec/system/matches/destroy_user_account_spec.rb deleted file mode 100644 index 9de64475e..000000000 --- a/spec/system/matches/destroy_user_account_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -require "rails_helper" - -RSpec.describe "Destroy user account from match email", type: :system do - let!(:user) { create(:user) } - let!(:second_user) { create(:user) } - let!(:partner) { create(:partner) } - let!(:center) { create(:vaccination_center, :from_paris) } - let!(:campaign) { create(:campaign, vaccination_center: center) } - let!(:match_confirmation_token) { "abcd" } - let!(:match) { create(:match, user: user, vaccination_center: center, match_confirmation_token: match_confirmation_token, expires_at: 1.hour.since, campaign: campaign) } - - subject { visit match_path(match_confirmation_token, source: "sms") } - - context "with a valid match" do - scenario "the user can destroy their account" do - visit edit_matches_users_path(match_confirmation_token: match_confirmation_token) - expect(page).to have_current_path(edit_matches_users_path, ignore_query: true) - expect(page).to have_selector(:id, dom_id(user, :delete)) - choose "reason_covidliste" - expect do - accept_confirm_modal do - click_on dom_id(user, :delete) - end - end.to change { User.active.count }.by(-1) - .and change { Match.pending.count }.by(-1) - end - - scenario "the user is not logged in" do - visit edit_matches_users_path(match_confirmation_token: match_confirmation_token) - expect(page).to have_selector(:id, dom_id(user, :delete)) - visit profile_path - expect(page).to have_current_path(new_user_session_path) - end - end - - context "with a confirmed match" do - before do - visit match_path(match_confirmation_token, source: "sms") - fill_in :user_firstname, with: user.firstname - fill_in :user_lastname, with: user.lastname - check :confirm_age - check :confirm_name - check :confirm_distance - check :confirm_hours - click_on("Je confirme le RDV") - end - scenario "the user cannot destroy their account" do - visit edit_matches_users_path(match_confirmation_token: match_confirmation_token) - expect(page).to have_current_path(edit_matches_users_path, ignore_query: true) - expect(page).to_not have_selector(:id, dom_id(user, :delete)) - expect(page).to have_text("Vous ne pouvez pas supprimer vos informations") - end - end -end