mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Merge 8465350c1b into ce8a7cd4ef
This commit is contained in:
commit
e416885c57
16 changed files with 220 additions and 16 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,26 @@ class ApiController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate_active_api_user!
|
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
|
||||||
|
|
||||||
|
if current_api_user.active_until&.past?
|
||||||
|
render json: { error: 'User subscription is not active' }, status: :unauthorized
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_api_user
|
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
|
end
|
||||||
|
|
||||||
def api_key
|
def api_key
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base
|
||||||
|
|
||||||
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
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
|
before_action :unread_notifications, :set_self_hosted_status, :store_client_header
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
@ -72,6 +73,13 @@ class ApplicationController < ActionController::Base
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def sign_out_deleted_users
|
||||||
|
return unless current_user&.deleted?
|
||||||
|
|
||||||
|
sign_out current_user
|
||||||
|
redirect_to root_path, alert: 'Your account has been deleted.'
|
||||||
|
end
|
||||||
|
|
||||||
def set_self_hosted_status
|
def set_self_hosted_status
|
||||||
@self_hosted = DawarichSettings.self_hosted?
|
@self_hosted = DawarichSettings.self_hosted?
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,16 @@ class Settings::UsersController < ApplicationController
|
||||||
def destroy
|
def destroy
|
||||||
@user = User.find(params[:id])
|
@user = User.find(params[:id])
|
||||||
|
|
||||||
if @user.destroy
|
begin
|
||||||
redirect_to settings_url, notice: 'User was successfully deleted.'
|
@user.mark_as_deleted!
|
||||||
else
|
Users::DestroyJob.perform_later(@user.id)
|
||||||
redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_content
|
|
||||||
|
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 owner of a family which has other members.',
|
||||||
|
status: :unprocessable_content
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,23 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
begin
|
||||||
|
resource.mark_as_deleted!
|
||||||
|
|
||||||
|
Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
|
||||||
|
|
||||||
|
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
|
protected
|
||||||
|
|
||||||
def after_sign_up_path_for(resource)
|
def after_sign_up_path_for(resource)
|
||||||
|
|
|
||||||
23
app/jobs/users/destroy_job.rb
Normal file
23
app/jobs/users/destroy_job.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::DestroyJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
def perform(user_id)
|
||||||
|
user = User.deleted_accounts.find_by(id: user_id)
|
||||||
|
|
||||||
|
return unless user
|
||||||
|
|
||||||
|
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
|
||||||
|
ExceptionReporter.call(e, "User deletion failed for user_id #{user_id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
31
app/models/concerns/soft_deletable.rb
Normal file
31
app/models/concerns/soft_deletable.rb
Normal 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
|
||||||
|
|
@ -10,6 +10,7 @@ module UserFamily
|
||||||
has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id',
|
has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id',
|
||||||
inverse_of: :invited_by, dependent: :destroy
|
inverse_of: :invited_by, dependent: :destroy
|
||||||
|
|
||||||
|
validate :cannot_delete_with_family_members, if: :deleted_at_changed?
|
||||||
before_destroy :check_family_ownership
|
before_destroy :check_family_ownership
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -107,6 +108,13 @@ module UserFamily
|
||||||
|
|
||||||
private
|
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
|
def check_family_ownership
|
||||||
return if can_delete_account?
|
return if can_delete_account?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
79
app/services/users/destroy.rb
Normal file
79
app/services/users/destroy.rb
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -16,6 +16,7 @@ en:
|
||||||
timeout: "Your session expired. Please sign in again to continue."
|
timeout: "Your session expired. Please sign in again to continue."
|
||||||
unauthenticated: "You need to sign in or sign up before continuing."
|
unauthenticated: "You need to sign in or sign up before continuing."
|
||||||
unconfirmed: "You have to confirm your email address before continuing."
|
unconfirmed: "You have to confirm your email address before continuing."
|
||||||
|
deleted: "Your account has been deleted."
|
||||||
mailer:
|
mailer:
|
||||||
confirmation_instructions:
|
confirmation_instructions:
|
||||||
subject: "Confirmation instructions"
|
subject: "Confirmation instructions"
|
||||||
|
|
@ -37,7 +38,8 @@ en:
|
||||||
updated: "Your password has been changed successfully. You are now signed in."
|
updated: "Your password has been changed successfully. You are now signed in."
|
||||||
updated_not_active: "Your password has been changed successfully."
|
updated_not_active: "Your password has been changed successfully."
|
||||||
registrations:
|
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 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."
|
||||||
|
|
|
||||||
8
db/migrate/20260108192905_add_deleted_at_to_users.rb
Normal file
8
db/migrate/20260108192905_add_deleted_at_to_users.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
class AddDeletedAtToUsers < ActiveRecord::Migration[8.0]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_column :users, :deleted_at, :datetime unless column_exists?(:users, :deleted_at)
|
||||||
|
add_index :users, :deleted_at, algorithm: :concurrently unless index_exists?(:users, :deleted_at)
|
||||||
|
end
|
||||||
|
end
|
||||||
4
db/schema.rb
generated
4
db/schema.rb
generated
|
|
@ -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_03_114630) 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"
|
||||||
|
|
@ -484,7 +484,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_03_114630) do
|
||||||
t.string "utm_campaign"
|
t.string "utm_campaign"
|
||||||
t.string "utm_term"
|
t.string "utm_term"
|
||||||
t.string "utm_content"
|
t.string "utm_content"
|
||||||
|
t.datetime "deleted_at"
|
||||||
t.index ["api_key"], name: "index_users_on_api_key"
|
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 ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", 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
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue