From e512634bbbb446252872e1fc5be369897558e777 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Thu, 30 Apr 2026 17:36:21 +0200 Subject: [PATCH 1/2] Notify users that they haven't login in a while and deactivate accounts --- app/mailers/user_mailer.rb | 11 ++++ ...count_deactivated_for_inactivity.html.mjml | 16 ++++++ ...ccount_deactivated_for_inactivity.text.erb | 8 +++ .../inactive_account_warning.html.mjml | 20 ++++++++ .../inactive_account_warning.text.erb | 10 ++++ config/locales/mailers.en.yml | 8 +++ config/locales/mailers.es.yml | 8 +++ config/locales/mailers.fr.yml | 8 +++ config/schedule.rb | 1 + lib/tasks/scheduler.rake | 40 +++++++++++++++ .../tasks/deactivate_inactive_users_spec.rb | 50 +++++++++++++++++++ spec/mailers/user_mailer_spec.rb | 27 ++++++++++ 12 files changed, 207 insertions(+) create mode 100644 app/views/mailers/user_mailer/account_deactivated_for_inactivity.html.mjml create mode 100644 app/views/mailers/user_mailer/account_deactivated_for_inactivity.text.erb create mode 100644 app/views/mailers/user_mailer/inactive_account_warning.html.mjml create mode 100644 app/views/mailers/user_mailer/inactive_account_warning.text.erb create mode 100644 spec/lib/tasks/deactivate_inactive_users_spec.rb diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 109828c7b..449fbe0bd 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -21,6 +21,17 @@ def user_acceptance(user) mail(to: user.email, subject: I18n.t("user_mailer.user_acceptance.subject")) end + def inactive_account_warning(user, disable_date) + @user = user + @disable_date = disable_date + mail(to: user.email, subject: I18n.t("user_mailer.inactive_account_warning.subject", disable_date: I18n.l(disable_date))) + end + + def account_deactivated_for_inactivity(user) + @user = user + mail(to: user.email, subject: I18n.t("user_mailer.account_deactivated_for_inactivity.subject")) + end + private def generate_reset_url(user) diff --git a/app/views/mailers/user_mailer/account_deactivated_for_inactivity.html.mjml b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.html.mjml new file mode 100644 index 000000000..dd465ed76 --- /dev/null +++ b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.html.mjml @@ -0,0 +1,16 @@ +

+ <%= t("mailers.greeting", name: @user.display_name, fallback: true) %> +

+ +

+ <%= t(".message") %> +

+ +

+ <%= t(".contact") %> +

+ +

+ <%= t("mailers.salutation") %>
+ <%= t("mailers.signature") %> +

diff --git a/app/views/mailers/user_mailer/account_deactivated_for_inactivity.text.erb b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.text.erb new file mode 100644 index 000000000..79de44ffd --- /dev/null +++ b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.text.erb @@ -0,0 +1,8 @@ +<%= t("mailers.greeting", name: @user.display_name, fallback: true) %> + +<%= t(".message") %> + +<%= t(".contact") %> + +<%= t("mailers.salutation") %> +<%= t("mailers.signature") %> diff --git a/app/views/mailers/user_mailer/inactive_account_warning.html.mjml b/app/views/mailers/user_mailer/inactive_account_warning.html.mjml new file mode 100644 index 000000000..702874d8f --- /dev/null +++ b/app/views/mailers/user_mailer/inactive_account_warning.html.mjml @@ -0,0 +1,20 @@ +

+ <%= t("mailers.greeting", name: @user.display_name, fallback: true) %> +

+ +

+ <%= t(".message", disable_date: l(@disable_date)) %> +

+ +

+ <%= t(".cta", link: link_to(ENV["FRONTEND_URL"], ENV["FRONTEND_URL"], target: "_blank")).html_safe %> +

+ +

+ <%= t("mailers.contact_us") %> +

+ +

+ <%= t("mailers.salutation") %>
+ <%= t("mailers.signature") %> +

