Rework some parts

This commit is contained in:
Eugene Burmakin 2026-01-08 21:12:47 +01:00
parent 17837979e2
commit 8465350c1b
15 changed files with 80 additions and 53 deletions

View file

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Map V2 points loading is significantly sped up. - Map V2 points loading is significantly sped up.
- Points size on Map V2 was reduced to prevent overlapping. - Points size on Map V2 was reduced to prevent overlapping.
- User deletion now being done in the background to prevent request timeouts for users with large amount of data.
# [0.37.2] - 2026-01-04 # [0.37.2] - 2026-01-04

View file

@ -29,11 +29,13 @@ class ApiController < ApplicationController
def authenticate_active_api_user! def authenticate_active_api_user!
if current_api_user.nil? if current_api_user.nil?
render json: { error: 'User account is not active or has been deleted' }, status: :unauthorized render json: { error: 'User account is not active or has been deleted' }, status: :unauthorized
return false return false
end end
unless current_api_user.active_until&.future? if current_api_user.active_until&.past?
render json: { error: 'User subscription is not active' }, status: :unauthorized render json: { error: 'User subscription is not active' }, status: :unauthorized
return false return false
end end

View file

@ -74,10 +74,10 @@ class ApplicationController < ActionController::Base
private private
def sign_out_deleted_users def sign_out_deleted_users
if current_user&.deleted? return unless current_user&.deleted?
sign_out current_user
redirect_to root_path, alert: 'Your account has been deleted.' sign_out current_user
end redirect_to root_path, alert: 'Your account has been deleted.'
end end
def set_self_hosted_status def set_self_hosted_status

View file

@ -48,7 +48,7 @@ class Settings::UsersController < ApplicationController
notice: 'User deletion has been initiated. The account will be fully removed shortly.' notice: 'User deletion has been initiated. The account will be fully removed shortly.'
rescue ActiveRecord::RecordInvalid rescue ActiveRecord::RecordInvalid
redirect_to settings_users_url, redirect_to settings_users_url,
alert: 'Cannot delete account while being a family owner with other members.', alert: 'Cannot delete account while being owner of a family which has other members.',
status: :unprocessable_content status: :unprocessable_content
end end
end end

View file

@ -30,10 +30,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
begin begin
resource.mark_as_deleted! resource.mark_as_deleted!
# Sign out immediately
Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name) Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
# Enqueue background job
Users::DestroyJob.perform_later(resource.id) Users::DestroyJob.perform_later(resource.id)
set_flash_message! :notice, :destroyed set_flash_message! :notice, :destroyed

View file

@ -3,15 +3,12 @@
class Users::DestroyJob < ApplicationJob class Users::DestroyJob < ApplicationJob
queue_as :default queue_as :default
sidekiq_options retry: false # No retry for destructive operations sidekiq_options retry: false
def perform(user_id) def perform(user_id)
user = User.deleted_accounts.find_by(id: user_id) user = User.deleted_accounts.find_by(id: user_id)
unless user return 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})" Rails.logger.info "Starting hard deletion for user #{user.id} (#{user.email})"
@ -21,8 +18,6 @@ class Users::DestroyJob < ApplicationJob
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
Rails.logger.warn "User #{user_id} not found, may have already been deleted" Rails.logger.warn "User #{user_id} not found, may have already been deleted"
rescue StandardError => e 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}") ExceptionReporter.call(e, "User deletion failed for user_id #{user_id}")
# Don't raise - leave user in deleted state for manual cleanup
end end
end end

View file

