mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	feat: automate account deletion (#11406)
- Automate the deletion of accounts that have requested deletion via account settings. - Add a Sidekiq job that runs daily to find accounts that have requested deletion and have passed the 7-day window. - This job deletes the account and then soft-deletes users if they do not belong to any other account. - This job also sends an email to the Chatwoot instance admin for compliance purposes. - The Chatwoot instance admin's email is configurable via the `CHATWOOT_INSTANCE_ADMIN_EMAIL` global config. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
		
							
								
								
									
										27
									
								
								app/jobs/internal/delete_accounts_job.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/jobs/internal/delete_accounts_job.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| class Internal::DeleteAccountsJob < ApplicationJob | ||||
|   queue_as :scheduled_jobs | ||||
|  | ||||
|   def perform | ||||
|     delete_expired_accounts | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def delete_expired_accounts | ||||
|     accounts_pending_deletion.each do |account| | ||||
|       AccountDeletionService.new(account: account).perform | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def accounts_pending_deletion | ||||
|     Account.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") | ||||
|            .select { |account| deletion_period_expired?(account) } | ||||
|   end | ||||
|  | ||||
|   def deletion_period_expired?(account) | ||||
|     deletion_time = account.custom_attributes['marked_for_deletion_at'] | ||||
|     return false if deletion_time.blank? | ||||
|  | ||||
|     DateTime.parse(deletion_time) <= Time.current | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,53 @@ | ||||
| class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer | ||||
|   def account_deleted(account) | ||||
|     return if instance_admin_email.blank? | ||||
|  | ||||
|     subject = subject_for(account) | ||||
|     meta = build_meta(account) | ||||
|  | ||||
|     send_notification(subject, to: instance_admin_email, meta: meta) | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def build_meta(account) | ||||
|     deleted_users = params[:soft_deleted_users] || [] | ||||
|  | ||||
|     user_info_list = [] | ||||
|     deleted_users.each do |user| | ||||
|       user_info_list << { | ||||
|         'user_id' => user[:id].to_s, | ||||
|         'user_email' => user[:original_email].to_s | ||||
|       } | ||||
|     end | ||||
|  | ||||
|     { | ||||
|       'instance_url' => instance_url, | ||||
|       'account_id' => account.id, | ||||
|       'account_name' => account.name, | ||||
|       'deleted_at' => format_time(Time.current.iso8601), | ||||
|       'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified', | ||||
|       'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']), | ||||
|       'soft_deleted_users' => user_info_list, | ||||
|       'deleted_user_count' => user_info_list.size | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def format_time(time_string) | ||||
|     return 'not specified' if time_string.blank? | ||||
|  | ||||
|     Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z') | ||||
|   end | ||||
|  | ||||
|   def subject_for(account) | ||||
|     "Account Deletion Notice for #{account.id} - #{account.name}" | ||||
|   end | ||||
|  | ||||
|   def instance_admin_email | ||||
|     GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL'] | ||||
|   end | ||||
|  | ||||
|   def instance_url | ||||
|     ENV.fetch('FRONTEND_URL', 'not available') | ||||
|   end | ||||
| end | ||||
							
								
								
									
										50
									
								
								app/services/account_deletion_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								app/services/account_deletion_service.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| class AccountDeletionService | ||||
