From 0dc2af3c7855112763ef4672e6f768c569f3c25d Mon Sep 17 00:00:00 2001 From: Pranjal Kushwaha Date: Thu, 3 Apr 2025 10:41:39 +0530 Subject: [PATCH] feat: Ability to delete account for administrators (#1874) ## Description Add account delete option in the user account settings. Fixes #1555 ## Type of change - [ ] New feature (non-breaking change which adds functionality) ![image](https://user-images.githubusercontent.com/40784971/110349673-edcc5200-8058-11eb-8ded-a31d15aa0759.png) ![image](https://user-images.githubusercontent.com/40784971/110349778-0c324d80-8059-11eb-9291-abfbffedde5e.png) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose Co-authored-by: Sojan Jose Co-authored-by: Muhsin Keloth --- app/dashboards/account_dashboard.rb | 3 +- .../dashboard/api/enterprise/account.js | 6 + .../api/enterprise/specs/account.spec.js | 17 ++ .../i18n/locale/en/generalSettings.json | 20 +++ .../dashboard/settings/account/Index.vue | 153 +++++++++++++++++- .../dashboard/store/modules/accounts.js | 23 +++ .../modules/specs/account/actions.spec.js | 37 +++++ app/jobs/account/contacts_export_job.rb | 2 +- app/jobs/data_import_job.rb | 4 +- .../account_notification_mailer.rb | 48 ++++++ .../base_mailer.rb | 31 ++++ .../channel_notifications_mailer.rb | 85 +--------- .../integrations_notification_mailer.rb | 12 ++ app/models/account.rb | 1 + app/models/concerns/reauthorizable.rb | 28 ++-- app/policies/account_policy.rb | 4 + .../api/v1/models/_account.json.jbuilder | 4 + .../account_deletion.liquid | 16 ++ .../automation_rule_disabled.liquid | 0 .../contact_export_complete.liquid | 0 .../contact_import_complete.liquid | 0 .../contact_import_failed.liquid | 0 .../dialogflow_disconnect.liquid | 0 .../slack_disconnect.liquid | 0 config/routes.rb | 1 + .../enterprise/api/v1/accounts_controller.rb | 37 ++++- enterprise/app/models/enterprise/account.rb | 132 +-------------- .../account/plan_usage_and_limits.rb | 130 +++++++++++++++ .../api/v1/accounts_controller_spec.rb | 95 +++++++++++ spec/enterprise/models/account_spec.rb | 49 ++++++ spec/jobs/account/contacts_export_job_spec.rb | 2 +- .../account_notification_mailer_spec.rb | 116 +++++++++++++ .../base_mailer_spec.rb | 75 +++++++++ .../channel_notifications_mailer_spec.rb | 82 ++-------- .../integrations_notification_mailer_spec.rb | 41 +++++ .../shared/smtp_config_shared.rb | 11 ++ spec/models/concerns/reauthorizable_shared.rb | 76 +++++++-- 37 files changed, 1030 insertions(+), 311 deletions(-) create mode 100644 app/mailers/administrator_notifications/account_notification_mailer.rb create mode 100644 app/mailers/administrator_notifications/base_mailer.rb create mode 100644 app/mailers/administrator_notifications/integrations_notification_mailer.rb create mode 100644 app/views/mailers/administrator_notifications/account_notification_mailer/account_deletion.liquid rename app/views/mailers/administrator_notifications/{channel_notifications_mailer => account_notification_mailer}/automation_rule_disabled.liquid (100%) rename app/views/mailers/administrator_notifications/{channel_notifications_mailer => account_notification_mailer}/contact_export_complete.liquid (100%) rename app/views/mailers/administrator_notifications/{channel_notifications_mailer => account_notification_mailer}/contact_import_complete.liquid (100%) rename app/views/mailers/administrator_notifications/{channel_notifications_mailer => account_notification_mailer}/contact_import_failed.liquid (100%) rename app/views/mailers/administrator_notifications/{channel_notifications_mailer => integrations_notification_mailer}/dialogflow_disconnect.liquid (100%) rename app/views/mailers/administrator_notifications/{channel_notifications_mailer => integrations_notification_mailer}/slack_disconnect.liquid (100%) create mode 100644 enterprise/app/models/enterprise/account/plan_usage_and_limits.rb create mode 100644 spec/mailers/administrator_notifications/account_notification_mailer_spec.rb create mode 100644 spec/mailers/administrator_notifications/base_mailer_spec.rb create mode 100644 spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb create mode 100644 spec/mailers/administrator_notifications/shared/smtp_config_shared.rb diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index f7b04a167..0bf4e44ca 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -81,7 +81,8 @@ class AccountDashboard < Administrate::BaseDashboard COLLECTION_FILTERS = { active: ->(resources) { resources.where(status: :active) }, suspended: ->(resources) { resources.where(status: :suspended) }, - recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) } + recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }, + marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") } }.freeze # Overwrite this method to customize how accounts are displayed diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index bb95335ad..3f12dc007 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient { getLimits() { return axios.get(`${this.url}limits`); } + + toggleDeletion(action) { + return axios.post(`${this.url}toggle_deletion`, { + action_type: action, + }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 4fb1bd0ee..9c65b0b67 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('update'); expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); + expect(accountAPI).toHaveProperty('toggleDeletion'); }); describe('API calls', () => { @@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => { '/enterprise/api/v1/subscription' ); }); + + it('#toggleDeletion with delete action', () => { + accountAPI.toggleDeletion('delete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'delete' } + ); + }); + + it('#toggleDeletion with undelete action', () => { + accountAPI.toggleDeletion('undelete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'undelete' } + ); + }); }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index 4e28e0b2f..cfda6c7da 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -14,6 +14,26 @@ "ERROR": "Could not update settings, try again!", "SUCCESS": "Successfully updated account settings" }, + "ACCOUNT_DELETE_SECTION": { + "TITLE": "Delete your Account", + "NOTE": "Once you delete your account, all your data will be deleted.", + "BUTTON_TEXT": "Delete Your Account", + "CONFIRM": { + "TITLE": "Delete Account", + "MESSAGE": "Deleting your Account is irreversible. Enter your account name below to confirm you want to permanently delete it.", + "BUTTON_TEXT": "Delete", + "DISMISS": "Cancel", + "PLACE_HOLDER": "Please type {accountName} to confirm" + }, + "SUCCESS": "Account marked for deletion", + "FAILURE": "Could not delete account, try again!", + "SCHEDULED_DELETION": { + "TITLE": "Account Scheduled for Deletion", + "MESSAGE_MANUAL": "This account is scheduled for deletion on {deletionDate}. This was requested by an administrator. You can cancel the deletion before this date.", + "MESSAGE_INACTIVITY": "This account is scheduled for deletion on {deletionDate} due to account inactivity. You can cancel the deletion before this date.", + "CLEAR_BUTTON": "Cancel Scheduled Deletion" + } + }, "FORM": { "ERROR": "Please fix form errors", "GENERAL_SECTION": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index a2a1f28fb..7afb3cf00 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -11,11 +11,15 @@ import semver from 'semver'; import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import V4Button from 'dashboard/components-next/button/Button.vue'; +import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; export default { components: { BaseSettingsHeader, V4Button, + WootConfirmDeleteModal, + NextButton, }, setup() { const { updateUISettings } = useUISettings(); @@ -35,6 +39,7 @@ export default { features: {}, autoResolveDuration: null, latestChatwootVersion: null, + showDeletePopup: false, }; }, validations: { @@ -55,6 +60,7 @@ export default { getAccount: 'accounts/getAccount', uiFlags: 'accounts/getUIFlags', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), showAutoResolutionConfig() { return this.isFeatureEnabledonAccount( @@ -101,6 +107,34 @@ export default { getAccountId() { return this.id.toString(); }, + confirmPlaceHolderText() { + return `${this.$t( + 'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER', + { + accountName: this.name, + } + )}`; + }, + isMarkedForDeletion() { + const { custom_attributes = {} } = this.currentAccount; + return !!custom_attributes.marked_for_deletion_at; + }, + markedForDeletionDate() { + const { custom_attributes = {} } = this.currentAccount; + if (!custom_attributes.marked_for_deletion_at) return null; + return new Date(custom_attributes.marked_for_deletion_at); + }, + markedForDeletionReason() { + const { custom_attributes = {} } = this.currentAccount; + return custom_attributes.marked_for_deletion_reason || 'manual_deletion'; + }, + formattedDeletionDate() { + if (!this.markedForDeletionDate) return ''; + return this.markedForDeletionDate.toLocaleString(); + }, + currentAccount() { + return this.getAccount(this.accountId) || {}; + }, }, mounted() { this.initializeAccount(); @@ -162,6 +196,56 @@ export default { rtl_view: isRTLSupported, }); }, + // Delete Function + openDeletePopup() { + this.showDeletePopup = true; + }, + closeDeletePopup() { + this.showDeletePopup = false; + }, + async markAccountForDeletion() { + this.closeDeletePopup(); + try { + // Use the enterprise API to toggle deletion with delete action + await this.$store.dispatch('accounts/toggleDeletion', { + action_type: 'delete', + }); + // Refresh account data + await this.$store.dispatch('accounts/get'); + useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS')); + } catch (error) { + // Handle error message + this.handleDeletionError(error); + } + }, + handleDeletionError(error) { + const errorKey = error.response?.data?.error_key; + if (errorKey) { + useAlert( + this.$t(`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.${errorKey}`) + ); + return; + } + const message = error.response?.data?.message; + if (message) { + useAlert(message); + return; + } + useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE')); + }, + async clearDeletionMark() { + try { + // Use the enterprise API to toggle deletion with undelete action + await this.$store.dispatch('accounts/toggleDeletion', { + action_type: 'undelete', + }); + // Refresh account data + await this.$store.dispatch('accounts/get'); + useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); + } catch (error) { + useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR')); + } + }, }, }; @@ -175,7 +259,7 @@ export default { -
+
+
+
+
+

+ {{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE') }} +

+

+ {{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE') }} +

+
+
+
+
+

+ {{ + $t( + `GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_${markedForDeletionReason === 'manual_deletion' ? 'MANUAL' : 'INACTIVITY'}`, + { + deletionDate: formattedDeletionDate, + } + ) + }} +

+ +
+
+
+ +
+
+
+ +
{{ `v${globalConfig.appVersion}` }}
diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index fb2c8b89f..561695d28 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -73,6 +73,29 @@ export const actions = { throw new Error(error); } }, + delete: async ({ commit }, { id }) => { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); + try { + await AccountAPI.delete(id); + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }); + } catch (error) { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }); + throw new Error(error); + } + }, + toggleDeletion: async ( + { commit }, + { action_type } = { action_type: 'delete' } + ) => { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); + try { + await EnterpriseAccountAPI.toggleDeletion(action_type); + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }); + } catch (error) { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }); + throw new Error(error); + } + }, create: async ({ commit }, accountInfo) => { commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true }); try { diff --git a/app/javascript/dashboard/store/modules/specs/account/actions.spec.js b/app/javascript/dashboard/store/modules/specs/account/actions.spec.js index 92f1328a5..57b4a2f80 100644 --- a/app/javascript/dashboard/store/modules/specs/account/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/account/actions.spec.js @@ -80,4 +80,41 @@ describe('#actions', () => { ]); }); }); + + describe('#toggleDeletion', () => { + it('sends correct actions with delete action if API is success', async () => { + axios.post.mockResolvedValue({}); + await actions.toggleDeletion({ commit }, { action_type: 'delete' }); + expect(commit.mock.calls).toEqual([ + [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }], + [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }], + ]); + expect(axios.post.mock.calls[0][1]).toEqual({ + action_type: 'delete', + }); + }); + + it('sends correct actions with undelete action if API is success', async () => { + axios.post.mockResolvedValue({}); + await actions.toggleDeletion({ commit }, { action_type: 'undelete' }); + expect(commit.mock.calls).toEqual([ + [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }], + [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }], + ]); + expect(axios.post.mock.calls[0][1]).toEqual({ + action_type: 'undelete', + }); + }); + + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.toggleDeletion({ commit }, { action_type: 'delete' }) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }], + [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }], + ]); + }); + }); }); diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb index 778542b3c..33edcaa34 100644 --- a/app/jobs/account/contacts_export_job.rb +++ b/app/jobs/account/contacts_export_job.rb @@ -51,7 +51,7 @@ class Account::ContactsExportJob < ApplicationJob def send_mail file_url = account_contact_export_url - mailer = AdministratorNotifications::ChannelNotificationsMailer.with(account: @account) + mailer = AdministratorNotifications::AccountNotificationMailer.with(account: @account) mailer.contact_export_complete(file_url, @account_user.email)&.deliver_later end diff --git a/app/jobs/data_import_job.rb b/app/jobs/data_import_job.rb index 9703d2e50..6146336fa 100644 --- a/app/jobs/data_import_job.rb +++ b/app/jobs/data_import_job.rb @@ -93,10 +93,10 @@ class DataImportJob < ApplicationJob end def send_import_notification_to_admin - AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later + AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later end def send_import_failed_notification_to_admin - AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_failed.deliver_later + AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later end end diff --git a/app/mailers/administrator_notifications/account_notification_mailer.rb b/app/mailers/administrator_notifications/account_notification_mailer.rb new file mode 100644 index 000000000..8837e4f8c --- /dev/null +++ b/app/mailers/administrator_notifications/account_notification_mailer.rb @@ -0,0 +1,48 @@ +class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer + def account_deletion(account, reason = 'manual_deletion') + subject = 'Your account has been marked for deletion' + action_url = settings_url('general') + meta = { + 'account_name' => account.name, + 'deletion_date' => account.custom_attributes['marked_for_deletion_at'], + 'reason' => reason + } + + send_notification(subject, action_url: action_url, meta: meta) + end + + def contact_import_complete(resource) + subject = 'Contact Import Completed' + + action_url = if resource.failed_records.attached? + Rails.application.routes.url_helpers.rails_blob_url(resource.failed_records) + else + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts" + end + + meta = { + 'failed_contacts' => resource.total_records - resource.processed_records, + 'imported_contacts' => resource.processed_records + } + + send_notification(subject, action_url: action_url, meta: meta) + end + + def contact_import_failed + subject = 'Contact Import Failed' + send_notification(subject) + end + + def contact_export_complete(file_url, email_to) + subject = "Your contact's export file is available to download." + send_notification(subject, to: email_to, action_url: file_url) + end + + def automation_rule_disabled(rule) + subject = 'Automation rule disabled due to validation errors.' + action_url = settings_url('automation/list') + meta = { 'rule_name' => rule.name } + + send_notification(subject, action_url: action_url, meta: meta) + end +end diff --git a/app/mailers/administrator_notifications/base_mailer.rb b/app/mailers/administrator_notifications/base_mailer.rb new file mode 100644 index 000000000..2cedddbdb --- /dev/null +++ b/app/mailers/administrator_notifications/base_mailer.rb @@ -0,0 +1,31 @@ +class AdministratorNotifications::BaseMailer < ApplicationMailer + # Common method to check SMTP configuration and send mail with liquid + def send_notification(subject, to: nil, action_url: nil, meta: {}) + return unless smtp_config_set_or_development? + + @action_url = action_url + @meta = meta || {} + + send_mail_with_liquid(to: to || admin_emails, subject: subject) and return + end + + # Helper method to generate inbox URL + def inbox_url(inbox) + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}" + end + + # Helper method to generate settings URL + def settings_url(section) + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/#{section}" + end + + private + + def admin_emails + Current.account.administrators.pluck(:email) + end + + def liquid_locals + super.merge({ meta: @meta }) + end +end diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb index dc4e6d7fe..e884b3df9 100644 --- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb +++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb @@ -1,93 +1,16 @@ -class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer - def slack_disconnect - return unless smtp_config_set_or_development? - - subject = 'Your Slack integration has expired' - @action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack" - send_mail_with_liquid(to: admin_emails, subject: subject) and return - end - - def dialogflow_disconnect - return unless smtp_config_set_or_development? - - subject = 'Your Dialogflow integration was disconnected' - send_mail_with_liquid(to: admin_emails, subject: subject) and return - end - +class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNotifications::BaseMailer def facebook_disconnect(inbox) - return unless smtp_config_set_or_development? - subject = 'Your Facebook page connection has expired' - @action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}" - send_mail_with_liquid(to: admin_emails, subject: subject) and return + send_notification(subject, action_url: inbox_url(inbox)) end def whatsapp_disconnect(inbox) - return unless smtp_config_set_or_development? - subject = 'Your Whatsapp connection has expired' - @action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}" - send_mail_with_liquid(to: admin_emails, subject: subject) and return + send_notification(subject, action_url: inbox_url(inbox)) end def email_disconnect(inbox) - return unless smtp_config_set_or_development? - subject = 'Your email inbox has been disconnected. Please update the credentials for SMTP/IMAP' - @action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}" - send_mail_with_liquid(to: admin_emails, subject: subject) and return - end - - def contact_import_complete(resource) - return unless smtp_config_set_or_development? - - subject = 'Contact Import Completed' - - @action_url = Rails.application.routes.url_helpers.rails_blob_url(resource.failed_records) if resource.failed_records.attached? - @action_url ||= "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts" - @meta = {} - @meta['failed_contacts'] = resource.total_records - resource.processed_records - @meta['imported_contacts'] = resource.processed_records - send_mail_with_liquid(to: admin_emails, subject: subject) and return - end - - def contact_import_failed - return unless smtp_config_set_or_development? - - subject = 'Contact Import Failed' - - @meta = {} - send_mail_with_liquid(to: admin_emails, subject: subject) and return - end - - def contact_export_complete(file_url, email_to) - return unless smtp_config_set_or_development? - - @action_url = file_url - subject = "Your contact's export file is available to download." - - send_mail_with_liquid(to: email_to, subject: subject) and return - end - - def automation_rule_disabled(rule) - return unless smtp_config_set_or_development? - - @action_url ||= "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/automation/list" - - subject = 'Automation rule disabled due to validation errors.'.freeze - @meta = {} - @meta['rule_name'] = rule.name - - send_mail_with_liquid(to: admin_emails, subject: subject) and return - end - - private - - def admin_emails - Current.account.administrators.pluck(:email) - end - - def liquid_locals - super.merge({ meta: @meta }) + send_notification(subject, action_url: inbox_url(inbox)) end end diff --git a/app/mailers/administrator_notifications/integrations_notification_mailer.rb b/app/mailers/administrator_notifications/integrations_notification_mailer.rb new file mode 100644 index 000000000..05477eca1 --- /dev/null +++ b/app/mailers/administrator_notifications/integrations_notification_mailer.rb @@ -0,0 +1,12 @@ +class AdministratorNotifications::IntegrationsNotificationMailer < AdministratorNotifications::BaseMailer + def slack_disconnect + subject = 'Your Slack integration has expired' + action_url = settings_url('integrations/slack') + send_notification(subject, action_url: action_url) + end + + def dialogflow_disconnect + subject = 'Your Dialogflow integration was disconnected' + send_notification(subject) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index eb95194c5..1cd59e1a4 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -162,5 +162,6 @@ class Account < ApplicationRecord end Account.prepend_mod_with('Account') +Account.prepend_mod_with('Account::PlanUsageAndLimits') Account.include_mod_with('Concerns::Account') Account.include_mod_with('Audit::Account') diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index b6792ebf1..32de1a8ef 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -39,33 +39,39 @@ module Reauthorizable def prompt_reauthorization! ::Redis::Alfred.set(reauthorization_required_key, true) - mailer = AdministratorNotifications::ChannelNotificationsMailer.with(account: account) - case self.class.name when 'Integrations::Hook' - process_integration_hook_reauthorization_emails(mailer) + process_integration_hook_reauthorization_emails when 'Channel::FacebookPage' - mailer.facebook_disconnect(inbox).deliver_later + send_channel_reauthorization_email(:facebook_disconnect) when 'Channel::Whatsapp' - mailer.whatsapp_disconnect(inbox).deliver_later + send_channel_reauthorization_email(:whatsapp_disconnect) when 'Channel::Email' - mailer.email_disconnect(inbox).deliver_later + send_channel_reauthorization_email(:email_disconnect) when 'AutomationRule' - update!(active: false) - mailer.automation_rule_disabled(self).deliver_later + handle_automation_rule_reauthorization end invalidate_inbox_cache unless instance_of?(::AutomationRule) end - def process_integration_hook_reauthorization_emails(mailer) + def process_integration_hook_reauthorization_emails if slack? - mailer.slack_disconnect.deliver_later + AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later elsif dialogflow? - mailer.dialogflow_disconnect.deliver_later + AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later end end + def send_channel_reauthorization_email(disconnect_type) + AdministratorNotifications::ChannelNotificationsMailer.with(account: account).public_send(disconnect_type, inbox).deliver_later + end + + def handle_automation_rule_reauthorization + update!(active: false) + AdministratorNotifications::AccountNotificationMailer.with(account: account).automation_rule_disabled(self).deliver_later + end + # call this after you successfully Reauthorized the object in UI def reauthorized! ::Redis::Alfred.delete(authorization_error_count_key) diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 5eb80c1ab..61e02ae77 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -26,4 +26,8 @@ class AccountPolicy < ApplicationPolicy def checkout? @account_user.administrator? end + + def toggle_deletion? + @account_user.administrator? + end end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index 5e9d9048a..52a199167 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -11,6 +11,10 @@ if resource.custom_attributes.present? json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present? json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present? json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present? + json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present? + if resource.custom_attributes['marked_for_deletion_reason'].present? + json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason'] + end end end json.domain @account.domain diff --git a/app/views/mailers/administrator_notifications/account_notification_mailer/account_deletion.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/account_deletion.liquid new file mode 100644 index 000000000..0873dbac1 --- /dev/null +++ b/app/views/mailers/administrator_notifications/account_notification_mailer/account_deletion.liquid @@ -0,0 +1,16 @@ +

