diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 74848252..01bd1b61 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bdf00702..26ba29e8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/settings/users_controller.rb b/app/controllers/settings/users_controller.rb index d60c5bf9..c05d1487 100644 --- a/app/controllers/settings/users_controller.rb +++ b/app/controllers/settings/users_controller.rb @@ -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 diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index b29b31b4..c307bc60 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -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) diff --git a/app/jobs/users/destroy_job.rb b/app/jobs/users/destroy_job.rb new file mode 100644 index 00000000..8b161dd4 --- /dev/null +++ b/app/jobs/users/destroy_job.rb @@ -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 diff --git a/app/models/concerns/user_family.rb b/app/models/concerns/user_family.rb index 53119792..1d5201ed 100644 --- a/app/models/concerns/user_family.rb +++ b/app/models/concerns/user_family.rb @@ -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? diff --git a/app/models/user.rb b/app/models/user.rb index 4279d494..ec87b297 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/users/destroy.rb b/app/services/users/destroy.rb new file mode 100644 index 00000000..12e9589e --- /dev/null +++ b/app/services/users/destroy.rb @@ -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 diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index 260e1c4b..0415002d 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -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." diff --git a/db/migrate/20260108192905_add_deleted_at_to_users.rb b/db/migrate/20260108192905_add_deleted_at_to_users.rb new file mode 100644 index 00000000..a21412aa --- /dev/null +++ b/db/migrate/20260108192905_add_deleted_at_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index d7baaeb4..cd7c8d08 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[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