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)   ## 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:
@@ -81,7 +81,8 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
COLLECTION_FILTERS = {
|
COLLECTION_FILTERS = {
|
||||||
active: ->(resources) { resources.where(status: :active) },
|
active: ->(resources) { resources.where(status: :active) },
|
||||||
suspended: ->(resources) { resources.where(status: :suspended) },
|
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
|
}.freeze
|
||||||
|
|
||||||
# Overwrite this method to customize how accounts are displayed
|
# Overwrite this method to customize how accounts are displayed
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
getLimits() {
|
getLimits() {
|
||||||
return axios.get(`${this.url}limits`);
|
return axios.get(`${this.url}limits`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleDeletion(action) {
|
||||||
|
return axios.post(`${this.url}toggle_deletion`, {
|
||||||
|
action_type: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
expect(accountAPI).toHaveProperty('update');
|
expect(accountAPI).toHaveProperty('update');
|
||||||
expect(accountAPI).toHaveProperty('delete');
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
expect(accountAPI).toHaveProperty('checkout');
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
|
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
'/enterprise/api/v1/subscription'
|
'/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' }
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,26 @@
|
|||||||
"ERROR": "Could not update settings, try again!",
|
"ERROR": "Could not update settings, try again!",
|
||||||
"SUCCESS": "Successfully updated account settings"
|
"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": {
|
"FORM": {
|
||||||
"ERROR": "Please fix form errors",
|
"ERROR": "Please fix form errors",
|
||||||
"GENERAL_SECTION": {
|
"GENERAL_SECTION": {
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import semver from 'semver';
|
|||||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
import V4Button from 'dashboard/components-next/button/Button.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 {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
BaseSettingsHeader,
|
BaseSettingsHeader,
|
||||||
V4Button,
|
V4Button,
|
||||||
|
WootConfirmDeleteModal,
|
||||||
|
NextButton,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { updateUISettings } = useUISettings();
|
const { updateUISettings } = useUISettings();
|
||||||
@@ -35,6 +39,7 @@ export default {
|
|||||||
features: {},
|
features: {},
|
||||||
autoResolveDuration: null,
|
autoResolveDuration: null,
|
||||||
latestChatwootVersion: null,
|
latestChatwootVersion: null,
|
||||||
|
showDeletePopup: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validations: {
|
validations: {
|
||||||
@@ -55,6 +60,7 @@ export default {
|
|||||||
getAccount: 'accounts/getAccount',
|
getAccount: 'accounts/getAccount',
|
||||||
uiFlags: 'accounts/getUIFlags',
|
uiFlags: 'accounts/getUIFlags',
|
||||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
|
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||||
}),
|
}),
|
||||||
showAutoResolutionConfig() {
|
showAutoResolutionConfig() {
|
||||||
return this.isFeatureEnabledonAccount(
|
return this.isFeatureEnabledonAccount(
|
||||||
@@ -101,6 +107,34 @@ export default {
|
|||||||
getAccountId() {
|
getAccountId() {
|
||||||
return this.id.toString();
|
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() {
|
mounted() {
|
||||||
this.initializeAccount();
|
this.initializeAccount();
|
||||||
@@ -162,6 +196,56 @@ export default {
|
|||||||
rtl_view: isRTLSupported,
|
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>
|
</script>
|
||||||
@@ -175,7 +259,7 @@ export default {
|
|||||||
</V4Button>
|
</V4Button>
|
||||||
</template>
|
</template>
|
||||||
</BaseSettingsHeader>
|
</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">
|
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
|
||||||
<div
|
<div
|
||||||
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
|
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
|
||||||
@@ -279,6 +363,73 @@ export default {
|
|||||||
<woot-code :script="getAccountId" />
|
<woot-code :script="getAccountId" />
|
||||||
</div>
|
</div>
|
||||||
</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 class="p-4 text-sm text-center">
|
||||||
<div>{{ `v${globalConfig.appVersion}` }}</div>
|
<div>{{ `v${globalConfig.appVersion}` }}</div>
|
||||||
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
|
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
|
||||||
|
|||||||
@@ -73,6 +73,29 @@ export const actions = {
|
|||||||
throw new Error(error);
|
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) => {
|
create: async ({ commit }, accountInfo) => {
|
||||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true });
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class Account::ContactsExportJob < ApplicationJob
|
|||||||
|
|
||||||
def send_mail
|
def send_mail
|
||||||
file_url = account_contact_export_url
|
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
|
mailer.contact_export_complete(file_url, @account_user.email)&.deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ class DataImportJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_import_notification_to_admin
|
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
|
end
|
||||||
|
|
||||||
def send_import_failed_notification_to_admin
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
31
app/mailers/administrator_notifications/base_mailer.rb
Normal file
31
app/mailers/administrator_notifications/base_mailer.rb
Normal 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
|
||||||
@@ -1,93 +1,16 @@
|
|||||||
class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
|
class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNotifications::BaseMailer
|
||||||
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
|
|
||||||
|
|
||||||
def facebook_disconnect(inbox)
|
def facebook_disconnect(inbox)
|
||||||
return unless smtp_config_set_or_development?
|
|
||||||
|
|
||||||
subject = 'Your Facebook page connection has expired'
|
subject = 'Your Facebook page connection has expired'
|
||||||
@action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
|
send_notification(subject, action_url: inbox_url(inbox))
|
||||||
send_mail_with_liquid(to: admin_emails, subject: subject) and return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_disconnect(inbox)
|
def whatsapp_disconnect(inbox)
|
||||||
return unless smtp_config_set_or_development?
|
|
||||||
|
|
||||||
subject = 'Your Whatsapp connection has expired'
|
subject = 'Your Whatsapp connection has expired'
|
||||||
@action_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
|
send_notification(subject, action_url: inbox_url(inbox))
|
||||||
send_mail_with_liquid(to: admin_emails, subject: subject) and return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def email_disconnect(inbox)
|
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'
|
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_notification(subject, action_url: inbox_url(inbox))
|
||||||
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 })
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -162,5 +162,6 @@ class Account < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
Account.prepend_mod_with('Account')
|
Account.prepend_mod_with('Account')
|
||||||
|
Account.prepend_mod_with('Account::PlanUsageAndLimits')
|
||||||
Account.include_mod_with('Concerns::Account')
|
Account.include_mod_with('Concerns::Account')
|
||||||
Account.include_mod_with('Audit::Account')
|
Account.include_mod_with('Audit::Account')
|
||||||
|
|||||||
@@ -39,33 +39,39 @@ module Reauthorizable
|
|||||||
def prompt_reauthorization!
|
def prompt_reauthorization!
|
||||||
::Redis::Alfred.set(reauthorization_required_key, true)
|
::Redis::Alfred.set(reauthorization_required_key, true)
|
||||||
|
|
||||||
mailer = AdministratorNotifications::ChannelNotificationsMailer.with(account: account)
|
|
||||||
|
|
||||||
case self.class.name
|
case self.class.name
|
||||||
when 'Integrations::Hook'
|
when 'Integrations::Hook'
|
||||||
process_integration_hook_reauthorization_emails(mailer)
|
process_integration_hook_reauthorization_emails
|
||||||
when 'Channel::FacebookPage'
|
when 'Channel::FacebookPage'
|
||||||
mailer.facebook_disconnect(inbox).deliver_later
|
send_channel_reauthorization_email(:facebook_disconnect)
|
||||||
when 'Channel::Whatsapp'
|
when 'Channel::Whatsapp'
|
||||||
mailer.whatsapp_disconnect(inbox).deliver_later
|
send_channel_reauthorization_email(:whatsapp_disconnect)
|
||||||
when 'Channel::Email'
|
when 'Channel::Email'
|
||||||
mailer.email_disconnect(inbox).deliver_later
|
send_channel_reauthorization_email(:email_disconnect)
|
||||||
when 'AutomationRule'
|
when 'AutomationRule'
|
||||||
update!(active: false)
|
handle_automation_rule_reauthorization
|
||||||
mailer.automation_rule_disabled(self).deliver_later
|
|
||||||
end
|
end
|
||||||
|
|
||||||
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_integration_hook_reauthorization_emails(mailer)
|
def process_integration_hook_reauthorization_emails
|
||||||
if slack?
|
if slack?
|
||||||
mailer.slack_disconnect.deliver_later
|
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later
|
||||||
elsif dialogflow?
|
elsif dialogflow?
|
||||||
mailer.dialogflow_disconnect.deliver_later
|
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later
|
||||||
end
|
end
|
||||||
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
|
# call this after you successfully Reauthorized the object in UI
|
||||||
def reauthorized!
|
def reauthorized!
|
||||||
::Redis::Alfred.delete(authorization_error_count_key)
|
::Redis::Alfred.delete(authorization_error_count_key)
|
||||||
|
|||||||
@@ -26,4 +26,8 @@ class AccountPolicy < ApplicationPolicy
|
|||||||
def checkout?
|
def checkout?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def toggle_deletion?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ if resource.custom_attributes.present?
|
|||||||
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].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.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.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
|
||||||
end
|
end
|
||||||
json.domain @account.domain
|
json.domain @account.domain
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -365,6 +365,7 @@ Rails.application.routes.draw do
|
|||||||
post :checkout
|
post :checkout
|
||||||
post :subscription
|
post :subscription
|
||||||
get :limits
|
get :limits
|
||||||
|
post :toggle_deletion
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
|||||||
include BillingHelper
|
include BillingHelper
|
||||||
before_action :fetch_account
|
before_action :fetch_account
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :check_cloud_env, only: [:limits]
|
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
|
||||||
|
|
||||||
def subscription
|
def subscription
|
||||||
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
|
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
|
render_invalid_billing_details
|
||||||
end
|
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
|
def check_cloud_env
|
||||||
installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')
|
installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')
|
||||||
render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud'
|
render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud'
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def default_limits
|
def default_limits
|
||||||
{
|
{
|
||||||
'conversation' => {},
|
'conversation' => {},
|
||||||
@@ -67,6 +80,24 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
|||||||
@account.custom_attributes['stripe_customer_id']
|
@account.custom_attributes['stripe_customer_id']
|
||||||
end
|
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
|
def render_invalid_billing_details
|
||||||
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
|
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,130 +1,14 @@
|
|||||||
module Enterprise::Account
|
module Enterprise::Account
|
||||||
CAPTAIN_RESPONSES = 'captain_responses'.freeze
|
def mark_for_deletion(reason = 'manual_deletion')
|
||||||
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
|
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
|
||||||
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
|
|
||||||
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
|
|
||||||
|
|
||||||
def usage_limits
|
# 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
|
||||||
agents: agent_limits.to_i,
|
|
||||||
inboxes: get_limits(:inboxes).to_i,
|
result
|
||||||
captain: {
|
|
||||||
documents: get_captain_limits(:documents),
|
|
||||||
responses: get_captain_limits(:responses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def increment_response_usage
|
def unmark_for_deletion
|
||||||
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -241,4 +241,99 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -221,4 +221,53 @@ RSpec.describe Account, type: :model do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ RSpec.describe Account::ContactsExportJob do
|
|||||||
|
|
||||||
it 'generates CSV file and attach to account' do
|
it 'generates CSV file and attach to account' do
|
||||||
mailer = double
|
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)
|
allow(mailer).to receive(:contact_export_complete)
|
||||||
|
|
||||||
described_class.perform_now(account.id, user.id, [], {})
|
described_class.perform_now(account.id, user.id, [], {})
|
||||||
|
|||||||
@@ -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
|
||||||
75
spec/mailers/administrator_notifications/base_mailer_spec.rb
Normal file
75
spec/mailers/administrator_notifications/base_mailer_spec.rb
Normal 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
|
||||||
@@ -1,45 +1,15 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
|
||||||
|
|
||||||
RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
|
RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
|
||||||
|
include_context 'with smtp config'
|
||||||
|
|
||||||
let(:class_instance) { described_class.new }
|
let(:class_instance) { described_class.new }
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: 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
|
describe 'facebook_disconnect' do
|
||||||
before do
|
before do
|
||||||
stub_request(:post, /graph.facebook.com/)
|
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_channel) { create(:channel_facebook_page, account: account) }
|
||||||
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, 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
|
context 'when sending the actual email' do
|
||||||
expect(mail.subject).to eq('Your Facebook page connection has expired')
|
let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now }
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders the receiver email' do
|
it 'renders the subject' do
|
||||||
expect(mail.to).to eq([administrator.email])
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -71,35 +44,4 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
|
|||||||
expect(mail.to).to eq([administrator.email])
|
expect(mail.to).to eq([administrator.email])
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -2,9 +2,9 @@ require 'rails_helper'
|
|||||||
|
|
||||||
shared_examples_for 'reauthorizable' do
|
shared_examples_for 'reauthorizable' do
|
||||||
let(:model) { described_class } # the class that includes the concern
|
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
|
it 'authorization_error!' do
|
||||||
obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym)
|
|
||||||
expect(obj.authorization_error_count).to eq 0
|
expect(obj.authorization_error_count).to eq 0
|
||||||
|
|
||||||
obj.authorization_error!
|
obj.authorization_error!
|
||||||
@@ -13,7 +13,6 @@ shared_examples_for 'reauthorizable' do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'prompts reauthorization when error threshold is passed' do
|
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
|
expect(obj.reauthorization_required?).to be false
|
||||||
|
|
||||||
obj.class::AUTHORIZATION_ERROR_THRESHOLD.times do
|
obj.class::AUTHORIZATION_ERROR_THRESHOLD.times do
|
||||||
@@ -23,25 +22,70 @@ shared_examples_for 'reauthorizable' do
|
|||||||
expect(obj.reauthorization_required?).to be true
|
expect(obj.reauthorization_required?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'prompt_reauthorization!' do
|
# Helper methods to set up mailer mocks
|
||||||
obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym)
|
def setup_automation_rule_mailer(_obj)
|
||||||
mailer = double
|
account_mailer = instance_double(AdministratorNotifications::AccountNotificationMailer)
|
||||||
mailer_method = double
|
automation_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
|
||||||
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(mailer)
|
allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).and_return(account_mailer)
|
||||||
# allow mailer to receive any methods and return mailer
|
allow(account_mailer).to receive(:automation_rule_disabled).and_return(automation_mailer_response)
|
||||||
allow(mailer).to receive(:method_missing).and_return(mailer_method)
|
end
|
||||||
allow(mailer_method).to receive(:deliver_later)
|
|
||||||
|
|
||||||
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!
|
# Allow the model to respond to slack? and dialogflow? methods
|
||||||
expect(obj.reauthorization_required?).to be true
|
allow(obj).to receive(:slack?).and_return(true)
|
||||||
expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account)
|
allow(obj).to receive(:dialogflow?).and_return(false)
|
||||||
expect(mailer_method).to have_received(:deliver_later)
|
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
|
end
|
||||||
|
|
||||||
it 'reauthorized!' do
|
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
|
# setting up the object with the errors to validate its cleared on action
|
||||||
obj.authorization_error!
|
obj.authorization_error!
|
||||||
obj.prompt_reauthorization!
|
obj.prompt_reauthorization!
|
||||||
|
|||||||
Reference in New Issue
Block a user