@ -6,6 +6,9 @@ class Users::TrialWebhookJob < ApplicationJob
def perform(user_id) def perform(user_id)
user = User.find(user_id) user = User.find(user_id)
# Skip webhook for soft-deleted users
return if user.deleted?
payload = { payload = {
user_id: user.id, user_id: user.id,
email: user.email, email: user.email,

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :active_accounts, -> { where(deleted_at: nil) }
scope :deleted_accounts, -> { where.not(deleted_at: nil) }
end
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
end

View file

@ -3,6 +3,7 @@
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
include UserFamily include UserFamily
include Omniauthable include Omniauthable
include SoftDeletable
devise :database_authenticatable, :registerable, devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable, :recoverable, :rememberable, :validatable, :trackable,
@ -36,33 +37,9 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
attribute :points_count, :integer, default: 0 attribute :points_count, :integer, default: 0
scope :active_or_trial, -> { where(status: %i[active trial]) } 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 } 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 def safe_settings
Users::SafeSettings.new(settings) Users::SafeSettings.new(settings)
end end

View file

@ -13,9 +13,27 @@ class Users::Destroy
cancel_scheduled_jobs cancel_scheduled_jobs
# Hard delete with transaction - all associations cascade via dependent: :destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
user.destroy! # Delete associated records first (dependent: :destroy associations)
user.points.delete_all
user.imports.delete_all
user.stats.delete_all
user.exports.delete_all
user.notifications.delete_all
user.areas.delete_all
user.visits.delete_all
user.places.delete_all
user.tags.delete_all
user.trips.delete_all
user.tracks.delete_all
user.raw_data_archives.delete_all
user.digests.delete_all
user.sent_family_invitations.delete_all if user.respond_to?(:sent_family_invitations)
user.family_membership&.delete
user.created_family&.delete
# Hard delete the user (bypasses soft-delete, skips callbacks)
user.delete
end end
Rails.logger.info "User #{user_id} (#{user_email}) and all associated data deleted" Rails.logger.info "User #{user_id} (#{user_email}) and all associated data deleted"

View file

@ -39,7 +39,7 @@ en:
updated_not_active: "Your password has been changed successfully." updated_not_active: "Your password has been changed successfully."
registrations: registrations:
destroyed: "Your account has been scheduled for deletion. Goodbye!" destroyed: "Your account has been scheduled for deletion. Goodbye!"
cannot_delete: "Cannot delete your account while you are a family owner with other members." cannot_delete: "Cannot delete your account while you own a family with other members."
signed_up: "Welcome! You have signed up successfully." 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_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." signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."

View file

@ -2,7 +2,7 @@ class AddDeletedAtToUsers < ActiveRecord::Migration[8.0]
disable_ddl_transaction! disable_ddl_transaction!
def change def change
add_column :users, :deleted_at, :datetime add_column :users, :deleted_at, :datetime unless column_exists?(:users, :deleted_at)
add_index :users, :deleted_at, algorithm: :concurrently add_index :users, :deleted_at, algorithm: :concurrently unless index_exists?(:users, :deleted_at)
end end
end end

2
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_01_08_112905) do ActiveRecord::Schema[8.0].define(version: 2026_01_08_192905) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "postgis" enable_extension "postgis"

View file

@ -44,12 +44,12 @@ RSpec.describe Users::TrialWebhookJob, type: :job do
end end
context 'when user is deleted' do context 'when user is deleted' do
it 'raises ActiveRecord::RecordNotFound' do it 'skips the webhook for soft-deleted users' do
user.destroy user.destroy
expect { expect(HTTParty).not_to receive(:post)
described_class.perform_now(user.id)
}.to raise_error(ActiveRecord::RecordNotFound) described_class.perform_now(user.id)
end end
end end
end end

View file

@ -118,8 +118,9 @@ RSpec.describe User, 'family methods', type: :model do
create(:family_invitation, family: family, invited_by: user) create(:family_invitation, family: family, invited_by: user)
end end
it 'destroys associated invitations when user is destroyed' do it 'soft-deletes user but keeps invitations' do
expect { user.destroy }.to change(Family::Invitation, :count).by(-1) expect { user.destroy }.not_to change(Family::Invitation, :count)
expect(user.deleted?).to be true
end end
end end
@ -128,8 +129,9 @@ RSpec.describe User, 'family methods', type: :model do
create(:family_membership, user: user, family: family) create(:family_membership, user: user, family: family)
end end
it 'destroys associated membership when user is destroyed' do it 'soft-deletes user but keeps membership' do
expect { user.destroy }.to change(Family::Membership, :count).by(-1) expect { user.destroy }.not_to change(Family::Membership, :count)
expect(user.deleted?).to be true
end end
end end
end end