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) |   description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL) | ||||||
| # ------- End of Chatwoot Internal Config for Self Hosted ----# | # ------- 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 ------ ## | ## ------ Configs added for enterprise clients ------ ## | ||||||
| - name: API_CHANNEL_NAME | - name: API_CHANNEL_NAME | ||||||
|   value: |   value: | ||||||
|   | |||||||
| @@ -39,3 +39,10 @@ process_stale_contacts_job: | |||||||
|   cron: '30 04 * * *' |   cron: '30 04 * * *' | ||||||
|   class: 'Internal::ProcessStaleContactsJob' |   class: 'Internal::ProcessStaleContactsJob' | ||||||
|   queue: housekeeping |   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 |   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 |     %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 | ||||||
| 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