Move user deletions to background job

This commit is contained in:
Eugene Burmakin 2026-01-08 20:29:28 +01:00
parent ce8a7cd4ef
commit 17837979e2
11 changed files with 185 additions and 8 deletions

View file

@ -27,13 +27,24 @@ class ApiController < ApplicationController
end
def authenticate_active_api_user!
render json: { error: 'User is not active' }, status: :unauthorized unless current_api_user&.active_until&.future?
if current_api_user.nil?
render json: { error: 'User account is not active or has been deleted' }, status: :unauthorized
return false
end
unless current_api_user.active_until&.future?
render json: { error: 'User subscription is not active' }, status: :unauthorized
return false
end
true
end
def current_api_user
@current_api_user ||= User.find_by(api_key:)
@current_api_user ||= begin
user = User.active_accounts.find_by(api_key:)
user if user&.active_for_authentication?
end
end
def api_key

View file

@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
before_action :sign_out_deleted_users
before_action :unread_notifications, :set_self_hosted_status, :store_client_header
protected
@ -72,6 +73,13 @@ class ApplicationController < ActionController::Base
private
def sign_out_deleted_users
if current_user&.deleted?
sign_out current_user
redirect_to root_path, alert: 'Your account has been deleted.'
end
end
def set_self_hosted_status
@self_hosted = DawarichSettings.self_hosted?
end

View file

@ -40,10 +40,16 @@ class Settings::UsersController < ApplicationController
def destroy
@user = User.find(params[:id])
if @user.destroy
redirect_to settings_url, notice: 'User was successfully deleted.'
else
redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_content
begin
@user.mark_as_deleted!
Users::DestroyJob.perform_later(@user.id)
redirect_to settings_users_url,
notice: 'User deletion has been initiated. The account will be fully removed shortly.'
rescue ActiveRecord::RecordInvalid
redirect_to settings_users_url,
alert: 'Cannot delete account while being a family owner with other members.',
status: :unprocessable_content
end
end

View file

@ -26,6 +26,25 @@ class Users::RegistrationsController < Devise::RegistrationsController
end
end
def destroy
begin
resource.mark_as_deleted!
# Sign out immediately
Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
# Enqueue background job
Users::DestroyJob.perform_later(resource.id)
set_flash_message! :notice, :destroyed
yield resource if block_given?
respond_with_navigational(resource) { redirect_to after_sign_out_path_for(resource_name) }
rescue ActiveRecord::RecordInvalid
set_flash_message! :alert, :cannot_delete
redirect_to edit_user_registration_path, status: :unprocessable_content
end
end
protected
def after_sign_up_path_for(resource)

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class Users::DestroyJob < ApplicationJob
queue_as :default
sidekiq_options retry: false # No retry for destructive operations
def perform(user_id)
user = User.deleted_accounts.find_by(id: user_id)
unless user
Rails.logger.warn "User #{user_id} not found or not marked for deletion, skipping"
return
end
Rails.logger.info "Starting hard deletion for user #{user.id} (#{user.email})"
Users::Destroy.new(user).call
Rails.logger.info "Successfully deleted user #{user_id}"
rescue ActiveRecord::RecordNotFound
Rails.logger.warn "User #{user_id} not found, may have already been deleted"
rescue StandardError => e
Rails.logger.error "Failed to delete user #{user_id}: #{e.message}"
ExceptionReporter.call(e, "User deletion failed for user_id #{user_id}")
# Don't raise - leave user in deleted state for manual cleanup
end
end

View file

@ -10,6 +10,7 @@ module UserFamily
has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id',
inverse_of: :invited_by, dependent: :destroy
validate :cannot_delete_with_family_members, if: :deleted_at_changed?
before_destroy :check_family_ownership
end
@ -107,6 +108,13 @@ module UserFamily
private
def cannot_delete_with_family_members
return unless deleted_at.present? && deleted_at_changed?
return if can_delete_account?
errors.add(:base, 'Cannot delete account while being a family owner with other members')
end
def check_family_ownership
return if can_delete_account?

