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 <sojan@pepalo.com>
Co-authored-by: Sojan Jose <sojan.official@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Pranjal Kushwaha
2025-04-03 10:41:39 +05:30
committed by GitHub
parent 8bf2081aff
commit 0dc2af3c78
37 changed files with 1030 additions and 311 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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' }
);
});
});
});

View File

@@ -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": {

View File

@@ -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'));
}
},
},
};
</script>
@@ -175,7 +259,7 @@ export default {
</V4Button>
</template>
</BaseSettingsHeader>
<div class="flex-grow flex-shrink min-w-0 overflow-auto mt-3">
<div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
@@ -279,6 +363,73 @@ export default {
<woot-code :script="getAccountId" />
</div>
</div>
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<div
class="flex flex-row pt-4 mt-2 border-t border-slate-25 dark:border-slate-800 text-black-900 dark:text-slate-300"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<div v-if="isMarkedForDeletion">
<div
class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-red-50 dark:bg-red-900 rounded"
>
<p class="mb-4">
{{
$t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_${markedForDeletionReason === 'manual_deletion' ? 'MANUAL' : 'INACTIVITY'}`,
{
deletionDate: formattedDeletionDate,
}
)
}}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="openDeletePopup()"
/>
</div>
</div>
</div>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')
"
:confirm-value="name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="closeDeletePopup"
/>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">

View File

@@ -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 {

View File

@@ -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 }],
]);
});
});
});

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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)

View File

@@ -26,4 +26,8 @@ class AccountPolicy < ApplicationPolicy
def checkout?
@account_user.administrator?
end
def toggle_deletion?
@account_user.administrator?
end
end

View File

@@ -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

View File

@@ -0,0 +1,16 @@
<p>Hello,</p>
<p>Your account <strong>{{ meta.account_name }}</strong> has been marked for deletion. The account will be permanently deleted on <strong>{{ meta.deletion_date }}</strong>.</p>
{% if meta.reason == 'manual_deletion' %}
<p>This action was requested by one of the administrators of your account.</p>
{% else %}
<p>Reason for deletion: {{ meta.reason }}</p>
{% endif %}
<p>If this was done in error, you can cancel the deletion process by visiting your account settings.</p>
<p><a href="{{ action_url }}">Cancel Account Deletion</a></p>
<p>Thank you,<br>
Team Chatwoot</p>

View File

@@ -365,6 +365,7 @@ Rails.application.routes.draw do
post :checkout
post :subscription
get :limits
post :toggle_deletion
end
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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, [], {})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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!