diff --git a/app/views/mailers/user_mailer/inactive_account_warning.text.erb b/app/views/mailers/user_mailer/inactive_account_warning.text.erb new file mode 100644 index 000000000..95058db85 --- /dev/null +++ b/app/views/mailers/user_mailer/inactive_account_warning.text.erb @@ -0,0 +1,10 @@ +<%= t("mailers.greeting", name: @user.display_name, fallback: true) %> + +<%= t(".message", disable_date: l(@disable_date)) %> + +<%= t(".cta", link: ENV["FRONTEND_URL"]) %> + +<%= t("mailers.contact_us") %> + +<%= t("mailers.salutation") %> +<%= t("mailers.signature") %> diff --git a/config/locales/mailers.en.yml b/config/locales/mailers.en.yml index 814352bfe..59fa04cff 100644 --- a/config/locales/mailers.en.yml +++ b/config/locales/mailers.en.yml @@ -22,6 +22,14 @@ en: message: Please click on the link to reset your password to connect to the Open Timber Portal link_expiration: The above link will expire in 6 hours. If you made multiple requests to reset your password, please make sure to click on the link in the most recent e-mail. outro: If you didn't request this password reset, please ignore this email. Your password will not change until you access the link above and create a new password. + inactive_account_warning: + subject: "Open Timber Portal account inactivity notice - disable date %{disable_date}" + message: "Your account has not been used for 18 months. Please log in soon to keep it active. If there is no login activity, your account will be disabled on %{disable_date}." + cta: "Log in here: %{link}" + account_deactivated_for_inactivity: + subject: "Your Open Timber Portal account has been deactivated" + message: "Because your account has not been used for 2 years, it is now deactivated." + contact: "If you need help or think this is an error, please contact the OTP team." observation_mailer: observation_created: subject: Observation %{id} was successfully created diff --git a/config/locales/mailers.es.yml b/config/locales/mailers.es.yml index 7cd1a25e5..5af1fec6d 100644 --- a/config/locales/mailers.es.yml +++ b/config/locales/mailers.es.yml @@ -22,6 +22,14 @@ es: message: "Haga clic en el enlace para restablecer su contraseña y conectarse al Open Timber Portal" link_expiration: "El enlace anterior expirará en 6 horas. Si realizó múltiples solicitudes para restablecer su contraseña, asegúrese de hacer clic en el enlace del correo electrónico más reciente." outro: "Si no solicitó este restablecimiento de contraseña, ignore este correo electrónico. Su contraseña no cambiará hasta que acceda al enlace anterior y cree una nueva contraseña." + inactive_account_warning: + subject: "Aviso de inactividad de su cuenta de Open Timber Portal - fecha de desactivacion %{disable_date}" + message: "Su cuenta no se ha utilizado durante 18 meses. Inicie sesion pronto para mantenerla activa. Si no hay actividad de inicio de sesion, su cuenta se desactivara el %{disable_date}." + cta: "Inicie sesion aqui: %{link}" + account_deactivated_for_inactivity: + subject: "Su cuenta de Open Timber Portal ha sido desactivada" + message: "Debido a que su cuenta no se ha utilizado en 2 anos, ahora esta desactivada." + contact: "Si necesita ayuda o cree que esto es un error, contacte al equipo de OTP." observation_mailer: observation_created: subject: "La observación %{id} se creó exitosamente" diff --git a/config/locales/mailers.fr.yml b/config/locales/mailers.fr.yml index e7ada8003..70fc9c8c8 100644 --- a/config/locales/mailers.fr.yml +++ b/config/locales/mailers.fr.yml @@ -22,6 +22,14 @@ fr: message: Veuillez cliquer sur le lien afin de réinitialiser votre mot de passe pour vous connecter à l’Open Timber Portal link_expiration: Le lien ci-dessus expire après 6 heures. Si vous avez fait plusieurs demandes de réinitialisation, veuillez vous assurer de cliquer sur le lien qui figure dans le mail le plus récent. outro: Si vous n’avez pas fait cette demande de réinitialisation, veuillez ignorer cet e-mail. Votre mot de passe ne changera pas tant que vous n'aurez pas cliqué sur le lien ci-dessus et créé un nouveau mot de passe. + inactive_account_warning: + subject: "Avis d'inactivite du compte Open Timber Portal - date de desactivation %{disable_date}" + message: "Votre compte n'a pas ete utilise depuis 18 mois. Connectez-vous bientot pour le garder actif. Sans nouvelle connexion, votre compte sera desactive le %{disable_date}." + cta: "Connectez-vous ici: %{link}" + account_deactivated_for_inactivity: + subject: "Votre compte Open Timber Portal a ete desactive" + message: "Parce que votre compte n'a pas ete utilise depuis 2 ans, il est maintenant desactive." + contact: "Si vous avez besoin d'aide ou pensez qu'il s'agit d'une erreur, contactez l'equipe OTP." observation_mailer: observation_created: subject: L’observation %{id} a été créée diff --git a/config/schedule.rb b/config/schedule.rb index a48883924..6ec8624d5 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -16,6 +16,7 @@ rake "scheduler:set_active_fmu_operator", check_in: "update-fmus" rake "scheduler:generate_documents_stats", check_in: "generate-documents-stats" rake "scheduler:generate_observation_reports_stats", check_in: "generate-observation-reports-stats" + rake "scheduler:deactivate_inactive_users", check_in: "deactivate-inactive-users" end every 1.hour do diff --git a/lib/tasks/scheduler.rake b/lib/tasks/scheduler.rake index e7dd5e5c0..dca7fa8a4 100644 --- a/lib/tasks/scheduler.rake +++ b/lib/tasks/scheduler.rake @@ -90,4 +90,44 @@ namespace :scheduler do Rails.logger.info "Sent quarterly newsletters to operators. It took #{time} ms." Rails.logger.info "::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::" end + + desc "Warn and deactivate inactive users" + task deactivate_inactive_users: :environment do + Rails.logger.info "::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::" + Rails.logger.info "Going to process inactive users at: #{Time.zone.now.strftime("%d/%m/%Y %H:%M")}" + + warning_start = 18.months.ago.beginning_of_day + warning_end = 18.months.ago.end_of_day + deactivation_threshold = 2.years.ago.end_of_day + + warned_users = 0 + deactivated_users = 0 + + time = Benchmark.ms do + warning_users = User.where(is_active: true) + .where("COALESCE(last_sign_in_at, created_at) BETWEEN ? AND ?", warning_start, warning_end) + + warning_users.find_each do |user| + disable_date = (user.last_sign_in_at || user.created_at).to_date + 2.years + I18n.with_locale(user.locale.presence || I18n.default_locale) do + UserMailer.inactive_account_warning(user, disable_date).deliver_now + end + warned_users += 1 + end + + users_to_deactivate = User.where(is_active: true) + .where("COALESCE(last_sign_in_at, created_at) <= ?", deactivation_threshold) + + users_to_deactivate.find_each do |user| + user.update!(is_active: false, deactivated_at: Time.zone.now) + I18n.with_locale(user.locale.presence || I18n.default_locale) do + UserMailer.account_deactivated_for_inactivity(user).deliver_now + end + deactivated_users += 1 + end + end + + Rails.logger.info "Processed inactive users. Warned=#{warned_users}, Deactivated=#{deactivated_users}. It took #{time} ms." + Rails.logger.info "::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::" + end end diff --git a/spec/lib/tasks/deactivate_inactive_users_spec.rb b/spec/lib/tasks/deactivate_inactive_users_spec.rb new file mode 100644 index 000000000..32d9f6dfa --- /dev/null +++ b/spec/lib/tasks/deactivate_inactive_users_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +Rails.application.load_tasks if Rake::Task.tasks.empty? + +describe "deactivate_inactive_users" do + include ActiveSupport::Testing::TimeHelpers + + after(:each) do + Rake::Task["scheduler:deactivate_inactive_users"].reenable + ActionMailer::Base.deliveries.clear + end + + subject(:run_task) { Rake::Task["scheduler:deactivate_inactive_users"].invoke } + + around do |example| + travel_to(Time.zone.parse("2026-04-30 10:00:00")) { example.run } + end + + it "sends one warning email at 18 months inactivity" do + warned_user = create(:operator_user, last_sign_in_at: 18.months.ago) + create(:operator_user, last_sign_in_at: 19.months.ago) + + expect { run_task }.to change { ActionMailer::Base.deliveries.count }.by(1) + + mail = ActionMailer::Base.deliveries.first + expect(mail.to).to eq([warned_user.email]) + expect(mail.body.encoded).to include(I18n.l(warned_user.last_sign_in_at.to_date + 2.years)) + end + + it "deactivates users inactive for 2 years or more" do + old_user = create(:operator_user, is_active: true, last_sign_in_at: 2.years.ago - 1.day, deactivated_at: nil) + active_user = create(:operator_user, is_active: true, last_sign_in_at: 1.year.ago) + + expect { run_task } + .to change { old_user.reload.is_active }.from(true).to(false) + .and change { active_user.reload.is_active }.by(0) + + expect(old_user.reload.deactivated_at).to be_present + expect(ActionMailer::Base.deliveries.last.to).to eq([old_user.email]) + expect(ActionMailer::Base.deliveries.last.subject).to eq(I18n.t("user_mailer.account_deactivated_for_inactivity.subject")) + end + + it "uses created_at when user has never logged in" do + never_logged_user = create(:operator_user, last_sign_in_at: nil, created_at: 18.months.ago) + + expect { run_task }.to change { ActionMailer::Base.deliveries.count }.by(1) + + expect(ActionMailer::Base.deliveries.first.to).to eq([never_logged_user.email]) + end +end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index fc61fc48c..1d4fadd14 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -30,4 +30,31 @@ expect(mail.body.encoded).to match(I18n.t("user_mailer.forgotten_password.message")) end end + + describe "inactive_account_warning" do + let(:disable_date) { Date.new(2028, 1, 1) } + let(:mail) { UserMailer.inactive_account_warning(user, disable_date) } + + it "renders the headers" do + expect(mail.subject).to eq(I18n.t("user_mailer.inactive_account_warning.subject", disable_date: I18n.l(disable_date))) + expect(mail.to).to eq([user.email]) + end + + it "renders the body" do + expect(mail.body.encoded).to match(I18n.t("user_mailer.inactive_account_warning.message", disable_date: I18n.l(disable_date))) + end + end + + describe "account_deactivated_for_inactivity" do + let(:mail) { UserMailer.account_deactivated_for_inactivity(user) } + + it "renders the headers" do + expect(mail.subject).to eq(I18n.t("user_mailer.account_deactivated_for_inactivity.subject")) + expect(mail.to).to eq([user.email]) + end + + it "renders the body" do + expect(mail.body.encoded).to match(I18n.t("user_mailer.account_deactivated_for_inactivity.message")) + end + end end From 9dbff88c928998ec4b1c82041d60bb62929621e3 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Fri, 1 May 2026 13:45:29 +0200 Subject: [PATCH 2/2] Change the 'user soon deactivated' email to be sent only once per month --- ...ast_inactivity_warning_sent_at_to_users.rb | 5 +++ db/schema.rb | 3 +- lib/tasks/scheduler.rake | 9 ++++-- .../tasks/deactivate_inactive_users_spec.rb | 32 +++++++++++++++---- 4 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 db/migrate/20260430153500_add_last_inactivity_warning_sent_at_to_users.rb diff --git a/db/migrate/20260430153500_add_last_inactivity_warning_sent_at_to_users.rb b/db/migrate/20260430153500_add_last_inactivity_warning_sent_at_to_users.rb new file mode 100644 index 000000000..d3e94fefe --- /dev/null +++ b/db/migrate/20260430153500_add_last_inactivity_warning_sent_at_to_users.rb @@ -0,0 +1,5 @@ +class AddLastInactivityWarningSentAtToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :last_inactivity_warning_sent_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 7d6974c30..d714b857e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_02_23_113655) do +ActiveRecord::Schema[7.2].define(version: 2026_04_30_153500) do create_schema "tiger" create_schema "tiger_data" create_schema "topology" @@ -1122,6 +1122,7 @@ t.string "last_name" t.boolean "organization_account", default: false, null: false t.boolean "should_change_password", default: false, null: false + t.datetime "last_inactivity_warning_sent_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["name"], name: "index_users_on_name" t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/lib/tasks/scheduler.rake b/lib/tasks/scheduler.rake index dca7fa8a4..e16683370 100644 --- a/lib/tasks/scheduler.rake +++ b/lib/tasks/scheduler.rake @@ -96,8 +96,8 @@ namespace :scheduler do Rails.logger.info "::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::" Rails.logger.info "Going to process inactive users at: #{Time.zone.now.strftime("%d/%m/%Y %H:%M")}" - warning_start = 18.months.ago.beginning_of_day - warning_end = 18.months.ago.end_of_day + warning_threshold = 18.months.ago.end_of_day + warning_cooldown = 1.month.ago.end_of_day deactivation_threshold = 2.years.ago.end_of_day warned_users = 0 @@ -105,13 +105,16 @@ namespace :scheduler do time = Benchmark.ms do warning_users = User.where(is_active: true) - .where("COALESCE(last_sign_in_at, created_at) BETWEEN ? AND ?", warning_start, warning_end) + .where("COALESCE(last_sign_in_at, created_at) <= ?", warning_threshold) + .where("COALESCE(last_sign_in_at, created_at) > ?", deactivation_threshold) + .where("last_inactivity_warning_sent_at IS NULL OR last_inactivity_warning_sent_at <= ?", warning_cooldown) warning_users.find_each do |user| disable_date = (user.last_sign_in_at || user.created_at).to_date + 2.years I18n.with_locale(user.locale.presence || I18n.default_locale) do UserMailer.inactive_account_warning(user, disable_date).deliver_now end + user.update!(last_inactivity_warning_sent_at: Time.zone.now) warned_users += 1 end diff --git a/spec/lib/tasks/deactivate_inactive_users_spec.rb b/spec/lib/tasks/deactivate_inactive_users_spec.rb index 32d9f6dfa..30f25a7a8 100644 --- a/spec/lib/tasks/deactivate_inactive_users_spec.rb +++ b/spec/lib/tasks/deactivate_inactive_users_spec.rb @@ -16,9 +16,9 @@ travel_to(Time.zone.parse("2026-04-30 10:00:00")) { example.run } end - it "sends one warning email at 18 months inactivity" do - warned_user = create(:operator_user, last_sign_in_at: 18.months.ago) - create(:operator_user, last_sign_in_at: 19.months.ago) + it "sends warning emails to users inactive for at least 18 months" do + warned_user = create(:operator_user, last_sign_in_at: 18.months.ago - 1.day) + create(:operator_user, last_sign_in_at: 17.months.ago) expect { run_task }.to change { ActionMailer::Base.deliveries.count }.by(1) @@ -27,13 +27,33 @@ expect(mail.body.encoded).to include(I18n.l(warned_user.last_sign_in_at.to_date + 2.years)) end + it "does not send warning email again within one month" do + create( + :operator_user, + last_sign_in_at: 19.months.ago, + last_inactivity_warning_sent_at: 20.days.ago + ) + + expect { run_task }.not_to change { ActionMailer::Base.deliveries.count } + end + + it "sends warning email again when one month has passed since last warning" do + warned_user = create( + :operator_user, + last_sign_in_at: 19.months.ago, + last_inactivity_warning_sent_at: 1.month.ago - 1.day + ) + + expect { run_task }.to change { ActionMailer::Base.deliveries.count }.by(1) + expect(warned_user.reload.last_inactivity_warning_sent_at).to be_present + end + it "deactivates users inactive for 2 years or more" do old_user = create(:operator_user, is_active: true, last_sign_in_at: 2.years.ago - 1.day, deactivated_at: nil) active_user = create(:operator_user, is_active: true, last_sign_in_at: 1.year.ago) - expect { run_task } - .to change { old_user.reload.is_active }.from(true).to(false) - .and change { active_user.reload.is_active }.by(0) + expect { run_task }.to change { old_user.reload.is_active }.from(true).to(false) + expect(active_user.reload.is_active).to eq(true) expect(old_user.reload.deactivated_at).to be_present expect(ActionMailer::Base.deliveries.last.to).to eq([old_user.email])