Hello,

+ +

Your account {{ meta.account_name }} has been marked for deletion. The account will be permanently deleted on {{ meta.deletion_date }}.

+ +{% if meta.reason == 'manual_deletion' %} +

This action was requested by one of the administrators of your account.

+{% else %} +

Reason for deletion: {{ meta.reason }}

+{% endif %} + +

If this was done in error, you can cancel the deletion process by visiting your account settings.

+ +

Cancel Account Deletion

+ +

Thank you,
+Team Chatwoot

\ No newline at end of file diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/automation_rule_disabled.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/automation_rule_disabled.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_export_complete.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_export_complete.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_complete.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_complete.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_complete.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_complete.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_failed.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_failed.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/dialogflow_disconnect.liquid b/app/views/mailers/administrator_notifications/integrations_notification_mailer/dialogflow_disconnect.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/dialogflow_disconnect.liquid rename to app/views/mailers/administrator_notifications/integrations_notification_mailer/dialogflow_disconnect.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/slack_disconnect.liquid b/app/views/mailers/administrator_notifications/integrations_notification_mailer/slack_disconnect.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/slack_disconnect.liquid rename to app/views/mailers/administrator_notifications/integrations_notification_mailer/slack_disconnect.liquid diff --git a/config/routes.rb b/config/routes.rb index 5bc965337..87344924d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -365,6 +365,7 @@ Rails.application.routes.draw do post :checkout post :subscription get :limits + post :toggle_deletion end end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 86ec2fb55..70f1d177d 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -2,7 +2,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper before_action :fetch_account before_action :check_authorization - before_action :check_cloud_env, only: [:limits] + before_action :check_cloud_env, only: [:limits, :toggle_deletion] def subscription if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? @@ -42,13 +42,26 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController render_invalid_billing_details end + def toggle_deletion + action_type = params[:action_type] + + case action_type + when 'delete' + mark_for_deletion + when 'undelete' + unmark_for_deletion + else + render json: { error: 'Invalid action_type. Must be either "delete" or "undelete"' }, status: :unprocessable_entity + end + end + + private + def check_cloud_env installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV') render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud' end - private - def default_limits { 'conversation' => {}, @@ -67,6 +80,24 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController @account.custom_attributes['stripe_customer_id'] end + def mark_for_deletion + reason = 'manual_deletion' + + if @account.mark_for_deletion(reason) + render json: { message: 'Account marked for deletion' }, status: :ok + else + render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + + def unmark_for_deletion + if @account.unmark_for_deletion + render json: { message: 'Account unmarked for deletion' }, status: :ok + else + render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + def render_invalid_billing_details render_could_not_create_error('Please subscribe to a plan before viewing the billing details') end diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index a1b0d0449..37bffc5a6 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -1,130 +1,14 @@ module Enterprise::Account - CAPTAIN_RESPONSES = 'captain_responses'.freeze - CAPTAIN_DOCUMENTS = 'captain_documents'.freeze - CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze - CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze + def mark_for_deletion(reason = 'manual_deletion') + result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save - def usage_limits - { - agents: agent_limits.to_i, - inboxes: get_limits(:inboxes).to_i, - captain: { - documents: get_captain_limits(:documents), - responses: get_captain_limits(:responses) - } - } + # Send notification to admin users if the account was successfully marked for deletion + AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result + + result end - def increment_response_usage - current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 - custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1 - save - end - - def reset_response_usage - custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0 - save - end - - def update_document_usage - # this will ensure that the document count is always accurate - custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count - save - end - - def subscribed_features - plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value - return [] if plan_features.blank? - - plan_features[plan_name] - end - - def captain_monthly_limit - default_limits = default_captain_limits - - { - documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'], - responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses'] - }.with_indifferent_access - end - - private - - def get_captain_limits(type) - total_count = captain_monthly_limit[type.to_s].to_i - - consumed = if type == :documents - custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0 - else - custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 - end - - consumed = 0 if consumed.negative? - - { - total_count: total_count, - current_available: (total_count - consumed).clamp(0, total_count), - consumed: consumed - } - end - - def default_captain_limits - max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access - zero_limits = { documents: 0, responses: 0 }.with_indifferent_access - plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value - - # If there are no limits configured, we allow max usage - return max_limits if plan_quota.blank? - - # if there is plan_quota configred, but plan_name is not present, we return zero limits - return zero_limits if plan_name.blank? - - begin - # Now we parse the plan_quota and return the limits for the plan name - # but if there's no plan_name present in the plan_quota, we return zero limits - plan_quota = JSON.parse(plan_quota) if plan_quota.present? - plan_quota[plan_name.downcase] || zero_limits - rescue StandardError - # if there's any error in parsing the plan_quota, we return max limits - # this is to ensure that we don't block the user from using the product - max_limits - end - end - - def plan_name - custom_attributes['plan_name'] - end - - def agent_limits - subscribed_quantity = custom_attributes['subscribed_quantity'] - subscribed_quantity || get_limits(:agents) - end - - def get_limits(limit_name) - config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT" - return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present? - - return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present? - - ChatwootApp.max_limit - end - - def validate_limit_keys - errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash - self[:limits] = {} if self[:limits].blank? - - limit_schema = { - 'type' => 'object', - 'properties' => { - 'inboxes' => { 'type': 'number' }, - 'agents' => { 'type': 'number' }, - 'captain_responses' => { 'type': 'number' }, - 'captain_documents' => { 'type': 'number' } - }, - 'required' => [], - 'additionalProperties' => false - } - - errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits]) + def unmark_for_deletion + custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save end end diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb new file mode 100644 index 000000000..ce03efa41 --- /dev/null +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -0,0 +1,130 @@ +module Enterprise::Account::PlanUsageAndLimits + CAPTAIN_RESPONSES = 'captain_responses'.freeze + CAPTAIN_DOCUMENTS = 'captain_documents'.freeze + CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze + CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze + + def usage_limits + { + agents: agent_limits.to_i, + inboxes: get_limits(:inboxes).to_i, + captain: { + documents: get_captain_limits(:documents), + responses: get_captain_limits(:responses) + } + } + end + + def increment_response_usage + current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 + custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1 + save + end + + def reset_response_usage + custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0 + save + end + + def update_document_usage + # this will ensure that the document count is always accurate + custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count + save + end + + def subscribed_features + plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value + return [] if plan_features.blank? + + plan_features[plan_name] + end + + def captain_monthly_limit + default_limits = default_captain_limits + + { + documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'], + responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses'] + }.with_indifferent_access + end + + private + + def get_captain_limits(type) + total_count = captain_monthly_limit[type.to_s].to_i + + consumed = if type == :documents + custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0 + else + custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 + end + + consumed = 0 if consumed.negative? + + { + total_count: total_count, + current_available: (total_count - consumed).clamp(0, total_count), + consumed: consumed + } + end + + def default_captain_limits + max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access + zero_limits = { documents: 0, responses: 0 }.with_indifferent_access + plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value + + # If there are no limits configured, we allow max usage + return max_limits if plan_quota.blank? + + # if there is plan_quota configred, but plan_name is not present, we return zero limits + return zero_limits if plan_name.blank? + + begin + # Now we parse the plan_quota and return the limits for the plan name + # but if there's no plan_name present in the plan_quota, we return zero limits + plan_quota = JSON.parse(plan_quota) if plan_quota.present? + plan_quota[plan_name.downcase] || zero_limits + rescue StandardError + # if there's any error in parsing the plan_quota, we return max limits + # this is to ensure that we don't block the user from using the product + max_limits + end + end + + def plan_name + custom_attributes['plan_name'] + end + + def agent_limits + subscribed_quantity = custom_attributes['subscribed_quantity'] + subscribed_quantity || get_limits(:agents) + end + + def get_limits(limit_name) + config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT" + return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present? + + return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present? + + ChatwootApp.max_limit + end + + def validate_limit_keys + errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash + self[:limits] = {} if self[:limits].blank? + + limit_schema = { + 'type' => 'object', + 'properties' => { + 'inboxes' => { 'type': 'number' }, + 'agents' => { 'type': 'number' }, + 'captain_responses' => { 'type': 'number' }, + 'captain_documents' => { 'type': 'number' } + }, + 'required' => [], + 'additionalProperties' => false + } + + errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits]) + end +end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index ac26dc525..0bd917c06 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -241,4 +241,99 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do end end end + + describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + context 'when it is an agent' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when deployment environment is not cloud' do + before do + # Set deployment environment to something other than cloud + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted') + end + + it 'returns not found' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'delete' }, + as: :json + + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq('Not found') + end + end + + context 'when it is an admin' do + before do + # Create the installation config for cloud environment + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') + end + + it 'marks the account for deletion when action is delete' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'delete' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_present + expect(account.custom_attributes['marked_for_deletion_reason']).to eq('manual_deletion') + end + + it 'unmarks the account for deletion when action is undelete' do + # First mark the account for deletion + account.update!( + custom_attributes: { + 'marked_for_deletion_at' => 7.days.from_now.iso8601, + 'marked_for_deletion_reason' => 'manual_deletion' + } + ) + + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'undelete' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_nil + expect(account.custom_attributes['marked_for_deletion_reason']).to be_nil + end + + it 'returns error for invalid action' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'invalid' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to include('Invalid action_type') + end + + it 'returns error when action parameter is missing' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to include('Invalid action_type') + end + end + end + end end diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index e36a7217d..4d851d50e 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -221,4 +221,53 @@ RSpec.describe Account, type: :model do end end end + + describe 'account deletion' do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe '#mark_for_deletion' do + it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do + expect do + account.mark_for_deletion('test_reason') + end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present) + .and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason') + end + + it 'sends a notification email to admin users' do + mailer = double + expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer) + expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer) + expect(mailer).to receive(:deliver_later) + + account.mark_for_deletion('test_reason') + end + + it 'returns true when successful' do + expect(account.mark_for_deletion).to be_truthy + end + end + + describe '#unmark_for_deletion' do + before do + account.update!( + custom_attributes: { + 'marked_for_deletion_at' => 7.days.from_now.iso8601, + 'marked_for_deletion_reason' => 'test_reason' + } + ) + end + + it 'removes the marked_for_deletion_at and marked_for_deletion_reason attributes' do + expect do + account.unmark_for_deletion + end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(be_present).to(nil) + .and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from('test_reason').to(nil) + end + + it 'returns true when successful' do + expect(account.unmark_for_deletion).to be_truthy + end + end + end end diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb index 7c1858d0a..e6d3fda6a 100644 --- a/spec/jobs/account/contacts_export_job_spec.rb +++ b/spec/jobs/account/contacts_export_job_spec.rb @@ -60,7 +60,7 @@ RSpec.describe Account::ContactsExportJob do it 'generates CSV file and attach to account' do mailer = double - allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).with(account: account).and_return(mailer) + allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer) allow(mailer).to receive(:contact_export_complete) described_class.perform_now(account.id, user.id, [], {}) diff --git a/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb new file mode 100644 index 000000000..44df38e88 --- /dev/null +++ b/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' +require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb' + +RSpec.describe AdministratorNotifications::AccountNotificationMailer do + include_context 'with smtp config' + + let!(:account) { create(:account) } + let!(:admin) { create(:user, account: account, role: :administrator) } + + describe 'account_deletion' do + let(:reason) { 'manual_deletion' } + let(:mail) { described_class.with(account: account).account_deletion(account, reason) } + let(:deletion_date) { 7.days.from_now.iso8601 } + + before do + account.update!(custom_attributes: { + 'marked_for_deletion_at' => deletion_date, + 'marked_for_deletion_reason' => reason + }) + end + + it 'renders the subject' do + expect(mail.subject).to eq('Your account has been marked for deletion') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + + it 'includes the account name in the email body' do + expect(mail.body.encoded).to include(account.name) + end + + it 'includes the deletion date in the email body' do + expect(mail.body.encoded).to include(deletion_date) + end + + it 'includes a link to cancel the deletion' do + expect(mail.body.encoded).to include('Cancel Account Deletion') + end + + context 'when reason is manual_deletion' do + it 'includes the administrator message' do + expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account') + end + end + + context 'when reason is not manual_deletion' do + let(:reason) { 'inactivity' } + + it 'includes the reason directly' do + expect(mail.body.encoded).to include('Reason for deletion: inactivity') + end + end + end + + describe 'contact_import_complete' do + let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) } + let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Contact Import Completed') + end + + it 'renders the processed records' do + expect(mail.body.encoded).to include('Number of records imported: 8') + expect(mail.body.encoded).to include('Number of records failed: 2') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + end + + describe 'contact_import_failed' do + let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Contact Import Failed') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + end + + describe 'contact_export_complete' do + let!(:file_url) { 'http://test.com/test' } + let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Your contact's export file is available to download.") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + end + + describe 'automation_rule_disabled' do + let(:rule) { instance_double(AutomationRule, name: 'Test Rule') } + let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Automation rule disabled due to validation errors.') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + + it 'includes the rule name in the email body' do + expect(mail.body.encoded).to include('Test Rule') + end + end +end diff --git a/spec/mailers/administrator_notifications/base_mailer_spec.rb b/spec/mailers/administrator_notifications/base_mailer_spec.rb new file mode 100644 index 000000000..619fef0a7 --- /dev/null +++ b/spec/mailers/administrator_notifications/base_mailer_spec.rb @@ -0,0 +1,75 @@ +require 'rails_helper' + +RSpec.describe AdministratorNotifications::BaseMailer do + let!(:account) { create(:account) } + let!(:admin1) { create(:user, account: account, role: :administrator) } + let!(:admin2) { create(:user, account: account, role: :administrator) } + let!(:agent) { create(:user, account: account, role: :agent) } + let(:mailer) { described_class.new } + let!(:inbox) { create(:inbox, account: account) } + + before do + Current.account = account + end + + describe 'admin_emails' do + it 'returns emails of all administrators' do + # Call the private method + admin_emails = mailer.send(:admin_emails) + + expect(admin_emails).to include(admin1.email) + expect(admin_emails).to include(admin2.email) + expect(admin_emails).not_to include(agent.email) + end + end + + describe 'helper methods' do + it 'generates correct inbox URL' do + url = mailer.inbox_url(inbox) + expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/inboxes/#{inbox.id}" + expect(url).to eq(expected_url) + end + + it 'generates correct settings URL' do + url = mailer.settings_url('automation/list') + expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/automation/list" + expect(url).to eq(expected_url) + end + end + + describe 'send_notification' do + before do + allow(mailer).to receive(:smtp_config_set_or_development?).and_return(true) + end + + it 'sends email with correct parameters' do + subject = 'Test Subject' + action_url = 'https://example.com' + meta = { 'key' => 'value' } + + # Mock the send_mail_with_liquid method + expect(mailer).to receive(:send_mail_with_liquid).with( + to: [admin1.email, admin2.email], + subject: subject + ).and_return(true) + + mailer.send_notification(subject, action_url: action_url, meta: meta) + + # Check that instance variables are set correctly + expect(mailer.instance_variable_get(:@action_url)).to eq(action_url) + expect(mailer.instance_variable_get(:@meta)).to eq(meta) + end + + it 'uses provided email addresses when specified' do + subject = 'Test Subject' + custom_email = 'custom@example.com' + + expect(mailer).to receive(:send_mail_with_liquid).with( + to: custom_email, + subject: subject + ).and_return(true) + + mailer.send_notification(subject, to: custom_email) + end + end +end diff --git a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb index 944475fb2..1be1314da 100644 --- a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb @@ -1,45 +1,15 @@ # frozen_string_literal: true require 'rails_helper' +require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb' RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do + include_context 'with smtp config' + let(:class_instance) { described_class.new } let!(:account) { create(:account) } let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: account) } - before do - allow(described_class).to receive(:new).and_return(class_instance) - allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) - end - - describe 'slack_disconnect' do - let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq('Your Slack integration has expired') - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end - - describe 'dialogflow disconnect' do - let(:mail) { described_class.with(account: account).dialogflow_disconnect.deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq('Your Dialogflow integration was disconnected') - end - - it 'renders the content' do - expect(mail.body).to include('Your Dialogflow integration was disconnected because of permission issues.') - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end - describe 'facebook_disconnect' do before do stub_request(:post, /graph.facebook.com/) @@ -47,14 +17,17 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do let!(:facebook_channel) { create(:channel_facebook_page, account: account) } let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) } - let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now } - it 'renders the subject' do - expect(mail.subject).to eq('Your Facebook page connection has expired') - end + context 'when sending the actual email' do + let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now } - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) + it 'renders the subject' do + expect(mail.subject).to eq('Your Facebook page connection has expired') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([administrator.email]) + end end end @@ -71,35 +44,4 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do expect(mail.to).to eq([administrator.email]) end end - - describe 'contact_import_complete' do - let!(:data_import) { build(:data_import, total_records: 10, processed_records: 10) } - let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq('Contact Import Completed') - end - - it 'renders the processed records' do - expect(mail.body.encoded).to match('Number of records imported: 10') - expect(mail.body.encoded).to match('Number of records failed: 0') - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end - - describe 'contact_export_complete' do - let!(:file_url) { 'http://test.com/test' } - let(:mail) { described_class.with(account: account).contact_export_complete(file_url, administrator.email).deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq("Your contact's export file is available to download.") - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end end diff --git a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb new file mode 100644 index 000000000..331d33d06 --- /dev/null +++ b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' +require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb' + +RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do + include_context 'with smtp config' + + let!(:account) { create(:account) } + let!(:administrator) { create(:user, :administrator, email: 'admin@example.com', account: account) } + + describe 'slack_disconnect' do + let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Your Slack integration has expired') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([administrator.email]) + end + + it 'includes reconnect instructions in the body' do + expect(mail.body.encoded).to include('To continue receiving messages on Slack, please delete the integration and connect your workspace again') + end + end + + describe 'dialogflow_disconnect' do + let(:mail) { described_class.with(account: account).dialogflow_disconnect.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Your Dialogflow integration was disconnected') + end + + it 'renders the content' do + expect(mail.body.encoded).to include('Your Dialogflow integration was disconnected because of permission issues') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([administrator.email]) + end + end +end diff --git a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb new file mode 100644 index 000000000..96d4dbb0d --- /dev/null +++ b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with smtp config' do + before do + # We need to use allow_any_instance_of here because smtp_config_set_or_development? + # is defined in ApplicationMailer and needs to be stubbed for all mailer instances + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true) + # rubocop:enable RSpec/AnyInstance + end +end diff --git a/spec/models/concerns/reauthorizable_shared.rb b/spec/models/concerns/reauthorizable_shared.rb index 9efe232e8..a71800267 100644 --- a/spec/models/concerns/reauthorizable_shared.rb +++ b/spec/models/concerns/reauthorizable_shared.rb @@ -2,9 +2,9 @@ require 'rails_helper' shared_examples_for 'reauthorizable' do let(:model) { described_class } # the class that includes the concern + let(:obj) { FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) } it 'authorization_error!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) expect(obj.authorization_error_count).to eq 0 obj.authorization_error! @@ -13,7 +13,6 @@ shared_examples_for 'reauthorizable' do end it 'prompts reauthorization when error threshold is passed' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) expect(obj.reauthorization_required?).to be false obj.class::AUTHORIZATION_ERROR_THRESHOLD.times do @@ -23,25 +22,70 @@ shared_examples_for 'reauthorizable' do expect(obj.reauthorization_required?).to be true end - it 'prompt_reauthorization!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) - mailer = double - mailer_method = double - allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(mailer) - # allow mailer to receive any methods and return mailer - allow(mailer).to receive(:method_missing).and_return(mailer_method) - allow(mailer_method).to receive(:deliver_later) + # Helper methods to set up mailer mocks + def setup_automation_rule_mailer(_obj) + account_mailer = instance_double(AdministratorNotifications::AccountNotificationMailer) + automation_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).and_return(account_mailer) + allow(account_mailer).to receive(:automation_rule_disabled).and_return(automation_mailer_response) + end - expect(obj.reauthorization_required?).to be false + def setup_integrations_hook_mailer(obj) + integrations_mailer = instance_double(AdministratorNotifications::IntegrationsNotificationMailer) + slack_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + dialogflow_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer) + allow(integrations_mailer).to receive(:slack_disconnect).and_return(slack_mailer_response) + allow(integrations_mailer).to receive(:dialogflow_disconnect).and_return(dialogflow_mailer_response) - obj.prompt_reauthorization! - expect(obj.reauthorization_required?).to be true - expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account) - expect(mailer_method).to have_received(:deliver_later) + # Allow the model to respond to slack? and dialogflow? methods + allow(obj).to receive(:slack?).and_return(true) + allow(obj).to receive(:dialogflow?).and_return(false) + end + + def setup_channel_mailer(_obj) + channel_mailer = instance_double(AdministratorNotifications::ChannelNotificationsMailer) + facebook_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + whatsapp_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + email_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(channel_mailer) + allow(channel_mailer).to receive(:facebook_disconnect).and_return(facebook_mailer_response) + allow(channel_mailer).to receive(:whatsapp_disconnect).and_return(whatsapp_mailer_response) + allow(channel_mailer).to receive(:email_disconnect).and_return(email_mailer_response) + end + + describe 'prompt_reauthorization!' do + before do + # Setup mailer mocks based on model type + if model.to_s == 'AutomationRule' + setup_automation_rule_mailer(obj) + elsif model.to_s == 'Integrations::Hook' + setup_integrations_hook_mailer(obj) + else + setup_channel_mailer(obj) + end + end + + it 'sets reauthorization required flag' do + expect(obj.reauthorization_required?).to be false + obj.prompt_reauthorization! + expect(obj.reauthorization_required?).to be true + end + + it 'calls the correct mailer based on model type' do + obj.prompt_reauthorization! + + if model.to_s == 'AutomationRule' + expect(AdministratorNotifications::AccountNotificationMailer).to have_received(:with).with(account: obj.account) + elsif model.to_s == 'Integrations::Hook' + expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: obj.account) + else + expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account) + end + end end it 'reauthorized!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) # setting up the object with the errors to validate its cleared on action obj.authorization_error! obj.prompt_reauthorization!