View file

@ -36,9 +36,33 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
attribute :points_count, :integer, default: 0
scope :active_or_trial, -> { where(status: %i[active trial]) }
scope :active_accounts, -> { where(deleted_at: nil) }
scope :deleted_accounts, -> { where.not(deleted_at: nil) }
enum :status, { inactive: 0, active: 1, trial: 2 }
# Soft-delete methods
def deleted?
deleted_at.present?
end
def mark_as_deleted!
update!(deleted_at: Time.current)
end
def destroy
mark_as_deleted!
end
# Devise authentication overrides
def active_for_authentication?
super && !deleted?
end
def inactive_message
deleted? ? :deleted : super
end
def safe_settings
Users::SafeSettings.new(settings)
end

View file

@ -0,0 +1,61 @@
# frozen_string_literal: true
class Users::Destroy
attr_reader :user
def initialize(user)
@user = user
end
def call
user_id = user.id
user_email = user.email
cancel_scheduled_jobs
# Hard delete with transaction - all associations cascade via dependent: :destroy
ActiveRecord::Base.transaction do
user.destroy!
end
Rails.logger.info "User #{user_id} (#{user_email}) and all associated data deleted"
cleanup_user_cache(user_id)
true
rescue StandardError => e
Rails.logger.error "Error during user deletion: #{e.message}"
ExceptionReporter.call(e, "User destroy service failed for user_id #{user_id}")
raise
end
private
def cancel_scheduled_jobs
scheduled_set = Sidekiq::ScheduledSet.new
jobs_cancelled = scheduled_set.select { |job|
job.klass == 'Users::MailerSendingJob' && job.args.first == user.id
}.map(&:delete).count
Rails.logger.info "Cancelled #{jobs_cancelled} scheduled jobs for user #{user.id}"
rescue StandardError => e
Rails.logger.warn "Failed to cancel scheduled jobs for user #{user.id}: #{e.message}"
ExceptionReporter.call(e, "Failed to cancel scheduled jobs during user deletion")
end
def cleanup_user_cache(user_id)
cache_keys = [
"dawarich/user_#{user_id}_countries_visited",
"dawarich/user_#{user_id}_cities_visited",
"dawarich/user_#{user_id}_total_distance",
"dawarich/user_#{user_id}_years_tracked"
]
cache_keys.each { |key| Rails.cache.delete(key) }
Rails.logger.info "Cleared cache for user #{user_id}"
rescue StandardError => e
Rails.logger.warn "Failed to clear cache for user #{user_id}: #{e.message}"
end
end

View file

@ -16,6 +16,7 @@ en:
timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing."
unconfirmed: "You have to confirm your email address before continuing."
deleted: "Your account has been deleted."
mailer:
confirmation_instructions:
subject: "Confirmation instructions"
@ -37,7 +38,8 @@ en:
updated: "Your password has been changed successfully. You are now signed in."
updated_not_active: "Your password has been changed successfully."
registrations:
destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
destroyed: "Your account has been scheduled for deletion. Goodbye!"
cannot_delete: "Cannot delete your account while you are a family owner with other members."
signed_up: "Welcome! You have signed up successfully."
signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."

View file

@ -0,0 +1,8 @@
class AddDeletedAtToUsers < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_column :users, :deleted_at, :datetime
add_index :users, :deleted_at, algorithm: :concurrently
end
end

4
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_01_03_114630) do
ActiveRecord::Schema[8.0].define(version: 2026_01_08_112905) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -484,7 +484,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_03_114630) do
t.string "utm_campaign"
t.string "utm_term"
t.string "utm_content"
t.datetime "deleted_at"
t.index ["api_key"], name: "index_users_on_api_key"
t.index ["deleted_at"], name: "index_users_on_deleted_at"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true