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 5/9] 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 @@
+
+
+
+
+
+
+
{{ errorStateMessage }}
+
+
+
+
+
+ {{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
+
+
+
+
+
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 6/9] 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 7/9] chore: Fix facebook inbox create button (#11237)
**Before**

**After**

---
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 {
From 1a78a9243fc416fcea4eac05d97dbbd0dde8030a Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Thu, 3 Apr 2025 16:00:32 -0700
Subject: [PATCH 8/9] chore: Clean up report & knowledge base policies (#11234)
- Removes the portal_members table and all associated records
- Updates policies to use custom roles with knowledge_base_manage
permission
- Updates controllers, models, and views to work without portal
membership
- Adds tests for the new permission model
---
.../api/v1/accounts/portals_controller.rb | 9 --
.../api/v2/accounts/reports_controller.rb | 6 +-
app/models/account.rb | 27 ++---
app/models/portal.rb | 10 --
app/models/portal_member.rb | 20 ----
app/models/user.rb | 4 -
app/policies/article_policy.rb | 22 ++--
app/policies/category_policy.rb | 20 ++--
app/policies/portal_policy.rb | 16 +--
app/policies/report_policy.rb | 2 +-
.../v1/accounts/portals/_portal.json.jbuilder | 8 --
config/routes.rb | 1 -
.../20250402233933_remove_portal_members.rb | 16 +++
db/schema.rb | 11 +-
.../api/v2/accounts/reports_controller.rb | 7 --
.../app/policies/enterprise/portal_policy.rb | 20 ----
.../v1/accounts/articles_controller_spec.rb | 29 +++--
.../v1/accounts/categories_controller_spec.rb | 29 +++--
.../v1/accounts/portals_controller_spec.rb | 41 +------
.../v1/accounts/articles_controller_spec.rb | 103 ++++++++++++++++
.../v1/accounts/categories_controller_spec.rb | 111 ++++++++++++++++++
.../contacts/conversations_controller_spec.rb | 0
.../v1/accounts/portals_controller_spec.rb | 90 ++++++++++++++
.../v2/accounts/reports_controller_spec.rb | 67 +++++++++++
.../policies/article_policy_spec.rb | 28 +++++
.../policies/category_policy_spec.rb | 27 +++++
.../enterprise/policies/portal_policy_spec.rb | 32 +++++
.../enterprise/policies/report_policy_spec.rb | 26 ++++
.../permission_filter_service_spec.rb | 0
spec/factories/portal_members.rb | 6 -
spec/models/portal_member_spec.rb | 8 --
spec/models/portal_spec.rb | 2 -
spec/policies/article_policy_spec.rb | 34 ++++++
spec/policies/category_policy_spec.rb | 34 ++++++
spec/policies/portal_policy_spec.rb | 35 ++++++
spec/policies/report_policy_spec.rb | 25 ++++
36 files changed, 694 insertions(+), 232 deletions(-)
delete mode 100644 app/models/portal_member.rb
create mode 100644 db/migrate/20250402233933_remove_portal_members.rb
delete mode 100644 enterprise/app/controllers/enterprise/api/v2/accounts/reports_controller.rb
create mode 100644 spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb
create mode 100644 spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb
rename {enterprise/spec/controllers => spec/enterprise/controllers/enterprise}/api/v1/accounts/contacts/conversations_controller_spec.rb (100%)
create mode 100644 spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
create mode 100644 spec/enterprise/controllers/enterprise/api/v2/accounts/reports_controller_spec.rb
create mode 100644 spec/enterprise/policies/article_policy_spec.rb
create mode 100644 spec/enterprise/policies/category_policy_spec.rb
create mode 100644 spec/enterprise/policies/portal_policy_spec.rb
create mode 100644 spec/enterprise/policies/report_policy_spec.rb
rename {enterprise/spec => spec/enterprise}/services/enterprise/conversations/permission_filter_service_spec.rb (100%)
delete mode 100644 spec/factories/portal_members.rb
delete mode 100644 spec/models/portal_member_spec.rb
create mode 100644 spec/policies/article_policy_spec.rb
create mode 100644 spec/policies/category_policy_spec.rb
create mode 100644 spec/policies/portal_policy_spec.rb
create mode 100644 spec/policies/report_policy_spec.rb
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index fe9a03ef5..6cfed161f 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals
end
- def add_members
- agents = Current.account.agents.where(id: portal_member_params[:member_ids])
- @portal.members << agents
- end
-
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
@@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
{ channel_web_widget_id: inbox.channel.id }
end
- def portal_member_params
- params.require(:portal).permit(:account_id, member_ids: [])
- end
-
def set_current_page
@current_page = params[:page] || 1
end
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index af3655e59..6e2d0ff4c 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
- return if Current.account_user.administrator?
-
- raise Pundit::NotAuthorizedError
+ authorize :report, :view?
end
def common_params
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
-
-Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
diff --git a/app/models/account.rb b/app/models/account.rb
index 1cd59e1a4..decfc9d2e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -2,20 +2,19 @@
#
# Table name: accounts
#
-# id :integer not null, primary key
-# auto_resolve_duration :integer
-# contactable_contacts_count :integer default(0)
-# custom_attributes :jsonb
-# domain :string(100)
-# feature_flags :bigint default(0), not null
-# internal_attributes :jsonb not null
-# limits :jsonb
-# locale :integer default("en")
-# name :string not null
-# status :integer default("active")
-# support_email :string(100)
-# created_at :datetime not null
-# updated_at :datetime not null
+# id :integer not null, primary key
+# auto_resolve_duration :integer
+# custom_attributes :jsonb
+# domain :string(100)
+# feature_flags :bigint default(0), not null
+# internal_attributes :jsonb not null
+# limits :jsonb
+# locale :integer default("en")
+# name :string not null
+# status :integer default("active")
+# support_email :string(100)
+# created_at :datetime not null
+# updated_at :datetime not null
#
# Indexes
#
diff --git a/app/models/portal.rb b/app/models/portal.rb
index cc64be1fd..cb87929f5 100644
--- a/app/models/portal.rb
+++ b/app/models/portal.rb
@@ -30,14 +30,6 @@ class Portal < ApplicationRecord
has_many :categories, dependent: :destroy_async
has_many :folders, through: :categories
has_many :articles, dependent: :destroy_async
- has_many :portal_members,
- class_name: :PortalMember,
- dependent: :destroy_async
- has_many :members,
- through: :portal_members,
- class_name: :User,
- dependent: :nullify,
- source: :user
has_one_attached :logo
has_many :inboxes, dependent: :nullify
belongs_to :channel_web_widget, class_name: 'Channel::WebWidget', optional: true
@@ -49,8 +41,6 @@ class Portal < ApplicationRecord
validates :custom_domain, uniqueness: true, allow_nil: true
validate :config_json_format
- accepts_nested_attributes_for :members
-
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale website_token].freeze
diff --git a/app/models/portal_member.rb b/app/models/portal_member.rb
deleted file mode 100644
index e0e687ae1..000000000
--- a/app/models/portal_member.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# == Schema Information
-#
-# Table name: portal_members
-#
-# id :bigint not null, primary key
-# created_at :datetime not null
-# updated_at :datetime not null
-# portal_id :bigint
-# user_id :bigint
-#
-# Indexes
-#
-# index_portal_members_on_portal_id_and_user_id (portal_id,user_id) UNIQUE
-# index_portal_members_on_user_id_and_portal_id (user_id,portal_id) UNIQUE
-#
-class PortalMember < ApplicationRecord
- belongs_to :portal, class_name: 'Portal'
- belongs_to :user, class_name: 'User'
- validates :user_id, uniqueness: { scope: :portal_id }
-end
diff --git a/app/models/user.rb b/app/models/user.rb
index faadb3271..5594b0ca6 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -95,10 +95,6 @@ class User < ApplicationRecord
has_many :team_members, dependent: :destroy_async
has_many :teams, through: :team_members
has_many :articles, foreign_key: 'author_id', dependent: :nullify, inverse_of: :author
- has_many :portal_members, class_name: :PortalMember, dependent: :destroy_async
- has_many :portals, through: :portal_members, source: :portal,
- class_name: :Portal,
- dependent: :nullify
# rubocop:disable Rails/HasManyOrHasOneDependent
# we are handling this in `remove_macros` callback
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
diff --git a/app/policies/article_policy.rb b/app/policies/article_policy.rb
index e48442337..282a507be 100644
--- a/app/policies/article_policy.rb
+++ b/app/policies/article_policy.rb
@@ -1,37 +1,31 @@
class ArticlePolicy < ApplicationPolicy
def index?
- @account_user.administrator? || @account.users.include?(@user)
+ @account.users.include?(@user)
end
def update?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def show?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def edit?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def create?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def destroy?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def reorder?
- @account_user.administrator? || portal_member?
- end
-
- private
-
- def portal_member?
- @record.first.portal.members.include?(@user)
+ @account_user.administrator?
end
end
-ArticlePolicy.prepend_mod_with('Enterprise::ArticlePolicy')
+ArticlePolicy.prepend_mod_with('ArticlePolicy')
diff --git a/app/policies/category_policy.rb b/app/policies/category_policy.rb
index 3dca6b847..104022595 100644
--- a/app/policies/category_policy.rb
+++ b/app/policies/category_policy.rb
@@ -1,33 +1,27 @@
class CategoryPolicy < ApplicationPolicy
def index?
- @account_user.administrator? || @account.users.include?(@user)
+ @account.users.include?(@user)
end
def update?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def show?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def edit?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def create?
- @account_user.administrator? || portal_member?
+ @account_user.administrator?
end
def destroy?
- @account_user.administrator? || portal_member?
- end
-
- private
-
- def portal_member?
- @record.first.portal.members.include?(@user)
+ @account_user.administrator?
end
end
-CategoryPolicy.prepend_mod_with('Enterprise::CategoryPolicy')
+CategoryPolicy.prepend_mod_with('CategoryPolicy')
diff --git a/app/policies/portal_policy.rb b/app/policies/portal_policy.rb
index c52b44cac..1e09c41f6 100644
--- a/app/policies/portal_policy.rb
+++ b/app/policies/portal_policy.rb
@@ -1,6 +1,6 @@
class PortalPolicy < ApplicationPolicy
def index?
- @account_user.administrator? || @account.users.include?(@user)
+ @account.users.include?(@user)
end
def update?
@@ -8,7 +8,7 @@ class PortalPolicy < ApplicationPolicy
end
def show?
- @account_user.administrator? || portal_member?
+ @account.users.include?(@user)
end
def edit?
@@ -23,19 +23,9 @@ class PortalPolicy < ApplicationPolicy
@account_user.administrator?
end
- def add_members?
- @account_user.administrator?
- end
-
def logo?
@account_user.administrator?
end
-
- private
-
- def portal_member?
- @record.first.members.include?(@user)
- end
end
-PortalPolicy.prepend_mod_with('Enterprise::PortalPolicy')
+PortalPolicy.prepend_mod_with('PortalPolicy')
diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb
index a52607a67..a2701ffd2 100644
--- a/app/policies/report_policy.rb
+++ b/app/policies/report_policy.rb
@@ -4,4 +4,4 @@ class ReportPolicy < ApplicationPolicy
end
end
-ReportPolicy.prepend_mod_with('Enterprise::ReportPolicy')
+ReportPolicy.prepend_mod_with('ReportPolicy')
diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
index 199ab352f..5e267f60c 100644
--- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder
+++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
@@ -25,14 +25,6 @@ end
json.logo portal.file_base_data if portal.logo.present?
-json.portal_members do
- if portal.members.any?
- json.array! portal.members.each do |member|
- json.partial! 'api/v1/models/agent', formats: [:json], resource: member
- end
- end
-end
-
json.meta do
json.all_articles_count articles.try(:size)
json.archived_articles_count articles.try(:archived).try(:size)
diff --git a/config/routes.rb b/config/routes.rb
index 3ec088890..46199db12 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -264,7 +264,6 @@ Rails.application.routes.draw do
resources :portals do
member do
patch :archive
- put :add_members
delete :logo
end
resources :categories
diff --git a/db/migrate/20250402233933_remove_portal_members.rb b/db/migrate/20250402233933_remove_portal_members.rb
new file mode 100644
index 000000000..f145f5288
--- /dev/null
+++ b/db/migrate/20250402233933_remove_portal_members.rb
@@ -0,0 +1,16 @@
+class RemovePortalMembers < ActiveRecord::Migration[7.0]
+ def up
+ drop_table :portal_members
+ end
+
+ def down
+ create_table :portal_members do |t|
+ t.references :portal, index: false
+ t.references :user, index: false
+ t.timestamps
+ end
+
+ add_index :portal_members, [:portal_id, :user_id], unique: true
+ add_index :portal_members, [:user_id, :portal_id], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1f7217cd8..e4706342e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) do
+ActiveRecord::Schema[7.0].define(version: 2025_04_02_233933) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -871,15 +871,6 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) do
t.datetime "updated_at", null: false
end
- create_table "portal_members", force: :cascade do |t|
- t.bigint "portal_id"
- t.bigint "user_id"
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["portal_id", "user_id"], name: "index_portal_members_on_portal_id_and_user_id", unique: true
- t.index ["user_id", "portal_id"], name: "index_portal_members_on_user_id_and_portal_id", unique: true
- end
-
create_table "portals", force: :cascade do |t|
t.integer "account_id", null: false
t.string "name", null: false
diff --git a/enterprise/app/controllers/enterprise/api/v2/accounts/reports_controller.rb b/enterprise/app/controllers/enterprise/api/v2/accounts/reports_controller.rb
deleted file mode 100644
index b5e2a9efe..000000000
--- a/enterprise/app/controllers/enterprise/api/v2/accounts/reports_controller.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-module Enterprise::Api::V2::Accounts::ReportsController
- def check_authorization
- return if Current.account_user.custom_role&.permissions&.include?('report_manage')
-
- super
- end
-end
diff --git a/enterprise/app/policies/enterprise/portal_policy.rb b/enterprise/app/policies/enterprise/portal_policy.rb
index 45c3ea140..73e911efb 100644
--- a/enterprise/app/policies/enterprise/portal_policy.rb
+++ b/enterprise/app/policies/enterprise/portal_policy.rb
@@ -1,32 +1,12 @@
module Enterprise::PortalPolicy
- def index?
- @account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
- end
-
def update?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
- def show?
- @account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
- end
-
def edit?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
- def create?
- @account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
- end
-
- def destroy?
- @account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
- end
-
- def add_members?
- @account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
- end
-
def logo?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || super
end
diff --git a/spec/controllers/api/v1/accounts/articles_controller_spec.rb b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
index 754629b7b..42fd062dc 100644
--- a/spec/controllers/api/v1/accounts/articles_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
@@ -3,12 +3,11 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, locale: 'en', slug: 'category_slug') }
let!(:article) { create(:article, category: category, portal: portal, account_id: account.id, author_id: agent.id) }
- before { create(:portal_member, user: agent, portal: portal) }
-
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/articles' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -33,7 +32,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -56,7 +55,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -84,7 +83,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -110,7 +109,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
@@ -144,7 +143,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
params: article_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql(article_params[:article][:title])
@@ -165,7 +164,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
context 'when it is an authenticated user' do
it 'deletes category' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
deleted_article = Article.find_by(id: article.id)
expect(deleted_article).to be_nil
@@ -187,7 +186,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
- headers: agent.create_new_auth_token,
+ headers: admin.create_new_auth_token,
params: {}
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -199,7 +198,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
- headers: agent.create_new_auth_token,
+ headers: admin.create_new_auth_token,
params: {}
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -213,7 +212,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
- headers: agent.create_new_auth_token,
+ headers: admin.create_new_auth_token,
params: { category_slug: category.slug }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -230,14 +229,14 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
- headers: agent.create_new_auth_token,
+ headers: admin.create_new_auth_token,
params: { query: 'funny' }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].count).to be 1
expect(json_response['meta']['all_articles_count']).to be 2
expect(json_response['meta']['articles_count']).to be 1
- expect(json_response['meta']['mine_articles_count']).to be 1
+ expect(json_response['meta']['mine_articles_count']).to be 0
end
end
@@ -247,7 +246,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(article2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article2.id}",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -263,7 +262,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
associated_article_id: root_article.id)
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{root_article.id}",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
diff --git a/spec/controllers/api/v1/accounts/categories_controller_spec.rb b/spec/controllers/api/v1/accounts/categories_controller_spec.rb
index 03e658aac..48cc001e3 100644
--- a/spec/controllers/api/v1/accounts/categories_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/categories_controller_spec.rb
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug', position: 1) }
let!(:category_to_associate) do
@@ -15,8 +16,6 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
create(:category, name: 'related category 2', portal: portal, account_id: account.id, slug: 'category_slug_2', position: 4)
end
- before { create(:portal_member, user: agent, portal: portal) }
-
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/categories' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -59,7 +58,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -75,11 +74,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates multiple sub_categories under one parent_category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params_2,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category.reload.sub_category_ids).to eql(Category.last(2).pluck(:id))
@@ -88,11 +87,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'creates multiple associated_categories with one category' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params_2,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category_to_associate.reload.associated_category_ids).to eql(Category.last(2).pluck(:id))
@@ -101,11 +100,11 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
it 'will throw an error on locale, category_id uniqueness' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to eql('Locale should be unique in the category and portal')
@@ -123,7 +122,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
@@ -158,7 +157,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
json_response = response.parsed_body
@@ -181,7 +180,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
@@ -209,7 +208,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{related_category_2.id}",
params: category_params,
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
@@ -230,7 +229,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
context 'when it is an authenticated user' do
it 'deletes category' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
deleted_category = Category.find_by(id: category.id)
expect(deleted_category).to be_nil
@@ -255,7 +254,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
expect(category2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].count).to be(category_count + 1)
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index 7da6bf52f..aeec9cab4 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -8,8 +8,6 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
let(:agent_2) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, slug: 'portal-1', name: 'test_portal', account_id: account.id) }
- before { create(:portal_member, user: agent, portal: portal) }
-
describe 'GET /api/v1/accounts/{account.id}/portals' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -23,7 +21,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
portal2 = create(:portal, name: 'test_portal_2', account_id: account.id, slug: 'portal-2')
expect(portal2.id).not_to be_nil
get "/api/v1/accounts/#{account.id}/portals",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -45,7 +43,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
context 'when it is an authenticated user' do
it 'get one portals' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -62,7 +60,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
create(:article, category_id: es_cat.id, portal_id: portal.id, author_id: agent.id)
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}?locale=en",
- headers: agent.create_new_auth_token
+ headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
@@ -178,38 +176,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end
end
- describe 'PUT /api/v1/accounts/{account.id}/portals/{portal.slug}/add_members' do
- let(:new_account) { create(:account) }
- let(:new_agent) { create(:user, account: new_account, role: :agent) }
-
- context 'when it is an unauthenticated user' do
- it 'returns unauthorized' do
- put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/add_members", params: {}
-
- expect(response).to have_http_status(:unauthorized)
- end
- end
-
- context 'when it is an authenticated user' do
- it 'add members to the portal' do
- portal_params = {
- portal: {
- member_ids: [agent_1.id, agent_2.id]
- }
- }
- expect(portal.members.count).to be(1)
-
- put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/add_members",
- params: portal_params,
- headers: admin.create_new_auth_token
-
- expect(response).to have_http_status(:success)
- json_response = response.parsed_body
- expect(portal.reload.member_ids).to include(agent_1.id)
- expect(json_response['portal_members'].length).to be(3)
- end
- end
- end
+ # Portal members endpoint removed
describe 'DELETE /api/v1/accounts/{account.id}/portals/{portal.slug}/logo' do
context 'when it is an unauthenticated user' do
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb
new file mode 100644
index 000000000..43d6fc02d
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles_controller_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Articles API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
+ let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, locale: 'en', slug: 'category_slug') }
+ let!(:article) { create(:article, category: category, portal: portal, account_id: account.id, author_id: admin.id) }
+
+ # Create a custom role with knowledge_base_manage permission
+ let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+ # Create user without account
+ let!(:agent_with_role) { create(:user) }
+ # Then create account_user association with custom_role
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+
+ # Ensure the account_user with custom role is created before tests run
+ before do
+ agent_with_role_account_user
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/articles' do
+ let(:article_params) do
+ {
+ article: {
+ category_id: category.id,
+ title: 'New Article',
+ slug: 'new-article',
+ content: 'This is a new article',
+ author_id: agent_with_role.id,
+ status: 'draft'
+ }
+ }
+ end
+
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
+ params: article_params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['payload']['title']).to eq('New Article')
+ end
+ end
+ end
+
+ describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
+ let(:article_params) do
+ {
+ article: {
+ title: 'Updated Article',
+ content: 'This is an updated article'
+ }
+ }
+ end
+
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ params: article_params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['payload']['title']).to eq('Updated Article')
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(Article.find_by(id: article.id)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb
new file mode 100644
index 000000000..f83542743
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/categories_controller_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Categories API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id, config: { allowed_locales: %w[en es] }) }
+ let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug', position: 1) }
+
+ # Create a custom role with knowledge_base_manage permission
+ let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+ let!(:agent_with_role) { create(:user) }
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+
+ # Ensure the account_user with custom role is created before tests run
+ before do
+ agent_with_role_account_user
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['payload']['name']).to eq('category')
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
+ let(:category_params) do
+ {
+ category: {
+ name: 'New Category',
+ slug: 'new-category',
+ locale: 'en',
+ description: 'This is a new category'
+ }
+ }
+ end
+
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
+ params: category_params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['payload']['name']).to eq('New Category')
+ end
+ end
+ end
+
+ describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
+ let(:category_params) do
+ {
+ category: {
+ name: 'Updated Category',
+ description: 'This is an updated category'
+ }
+ }
+ end
+
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
+ params: category_params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['payload']['name']).to eq('Updated Category')
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+end
diff --git a/enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/contacts/conversations_controller_spec.rb
similarity index 100%
rename from enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb
rename to spec/enterprise/controllers/enterprise/api/v1/accounts/contacts/conversations_controller_spec.rb
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
new file mode 100644
index 000000000..cb296494c
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Portal API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
+
+ # Create a custom role with knowledge_base_manage permission
+ let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+ # Create user without account
+ let!(:agent_with_role) { create(:user) }
+ # Then create account_user association with custom_role
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+
+ # Ensure the account_user with custom role is created before tests run
+ before do
+ agent_with_role_account_user
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/portals' do
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ get "/api/v1/accounts/#{account.id}/portals",
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug' do
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['name']).to eq('test_portal')
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/portals' do
+ let(:portal_params) do
+ { portal: {
+ name: 'test_portal',
+ slug: 'test_kbase',
+ custom_domain: 'https://support.chatwoot.dev'
+ } }
+ end
+
+ context 'when it is an authenticated user' do
+ it 'restricts portal creation for agents with knowledge_base_manage permission' do
+ post "/api/v1/accounts/#{account.id}/portals",
+ params: portal_params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug' do
+ let(:portal_params) do
+ { portal: { name: 'updated_portal' } }
+ end
+
+ context 'when it is an authenticated user' do
+ it 'returns success for agents with knowledge_base_manage permission' do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
+ params: portal_params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['name']).to eq('updated_portal')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/enterprise/api/v2/accounts/reports_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v2/accounts/reports_controller_spec.rb
new file mode 100644
index 000000000..ff05af909
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v2/accounts/reports_controller_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Reports API', type: :request do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ # Create a custom role with report_manage permission
+ let!(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
+ let!(:agent_with_role) { create(:user) }
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+
+ let(:default_timezone) { 'UTC' }
+ let(:start_of_today) { Time.current.in_time_zone(default_timezone).beginning_of_day.to_i }
+ let(:end_of_today) { Time.current.in_time_zone(default_timezone).end_of_day.to_i }
+ let(:params) { { timezone_offset: Time.zone.utc_offset } }
+
+ before do
+ agent_with_role_account_user
+ end
+
+ describe 'GET /api/v2/accounts/:account_id/reports' do
+ context 'when it is an authenticated user' do
+ let(:params) do
+ super().merge(
+ metric: 'conversations_count',
+ type: :account,
+ since: start_of_today.to_s,
+ until: end_of_today.to_s
+ )
+ end
+
+ it 'returns success for agents with report_manage permission' do
+ get "/api/v2/accounts/#{account.id}/reports",
+ params: params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+
+ describe 'GET /api/v2/accounts/:account_id/reports/summary' do
+ context 'when it is an authenticated user' do
+ let(:params) do
+ super().merge(
+ type: :account,
+ since: start_of_today.to_s,
+ until: end_of_today.to_s
+ )
+ end
+
+ it 'returns success for agents with report_manage permission' do
+ get "/api/v2/accounts/#{account.id}/reports/summary",
+ params: params,
+ headers: agent_with_role.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/policies/article_policy_spec.rb b/spec/enterprise/policies/article_policy_spec.rb
new file mode 100644
index 000000000..2417ab9e2
--- /dev/null
+++ b/spec/enterprise/policies/article_policy_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise::ArticlePolicy', type: :policy do
+ subject(:article_policy) { ArticlePolicy }
+
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account) } # Needed for author
+ let(:portal) { create(:portal, account: account) }
+ let(:article) { create(:article, account: account, portal: portal, author: agent) }
+
+ # Create a custom role with knowledge_base_manage permission
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+ let(:agent_with_role) { create(:user) } # Create without account
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+ let(:agent_with_role_context) do
+ { user: agent_with_role, account: account, account_user: agent_with_role_account_user }
+ end
+
+ permissions :index?, :update?, :show?, :edit?, :create?, :destroy?, :reorder? do
+ context 'when agent with knowledge_base_manage permission' do
+ it { expect(article_policy).to permit(agent_with_role_context, article) }
+ end
+ end
+end
diff --git a/spec/enterprise/policies/category_policy_spec.rb b/spec/enterprise/policies/category_policy_spec.rb
new file mode 100644
index 000000000..004428a2b
--- /dev/null
+++ b/spec/enterprise/policies/category_policy_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise::CategoryPolicy', type: :policy do
+ subject(:category_policy) { CategoryPolicy }
+
+ let(:account) { create(:account) }
+ let(:portal) { create(:portal, account: account) }
+ let(:category) { create(:category, account: account, portal: portal, slug: 'test-category') }
+
+ # Create a custom role with knowledge_base_manage permission
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+ let(:agent_with_role) { create(:user) } # Create without account
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+ let(:agent_with_role_context) do
+ { user: agent_with_role, account: account, account_user: agent_with_role_account_user }
+ end
+
+ permissions :index?, :update?, :show?, :edit?, :create?, :destroy? do
+ context 'when agent with knowledge_base_manage permission' do
+ it { expect(category_policy).to permit(agent_with_role_context, category) }
+ end
+ end
+end
diff --git a/spec/enterprise/policies/portal_policy_spec.rb b/spec/enterprise/policies/portal_policy_spec.rb
new file mode 100644
index 000000000..1e419b02c
--- /dev/null
+++ b/spec/enterprise/policies/portal_policy_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise::PortalPolicy', type: :policy do
+ subject(:portal_policy) { PortalPolicy }
+
+ let(:account) { create(:account) }
+ let(:portal) { create(:portal, account: account) }
+
+ # Create a custom role with knowledge_base_manage permission
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+ let(:agent_with_role) { create(:user) } # Create without account
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+ let(:agent_with_role_context) do
+ { user: agent_with_role, account: account, account_user: agent_with_role_account_user }
+ end
+
+ permissions :update?, :edit?, :logo? do
+ context 'when agent with knowledge_base_manage permission' do
+ it { expect(portal_policy).to permit(agent_with_role_context, portal) }
+ end
+ end
+
+ permissions :create?, :destroy? do
+ context 'when agent with knowledge_base_manage permission' do
+ it { expect(portal_policy).not_to permit(agent_with_role_context, portal) }
+ end
+ end
+end
diff --git a/spec/enterprise/policies/report_policy_spec.rb b/spec/enterprise/policies/report_policy_spec.rb
new file mode 100644
index 000000000..0d87c22d7
--- /dev/null
+++ b/spec/enterprise/policies/report_policy_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise::ReportPolicy', type: :policy do
+ subject(:report_policy) { ReportPolicy }
+
+ let(:account) { create(:account) }
+ let(:report) { :report }
+
+ # Create a custom role with report_manage permission
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
+ let(:agent_with_role) { create(:user) } # Create without account
+ let(:agent_with_role_account_user) do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+ let(:agent_with_role_context) do
+ { user: agent_with_role, account: account, account_user: agent_with_role_account_user }
+ end
+
+ permissions :view? do
+ context 'when agent with report_manage permission' do
+ it { expect(report_policy).to permit(agent_with_role_context, report) }
+ end
+ end
+end
diff --git a/enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb b/spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb
similarity index 100%
rename from enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb
rename to spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb
diff --git a/spec/factories/portal_members.rb b/spec/factories/portal_members.rb
deleted file mode 100644
index 9cab7b054..000000000
--- a/spec/factories/portal_members.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-FactoryBot.define do
- factory :portal_member do
- portal
- user
- end
-end
diff --git a/spec/models/portal_member_spec.rb b/spec/models/portal_member_spec.rb
deleted file mode 100644
index 646ff9c27..000000000
--- a/spec/models/portal_member_spec.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe PortalMember do
- describe 'associations' do
- it { is_expected.to belong_to(:portal) }
- it { is_expected.to belong_to(:user) }
- end
-end
diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb
index 1b33c8d90..1a108bbd6 100644
--- a/spec/models/portal_spec.rb
+++ b/spec/models/portal_spec.rb
@@ -12,8 +12,6 @@ RSpec.describe Portal do
it { is_expected.to have_many(:categories) }
it { is_expected.to have_many(:folders) }
it { is_expected.to have_many(:articles) }
- it { is_expected.to have_many(:portal_members) }
- it { is_expected.to have_many(:members) }
it { is_expected.to have_many(:inboxes) }
end
diff --git a/spec/policies/article_policy_spec.rb b/spec/policies/article_policy_spec.rb
new file mode 100644
index 000000000..2c1b5f9d1
--- /dev/null
+++ b/spec/policies/article_policy_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+RSpec.describe ArticlePolicy, type: :policy do
+ subject(:article_policy) { described_class }
+
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account) }
+ let(:portal) { create(:portal, account: account) }
+ let(:article) { create(:article, account: account, portal: portal, author: administrator) }
+
+ let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
+ let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
+
+ permissions :index? do
+ context 'when administrator' do
+ it { expect(article_policy).to permit(administrator_context, article) }
+ end
+
+ context 'when agent' do
+ it { expect(article_policy).to permit(agent_context, article) }
+ end
+ end
+
+ permissions :update?, :show?, :edit?, :create?, :destroy?, :reorder? do
+ context 'when administrator' do
+ it { expect(article_policy).to permit(administrator_context, article) }
+ end
+
+ context 'when agent' do
+ it { expect(article_policy).not_to permit(agent_context, article) }
+ end
+ end
+end
diff --git a/spec/policies/category_policy_spec.rb b/spec/policies/category_policy_spec.rb
new file mode 100644
index 000000000..933348635
--- /dev/null
+++ b/spec/policies/category_policy_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+RSpec.describe CategoryPolicy, type: :policy do
+ subject(:category_policy) { described_class }
+
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account) }
+ let(:portal) { create(:portal, account: account) }
+ let(:category) { create(:category, account: account, portal: portal, slug: 'test-category') }
+
+ let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
+ let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
+
+ permissions :index? do
+ context 'when administrator' do
+ it { expect(category_policy).to permit(administrator_context, category) }
+ end
+
+ context 'when agent' do
+ it { expect(category_policy).to permit(agent_context, category) }
+ end
+ end
+
+ permissions :update?, :show?, :edit?, :create?, :destroy? do
+ context 'when administrator' do
+ it { expect(category_policy).to permit(administrator_context, category) }
+ end
+
+ context 'when agent' do
+ it { expect(category_policy).not_to permit(agent_context, category) }
+ end
+ end
+end
diff --git a/spec/policies/portal_policy_spec.rb b/spec/policies/portal_policy_spec.rb
new file mode 100644
index 000000000..061e8b97e
--- /dev/null
+++ b/spec/policies/portal_policy_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PortalPolicy, type: :policy do
+ subject(:portal_policy) { described_class }
+
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account) }
+ let(:portal) { create(:portal, account: account) }
+
+ let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
+ let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
+
+ permissions :index?, :show? do
+ context 'when administrator' do
+ it { expect(portal_policy).to permit(administrator_context, portal) }
+ end
+
+ context 'when agent' do
+ it { expect(portal_policy).to permit(agent_context, portal) }
+ end
+ end
+
+ permissions :update?, :edit?, :create?, :destroy?, :logo? do
+ context 'when administrator' do
+ it { expect(portal_policy).to permit(administrator_context, portal) }
+ end
+
+ context 'when agent' do
+ it { expect(portal_policy).not_to permit(agent_context, portal) }
+ end
+ end
+end
diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb
new file mode 100644
index 000000000..fde467bd8
--- /dev/null
+++ b/spec/policies/report_policy_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe ReportPolicy, type: :policy do
+ subject(:report_policy) { described_class }
+
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account) }
+ let(:report) { :report }
+
+ let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
+ let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
+
+ permissions :view? do
+ context 'when administrator' do
+ it { expect(report_policy).to permit(administrator_context, report) }
+ end
+
+ context 'when agent' do
+ it { expect(report_policy).not_to permit(agent_context, report) }
+ end
+ end
+end
From ae0b68147e3a8a4a7effda9888c939f680f949f6 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Fri, 4 Apr 2025 13:03:41 +0530
Subject: [PATCH 9/9] chore: Reply window fixes (#11242)
- Fixed the can reply window links.
- Added the supported file types for Instagram.
---
.../widgets/WootWriter/ReplyBottomPanel.vue | 12 ++++++++++++
.../widgets/conversation/MessagesView.vue | 13 ++++++++++---
.../components/widgets/conversation/ReplyBox.vue | 1 +
app/javascript/shared/constants/links.js | 2 ++
app/javascript/shared/constants/messages.js | 4 ++++
5 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index 26249e881..cf59532e0 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -10,6 +10,7 @@ import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
+ ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -113,6 +114,10 @@ export default {
type: String,
required: true,
},
+ conversationType: {
+ type: String,
+ default: '',
+ },
},
emits: [
'replaceText',
@@ -187,6 +192,9 @@ export default {
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
},
+ isInstagramDM() {
+ return this.conversationType === 'instagram_direct_message';
+ },
allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
@@ -194,6 +202,10 @@ export default {
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
+ if (this.isAInstagramChannel || this.isInstagramDM) {
+ return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
+ }
+
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
index ae798d8d9..865122386 100644
--- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
@@ -226,16 +226,23 @@ export default {
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
- if (this.isAWhatsAppChannel) {
+ if (this.isAFacebookInbox || this.isAInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
+ if (this.isAWhatsAppCloudChannel) {
+ return REPLY_POLICY.WHATSAPP_CLOUD;
+ }
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
- if (this.isAWhatsAppChannel) {
+ if (
+ this.isAWhatsAppChannel ||
+ this.isAFacebookInbox ||
+ this.isAInstagramChannel
+ ) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
@@ -485,7 +492,7 @@ export default {