|   attr_reader :account, :soft_deleted_users | ||||
|  | ||||
|   def initialize(account:) | ||||
|     @account = account | ||||
|     @soft_deleted_users = [] | ||||
|   end | ||||
|  | ||||
|   def perform | ||||
|     Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion") | ||||
|  | ||||
|     soft_delete_orphaned_users | ||||
|     send_compliance_notification | ||||
|     DeleteObjectJob.perform_later(account) | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def send_compliance_notification | ||||
|     AdministratorNotifications::AccountComplianceMailer.with( | ||||
|       account: account, | ||||
|       soft_deleted_users: soft_deleted_users | ||||
|     ).account_deleted(account).deliver_later | ||||
|   end | ||||
|  | ||||
|   def soft_delete_orphaned_users | ||||
|     account.users.each do |user| | ||||
|       # Find all account_users for this user excluding the current account | ||||
|       other_accounts = user.account_users.where.not(account_id: account.id).count | ||||
|  | ||||
|       # If user has no other accounts, soft delete them | ||||
|       next unless other_accounts.zero? | ||||
|  | ||||
|       # Soft delete user by appending -deleted.com to email | ||||
|       original_email = user.email | ||||
|       user.email = "#{original_email}-deleted.com" | ||||
|       user.skip_reconfirmation! | ||||
|       user.save! | ||||
|  | ||||
|       user_info = { | ||||
|         id: user.id.to_s, | ||||
|         original_email: original_email | ||||
|       } | ||||
|  | ||||
|       soft_deleted_users << user_info | ||||
|  | ||||
|       Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}") | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,30 @@ | ||||
| <p>Hello,</p> | ||||
|  | ||||
| <p>This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.</p> | ||||
|  | ||||
| <p> | ||||
|   <strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br> | ||||
|   <strong>Account ID:</strong> {{ meta.account_id }}<br> | ||||
|   <strong>Account Name:</strong> {{ meta.account_name }}<br> | ||||
|   <strong>Deleted At:</strong> {{ meta.deleted_at }}<br> | ||||
|   <strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br> | ||||
|   <strong>Deletion Reason:</strong> {{ meta.deletion_reason }} | ||||
| </p> | ||||
|  | ||||
| {% if meta.deleted_user_count > 0 %} | ||||
| <p> | ||||
|   <strong>Deleted Users ({{ meta.deleted_user_count }}):</strong><br> | ||||
|   {% for user in meta.soft_deleted_users %} | ||||
|     User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}<br>{% endunless %} | ||||
|   {% endfor %} | ||||
| </p> | ||||
| {% else %} | ||||
| <p> | ||||
|   <strong>Deleted Users:</strong> None | ||||
| </p> | ||||
| {% endif %} | ||||
|  | ||||
| <p>This email serves as a record for compliance purposes.</p> | ||||
|  | ||||
| <p>Thank you,<br> | ||||
| Chatwoot System</p> | ||||
| @@ -235,6 +235,14 @@ | ||||
|   description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL) | ||||
| # ------- End of Chatwoot Internal Config for Self Hosted ----# | ||||
|  | ||||
| # ------- Compliance Related Config ----# | ||||
| - name: CHATWOOT_INSTANCE_ADMIN_EMAIL | ||||
|   display_title: 'Instance Admin Email' | ||||
|   value: | ||||
|   description: 'The email of the instance administrator to receive compliance-related notifications' | ||||
|   locked: false | ||||
| # ------- End of Compliance Related Config ----# | ||||
|  | ||||
| ## ------ Configs added for enterprise clients ------ ## | ||||
| - name: API_CHANNEL_NAME | ||||
|   value: | ||||
|   | ||||
| @@ -39,3 +39,10 @@ process_stale_contacts_job: | ||||
|   cron: '30 04 * * *' | ||||
|   class: 'Internal::ProcessStaleContactsJob' | ||||
|   queue: housekeeping | ||||
|  | ||||
| # executed daily at 0100 UTC | ||||
| # to delete accounts marked for deletion | ||||
| delete_accounts_job: | ||||
|   cron: '0 1 * * *' | ||||
|   class: 'Internal::DeleteAccountsJob' | ||||
|   queue: scheduled_jobs | ||||
|   | ||||
| @@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController | ||||
|  | ||||
|   def internal_config_options | ||||
|     %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS | ||||
|        CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL] | ||||
|        CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL] | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										44
									
								
								spec/jobs/internal/delete_accounts_job_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								spec/jobs/internal/delete_accounts_job_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe Internal::DeleteAccountsJob do | ||||
