diff --git a/app/jobs/audits_backfill_job.rb b/app/jobs/audits_backfill_job.rb new file mode 100644 index 00000000000..0c44bf0a9e2 --- /dev/null +++ b/app/jobs/audits_backfill_job.rb @@ -0,0 +1,42 @@ +# Job to fill the user_past_usernames and user_past_emails tables using information from the audits table +class AuditsBackfillJob < RedisSetJob + queue_as :utilities + + def self.base_key + "audits_backfill" + end + + def self.job_size + 1000 + end + + def self.batch_size + 100 + end + + def perform_on_batch(user_ids) + # scans by users so audits can be taken from users and limited appropriately + User.where(id: user_ids).find_each do |user| + # gets past data audits within limits + past_data = user.audits.order(id: :desc).where(action: "update").limit(ArchiveConfig.USER_HISTORIC_VALUES_LIMIT).filter_map do |audit| + if audit.audited_changes.key?("login") && audit.audited_changes["login"].first.present? + { username: audit.audited_changes["login"].first, changed_at: audit.created_at } + elsif audit.audited_changes.key?("email") && audit.audited_changes["email"].first.present? + { email: audit.audited_changes["email"].first, changed_at: audit.created_at } + end + end + past_data = past_data.uniq { |audit| audit[:username] } + .reject { |audit| audit[:username] == user.login } + past_data = past_data.uniq { |audit| audit[:email] } + .reject { |audit| audit[:email] == user.email } + # adds each type of past data to the appropriate database table + past_data.each do |audit| + window = audit[:changed_at] - 60.seconds..audit[:changed_at] + 60.seconds + UserPastUsername.create!(user_id: user.id, username: audit[:username], changed_at: audit[:changed_at]) if audit.key?(:username) && !UserPastUsername.exists?(user_id: user.id, username: audit[:username], changed_at: window) + next unless audit.key?(:email) + + UserPastEmail.create!(user_id: user.id, email_address: audit[:email], changed_at: audit[:changed_at]) unless UserPastEmail.exists?(user_id: user.id, email_address: audit[:email], changed_at: window) + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index e822a5d4eb9..5db26aab620 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,6 +89,10 @@ class User < ApplicationRecord has_many :skins, foreign_key: "author_id", dependent: :nullify has_many :work_skins, foreign_key: "author_id", dependent: :nullify + # the user's past credentials + has_many :user_past_emails, dependent: :destroy + has_many :user_past_usernames, dependent: :destroy + before_update :add_renamed_at, if: :will_save_change_to_login? after_update :update_pseud_name after_update :send_wrangler_username_change_notification, if: :is_tag_wrangler? @@ -96,7 +100,6 @@ class User < ApplicationRecord after_update :log_email_change, if: :saved_change_to_email? after_update :expire_caches before_destroy :remove_user_from_kudos - # Extra callback to make sure readings are deleted in an order consistent # with the ReadingsJob. # @@ -197,6 +200,14 @@ def unread_inbox_comments_count unread_inbox_comments.with_bad_comments_removed.count end + def past_emails + self.user_past_emails + end + + def past_usernames + self.user_past_usernames + end + scope :alphabetical, -> { order(:login) } scope :starting_with, ->(letter) { where("login like ?", "#{letter}%") } scope :valid, -> { where(banned: false, suspended: false) } @@ -584,6 +595,7 @@ def log_change_if_login_was_edited else "Old Username: #{login_before_last_save}; New Username: #{login}" end + user_past_usernames.create!(username: login_before_last_save, changed_at: self.updated_at) create_log_item(options) end @@ -600,6 +612,7 @@ def log_email_change admin_id: current_admin&.id } options[:note] = "Change made by #{current_admin&.login}" if current_admin + user_past_emails.create!(email_address: email_before_last_save, changed_at: self.updated_at) create_log_item(options) end diff --git a/app/models/user_past_email.rb b/app/models/user_past_email.rb new file mode 100644 index 00000000000..732bac8c7df --- /dev/null +++ b/app/models/user_past_email.rb @@ -0,0 +1,4 @@ +# Allows admins to track all past emails of a user +class UserPastEmail < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user_past_username.rb b/app/models/user_past_username.rb new file mode 100644 index 00000000000..59e9e673a08 --- /dev/null +++ b/app/models/user_past_username.rb @@ -0,0 +1,4 @@ +# Allows admins to track all past usernames of a user +class UserPastUsername < ApplicationRecord + belongs_to :user +end diff --git a/db/migrate/20260103083335_create_user_past_emails.rb b/db/migrate/20260103083335_create_user_past_emails.rb new file mode 100644 index 00000000000..50b6a761d97 --- /dev/null +++ b/db/migrate/20260103083335_create_user_past_emails.rb @@ -0,0 +1,12 @@ +class CreateUserPastEmails < ActiveRecord::Migration[7.2] + def change + create_table :user_past_emails do |t| + t.references :user, null: false, foreign_key: true, type: :integer + + t.string :email_address + t.datetime :changed_at + + t.timestamps + end + end +end diff --git a/db/migrate/20260103083447_create_user_past_usernames.rb b/db/migrate/20260103083447_create_user_past_usernames.rb new file mode 100644 index 00000000000..af18c549a98 --- /dev/null +++ b/db/migrate/20260103083447_create_user_past_usernames.rb @@ -0,0 +1,12 @@ +class CreateUserPastUsernames < ActiveRecord::Migration[7.2] + def change + create_table :user_past_usernames do |t| + t.references :user, null: false, foreign_key: true, type: :integer + + t.string :username + t.datetime :changed_at + + t.timestamps + end + end +end diff --git a/lib/tasks/after_tasks.rake b/lib/tasks/after_tasks.rake index 6582f19832a..e4210432854 100644 --- a/lib/tasks/after_tasks.rake +++ b/lib/tasks/after_tasks.rake @@ -629,5 +629,17 @@ namespace :After do end puts "Job complete." end + + desc "Run backfill for user_past_emails and user_past_usernames" + task(backfill: :environment) do + User.find_in_batches.with_index do |batch, index| + REDIS_GENERAL.sadd?("audits_backfill", batch.map(&:id)) + + batch_number = index + 1 + puts "Batch #{batch_number} complete." + end + AuditsBackfillJob.spawn_jobs + puts "Backfill started and running on resque in background" + end # This is the end that you have to put new tasks above. end diff --git a/spec/jobs/audits_backfill_job_spec.rb b/spec/jobs/audits_backfill_job_spec.rb new file mode 100644 index 00000000000..459abe12888 --- /dev/null +++ b/spec/jobs/audits_backfill_job_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe AuditsBackfillJob do + let(:existing_user) { create(:user) } + + context "when audits previously exist" do + before do + # Manually create audit record so user_past_usernames isn't updated + existing_user.audits.create!(action: "update", auditable: existing_user, user: existing_user, + auditable_id: existing_user.id, audited_changes: { "login" => ["old_login", existing_user.login] }) + existing_user.audits.create!(action: "update", auditable: existing_user, user: existing_user, + auditable_id: existing_user.id, audited_changes: { "email" => ["old@example.com", existing_user.email] }) + end + + it "creates backfilled records in user_past_usernames" do + user_ids = [existing_user.id] + AuditsBackfillJob.new.perform_on_batch(user_ids) + expect(existing_user.past_usernames.last.username).to eq("old_login") + end + + it "creates backfilled records in user_past_emails" do + user_ids = [existing_user.id] + AuditsBackfillJob.new.perform_on_batch(user_ids) + expect(existing_user.past_emails.last.email_address).to eq("old@example.com") + end + + it "contains both backfilled and new changes" do + user_ids = [existing_user.id] + AuditsBackfillJob.new.perform_on_batch(user_ids) + old_username = existing_user.login + existing_user.update!(login: "new_login") + expect(existing_user.past_usernames.count).to eq(2) + expect(existing_user.past_usernames.last.username).to eq(old_username) + expect(existing_user.past_usernames.first.username).to eq("old_login") + end + end + + it "doesn't create duplicate records" do + # change with update first, then run backfill + old_username = existing_user.login + existing_user.update!(login: "new_login") + user_ids = [existing_user.id] + AuditsBackfillJob.new.perform_on_batch(user_ids) + + expect(existing_user.past_usernames.count).to eq(1) + expect(existing_user.past_usernames.last.username).to eq(old_username) + end + + it "handles audits with a empty field" do + existing_user.audits.create!(action: "update", auditable: existing_user, user: existing_user, + auditable_id: existing_user.id, audited_changes: { "email" => ["", existing_user.email] }) + user_ids = [existing_user.id] + AuditsBackfillJob.new.perform_on_batch(user_ids) + expect(existing_user.past_emails.count).to eq(0) + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d55c1bf1f3d..b00564fdfc1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -254,6 +254,15 @@ expect(existing_user.renamed_at).to eq(Time.current) expect(existing_user.admin_renamed_at).to be nil end + + it "records past username" do + freeze_time + existing_user.update!(login: "new_username") + user_change = existing_user.past_usernames.last + expect(user_change.user_id).to eq(existing_user.id) + expect(user_change.username).not_to eq("new_username") + expect(user_change.changed_at).to eq(Time.current) + end context "username was recently changed" do before do @@ -340,6 +349,21 @@ expect(log_item.admin_id).to be_nil expect(log_item.note).to eq("System Generated") end + + it "records previous email" do + old_email = existing_user.past_emails.last + expect(old_email.email_address).not_to eq(existing_user.email) + expect(old_email.user_id).to eq(existing_user.id) + expect(old_email.changed_at).to eq(existing_user.updated_at) + end + + it "doesn't record previous email before confirmation" do + old_email = existing_user.user_past_emails.last + existing_user.update!(email: "newemail2@example.com") + existing_user.reload + after_email = existing_user.user_past_emails.last + expect(old_email).to eq(after_email) + end end context "as an admin" do