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

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

+

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

+
+
+
+
+

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

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

Hello,

+ +

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

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

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

+{% else %} +

Reason for deletion: {{ meta.reason }}

+{% endif %} + +

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

+ +

Cancel Account Deletion

+ +

Thank you,
+Team Chatwoot

\ No newline at end of file diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/automation_rule_disabled.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/automation_rule_disabled.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_export_complete.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_export_complete.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_complete.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_complete.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_complete.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_complete.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid b/app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_failed.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid rename to app/views/mailers/administrator_notifications/account_notification_mailer/contact_import_failed.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/dialogflow_disconnect.liquid b/app/views/mailers/administrator_notifications/integrations_notification_mailer/dialogflow_disconnect.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/dialogflow_disconnect.liquid rename to app/views/mailers/administrator_notifications/integrations_notification_mailer/dialogflow_disconnect.liquid diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/slack_disconnect.liquid b/app/views/mailers/administrator_notifications/integrations_notification_mailer/slack_disconnect.liquid similarity index 100% rename from app/views/mailers/administrator_notifications/channel_notifications_mailer/slack_disconnect.liquid rename to app/views/mailers/administrator_notifications/integrations_notification_mailer/slack_disconnect.liquid diff --git a/config/routes.rb b/config/routes.rb index 5bc965337..87344924d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -365,6 +365,7 @@ Rails.application.routes.draw do post :checkout post :subscription get :limits + post :toggle_deletion end end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 86ec2fb55..70f1d177d 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -2,7 +2,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper before_action :fetch_account before_action :check_authorization - before_action :check_cloud_env, only: [:limits] + before_action :check_cloud_env, only: [:limits, :toggle_deletion] def subscription if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? @@ -42,13 +42,26 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController render_invalid_billing_details end + def toggle_deletion + action_type = params[:action_type] + + case action_type + when 'delete' + mark_for_deletion + when 'undelete' + unmark_for_deletion + else + render json: { error: 'Invalid action_type. Must be either "delete" or "undelete"' }, status: :unprocessable_entity + end + end + + private + def check_cloud_env installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV') render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud' end - private - def default_limits { 'conversation' => {}, @@ -67,6 +80,24 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController @account.custom_attributes['stripe_customer_id'] end + def mark_for_deletion + reason = 'manual_deletion' + + if @account.mark_for_deletion(reason) + render json: { message: 'Account marked for deletion' }, status: :ok + else + render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + + def unmark_for_deletion + if @account.unmark_for_deletion + render json: { message: 'Account unmarked for deletion' }, status: :ok + else + render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + def render_invalid_billing_details render_could_not_create_error('Please subscribe to a plan before viewing the billing details') end diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index a1b0d0449..37bffc5a6 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -1,130 +1,14 @@ module Enterprise::Account - CAPTAIN_RESPONSES = 'captain_responses'.freeze - CAPTAIN_DOCUMENTS = 'captain_documents'.freeze - CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze - CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze + def mark_for_deletion(reason = 'manual_deletion') + result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save - def usage_limits - { - agents: agent_limits.to_i, - inboxes: get_limits(:inboxes).to_i, - captain: { - documents: get_captain_limits(:documents), - responses: get_captain_limits(:responses) - } - } + # Send notification to admin users if the account was successfully marked for deletion + AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result + + result end - def increment_response_usage - current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 - custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1 - save - end - - def reset_response_usage - custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0 - save - end - - def update_document_usage - # this will ensure that the document count is always accurate - custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count - save - end - - def subscribed_features - plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value - return [] if plan_features.blank? - - plan_features[plan_name] - end - - def captain_monthly_limit - default_limits = default_captain_limits - - { - documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'], - responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses'] - }.with_indifferent_access - end - - private - - def get_captain_limits(type) - total_count = captain_monthly_limit[type.to_s].to_i - - consumed = if type == :documents - custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0 - else - custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 - end - - consumed = 0 if consumed.negative? - - { - total_count: total_count, - current_available: (total_count - consumed).clamp(0, total_count), - consumed: consumed - } - end - - def default_captain_limits - max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access - zero_limits = { documents: 0, responses: 0 }.with_indifferent_access - plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value - - # If there are no limits configured, we allow max usage - return max_limits if plan_quota.blank? - - # if there is plan_quota configred, but plan_name is not present, we return zero limits - return zero_limits if plan_name.blank? - - begin - # Now we parse the plan_quota and return the limits for the plan name - # but if there's no plan_name present in the plan_quota, we return zero limits - plan_quota = JSON.parse(plan_quota) if plan_quota.present? - plan_quota[plan_name.downcase] || zero_limits - rescue StandardError - # if there's any error in parsing the plan_quota, we return max limits - # this is to ensure that we don't block the user from using the product - max_limits - end - end - - def plan_name - custom_attributes['plan_name'] - end - - def agent_limits - subscribed_quantity = custom_attributes['subscribed_quantity'] - subscribed_quantity || get_limits(:agents) - end - - def get_limits(limit_name) - config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT" - return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present? - - return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present? - - ChatwootApp.max_limit - end - - def validate_limit_keys - errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash - self[:limits] = {} if self[:limits].blank? - - limit_schema = { - 'type' => 'object', - 'properties' => { - 'inboxes' => { 'type': 'number' }, - 'agents' => { 'type': 'number' }, - 'captain_responses' => { 'type': 'number' }, - 'captain_documents' => { 'type': 'number' } - }, - 'required' => [], - 'additionalProperties' => false - } - - errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits]) + def unmark_for_deletion + custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save end end diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb new file mode 100644 index 000000000..ce03efa41 --- /dev/null +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -0,0 +1,130 @@ +module Enterprise::Account::PlanUsageAndLimits + CAPTAIN_RESPONSES = 'captain_responses'.freeze + CAPTAIN_DOCUMENTS = 'captain_documents'.freeze + CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze + CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze + + def usage_limits + { + agents: agent_limits.to_i, + inboxes: get_limits(:inboxes).to_i, + captain: { + documents: get_captain_limits(:documents), + responses: get_captain_limits(:responses) + } + } + end + + def increment_response_usage + current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 + custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1 + save + end + + def reset_response_usage + custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0 + save + end + + def update_document_usage + # this will ensure that the document count is always accurate + custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count + save + end + + def subscribed_features + plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value + return [] if plan_features.blank? + + plan_features[plan_name] + end + + def captain_monthly_limit + default_limits = default_captain_limits + + { + documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'], + responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses'] + }.with_indifferent_access + end + + private + + def get_captain_limits(type) + total_count = captain_monthly_limit[type.to_s].to_i + + consumed = if type == :documents + custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0 + else + custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 + end + + consumed = 0 if consumed.negative? + + { + total_count: total_count, + current_available: (total_count - consumed).clamp(0, total_count), + consumed: consumed + } + end + + def default_captain_limits + max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access + zero_limits = { documents: 0, responses: 0 }.with_indifferent_access + plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value + + # If there are no limits configured, we allow max usage + return max_limits if plan_quota.blank? + + # if there is plan_quota configred, but plan_name is not present, we return zero limits + return zero_limits if plan_name.blank? + + begin + # Now we parse the plan_quota and return the limits for the plan name + # but if there's no plan_name present in the plan_quota, we return zero limits + plan_quota = JSON.parse(plan_quota) if plan_quota.present? + plan_quota[plan_name.downcase] || zero_limits + rescue StandardError + # if there's any error in parsing the plan_quota, we return max limits + # this is to ensure that we don't block the user from using the product + max_limits + end + end + + def plan_name + custom_attributes['plan_name'] + end + + def agent_limits + subscribed_quantity = custom_attributes['subscribed_quantity'] + subscribed_quantity || get_limits(:agents) + end + + def get_limits(limit_name) + config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT" + return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present? + + return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present? + + ChatwootApp.max_limit + end + + def validate_limit_keys + errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash + self[:limits] = {} if self[:limits].blank? + + limit_schema = { + 'type' => 'object', + 'properties' => { + 'inboxes' => { 'type': 'number' }, + 'agents' => { 'type': 'number' }, + 'captain_responses' => { 'type': 'number' }, + 'captain_documents' => { 'type': 'number' } + }, + 'required' => [], + 'additionalProperties' => false + } + + errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits]) + end +end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index ac26dc525..0bd917c06 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -241,4 +241,99 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do end end end + + describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + context 'when it is an agent' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when deployment environment is not cloud' do + before do + # Set deployment environment to something other than cloud + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted') + end + + it 'returns not found' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'delete' }, + as: :json + + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq('Not found') + end + end + + context 'when it is an admin' do + before do + # Create the installation config for cloud environment + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') + end + + it 'marks the account for deletion when action is delete' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'delete' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_present + expect(account.custom_attributes['marked_for_deletion_reason']).to eq('manual_deletion') + end + + it 'unmarks the account for deletion when action is undelete' do + # First mark the account for deletion + account.update!( + custom_attributes: { + 'marked_for_deletion_at' => 7.days.from_now.iso8601, + 'marked_for_deletion_reason' => 'manual_deletion' + } + ) + + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'undelete' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_nil + expect(account.custom_attributes['marked_for_deletion_reason']).to be_nil + end + + it 'returns error for invalid action' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'invalid' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to include('Invalid action_type') + end + + it 'returns error when action parameter is missing' do + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to include('Invalid action_type') + end + end + end + end end diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index e36a7217d..4d851d50e 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -221,4 +221,53 @@ RSpec.describe Account, type: :model do end end end + + describe 'account deletion' do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe '#mark_for_deletion' do + it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do + expect do + account.mark_for_deletion('test_reason') + end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present) + .and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason') + end + + it 'sends a notification email to admin users' do + mailer = double + expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer) + expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer) + expect(mailer).to receive(:deliver_later) + + account.mark_for_deletion('test_reason') + end + + it 'returns true when successful' do + expect(account.mark_for_deletion).to be_truthy + end + end + + describe '#unmark_for_deletion' do + before do + account.update!( + custom_attributes: { + 'marked_for_deletion_at' => 7.days.from_now.iso8601, + 'marked_for_deletion_reason' => 'test_reason' + } + ) + end + + it 'removes the marked_for_deletion_at and marked_for_deletion_reason attributes' do + expect do + account.unmark_for_deletion + end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(be_present).to(nil) + .and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from('test_reason').to(nil) + end + + it 'returns true when successful' do + expect(account.unmark_for_deletion).to be_truthy + end + end + end end diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb index 7c1858d0a..e6d3fda6a 100644 --- a/spec/jobs/account/contacts_export_job_spec.rb +++ b/spec/jobs/account/contacts_export_job_spec.rb @@ -60,7 +60,7 @@ RSpec.describe Account::ContactsExportJob do it 'generates CSV file and attach to account' do mailer = double - allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).with(account: account).and_return(mailer) + allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer) allow(mailer).to receive(:contact_export_complete) described_class.perform_now(account.id, user.id, [], {}) diff --git a/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb new file mode 100644 index 000000000..44df38e88 --- /dev/null +++ b/spec/mailers/administrator_notifications/account_notification_mailer_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' +require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb' + +RSpec.describe AdministratorNotifications::AccountNotificationMailer do + include_context 'with smtp config' + + let!(:account) { create(:account) } + let!(:admin) { create(:user, account: account, role: :administrator) } + + describe 'account_deletion' do + let(:reason) { 'manual_deletion' } + let(:mail) { described_class.with(account: account).account_deletion(account, reason) } + let(:deletion_date) { 7.days.from_now.iso8601 } + + before do + account.update!(custom_attributes: { + 'marked_for_deletion_at' => deletion_date, + 'marked_for_deletion_reason' => reason + }) + end + + it 'renders the subject' do + expect(mail.subject).to eq('Your account has been marked for deletion') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + + it 'includes the account name in the email body' do + expect(mail.body.encoded).to include(account.name) + end + + it 'includes the deletion date in the email body' do + expect(mail.body.encoded).to include(deletion_date) + end + + it 'includes a link to cancel the deletion' do + expect(mail.body.encoded).to include('Cancel Account Deletion') + end + + context 'when reason is manual_deletion' do + it 'includes the administrator message' do + expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account') + end + end + + context 'when reason is not manual_deletion' do + let(:reason) { 'inactivity' } + + it 'includes the reason directly' do + expect(mail.body.encoded).to include('Reason for deletion: inactivity') + end + end + end + + describe 'contact_import_complete' do + let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) } + let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Contact Import Completed') + end + + it 'renders the processed records' do + expect(mail.body.encoded).to include('Number of records imported: 8') + expect(mail.body.encoded).to include('Number of records failed: 2') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + end + + describe 'contact_import_failed' do + let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Contact Import Failed') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + end + + describe 'contact_export_complete' do + let!(:file_url) { 'http://test.com/test' } + let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Your contact's export file is available to download.") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + end + + describe 'automation_rule_disabled' do + let(:rule) { instance_double(AutomationRule, name: 'Test Rule') } + let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Automation rule disabled due to validation errors.') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([admin.email]) + end + + it 'includes the rule name in the email body' do + expect(mail.body.encoded).to include('Test Rule') + end + end +end diff --git a/spec/mailers/administrator_notifications/base_mailer_spec.rb b/spec/mailers/administrator_notifications/base_mailer_spec.rb new file mode 100644 index 000000000..619fef0a7 --- /dev/null +++ b/spec/mailers/administrator_notifications/base_mailer_spec.rb @@ -0,0 +1,75 @@ +require 'rails_helper' + +RSpec.describe AdministratorNotifications::BaseMailer do + let!(:account) { create(:account) } + let!(:admin1) { create(:user, account: account, role: :administrator) } + let!(:admin2) { create(:user, account: account, role: :administrator) } + let!(:agent) { create(:user, account: account, role: :agent) } + let(:mailer) { described_class.new } + let!(:inbox) { create(:inbox, account: account) } + + before do + Current.account = account + end + + describe 'admin_emails' do + it 'returns emails of all administrators' do + # Call the private method + admin_emails = mailer.send(:admin_emails) + + expect(admin_emails).to include(admin1.email) + expect(admin_emails).to include(admin2.email) + expect(admin_emails).not_to include(agent.email) + end + end + + describe 'helper methods' do + it 'generates correct inbox URL' do + url = mailer.inbox_url(inbox) + expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/inboxes/#{inbox.id}" + expect(url).to eq(expected_url) + end + + it 'generates correct settings URL' do + url = mailer.settings_url('automation/list') + expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/automation/list" + expect(url).to eq(expected_url) + end + end + + describe 'send_notification' do + before do + allow(mailer).to receive(:smtp_config_set_or_development?).and_return(true) + end + + it 'sends email with correct parameters' do + subject = 'Test Subject' + action_url = 'https://example.com' + meta = { 'key' => 'value' } + + # Mock the send_mail_with_liquid method + expect(mailer).to receive(:send_mail_with_liquid).with( + to: [admin1.email, admin2.email], + subject: subject + ).and_return(true) + + mailer.send_notification(subject, action_url: action_url, meta: meta) + + # Check that instance variables are set correctly + expect(mailer.instance_variable_get(:@action_url)).to eq(action_url) + expect(mailer.instance_variable_get(:@meta)).to eq(meta) + end + + it 'uses provided email addresses when specified' do + subject = 'Test Subject' + custom_email = 'custom@example.com' + + expect(mailer).to receive(:send_mail_with_liquid).with( + to: custom_email, + subject: subject + ).and_return(true) + + mailer.send_notification(subject, to: custom_email) + end + end +end diff --git a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb index 944475fb2..1be1314da 100644 --- a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb @@ -1,45 +1,15 @@ # frozen_string_literal: true require 'rails_helper' +require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb' RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do + include_context 'with smtp config' + let(:class_instance) { described_class.new } let!(:account) { create(:account) } let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: account) } - before do - allow(described_class).to receive(:new).and_return(class_instance) - allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) - end - - describe 'slack_disconnect' do - let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq('Your Slack integration has expired') - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end - - describe 'dialogflow disconnect' do - let(:mail) { described_class.with(account: account).dialogflow_disconnect.deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq('Your Dialogflow integration was disconnected') - end - - it 'renders the content' do - expect(mail.body).to include('Your Dialogflow integration was disconnected because of permission issues.') - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end - describe 'facebook_disconnect' do before do stub_request(:post, /graph.facebook.com/) @@ -47,14 +17,17 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do let!(:facebook_channel) { create(:channel_facebook_page, account: account) } let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) } - let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now } - it 'renders the subject' do - expect(mail.subject).to eq('Your Facebook page connection has expired') - end + context 'when sending the actual email' do + let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now } - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) + it 'renders the subject' do + expect(mail.subject).to eq('Your Facebook page connection has expired') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([administrator.email]) + end end end @@ -71,35 +44,4 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do expect(mail.to).to eq([administrator.email]) end end - - describe 'contact_import_complete' do - let!(:data_import) { build(:data_import, total_records: 10, processed_records: 10) } - let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq('Contact Import Completed') - end - - it 'renders the processed records' do - expect(mail.body.encoded).to match('Number of records imported: 10') - expect(mail.body.encoded).to match('Number of records failed: 0') - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end - - describe 'contact_export_complete' do - let!(:file_url) { 'http://test.com/test' } - let(:mail) { described_class.with(account: account).contact_export_complete(file_url, administrator.email).deliver_now } - - it 'renders the subject' do - expect(mail.subject).to eq("Your contact's export file is available to download.") - end - - it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) - end - end end diff --git a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb new file mode 100644 index 000000000..331d33d06 --- /dev/null +++ b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' +require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb' + +RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do + include_context 'with smtp config' + + let!(:account) { create(:account) } + let!(:administrator) { create(:user, :administrator, email: 'admin@example.com', account: account) } + + describe 'slack_disconnect' do + let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Your Slack integration has expired') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([administrator.email]) + end + + it 'includes reconnect instructions in the body' do + expect(mail.body.encoded).to include('To continue receiving messages on Slack, please delete the integration and connect your workspace again') + end + end + + describe 'dialogflow_disconnect' do + let(:mail) { described_class.with(account: account).dialogflow_disconnect.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Your Dialogflow integration was disconnected') + end + + it 'renders the content' do + expect(mail.body.encoded).to include('Your Dialogflow integration was disconnected because of permission issues') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([administrator.email]) + end + end +end diff --git a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb new file mode 100644 index 000000000..96d4dbb0d --- /dev/null +++ b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with smtp config' do + before do + # We need to use allow_any_instance_of here because smtp_config_set_or_development? + # is defined in ApplicationMailer and needs to be stubbed for all mailer instances + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true) + # rubocop:enable RSpec/AnyInstance + end +end diff --git a/spec/models/concerns/reauthorizable_shared.rb b/spec/models/concerns/reauthorizable_shared.rb index 9efe232e8..a71800267 100644 --- a/spec/models/concerns/reauthorizable_shared.rb +++ b/spec/models/concerns/reauthorizable_shared.rb @@ -2,9 +2,9 @@ require 'rails_helper' shared_examples_for 'reauthorizable' do let(:model) { described_class } # the class that includes the concern + let(:obj) { FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) } it 'authorization_error!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) expect(obj.authorization_error_count).to eq 0 obj.authorization_error! @@ -13,7 +13,6 @@ shared_examples_for 'reauthorizable' do end it 'prompts reauthorization when error threshold is passed' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) expect(obj.reauthorization_required?).to be false obj.class::AUTHORIZATION_ERROR_THRESHOLD.times do @@ -23,25 +22,70 @@ shared_examples_for 'reauthorizable' do expect(obj.reauthorization_required?).to be true end - it 'prompt_reauthorization!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) - mailer = double - mailer_method = double - allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(mailer) - # allow mailer to receive any methods and return mailer - allow(mailer).to receive(:method_missing).and_return(mailer_method) - allow(mailer_method).to receive(:deliver_later) + # Helper methods to set up mailer mocks + def setup_automation_rule_mailer(_obj) + account_mailer = instance_double(AdministratorNotifications::AccountNotificationMailer) + automation_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).and_return(account_mailer) + allow(account_mailer).to receive(:automation_rule_disabled).and_return(automation_mailer_response) + end - expect(obj.reauthorization_required?).to be false + def setup_integrations_hook_mailer(obj) + integrations_mailer = instance_double(AdministratorNotifications::IntegrationsNotificationMailer) + slack_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + dialogflow_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer) + allow(integrations_mailer).to receive(:slack_disconnect).and_return(slack_mailer_response) + allow(integrations_mailer).to receive(:dialogflow_disconnect).and_return(dialogflow_mailer_response) - obj.prompt_reauthorization! - expect(obj.reauthorization_required?).to be true - expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account) - expect(mailer_method).to have_received(:deliver_later) + # Allow the model to respond to slack? and dialogflow? methods + allow(obj).to receive(:slack?).and_return(true) + allow(obj).to receive(:dialogflow?).and_return(false) + end + + def setup_channel_mailer(_obj) + channel_mailer = instance_double(AdministratorNotifications::ChannelNotificationsMailer) + facebook_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + whatsapp_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + email_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true) + allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(channel_mailer) + allow(channel_mailer).to receive(:facebook_disconnect).and_return(facebook_mailer_response) + allow(channel_mailer).to receive(:whatsapp_disconnect).and_return(whatsapp_mailer_response) + allow(channel_mailer).to receive(:email_disconnect).and_return(email_mailer_response) + end + + describe 'prompt_reauthorization!' do + before do + # Setup mailer mocks based on model type + if model.to_s == 'AutomationRule' + setup_automation_rule_mailer(obj) + elsif model.to_s == 'Integrations::Hook' + setup_integrations_hook_mailer(obj) + else + setup_channel_mailer(obj) + end + end + + it 'sets reauthorization required flag' do + expect(obj.reauthorization_required?).to be false + obj.prompt_reauthorization! + expect(obj.reauthorization_required?).to be true + end + + it 'calls the correct mailer based on model type' do + obj.prompt_reauthorization! + + if model.to_s == 'AutomationRule' + expect(AdministratorNotifications::AccountNotificationMailer).to have_received(:with).with(account: obj.account) + elsif model.to_s == 'Integrations::Hook' + expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: obj.account) + else + expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account) + end + end end it 'reauthorized!' do - obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) # setting up the object with the errors to validate its cleared on action obj.authorization_error! obj.prompt_reauthorization! From 7a24672b665ffd00f08e97de40596a0f22880316 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 3 Apr 2025 13:57:14 +0530 Subject: [PATCH 2/6] feat: Added the ability to create Instagram channel (#11182) This PR is part of https://github.com/chatwoot/chatwoot/pull/11054 to make the review cycle easier. --- .../instagram/authorizations_controller.rb | 30 ++++ app/controllers/concerns/instagram_concern.rb | 75 ++++++++++ .../instagram/callbacks_controller.rb | 123 ++++++++++++++++ .../super_admin/app_configs_controller.rb | 2 + .../webhooks/instagram_controller.rb | 5 +- app/helpers/instagram/integration_helper.rb | 49 +++++++ .../dashboard/api/channel/instagramClient.js | 14 ++ .../dashboard/i18n/locale/en/inboxMgmt.json | 9 +- .../settings/inbox/ChannelFactory.vue | 2 + .../settings/inbox/channels/Instagram.vue | 129 ++++++++++++++++ app/models/channel/instagram.rb | 36 ++++- .../instagram/refresh_oauth_token_service.rb | 84 +++++++++++ .../super_admin/application/_icons.html.erb | 8 +- config/features.yml | 4 + config/installation_config.yml | 27 ++++ config/routes.rb | 8 +- .../app/helpers/super_admin/features.yml | 6 + .../authorizations_controller_spec.rb | 54 +++++++ .../concerns/instagram_concern_spec.rb | 138 ++++++++++++++++++ .../instagram/callbacks_controller_spec.rb | 113 ++++++++++++++ spec/factories/channel/channel_instagram.rb | 15 ++ .../instagram/integration_helper_spec.rb | 98 +++++++++++++ .../refresh_oauth_token_service_spec.rb | 127 ++++++++++++++++ 23 files changed, 1150 insertions(+), 6 deletions(-) create mode 100644 app/controllers/api/v1/accounts/instagram/authorizations_controller.rb create mode 100644 app/controllers/concerns/instagram_concern.rb create mode 100644 app/controllers/instagram/callbacks_controller.rb create mode 100644 app/helpers/instagram/integration_helper.rb create mode 100644 app/javascript/dashboard/api/channel/instagramClient.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue create mode 100644 app/services/instagram/refresh_oauth_token_service.rb create mode 100644 spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb create mode 100644 spec/controllers/concerns/instagram_concern_spec.rb create mode 100644 spec/controllers/instagram/callbacks_controller_spec.rb create mode 100644 spec/helpers/instagram/integration_helper_spec.rb create mode 100644 spec/services/instagram/refresh_oauth_token_service_spec.rb diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb new file mode 100644 index 000000000..eace4411a --- /dev/null +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController + include InstagramConcern + include Instagram::IntegrationHelper + before_action :check_authorization + + def create + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization + redirect_url = instagram_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/instagram/callback", + scope: REQUIRED_SCOPES.join(','), + enable_fb_login: '0', + force_authentication: '1', + response_type: 'code', + state: generate_instagram_token(Current.account.id) + } + ) + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb new file mode 100644 index 000000000..f4dcfa010 --- /dev/null +++ b/app/controllers/concerns/instagram_concern.rb @@ -0,0 +1,75 @@ +module InstagramConcern + extend ActiveSupport::Concern + include HTTParty + + def instagram_client + ::OAuth2::Client.new( + client_id, + client_secret, + { + site: 'https://api.instagram.com', + authorize_url: 'https://api.instagram.com/oauth/authorize', + token_url: 'https://api.instagram.com/oauth/access_token', + auth_scheme: :request_body, + token_method: :post + } + ) + end + + private + + def client_id + GlobalConfigService.load('INSTAGRAM_APP_ID', nil) + end + + def client_secret + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def exchange_for_long_lived_token(short_lived_token) + endpoint = 'https://graph.instagram.com/access_token' + params = { + grant_type: 'ig_exchange_token', + client_secret: client_secret, + access_token: short_lived_token, + client_id: client_id + } + + make_api_request(endpoint, params, 'Failed to exchange token') + end + + def fetch_instagram_user_details(access_token) + endpoint = 'https://graph.instagram.com/v22.0/me' + params = { + fields: 'id,username,user_id,name,profile_picture_url,account_type', + access_token: access_token + } + + make_api_request(endpoint, params, 'Failed to fetch Instagram user details') + end + + def make_api_request(endpoint, params, error_prefix) + response = HTTParty.get( + endpoint, + query: params, + headers: { 'Accept' => 'application/json' } + ) + + unless response.success? + Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}" + raise "#{error_prefix}: #{response.body}" + end + + begin + JSON.parse(response.body) + rescue JSON::ParserError => e + ChatwootExceptionTracker.new(e).capture_exception + Rails.logger.error "Invalid JSON response: #{response.body}" + raise e + end + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb new file mode 100644 index 000000000..02add933f --- /dev/null +++ b/app/controllers/instagram/callbacks_controller.rb @@ -0,0 +1,123 @@ +class Instagram::CallbacksController < ApplicationController + include InstagramConcern + include Instagram::IntegrationHelper + + def show + # Check if Instagram redirected with an error (user canceled authorization) + # See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + if params[:error].present? + handle_authorization_error + return + end + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + # Process the authorization code and create inbox + def process_successful_authorization + @response = instagram_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/#{provider_name}/callback", + grant_type: 'authorization_code' + ) + + @long_lived_token_response = exchange_for_long_lived_token(@response.token) + inbox, = create_channel_with_inbox + redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + + # Handle all errors that might occur during authorization + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response + def handle_error(error) + Rails.logger.error("Instagram Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + error_info = extract_error_info(error) + redirect_to_error_page(error_info) + end + + # Extract error details from the exception + def extract_error_info(error) + if error.is_a?(OAuth2::Error) + begin + # Instagram returns JSON error response which we parse to extract error details + JSON.parse(error.message) + rescue JSON::ParseError + # Fall back to a generic OAuth error if JSON parsing fails + { 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message } + end + else + # For other unexpected errors + { 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message } + end + end + + # Handles the case when a user denies permissions or cancels the authorization flow + # Error parameters are documented at: + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + def handle_authorization_error + error_info = { + 'error_type' => params[:error] || 'authorization_error', + 'code' => 400, + 'error_message' => params[:error_description] || 'Authorization was denied' + } + + Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}") + redirect_to_error_page(error_info) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_info) + redirect_to app_new_instagram_inbox_url( + account_id: account_id, + error_type: error_info['error_type'], + code: error_info['code'], + error_message: error_info['error_message'] + ) + end + + def create_channel_with_inbox + ActiveRecord::Base.transaction do + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + user_details = fetch_instagram_user_details(@long_lived_token_response['access_token']) + + channel_instagram = Channel::Instagram.create!( + access_token: @long_lived_token_response['access_token'], + instagram_id: user_details['user_id'].to_s, + account: account, + expires_at: expires_at + ) + + account.inboxes.create!( + account: account, + channel: channel_instagram, + name: user_details['username'] + ) + end + end + + def account_id + return unless params[:state] + + verify_instagram_token(params[:state]) + end + + def oauth_code + params[:code] + end + + def account + @account ||= Account.find(account_id) + end + + def provider_name + 'instagram' + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 3e17a7369..550b6c893 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -43,6 +43,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController ['MAILER_INBOUND_EMAIL_DOMAIN'] when 'linear' %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] + when 'instagram' + %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] else %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index b658915ed..3d46334ca 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API private def valid_token?(token) - token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') + # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and + # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) + token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || + token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end end diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb new file mode 100644 index 000000000..8ba57bf95 --- /dev/null +++ b/app/helpers/instagram/integration_helper.rb @@ -0,0 +1,49 @@ +module Instagram::IntegrationHelper + REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze + + # Generates a signed JWT token for Instagram integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_instagram_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Instagram token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Instagram JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_instagram_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/channel/instagramClient.js b/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 000000000..51ae26448 --- /dev/null +++ b/app/javascript/dashboard/api/channel/instagramClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class InstagramChannel extends ApiClient { + constructor() { + super('instagram', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new InstagramChannel(); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 20f5ebed3..3834b0a3d 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -45,6 +45,12 @@ "PICK_NAME": "Pick a Name for your Inbox", "PICK_A_VALUE": "Pick a value" }, + "INSTAGRAM": { + "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", + "HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ", + "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", + "ERROR_AUTH": "Something went wrong with your Instagram authentication, please try again" + }, "TWITTER": { "HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ", "ERROR_MESSAGE": "There was an error connecting to Twitter, please try again", @@ -753,7 +759,8 @@ "EMAIL": "Email", "TELEGRAM": "Telegram", "LINE": "Line", - "API": "API Channel" + "API": "API Channel", + "INSTAGRAM": "Instagram" } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index b34c50c7f..7ea58e3e5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -9,6 +9,7 @@ import Sms from './channels/Sms.vue'; import Whatsapp from './channels/Whatsapp.vue'; import Line from './channels/Line.vue'; import Telegram from './channels/Telegram.vue'; +import Instagram from './channels/Instagram.vue'; const channelViewList = { facebook: Facebook, @@ -20,6 +21,7 @@ const channelViewList = { whatsapp: Whatsapp, line: Line, telegram: Telegram, + instagram: Instagram, }; export default defineComponent({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue new file mode 100644 index 000000000..ff0933e11 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue @@ -0,0 +1,129 @@ + + + diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb index fcfcb852e..b5ce02ce7 100644 --- a/app/models/channel/instagram.rb +++ b/app/models/channel/instagram.rb @@ -16,13 +16,47 @@ # class Channel::Instagram < ApplicationRecord include Channelable - + include Reauthorizable self.table_name = 'channel_instagram' validates :access_token, presence: true validates :instagram_id, uniqueness: true, presence: true + after_create_commit :subscribe + before_destroy :unsubscribe + def name 'Instagram' end + + def subscribe + # ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions + HTTParty.post( + "https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps", + query: { + subscribed_fields: %w[messages message_reactions messaging_seen], + access_token: access_token + } + ) + rescue StandardError => e + Rails.logger.debug { "Rescued: #{e.inspect}" } + true + end + + def unsubscribe + HTTParty.delete( + "https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps", + query: { + access_token: access_token + } + ) + true + rescue StandardError => e + Rails.logger.debug { "Rescued: #{e.inspect}" } + true + end + + def access_token + Instagram::RefreshOauthTokenService.new(channel: self).access_token + end end diff --git a/app/services/instagram/refresh_oauth_token_service.rb b/app/services/instagram/refresh_oauth_token_service.rb new file mode 100644 index 000000000..087fbcfa2 --- /dev/null +++ b/app/services/instagram/refresh_oauth_token_service.rb @@ -0,0 +1,84 @@ +# Service to handle Instagram access token refresh logic +# Instagram tokens are valid for 60 days and can be refreshed to extend validity +# This service implements the refresh logic per official Instagram API guidelines +class Instagram::RefreshOauthTokenService + attr_reader :channel + + def initialize(channel:) + @channel = channel + end + + # Returns a valid access token, refreshing it if necessary and eligible + def access_token + return unless token_valid? + + # If token is valid and eligible for refresh, attempt to refresh it + return channel[:access_token] unless token_eligible_for_refresh? + + attempt_token_refresh + end + + private + + # Checks if the current token is still valid (not expired) + def token_valid? + return false if channel.expires_at.blank? + + # Check if token is still valid + Time.current < channel.expires_at + end + + # Determines if a token is eligible for refresh based on Instagram's requirements + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#refresh-a-long-lived-token + + def token_eligible_for_refresh? + # Three conditions must be met: + # 1. Token is still valid + token_is_valid = Time.current < channel.expires_at + + # 2. Token is at least 24 hours old (based on updated_at) + token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago + + # 3. Token is approaching expiry (within 10 days) + approaching_expiry = channel.expires_at < 10.days.from_now + + token_is_valid && token_is_old_enough && approaching_expiry + end + + # Makes an API request to refresh the long-lived token + # @return [Hash] Response data containing new access_token and expires_in values + # @raise [RuntimeError] If API request fails + def refresh_long_lived_token + endpoint = 'https://graph.instagram.com/refresh_access_token' + params = { + grant_type: 'ig_refresh_token', + access_token: channel[:access_token] + } + + response = HTTParty.get(endpoint, query: params, headers: { 'Accept' => 'application/json' }) + + unless response.success? + Rails.logger.error "Failed to refresh Instagram token: #{response.body}" + raise "Failed to refresh Instagram token: #{response.body}" + end + + JSON.parse(response.body) + end + + def update_channel_tokens(token_data) + channel.update!( + access_token: token_data['access_token'], + expires_at: Time.current + token_data['expires_in'].seconds + ) + end + + # Attempts to refresh the token, returning either the new or existing token + def attempt_token_refresh + refreshed_token_data = refresh_long_lived_token + update_channel_tokens(refreshed_token_data) + channel.reload[:access_token] + rescue StandardError => e + Rails.logger.error("Token refresh failed: #{e.message}") + channel[:access_token] + end +end diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index 37f6a77ff..fb10c1035 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -151,8 +151,12 @@ - + + + + + - + \ No newline at end of file diff --git a/config/features.yml b/config/features.yml index 70d6c9fcf..59b9aa2ad 100644 --- a/config/features.yml +++ b/config/features.yml @@ -161,3 +161,7 @@ - name: search_with_gin display_name: Search messages with GIN enabled: false +- name: channel_instagram + display_name: Instagram Channel + enabled: false + chatwoot_internal: true diff --git a/config/installation_config.yml b/config/installation_config.yml index 15b815496..1a891c420 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -292,3 +292,30 @@ locked: false type: secret # ------- End of Shopify Related Config ------- # + +# ------- Instagram Channel Related Config ------- # +- name: INSTAGRAM_APP_ID + display_title: 'Instagram App ID' + locked: false +- name: INSTAGRAM_APP_SECRET + display_title: 'Instagram App Secret' + description: 'The App Secret used for Instagram authentication' + locked: false + type: secret +- name: INSTAGRAM_VERIFY_TOKEN + display_title: 'Instagram Verify Token' + description: 'The verify token used for Instagram Webhook' + locked: false + type: secret +- name: ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT + display_title: 'Enable human agent for instagram channel' + value: false + locked: false + description: 'Enable human agent for instagram channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/' + type: boolean +- name: INSTAGRAM_API_VERSION + display_title: 'Instagram API Version' + description: 'Configure this if you want to use a different Instagram API version. Make sure its prefixed with `v`' + value: 'v22.0' + locked: true +# ------- End of Instagram Channel Related Config ------- # diff --git a/config/routes.rb b/config/routes.rb index 87344924d..375d60b0c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,8 +18,10 @@ Rails.application.routes.draw do get '/app/*params', to: 'dashboard#index' get '/app/accounts/:account_id/settings/inboxes/new/twitter', to: 'dashboard#index', as: 'app_new_twitter_inbox' get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox' + get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox' get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents' get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents' + get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings' resource :widget, only: [:show] @@ -214,6 +216,10 @@ Rails.application.routes.draw do resource :authorization, only: [:create] end + namespace :instagram do + resource :authorization, only: [:create] + end + resources :webhooks, only: [:index, :create, :update, :destroy] namespace :integrations do resources :apps, only: [:index, :show] @@ -475,7 +481,7 @@ Rails.application.routes.draw do get 'microsoft/callback', to: 'microsoft/callbacks#show' get 'google/callback', to: 'google/callbacks#show' - + get 'instagram/callback', to: 'instagram/callbacks#show' # ---------------------------------------------------------------------- # Routes for external service verifications get '.well-known/assetlinks.json' => 'android_app#assetlinks' diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml index a54aaf6eb..26a47f0e9 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/enterprise/app/helpers/super_admin/features.yml @@ -91,3 +91,9 @@ shopify: enabled: true icon: 'icon-shopify' config_key: 'shopify' +instagram: + name: 'Instagram' + description: 'Configuration for setting up Instagram' + enabled: true + icon: 'icon-instagram' + config_key: 'instagram' diff --git a/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb new file mode 100644 index 000000000..003ecc023 --- /dev/null +++ b/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe 'Instagram Authorization API', type: :request do + let(:account) { create(:account) } + + describe 'POST /api/v1/accounts/{account.id}/instagram/authorization' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/instagram/authorization" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agent' do + post "/api/v1/accounts/#{account.id}/instagram/authorization", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a new authorization and returns the redirect url' do + post "/api/v1/accounts/#{account.id}/instagram/authorization", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['success']).to be true + + instagram_service = Class.new do + extend InstagramConcern + extend Instagram::IntegrationHelper + end + frontend_url = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + response_url = instagram_service.instagram_client.auth_code.authorize_url( + { + redirect_uri: "#{frontend_url}/instagram/callback", + scope: Instagram::IntegrationHelper::REQUIRED_SCOPES.join(','), + enable_fb_login: '0', + force_authentication: '1', + response_type: 'code', + state: instagram_service.generate_instagram_token(account.id) + } + ) + expect(response.parsed_body['url']).to eq response_url + end + end + end +end diff --git a/spec/controllers/concerns/instagram_concern_spec.rb b/spec/controllers/concerns/instagram_concern_spec.rb new file mode 100644 index 000000000..6ab03a272 --- /dev/null +++ b/spec/controllers/concerns/instagram_concern_spec.rb @@ -0,0 +1,138 @@ +require 'rails_helper' + +RSpec.describe InstagramConcern do + let(:dummy_class) { Class.new { include InstagramConcern } } + let(:dummy_instance) { dummy_class.new } + let(:client_id) { 'test_client_id' } + let(:client_secret) { 'test_client_secret' } + let(:short_lived_token) { 'short_lived_token' } + let(:long_lived_token) { 'long_lived_token' } + let(:access_token) { 'access_token' } + + before do + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_ID', nil).and_return(client_id) + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret) + allow(Rails.logger).to receive(:error) + end + + describe '#instagram_client' do + it 'creates an OAuth2 client with correct configuration', :aggregate_failures do + client = dummy_instance.instagram_client + + expect(client).to be_a(OAuth2::Client) + expect(client.id).to eq(client_id) + expect(client.secret).to eq(client_secret) + expect(client.site).to eq('https://api.instagram.com') + expect(client.options[:authorize_url]).to eq('https://api.instagram.com/oauth/authorize') + expect(client.options[:token_url]).to eq('https://api.instagram.com/oauth/access_token') + expect(client.options[:auth_scheme]).to eq(:request_body) + expect(client.options[:token_method]).to eq(:post) + end + end + + describe '#exchange_for_long_lived_token' do + let(:response_body) { { 'access_token' => long_lived_token, 'expires_in' => 5_184_000 }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + allow(mock_response).to receive(:inspect).and_return(response_body) + end + + it 'exchanges short lived token for long lived token' do + result = dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) + + expect(HTTParty).to have_received(:get).with( + 'https://graph.instagram.com/access_token', + { + query: { + grant_type: 'ig_exchange_token', + client_secret: client_secret, + access_token: short_lived_token, + client_id: client_id + }, + headers: { 'Accept' => 'application/json' } + } + ) + + expect(result).to eq({ 'access_token' => long_lived_token, 'expires_in' => 5_184_000 }) + end + + context 'when the request fails' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) } + + it 'raises an error' do + expect do + dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) + end.to raise_error(RuntimeError, 'Failed to exchange token: Error') + end + end + + context 'when the response is not valid JSON' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) } + + it 'raises a JSON parse error' do + allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON')) + + expect { dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) }.to raise_error(JSON::ParserError) + end + end + end + + describe '#fetch_instagram_user_details' do + let(:user_details) do + { + 'id' => '12345', + 'username' => 'test_user', + 'user_id' => '12345', + 'name' => 'Test User', + 'profile_picture_url' => 'https://example.com/profile.jpg', + 'account_type' => 'BUSINESS' + } + end + let(:response_body) { user_details.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + allow(mock_response).to receive(:inspect).and_return(response_body) + end + + it 'fetches Instagram user details' do + result = dummy_instance.send(:fetch_instagram_user_details, access_token) + + expect(HTTParty).to have_received(:get).with( + 'https://graph.instagram.com/v22.0/me', + { + query: { + fields: 'id,username,user_id,name,profile_picture_url,account_type', + access_token: access_token + }, + headers: { 'Accept' => 'application/json' } + } + ) + + expect(result).to eq(user_details) + end + + context 'when the request fails' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) } + + it 'raises an error' do + expect do + dummy_instance.send(:fetch_instagram_user_details, access_token) + end.to raise_error(RuntimeError, 'Failed to fetch Instagram user details: Error') + end + end + + context 'when the response is not valid JSON' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) } + + it 'raises a JSON parse error' do + allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON')) + + expect { dummy_instance.send(:fetch_instagram_user_details, access_token) }.to raise_error(JSON::ParserError) + end + end + end +end diff --git a/spec/controllers/instagram/callbacks_controller_spec.rb b/spec/controllers/instagram/callbacks_controller_spec.rb new file mode 100644 index 000000000..64b90491a --- /dev/null +++ b/spec/controllers/instagram/callbacks_controller_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.describe Instagram::CallbacksController do + let(:account) { create(:account) } + let(:valid_params) { { code: 'valid_code', state: "#{account.id}|valid_token" } } + let(:error_params) { { error: 'access_denied', error_description: 'User denied access', state: "#{account.id}|valid_token" } } + let(:oauth_client) { instance_double(OAuth2::Client) } + let(:auth_code_object) { instance_double(OAuth2::Strategy::AuthCode) } + let(:access_token) { instance_double(OAuth2::AccessToken, token: 'test_token') } + let(:long_lived_token_response) { { 'access_token' => 'long_lived_test_token', 'expires_in' => 5_184_000 } } + let(:user_details) { { 'username' => 'test_user', 'user_id' => '12345' } } + let(:exception_tracker) { instance_double(ChatwootExceptionTracker) } + + before do + allow(controller).to receive(:verify_instagram_token).and_return(account.id) + allow(controller).to receive(:instagram_client).and_return(oauth_client) + allow(controller).to receive(:base_url).and_return('https://app.chatwoot.com') + allow(controller).to receive(:account).and_return(account) + allow(oauth_client).to receive(:auth_code).and_return(auth_code_object) + allow(controller).to receive(:exchange_for_long_lived_token).and_return(long_lived_token_response) + allow(controller).to receive(:fetch_instagram_user_details).and_return(user_details) + allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) + allow(exception_tracker).to receive(:capture_exception) + + # Stub the exact request format that's being made + stub_request(:post, 'https://graph.instagram.com/v22.0/12345/subscribed_apps?access_token=long_lived_test_token&subscribed_fields%5B%5D=messages&subscribed_fields%5B%5D=message_reactions&subscribed_fields%5B%5D=messaging_seen') + .with( + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ) + .to_return(status: 200, body: '', headers: {}) + end + + describe '#show' do + context 'when authorization is successful' do + before do + allow(auth_code_object).to receive(:get_token).and_return(access_token) + end + + it 'creates instagram channel and inbox' do + expect do + get :show, params: valid_params + end.to change(Channel::Instagram, :count).by(1).and change(Inbox, :count).by(1) + + expect(Channel::Instagram.last.access_token).to eq('long_lived_test_token') + expect(Channel::Instagram.last.instagram_id).to eq('12345') + expect(Inbox.last.name).to eq('test_user') + + expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id)) + end + end + + context 'when user denies authorization' do + it 'redirects to error page with authorization error details' do + get :show, params: error_params + + expect(response).to redirect_to( + app_new_instagram_inbox_url( + account_id: account.id, + error_type: 'access_denied', + code: 400, + error_message: 'User denied access' + ) + ) + end + end + + context 'when an OAuth error occurs' do + before do + oauth_error = OAuth2::Error.new( + OpenStruct.new( + body: { error_type: 'OAuthException', code: 400, error_message: 'Invalid OAuth code' }.to_json, + status: 400 + ) + ) + allow(auth_code_object).to receive(:get_token).and_raise(oauth_error) + end + + it 'handles OAuth errors and redirects to error page' do + get :show, params: valid_params + + expected_url = app_new_instagram_inbox_url( + account_id: account.id, + error_type: 'OAuthException', + code: 400, + error_message: 'Invalid OAuth code' + ) + expect(response).to redirect_to(expected_url) + end + end + + context 'when a standard error occurs' do + before do + allow(auth_code_object).to receive(:get_token).and_raise(StandardError.new('Unknown error')) + end + + it 'handles standard errors and redirects to error page' do + get :show, params: valid_params + + expected_url = app_new_instagram_inbox_url( + account_id: account.id, + error_type: 'StandardError', + code: 500, + error_message: 'Unknown error' + ) + expect(response).to redirect_to(expected_url) + end + end + end +end diff --git a/spec/factories/channel/channel_instagram.rb b/spec/factories/channel/channel_instagram.rb index 9a0d33bb5..6665e4558 100644 --- a/spec/factories/channel/channel_instagram.rb +++ b/spec/factories/channel/channel_instagram.rb @@ -6,6 +6,21 @@ FactoryBot.define do expires_at { 60.days.from_now } updated_at { 25.hours.ago } + before :create do |channel| + WebMock::API.stub_request(:post, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps") + .with(query: { + access_token: channel.access_token, + subscribed_fields: %w[messages message_reactions messaging_seen] + }) + .to_return(status: 200, body: '', headers: {}) + + WebMock::API.stub_request(:delete, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps") + .with(query: { + access_token: channel.access_token + }) + .to_return(status: 200, body: '', headers: {}) + end + after(:create) do |channel| create(:inbox, channel: channel, account: channel.account) end diff --git a/spec/helpers/instagram/integration_helper_spec.rb b/spec/helpers/instagram/integration_helper_spec.rb new file mode 100644 index 000000000..7a8bb30a4 --- /dev/null +++ b/spec/helpers/instagram/integration_helper_spec.rb @@ -0,0 +1,98 @@ +require 'rails_helper' + +RSpec.describe Instagram::IntegrationHelper do + include described_class + + describe '#generate_instagram_token' do + let(:account_id) { 1 } + let(:client_secret) { 'test_secret' } + let(:current_time) { Time.current } + + before do + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret) + allow(Time).to receive(:current).and_return(current_time) + end + + it 'generates a valid JWT token with correct payload' do + token = generate_instagram_token(account_id) + decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first + + expect(decoded_token['sub']).to eq(account_id) + expect(decoded_token['iat']).to eq(current_time.to_i) + end + + context 'when client secret is not configured' do + let(:client_secret) { nil } + + it 'returns nil' do + expect(generate_instagram_token(account_id)).to be_nil + end + end + + context 'when an error occurs' do + before do + allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error')) + end + + it 'logs the error and returns nil' do + expect(Rails.logger).to receive(:error).with('Failed to generate Instagram token: Test error') + expect(generate_instagram_token(account_id)).to be_nil + end + end + end + + describe '#token_payload' do + let(:account_id) { 1 } + let(:current_time) { Time.current } + + before do + allow(Time).to receive(:current).and_return(current_time) + end + + it 'returns a hash with the correct structure' do + payload = token_payload(account_id) + + expect(payload).to be_a(Hash) + expect(payload[:sub]).to eq(account_id) + expect(payload[:iat]).to eq(current_time.to_i) + end + end + + describe '#verify_instagram_token' do + let(:account_id) { 1 } + let(:client_secret) { 'test_secret' } + let(:valid_token) do + JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256') + end + + before do + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret) + end + + it 'successfully verifies and returns account_id from valid token' do + expect(verify_instagram_token(valid_token)).to eq(account_id) + end + + context 'when token is blank' do + it 'returns nil' do + expect(verify_instagram_token('')).to be_nil + expect(verify_instagram_token(nil)).to be_nil + end + end + + context 'when client secret is not configured' do + let(:client_secret) { nil } + + it 'returns nil' do + expect(verify_instagram_token(valid_token)).to be_nil + end + end + + context 'when token is invalid' do + it 'logs the error and returns nil' do + expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Instagram token:/) + expect(verify_instagram_token('invalid_token')).to be_nil + end + end + end +end diff --git a/spec/services/instagram/refresh_oauth_token_service_spec.rb b/spec/services/instagram/refresh_oauth_token_service_spec.rb new file mode 100644 index 000000000..007159a88 --- /dev/null +++ b/spec/services/instagram/refresh_oauth_token_service_spec.rb @@ -0,0 +1,127 @@ +require 'rails_helper' + +RSpec.describe Instagram::RefreshOauthTokenService do + let(:account) { create(:account) } + let(:refresh_response) do + { + 'access_token' => 'new_refreshed_token', + 'expires_in' => 5_184_000 # 60 days in seconds + } + end + let(:fixed_token) { 'c061d0c51973a8fcab2ecec86f6aa41718414a10070967a5e9a58f49bf8a798e' } + let(:instagram_channel) do + create(:channel_instagram, + account: account, + access_token: fixed_token, + expires_at: 20.days.from_now) # Set default expiry + end + let(:service) { described_class.new(channel: instagram_channel) } + + before do + stub_request(:get, 'https://graph.instagram.com/refresh_access_token') + .with( + query: { + 'access_token' => fixed_token, + 'grant_type' => 'ig_refresh_token' + }, + headers: { + 'Accept' => 'application/json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ) + .to_return(status: 200, body: refresh_response.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + describe '#access_token' do + context 'when token is valid and not eligible for refresh' do + before do + instagram_channel.update!( + updated_at: 12.hours.ago # Less than 24 hours old + ) + end + + it 'returns existing token without refresh' do + expect(service).not_to receive(:refresh_long_lived_token) + expect(service.access_token).to eq(fixed_token) + end + end + + context 'when token is eligible for refresh' do + before do + instagram_channel.update!( + expires_at: 5.days.from_now, # Within 10 days window + updated_at: 25.hours.ago # More than 24 hours old + ) + end + + it 'refreshes the token and updates channel' do + expect(service.access_token).to eq('new_refreshed_token') + instagram_channel.reload + expect(instagram_channel.access_token).to eq('new_refreshed_token') + expect(instagram_channel.expires_at).to be_within(1.second).of(5_184_000.seconds.from_now) + end + end + end + + describe 'private methods' do + describe '#token_valid?' do + # For the expires_at null test, we need to modify the validation or use a different approach + context 'when expires_at is blank' do + it 'returns false' do + allow(instagram_channel).to receive(:expires_at).and_return(nil) + expect(service.send(:token_valid?)).to be false + end + end + + context 'when token is expired' do + it 'returns false' do + allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago) + expect(service.send(:token_valid?)).to be false + end + end + + context 'when token is valid' do + it 'returns true' do + allow(instagram_channel).to receive(:expires_at).and_return(1.day.from_now) + expect(service.send(:token_valid?)).to be true + end + end + end + + describe '#token_eligible_for_refresh?' do + context 'when token is too new' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(12.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(5.days.from_now) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + + context 'when token is not approaching expiry' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(20.days.from_now) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + + context 'when token is expired' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + end + end +end From 246deab6842904a2b6841bfb577bc20a11b0f93b Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 3 Apr 2025 14:30:48 +0530 Subject: [PATCH 3/6] feat: Instagram reauthorization (#11221) This PR is part of https://github.com/chatwoot/chatwoot/pull/11054 to make the review cycle easier. --- .../instagram/callbacks_controller.rb | 50 +++++++++++++++++-- app/javascript/dashboard/helper/inbox.js | 6 +++ .../dashboard/i18n/locale/en/inboxMgmt.json | 2 +- .../dashboard/settings/inbox/Settings.vue | 8 ++- .../inbox/channels/instagram/Reauthorize.vue | 37 ++++++++++++++ .../settings/inbox/components/ChannelName.vue | 1 + app/javascript/shared/mixins/inboxMixin.js | 3 ++ .../channel_notifications_mailer.rb | 5 ++ app/models/channel/instagram.rb | 2 + app/models/concerns/reauthorizable.rb | 24 ++++----- app/models/inbox.rb | 6 ++- app/views/api/v1/models/_inbox.json.jbuilder | 3 ++ .../instagram_disconnect.liquid | 8 +++ config/routes.rb | 1 + .../channel_notifications_mailer_spec.rb | 14 ++++++ spec/models/channel/instagram_spec.rb | 18 +++++++ spec/models/concerns/reauthorizable_shared.rb | 2 + 17 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/instagram/Reauthorize.vue create mode 100644 app/views/mailers/administrator_notifications/channel_notifications_mailer/instagram_disconnect.liquid diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb index 02add933f..4dc8ece1c 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -26,8 +26,13 @@ class Instagram::CallbacksController < ApplicationController ) @long_lived_token_response = exchange_for_long_lived_token(@response.token) - inbox, = create_channel_with_inbox - redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end end # Handle all errors that might occur during authorization @@ -82,12 +87,45 @@ class Instagram::CallbacksController < ApplicationController ) end - def create_channel_with_inbox + def find_or_create_inbox + user_details = fetch_instagram_user_details(@long_lived_token_response['access_token']) + channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s) + channel_exists = channel_instagram.present? + + if channel_instagram + update_channel(channel_instagram, user_details) + else + channel_instagram = create_channel_with_inbox(user_details) + end + + # reauthorize channel, this code path only triggers when instagram auth is successful + # reauthorized will also update cache keys for the associated inbox + channel_instagram.reauthorized! + + [channel_instagram.inbox, channel_exists] + end + + def find_channel_by_instagram_id(instagram_id) + Channel::Instagram.find_by(instagram_id: instagram_id, account: account) + end + + def update_channel(channel_instagram, user_details) + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + channel_instagram.update!( + access_token: @long_lived_token_response['access_token'], + expires_at: expires_at + ) + + # Update inbox name if username changed + channel_instagram.inbox.update!(name: user_details['username']) + channel_instagram + end + + def create_channel_with_inbox(user_details) ActiveRecord::Base.transaction do expires_at = Time.current + @long_lived_token_response['expires_in'].seconds - user_details = fetch_instagram_user_details(@long_lived_token_response['access_token']) - channel_instagram = Channel::Instagram.create!( access_token: @long_lived_token_response['access_token'], instagram_id: user_details['user_id'].to_s, @@ -100,6 +138,8 @@ class Instagram::CallbacksController < ApplicationController channel: channel_instagram, name: user_details['username'] ) + + channel_instagram end end diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index b608f4110..ff6d73c84 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -9,6 +9,7 @@ export const INBOX_TYPES = { TELEGRAM: 'Channel::Telegram', LINE: 'Channel::Line', SMS: 'Channel::Sms', + INSTAGRAM: 'Channel::Instagram', }; const INBOX_ICON_MAP_FILL = { @@ -20,6 +21,7 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.EMAIL]: 'i-ri-mail-fill', [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill', [INBOX_TYPES.LINE]: 'i-ri-line-fill', + [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -33,6 +35,7 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.EMAIL]: 'i-ri-mail-line', [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line', [INBOX_TYPES.LINE]: 'i-ri-line-line', + [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line', }; const DEFAULT_ICON_LINE = 'i-ri-chat-1-line'; @@ -118,6 +121,9 @@ export const getInboxClassByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'brand-line'; + case INBOX_TYPES.INSTAGRAM: + return 'brand-instagram'; + default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 3834b0a3d..056fcc78a 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -49,7 +49,7 @@ "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", "HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ", "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", - "ERROR_AUTH": "Something went wrong with your Instagram authentication, please try again" + "ERROR_AUTH": "There was an error connecting to Instagram, please try again" }, "TWITTER": { "HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 8bd2f3e6f..845206d1c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -7,6 +7,7 @@ import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner. import SettingsSection from '../../../../components/SettingsSection.vue'; import inboxMixin from 'shared/mixins/inboxMixin'; import FacebookReauthorize from './facebook/Reauthorize.vue'; +import InstagramReauthorize from './channels/instagram/Reauthorize.vue'; import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue'; import GoogleReauthorize from './channels/google/Reauthorize.vue'; import PreChatFormSettings from './PreChatForm/Settings.vue'; @@ -36,6 +37,7 @@ export default { MicrosoftReauthorize, GoogleReauthorize, NextButton, + InstagramReauthorize, }, mixins: [inboxMixin], setup() { @@ -202,6 +204,9 @@ export default { return true; return false; }, + instagramUnauthorized() { + return this.isAInstagramChannel && this.inbox.reauthorization_required; + }, microsoftUnauthorized() { return this.isAMicrosoftInbox && this.inbox.reauthorization_required; }, @@ -383,10 +388,11 @@ export default { /> -
+
+
+import { ref } from 'vue'; +import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue'; + +import instagramClient from 'dashboard/api/channel/instagramClient'; + +import { useI18n } from 'vue-i18n'; +import { useAlert } from 'dashboard/composables'; + +const { t } = useI18n(); + +const isRequestingAuthorization = ref(false); + +async function requestAuthorization() { + try { + isRequestingAuthorization.value = true; + const response = await instagramClient.generateAuthorization(); + + const { + data: { url }, + } = response; + + window.location.href = url; + } catch (error) { + useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH')); + } finally { + isRequestingAuthorization.value = false; + } +} + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 91cc2282e..b7b0a2c1d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -28,6 +28,7 @@ const i18nMap = { 'Channel::Telegram': 'TELEGRAM', 'Channel::Line': 'LINE', 'Channel::Api': 'API', + 'Channel::Instagram': 'INSTAGRAM', }; const twilioChannelName = () => { diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 82ee9db9e..94f5997f7 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -121,6 +121,9 @@ export default { this.isATwilioWhatsAppChannel ); }, + isAInstagramChannel() { + return this.channelType === INBOX_TYPES.INSTAGRAM; + }, }, methods: { inboxHasFeature(feature) { diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb index e884b3df9..05508a4ce 100644 --- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb +++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb @@ -4,6 +4,11 @@ class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNoti send_notification(subject, action_url: inbox_url(inbox)) end + def instagram_disconnect(inbox) + subject = 'Your Instagram connection has expired' + send_notification(subject, action_url: inbox_url(inbox)) + end + def whatsapp_disconnect(inbox) subject = 'Your Whatsapp connection has expired' send_notification(subject, action_url: inbox_url(inbox)) diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb index b5ce02ce7..092973fcd 100644 --- a/app/models/channel/instagram.rb +++ b/app/models/channel/instagram.rb @@ -19,6 +19,8 @@ class Channel::Instagram < ApplicationRecord include Reauthorizable self.table_name = 'channel_instagram' + AUTHORIZATION_ERROR_THRESHOLD = 1 + validates :access_token, presence: true validates :instagram_id, uniqueness: true, presence: true diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index 32de1a8ef..94bbed0d9 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -39,18 +39,7 @@ module Reauthorizable def prompt_reauthorization! ::Redis::Alfred.set(reauthorization_required_key, true) - case self.class.name - when 'Integrations::Hook' - process_integration_hook_reauthorization_emails - when 'Channel::FacebookPage' - send_channel_reauthorization_email(:facebook_disconnect) - when 'Channel::Whatsapp' - send_channel_reauthorization_email(:whatsapp_disconnect) - when 'Channel::Email' - send_channel_reauthorization_email(:email_disconnect) - when 'AutomationRule' - handle_automation_rule_reauthorization - end + reauthorization_handlers[self.class.name]&.call(self) invalidate_inbox_cache unless instance_of?(::AutomationRule) end @@ -82,6 +71,17 @@ module Reauthorizable private + def reauthorization_handlers + { + 'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails }, + 'Channel::FacebookPage' => ->(obj) { obj.send_channel_reauthorization_email(:facebook_disconnect) }, + 'Channel::Instagram' => ->(obj) { obj.send_channel_reauthorization_email(:instagram_disconnect) }, + 'Channel::Whatsapp' => ->(obj) { obj.send_channel_reauthorization_email(:whatsapp_disconnect) }, + 'Channel::Email' => ->(obj) { obj.send_channel_reauthorization_email(:email_disconnect) }, + 'AutomationRule' => ->(obj) { obj.handle_automation_rule_reauthorization } + } + end + def invalidate_inbox_cache inbox.update_account_cache if inbox.present? end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 508675858..20c8dc610 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -107,7 +107,11 @@ class Inbox < ApplicationRecord end def instagram? - facebook? && channel.instagram_id.present? + (facebook? || instagram_direct?) && channel.instagram_id.present? + end + + def instagram_direct? + channel_type == 'Channel::Instagram' end def web_widget? diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 9cad3edc1..9527b0787 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -54,6 +54,9 @@ if resource.facebook? json.reauthorization_required resource.channel.try(:reauthorization_required?) end +## Instagram Attributes +json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram? + ## Twilio Attributes json.messaging_service_sid resource.channel.try(:messaging_service_sid) json.phone_number resource.channel.try(:phone_number) diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/instagram_disconnect.liquid b/app/views/mailers/administrator_notifications/channel_notifications_mailer/instagram_disconnect.liquid new file mode 100644 index 000000000..d1d4e6345 --- /dev/null +++ b/app/views/mailers/administrator_notifications/channel_notifications_mailer/instagram_disconnect.liquid @@ -0,0 +1,8 @@ +

Hello,

+ +

Your Instagram Inbox Access has expired.

+

Please reconnect Instagram to continue receiving messages.

+ +

+Click here to re-connect. +

\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 375d60b0c..3ec088890 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,7 @@ Rails.application.routes.draw do get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents' get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents' get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents' + get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings' resource :widget, only: [:show] diff --git a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb index 1be1314da..e5cd7327b 100644 --- a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb @@ -44,4 +44,18 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do expect(mail.to).to eq([administrator.email]) end end + + describe 'instagram_disconnect' do + let!(:instagram_channel) { create(:channel_instagram, account: account) } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account) } + let(:mail) { described_class.with(account: account).instagram_disconnect(instagram_inbox).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Your Instagram connection has expired') + end + + it 'renders the receiver email' do + expect(mail.to).to eq([administrator.email]) + end + end end diff --git a/spec/models/channel/instagram_spec.rb b/spec/models/channel/instagram_spec.rb index 901fe392e..a3cea9e92 100644 --- a/spec/models/channel/instagram_spec.rb +++ b/spec/models/channel/instagram_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rails_helper' +require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb' RSpec.describe Channel::Instagram do let(:channel) { create(:channel_instagram) } @@ -14,4 +15,21 @@ RSpec.describe Channel::Instagram do it 'has a valid name' do expect(channel.name).to eq('Instagram') end + + describe 'concerns' do + it_behaves_like 'reauthorizable' + + context 'when prompt_reauthorization!' do + it 'calls channel notifier mail for instagram' do + admin_mailer = double + mailer_double = double + + expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer) + expect(admin_mailer).to receive(:instagram_disconnect).with(channel.inbox).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + + channel.prompt_reauthorization! + end + end + end end diff --git a/spec/models/concerns/reauthorizable_shared.rb b/spec/models/concerns/reauthorizable_shared.rb index a71800267..558312e6c 100644 --- a/spec/models/concerns/reauthorizable_shared.rb +++ b/spec/models/concerns/reauthorizable_shared.rb @@ -48,10 +48,12 @@ shared_examples_for 'reauthorizable' do 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) + instagram_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) + allow(channel_mailer).to receive(:instagram_disconnect).and_return(instagram_mailer_response) end describe 'prompt_reauthorization!' do From 196bdf15af57358a4bfd7e3839d198a027e24a2d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 3 Apr 2025 17:25:34 +0530 Subject: [PATCH 4/6] chore: Fix facebook inbox create button (#11237) **Before** ![CleanShot 2025-04-03 at 14 59 33@2x](https://github.com/user-attachments/assets/9e6b28f9-e198-4bc9-8891-af3d6d6a0084) **After** ![CleanShot 2025-04-03 at 16 51 22@2x](https://github.com/user-attachments/assets/5981d9c6-e645-47c8-bc46-2e488e30041e) --- app/javascript/dashboard/i18n/locale/en/inboxMgmt.json | 3 ++- .../routes/dashboard/settings/inbox/channels/Facebook.vue | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 056fcc78a..5e26a34c0 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -43,7 +43,8 @@ "INBOX_NAME": "Inbox Name", "ADD_NAME": "Add a name for your inbox", "PICK_NAME": "Pick a Name for your Inbox", - "PICK_A_VALUE": "Pick a value" + "PICK_A_VALUE": "Pick a value", + "CREATE_INBOX": "Create Inbox" }, "INSTAGRAM": { "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 8201fa14f..d67a9ea6a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -11,6 +11,7 @@ import ChannelApi from '../../../../../api/channels'; import PageHeader from '../../SettingsSubPageHeader.vue'; import router from '../../../../index'; import globalConfigMixin from 'shared/mixins/globalConfigMixin'; +import NextButton from 'dashboard/components-next/button/Button.vue'; import { loadScript } from 'dashboard/helper/DOMHelpers'; import * as Sentry from '@sentry/vue'; @@ -19,6 +20,7 @@ export default { components: { LoadingState, PageHeader, + NextButton, }, mixins: [globalConfigMixin], setup() { @@ -207,7 +209,7 @@ export default {