mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +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