|   subject(:job) { described_class.perform_later } | ||||
|  | ||||
|   let!(:account_marked_for_deletion) { create(:account) } | ||||
|   let!(:future_deletion_account) { create(:account) } | ||||
|   let!(:active_account) { create(:account) } | ||||
|   let(:account_deletion_service) { instance_double(AccountDeletionService, perform: true) } | ||||
|  | ||||
|   before do | ||||
|     account_marked_for_deletion.update!( | ||||
|       custom_attributes: { | ||||
|         'marked_for_deletion_at' => 1.day.ago.iso8601, | ||||
|         'marked_for_deletion_reason' => 'user_requested' | ||||
|       } | ||||
|     ) | ||||
|  | ||||
|     future_deletion_account.update!( | ||||
|       custom_attributes: { | ||||
|         'marked_for_deletion_at' => 3.days.from_now.iso8601, | ||||
|         'marked_for_deletion_reason' => 'user_requested' | ||||
|       } | ||||
|     ) | ||||
|  | ||||
|     allow(AccountDeletionService).to receive(:new).and_return(account_deletion_service) | ||||
|   end | ||||
|  | ||||
|   it 'enqueues the job' do | ||||
|     expect { job }.to have_enqueued_job(described_class) | ||||
|       .on_queue('scheduled_jobs') | ||||
|   end | ||||
|  | ||||
|   describe '#perform' do | ||||
|     it 'calls AccountDeletionService for accounts past deletion date' do | ||||
|       described_class.new.perform | ||||
|  | ||||
|       expect(AccountDeletionService).to have_received(:new).with(account: account_marked_for_deletion) | ||||
|       expect(AccountDeletionService).not_to have_received(:new).with(account: future_deletion_account) | ||||
|       expect(AccountDeletionService).not_to have_received(:new).with(account: active_account) | ||||
|       expect(account_deletion_service).to have_received(:perform) | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,34 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe AdministratorNotifications::AccountComplianceMailer do | ||||
|   let(:account) do | ||||
|     create(:account, custom_attributes: { 'marked_for_deletion_at' => 1.day.ago.iso8601, 'marked_for_deletion_reason' => 'user_requested' }) | ||||
|   end | ||||
|   let(:soft_deleted_users) do | ||||
|     [ | ||||
|       { id: 1, original_email: 'user1@example.com' }, | ||||
|       { id: 2, original_email: 'user2@example.com' } | ||||
|     ] | ||||
|   end | ||||
|  | ||||
|   describe 'account_deleted' do | ||||
|     it 'has the right subject format' do | ||||
|       subject = described_class.new.send(:subject_for, account) | ||||
|       expect(subject).to eq("Account Deletion Notice for #{account.id} - #{account.name}") | ||||
|     end | ||||
|  | ||||
|     it 'includes soft deleted users in meta when provided' do | ||||
|       mailer_instance = described_class.new | ||||
|       allow(mailer_instance).to receive(:params).and_return( | ||||
|         { soft_deleted_users: soft_deleted_users } | ||||
|       ) | ||||
|  | ||||
|       meta = mailer_instance.send(:build_meta, account) | ||||
|  | ||||
|       expect(meta['deleted_user_count']).to eq(2) | ||||
|       expect(meta['soft_deleted_users'].size).to eq(2) | ||||
|       expect(meta['soft_deleted_users'].first['user_id']).to eq('1') | ||||
|       expect(meta['soft_deleted_users'].first['user_email']).to eq('user1@example.com') | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										63
									
								
								spec/services/account_deletion_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								spec/services/account_deletion_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe AccountDeletionService do | ||||
|   let(:account) { create(:account) } | ||||
|   let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) } | ||||
|  | ||||
|   describe '#perform' do | ||||
|     before do | ||||
|       allow(DeleteObjectJob).to receive(:perform_later) | ||||
|       allow(AdministratorNotifications::AccountComplianceMailer).to receive(:with).and_return( | ||||
|         instance_double(AdministratorNotifications::AccountComplianceMailer, account_deleted: mailer) | ||||
|       ) | ||||
|     end | ||||
|  | ||||
|     it 'enqueues DeleteObjectJob with the account' do | ||||
|       described_class.new(account: account).perform | ||||
|  | ||||
|       expect(DeleteObjectJob).to have_received(:perform_later).with(account) | ||||
|     end | ||||
|  | ||||
|     it 'sends a compliance notification email' do | ||||
|       described_class.new(account: account).perform | ||||
|  | ||||
|       expect(AdministratorNotifications::AccountComplianceMailer).to have_received(:with) do |args| | ||||
|         expect(args[:account]).to eq(account) | ||||
|         expect(args).to include(:soft_deleted_users) | ||||
|       end | ||||
|       expect(mailer).to have_received(:deliver_later) | ||||
|     end | ||||
|  | ||||
|     context 'when handling users' do | ||||
|       let(:user_with_one_account) { create(:user) } | ||||
|       let(:user_with_multiple_accounts) { create(:user) } | ||||
|       let(:second_account) { create(:account) } | ||||
|  | ||||
|       before do | ||||
|         create(:account_user, user: user_with_one_account, account: account) | ||||
|         create(:account_user, user: user_with_multiple_accounts, account: account) | ||||
|         create(:account_user, user: user_with_multiple_accounts, account: second_account) | ||||
|       end | ||||
|  | ||||
|       it 'soft deletes users who only belong to the deleted account' do | ||||
|         original_email = user_with_one_account.email | ||||
|  | ||||
|         described_class.new(account: account).perform | ||||
|  | ||||
|         # Reload the user to get the updated email | ||||
|         user_with_one_account.reload | ||||
|         expect(user_with_one_account.email).to eq("#{original_email}-deleted.com") | ||||
|       end | ||||
|  | ||||
|       it 'does not modify emails for users belonging to multiple accounts' do | ||||
|         original_email = user_with_multiple_accounts.email | ||||
|  | ||||
|         described_class.new(account: account).perform | ||||
|  | ||||
|         # Reload the user to get the updated email | ||||
|         user_with_multiple_accounts.reload | ||||
|         expect(user_with_multiple_accounts.email).to eq(original_email) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user
	 Vishnu Narayanan
					Vishnu Narayanan