From ca83a27e954ea85b1907e8f2d4662840618bf30f Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 31 Mar 2025 19:30:02 -0700 Subject: [PATCH 01/71] chore(refactor): Improve conversation permission filtering (#11166) 1. Add permission filter service to separate permission filtering logic from conversation queries 2. Implement hierarchical permissions with cleaner logic: - conversation_manage gives access to all conversations - conversation_unassigned_manage gives access to unassigned and user's conversations - conversation_participating_manage gives access only to user's conversations --------- Co-authored-by: Pranav --- .gitignore | 3 + .../contacts/conversations_controller.rb | 24 +- .../v1/accounts/conversations_controller.rb | 2 +- app/finders/conversation_finder.rb | 6 + app/services/conversations/filter_service.rb | 22 +- .../permission_filter_service.rb | 17 ++ .../permission_filter_service.rb | 38 +++ .../contacts/conversations_controller_spec.rb | 123 ++++++++ .../permission_filter_service_spec.rb | 197 ++++++++++++ .../filter_service_frontend_alignment_spec.rb | 254 ++++++++++++++++ .../conversations/filter_service_spec.rb | 286 +++--------------- .../permission_filter_service_spec.rb | 47 +++ 12 files changed, 759 insertions(+), 260 deletions(-) create mode 100644 app/services/conversations/permission_filter_service.rb create mode 100644 enterprise/app/services/enterprise/conversations/permission_filter_service.rb create mode 100644 enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb create mode 100644 enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb create mode 100644 spec/services/conversations/filter_service_frontend_alignment_spec.rb create mode 100644 spec/services/conversations/permission_filter_service_spec.rb diff --git a/.gitignore b/.gitignore index 77c4a4740..53deb62a8 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ yarn-debug.log* # Vite uses dotenv and suggests to ignore local-only env files. See # https://vitejs.dev/guide/env-and-mode.html#env-files *.local + +# Claude.ai config file +CLAUDE.md diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index de0ac4db9..fda19b8c2 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -1,17 +1,21 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController def index - @conversations = Current.account.conversations.includes( + # Start with all conversations for this contact + conversations = Current.account.conversations.includes( :assignee, :contact, :inbox, :taggings - ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20) - end + ).where(contact_id: @contact.id) - private + # Apply permission-based filtering using the existing service + conversations = Conversations::PermissionFilterService.new( + conversations, + Current.user, + Current.account + ).perform - def inbox_ids - if Current.user.administrator? || Current.user.agent? - Current.user.assigned_inboxes.pluck(:id) - else - [] - end + # Only allow conversations from inboxes the user has access to + inbox_ids = Current.user.assigned_inboxes.pluck(:id) + conversations = conversations.where(inbox_id: inbox_ids) + + @conversations = conversations.order(last_activity_at: :desc).limit(20) end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 138c2bd68..8753918fc 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def filter - result = ::Conversations::FilterService.new(params.permit!, current_user).perform + result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform @conversations = result[:conversations] @conversations_count = result[:count] rescue CustomExceptions::CustomFilter::InvalidAttribute, diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 31f98e384..1694199ba 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -93,6 +93,12 @@ class ConversationFinder def find_all_conversations find_conversation_by_inbox + # Apply permission-based filtering + @conversations = Conversations::PermissionFilterService.new( + @conversations, + current_user, + current_account + ).perform filter_by_conversation_type if params[:conversation_type] @conversations end diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb index d5156b531..21fe3c716 100644 --- a/app/services/conversations/filter_service.rb +++ b/app/services/conversations/filter_service.rb @@ -1,8 +1,8 @@ class Conversations::FilterService < FilterService ATTRIBUTE_MODEL = 'conversation_attribute'.freeze - def initialize(params, user, filter_account = nil) - @account = filter_account || Current.account + def initialize(params, user, account) + @account = account super(params, user) end @@ -24,9 +24,25 @@ class Conversations::FilterService < FilterService end def base_relation - @account.conversations.includes( + conversations = @account.conversations.includes( :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox ) + + account_user = @account.account_users.find_by(user_id: @user.id) + is_administrator = account_user&.role == 'administrator' + + # Ensure we only include conversations from inboxes the user has access to + unless is_administrator + inbox_ids = @user.inboxes.where(account_id: @account.id).pluck(:id) + conversations = conversations.where(inbox_id: inbox_ids) + end + + # Apply permission-based filtering + Conversations::PermissionFilterService.new( + conversations, + @user, + @account + ).perform end def current_page diff --git a/app/services/conversations/permission_filter_service.rb b/app/services/conversations/permission_filter_service.rb new file mode 100644 index 000000000..a8561dbdf --- /dev/null +++ b/app/services/conversations/permission_filter_service.rb @@ -0,0 +1,17 @@ +class Conversations::PermissionFilterService + attr_reader :conversations, :user, :account + + def initialize(conversations, user, account) + @conversations = conversations + @user = user + @account = account + end + + def perform + # The base implementation simply returns all conversations + # Enterprise edition extends this with permission-based filtering + conversations + end +end + +Conversations::PermissionFilterService.prepend_mod_with('Conversations::PermissionFilterService') diff --git a/enterprise/app/services/enterprise/conversations/permission_filter_service.rb b/enterprise/app/services/enterprise/conversations/permission_filter_service.rb new file mode 100644 index 000000000..5da65ffa6 --- /dev/null +++ b/enterprise/app/services/enterprise/conversations/permission_filter_service.rb @@ -0,0 +1,38 @@ +module Enterprise::Conversations::PermissionFilterService + def perform + account_user = AccountUser.find_by(account_id: account.id, user_id: user.id) + permissions = account_user&.permissions || [] + user_role = account_user&.role + + # Skip filtering for administrators + return conversations if user_role == 'administrator' + # Skip filtering for regular agents (without custom roles/permissions) + return conversations if user_role == 'agent' && account_user&.custom_role_id.nil? + + filter_by_permissions(permissions) + end + + private + + def filter_by_permissions(permissions) + # Permission-based filtering with hierarchy + # conversation_manage > conversation_unassigned_manage > conversation_participating_manage + if permissions.include?('conversation_manage') + conversations + elsif permissions.include?('conversation_unassigned_manage') + filter_unassigned_and_mine + elsif permissions.include?('conversation_participating_manage') + conversations.assigned_to(user) + else + Conversation.none + end + end + + def filter_unassigned_and_mine + mine = conversations.assigned_to(user) + unassigned = conversations.unassigned + + Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations") + .where(account_id: account.id) + end +end diff --git a/enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb b/enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb new file mode 100644 index 000000000..a5f200013 --- /dev/null +++ b/enterprise/spec/controllers/api/v1/accounts/contacts/conversations_controller_spec.rb @@ -0,0 +1,123 @@ +require 'rails_helper' + +RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/conversations enterprise', type: :request do + let(:account) { create(:account) } + let(:contact) { create(:contact, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) } + + describe 'GET /api/v1/accounts/{account.id}/contacts/:id/conversations with custom role permissions' do + context 'with user having custom role' do + let(:agent_with_custom_role) { create(:user, account: account, role: :agent) } + let(:custom_role) { create(:custom_role, account: account) } + + before do + create(:inbox_member, user: agent_with_custom_role, inbox: inbox) + end + + context 'with conversation_participating_manage permission' do + let(:assigned_conversation) do + create(:conversation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, assignee: agent_with_custom_role) + end + + before do + # Create a conversation assigned to this agent + assigned_conversation + + # Create another conversation that shouldn't be visible + create(:conversation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, assignee: create(:user, account: account, role: :agent)) + + # Set up permissions + custom_role.update!(permissions: %w[conversation_participating_manage]) + + # Associate the custom role with the agent + account_user = AccountUser.find_by(user: agent_with_custom_role, account: account) + account_user.update!(role: :agent, custom_role: custom_role) + end + + it 'returns only conversations assigned to the agent' do + get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations", + headers: agent_with_custom_role.create_new_auth_token + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + + # Should only return the conversation assigned to this agent + expect(json_response['payload'].length).to eq 1 + expect(json_response['payload'][0]['id']).to eq assigned_conversation.display_id + end + end + + context 'with conversation_unassigned_manage permission' do + let(:unassigned_conversation) do + create(:conversation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, assignee: nil) + end + + let(:assigned_conversation) do + create(:conversation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, assignee: agent_with_custom_role) + end + + before do + # Create the conversations + unassigned_conversation + assigned_conversation + create(:conversation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, assignee: create(:user, account: account, role: :agent)) + + # Set up permissions + custom_role.update!(permissions: %w[conversation_unassigned_manage]) + + # Associate the custom role with the agent + account_user = AccountUser.find_by(user: agent_with_custom_role, account: account) + account_user.update!(role: :agent, custom_role: custom_role) + end + + it 'returns unassigned conversations AND conversations assigned to the agent' do + get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations", + headers: agent_with_custom_role.create_new_auth_token + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + + # Should return both unassigned and assigned to this agent conversations + expect(json_response['payload'].length).to eq 2 + conversation_ids = json_response['payload'].pluck('id') + expect(conversation_ids).to include(unassigned_conversation.display_id) + expect(conversation_ids).to include(assigned_conversation.display_id) + end + end + + context 'with conversation_manage permission' do + before do + # Create multiple conversations + 3.times do + create(:conversation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox) + end + + # Set up permissions + custom_role.update!(permissions: %w[conversation_manage]) + + # Associate the custom role with the agent + account_user = AccountUser.find_by(user: agent_with_custom_role, account: account) + account_user.update!(role: :agent, custom_role: custom_role) + end + + it 'returns all conversations' do + get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations", + headers: agent_with_custom_role.create_new_auth_token + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + + # Should return all conversations in this inbox + expect(json_response['payload'].length).to eq 3 + end + end + end + end +end diff --git a/enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb b/enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb new file mode 100644 index 000000000..b26832faf --- /dev/null +++ b/enterprise/spec/services/enterprise/conversations/permission_filter_service_spec.rb @@ -0,0 +1,197 @@ +require 'rails_helper' + +RSpec.describe Enterprise::Conversations::PermissionFilterService do + let(:account) { create(:account) } + # Create conversations with different states + let!(:assigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) } + let!(:unassigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil) } + let!(:another_assigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: create(:user, account: account)) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:inbox) { create(:inbox, account: account) } + + # This inbox_member is used to establish the agent's access to the inbox + before { create(:inbox_member, user: agent, inbox: inbox) } + + describe '#perform' do + context 'when user is an administrator' do + it 'returns all conversations' do + result = Conversations::PermissionFilterService.new( + account.conversations, + admin, + account + ).perform + + expect(result).to include(assigned_conversation) + expect(result).to include(unassigned_conversation) + expect(result).to include(another_assigned_conversation) + expect(result.count).to eq(3) + end + end + + context 'when user is a regular agent' do + it 'returns all conversations in assigned inboxes' do + inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id) + + result = Conversations::PermissionFilterService.new( + account.conversations.where(inbox_id: inbox_ids), + agent, + account + ).perform + + expect(result).to include(assigned_conversation) + expect(result).to include(unassigned_conversation) + expect(result).to include(another_assigned_conversation) + expect(result.count).to eq(3) + end + end + + context 'when user has conversation_manage permission' do + # Test with a new clean state for each test case + it 'returns all conversations' do + # Create a new isolated test environment + test_account = create(:account) + test_inbox = create(:inbox, account: test_account) + + # Create test agent + test_agent = create(:user, account: test_account, role: :agent) + create(:inbox_member, user: test_agent, inbox: test_inbox) + + # Create custom role with conversation_manage permission + test_custom_role = create(:custom_role, account: test_account, permissions: ['conversation_manage']) + account_user = AccountUser.find_by(user: test_agent, account: test_account) + account_user.update(role: :agent, custom_role: test_custom_role) + + # Create some conversations + assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) + unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil) + other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account)) + + # Run the test + result = Conversations::PermissionFilterService.new( + test_account.conversations, + test_agent, + test_account + ).perform + + # Should have access to all conversations + expect(result.count).to eq(3) + expect(result).to include(assigned_conversation) + expect(result).to include(unassigned_conversation) + expect(result).to include(other_assigned_conversation) + end + end + + context 'when user has conversation_participating_manage permission' do + it 'returns only conversations assigned to the agent' do + # Create a new isolated test environment + test_account = create(:account) + test_inbox = create(:inbox, account: test_account) + + # Create test agent + test_agent = create(:user, account: test_account, role: :agent) + create(:inbox_member, user: test_agent, inbox: test_inbox) + + # Create a custom role with only the conversation_participating_manage permission + test_custom_role = create(:custom_role, account: test_account, permissions: %w[conversation_participating_manage]) + + account_user = AccountUser.find_by(user: test_agent, account: test_account) + account_user.update(role: :agent, custom_role: test_custom_role) + + # Create some conversations + other_conversation = create(:conversation, account: test_account, inbox: test_inbox) + assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) + + # Run the test + result = Conversations::PermissionFilterService.new( + test_account.conversations, + test_agent, + test_account + ).perform + + # Should only see conversations assigned to this agent + expect(result.count).to eq(1) + expect(result.first.assignee).to eq(test_agent) + expect(result).to include(assigned_conversation) + expect(result).not_to include(other_conversation) + end + end + + context 'when user has conversation_unassigned_manage permission' do + it 'returns unassigned conversations AND mine' do + # Create a new isolated test environment + test_account = create(:account) + test_inbox = create(:inbox, account: test_account) + + # Create test agent + test_agent = create(:user, account: test_account, role: :agent) + create(:inbox_member, user: test_agent, inbox: test_inbox) + + # Create a custom role with only the conversation_unassigned_manage permission + test_custom_role = create(:custom_role, account: test_account, permissions: %w[conversation_unassigned_manage]) + + account_user = AccountUser.find_by(user: test_agent, account: test_account) + account_user.update(role: :agent, custom_role: test_custom_role) + + # Create some conversations + assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) + unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil) + other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account)) + + # Run the test + result = Conversations::PermissionFilterService.new( + test_account.conversations, + test_agent, + test_account + ).perform + + # Should see unassigned conversations AND conversations assigned to this agent + expect(result.count).to eq(2) + expect(result).to include(unassigned_conversation) + expect(result).to include(assigned_conversation) + + # Should NOT include conversations assigned to others + expect(result).not_to include(other_assigned_conversation) + end + end + + context 'when user has both participating and unassigned permissions (hierarchical test)' do + it 'gives higher priority to unassigned_manage over participating_manage' do + # Create a new isolated test environment + test_account = create(:account) + test_inbox = create(:inbox, account: test_account) + + # Create test agent + test_agent = create(:user, account: test_account, role: :agent) + create(:inbox_member, user: test_agent, inbox: test_inbox) + + # Create a custom role with both participating and unassigned permissions + permissions = %w[conversation_participating_manage conversation_unassigned_manage] + test_custom_role = create(:custom_role, account: test_account, permissions: permissions) + + account_user = AccountUser.find_by(user: test_agent, account: test_account) + account_user.update(role: :agent, custom_role: test_custom_role) + + # Create some conversations + assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) + unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil) + other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account)) + + # Run the test + result = Conversations::PermissionFilterService.new( + test_account.conversations, + test_agent, + test_account + ).perform + + # Should behave the same as conversation_unassigned_manage test + # - Show both unassigned and assigned to this agent + # - Do not show conversations assigned to others + expect(result.count).to eq(2) + expect(result).to include(unassigned_conversation) + expect(result).to include(assigned_to_agent) + expect(result).not_to include(other_assigned_conversation) + end + end + end +end diff --git a/spec/services/conversations/filter_service_frontend_alignment_spec.rb b/spec/services/conversations/filter_service_frontend_alignment_spec.rb new file mode 100644 index 000000000..a34ed9643 --- /dev/null +++ b/spec/services/conversations/filter_service_frontend_alignment_spec.rb @@ -0,0 +1,254 @@ +## This spec is to ensure alignment between frontend and backend filters +# ref: https://github.com/chatwoot/chatwoot/pull/11111 + +require 'rails_helper' + +describe Conversations::FilterService do + describe 'Frontend alignment tests' do + let!(:account) { create(:account) } + let!(:user_1) { create(:user, account: account, role: :administrator) } + let!(:inbox) { create(:inbox, account: account) } + let!(:params) { { payload: [], page: 1 } } + + before do + account.conversations.destroy_all + + # Create inbox membership + create(:inbox_member, user: user_1, inbox: inbox) + + # Create custom attribute definition for conversation_type + create(:custom_attribute_definition, + attribute_key: 'conversation_type', + account: account, + attribute_model: 'conversation_attribute', + attribute_display_type: 'list', + attribute_values: %w[platinum silver gold regular]) + end + + context 'with A AND B OR C filter chain' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['12345'], + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' } + ) + end + + it 'matches when all conditions are true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when first condition is false but third is true' do + conversation.update!(status: 'resolved', priority: 'urgent', display_id: '12345') + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when first and second condition is false but third is true' do + conversation.update!(status: 'resolved', priority: 'low', display_id: '12345') + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + + it 'does not match when all conditions are false' do + conversation.update!(status: 'resolved', priority: 'low', display_id: '67890') + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 0 + end + end + + context 'with A OR B AND C filter chain' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['low'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['67890'], + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' } + ) + end + + it 'matches when first condition is true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when second and third conditions are true' do + conversation.update!(status: 'resolved', priority: 'low', display_id: '67890') + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + end + + context 'with complex filter chain A AND B OR C AND D' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['67890'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'browser_language', + filter_operator: 'equal_to', + values: ['tr'], + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' }, + custom_attributes: { conversation_type: 'platinum' } + ) + end + + it 'matches when first two conditions are true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when last two conditions are true' do + conversation.update!( + status: 'resolved', + priority: 'low', + display_id: '67890', + additional_attributes: { 'browser_language': 'tr' } + ) + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + end + + context 'with mixed operators filter chain' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['67890'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'conversation_type', + filter_operator: 'equal_to', + values: ['platinum'], + custom_attribute_type: '', + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' }, + custom_attributes: { conversation_type: 'platinum' } + ) + end + + it 'matches when all conditions in the chain are true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + + it 'does not match when the last condition is false' do + conversation.update!(custom_attributes: { conversation_type: 'silver' }) + params[:payload] = filter_payload + result = described_class.new(params, user_1, account).perform + expect(result[:conversations].length).to be 1 + end + end + end +end diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index 5afa92e77..7bfa5875d 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -23,7 +23,6 @@ describe Conversations::FilterService do before do create(:inbox_member, user: user_1, inbox: inbox) create(:inbox_member, user: user_2, inbox: inbox) - Current.account = account en_conversation_1.update!(custom_attributes: { conversation_additional_information: 'test custom data' }) en_conversation_2.update!(custom_attributes: { conversation_additional_information: 'test custom data', conversation_type: 'platinum' }) @@ -72,7 +71,7 @@ describe Conversations::FilterService do it 'filter conversations by additional_attributes and status' do params[:payload] = payload - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2]) expect(result[:count][:all_count]).to be conversations.count end @@ -88,7 +87,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to eq 1 expect(result[:conversations][0][:id]).to eq conversation.id end @@ -107,7 +106,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to eq 2 expect(result[:conversations].pluck(:id)).to include(high_priority.id, urgent_priority.id) end @@ -127,7 +126,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform # Only include conversations with medium and low priority, excluding high and urgent expect(result[:conversations].length).to eq 2 @@ -137,7 +136,7 @@ describe Conversations::FilterService do it 'filter conversations by additional_attributes and status with pagination' do params[:payload] = payload params[:page] = 2 - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2]) expect(result[:count][:all_count]).to be conversations.count end @@ -156,7 +155,7 @@ describe Conversations::FilterService do create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id, status: 'pending', additional_attributes: { 'browser_language': 'tr' }) - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:count][:all_count]).to be 2 end @@ -174,7 +173,7 @@ describe Conversations::FilterService do create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id, status: 'pending', additional_attributes: { 'browser_language': 'tr' }) - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:count][:all_count]).to be 1 expect(result[:conversations].first.additional_attributes['browser_language']).to eq 'fr' @@ -184,7 +183,7 @@ describe Conversations::FilterService do payload = [{ attribute_key: 'conversation_type', filter_operator: 'not_equal_to', values: 'platinum', query_operator: nil, custom_attribute_type: 'conversation_attribute' }.with_indifferent_access] params[:payload] = payload - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform conversations = Conversation.where( "custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum'] ) @@ -213,7 +212,7 @@ describe Conversations::FilterService do query_operator: nil }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:count][:all_count]).to be 1 end @@ -237,7 +236,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:count][:all_count]).to be 2 expect(result[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort @@ -264,7 +263,7 @@ describe Conversations::FilterService do }.with_indifferent_access ] - expect { filter_service.new(params, user_1).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator) + expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator) end end end @@ -296,7 +295,7 @@ describe Conversations::FilterService do query_operator: nil }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to be 1 expect(result[:conversations][0][:id]).to be user_2_assigned_conversation.id end @@ -324,7 +323,7 @@ describe Conversations::FilterService do query_operator: nil }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to be 1 expect(result[:conversations][0][:id]).to be user_2_assigned_conversation.id end @@ -346,7 +345,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to be 1 end @@ -367,7 +366,7 @@ describe Conversations::FilterService do custom_attribute_type: nil }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to be 1 end @@ -393,7 +392,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to be 1 end end @@ -413,7 +412,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count expect(result[:conversations].length).to be expected_count end @@ -435,7 +434,7 @@ describe Conversations::FilterService do custom_attribute_type: '' }.with_indifferent_access ] - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expected_count = Conversation.where("created_at > ? AND custom_attributes->>'conversation_type' = ?", DateTime.parse('2022-01-20'), 'platinum').count @@ -471,7 +470,7 @@ describe Conversations::FilterService do expected_count = Conversation.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?", (Time.zone.today - 3.days), 'platinum').count - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to be expected_count end @@ -488,7 +487,7 @@ describe Conversations::FilterService do expected_count = Conversation.where('last_activity_at < ?', (Time.zone.today - 2.days)).count - result = filter_service.new(params, user_1).perform + result = filter_service.new(params, user_1, account).perform expect(result[:conversations].length).to be expected_count end end @@ -522,240 +521,35 @@ describe Conversations::FilterService do end end - describe 'Frontend alignment tests' do + describe '#base_relation' do let!(:account) { create(:account) } - let!(:user_1) { create(:user, account: account) } - let!(:inbox) { create(:inbox, account: account) } + let!(:user_1) { create(:user, account: account, role: :agent) } + let!(:admin) { create(:user, account: account, role: :administrator) } + let!(:inbox_1) { create(:inbox, account: account) } + let!(:inbox_2) { create(:inbox, account: account) } let!(:params) { { payload: [], page: 1 } } before do account.conversations.destroy_all + + # Make user_1 a regular agent with access to inbox_1 only + create(:inbox_member, user: user_1, inbox: inbox_1) + + # Create conversations in both inboxes + create(:conversation, account: account, inbox: inbox_1) + create(:conversation, account: account, inbox: inbox_2) end - context 'with A AND B OR C filter chain' do - let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } - let(:filter_payload) do - [ - { - attribute_key: 'status', - filter_operator: 'equal_to', - values: ['open'], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'priority', - filter_operator: 'equal_to', - values: ['urgent'], - query_operator: 'OR' - }.with_indifferent_access, - { - attribute_key: 'display_id', - filter_operator: 'equal_to', - values: ['12345'], - query_operator: nil - }.with_indifferent_access - ] - end - - before do - conversation.update!( - status: 'open', - priority: 'urgent', - display_id: '12345', - additional_attributes: { 'browser_language': 'en' } - ) - end - - it 'matches when all conditions are true' do - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - - it 'matches when first condition is false but third is true' do - conversation.update!(status: 'resolved', priority: 'urgent', display_id: '12345') - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - - it 'matches when first and second condition is false but third is true' do - conversation.update!(status: 'resolved', priority: 'low', display_id: '12345') - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - - it 'does not match when all conditions are false' do - conversation.update!(status: 'resolved', priority: 'low', display_id: '67890') - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 0 - end + it 'returns all conversations for administrators, even for inboxes they are not members of' do + service = filter_service.new(params, admin, account) + result = service.perform + expect(result[:conversations].count).to eq 2 end - context 'with A OR B AND C filter chain' do - let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } - let(:filter_payload) do - [ - { - attribute_key: 'status', - filter_operator: 'equal_to', - values: ['open'], - query_operator: 'OR' - }.with_indifferent_access, - { - attribute_key: 'priority', - filter_operator: 'equal_to', - values: ['low'], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'display_id', - filter_operator: 'equal_to', - values: ['67890'], - query_operator: nil - }.with_indifferent_access - ] - end - - before do - conversation.update!( - status: 'open', - priority: 'urgent', - display_id: '12345', - additional_attributes: { 'browser_language': 'en' } - ) - end - - it 'matches when first condition is true' do - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - - it 'matches when second and third conditions are true' do - conversation.update!(status: 'resolved', priority: 'low', display_id: '67890') - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - end - - context 'with complex filter chain A AND B OR C AND D' do - let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } - let(:filter_payload) do - [ - { - attribute_key: 'status', - filter_operator: 'equal_to', - values: ['open'], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'priority', - filter_operator: 'equal_to', - values: ['urgent'], - query_operator: 'OR' - }.with_indifferent_access, - { - attribute_key: 'display_id', - filter_operator: 'equal_to', - values: ['67890'], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'browser_language', - filter_operator: 'equal_to', - values: ['tr'], - query_operator: nil - }.with_indifferent_access - ] - end - - before do - conversation.update!( - status: 'open', - priority: 'urgent', - display_id: '12345', - additional_attributes: { 'browser_language': 'en' }, - custom_attributes: { conversation_type: 'platinum' } - ) - end - - it 'matches when first two conditions are true' do - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - - it 'matches when last two conditions are true' do - conversation.update!( - status: 'resolved', - priority: 'low', - display_id: '67890', - additional_attributes: { 'browser_language': 'tr' } - ) - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - end - - context 'with mixed operators filter chain' do - let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } - let(:filter_payload) do - [ - { - attribute_key: 'status', - filter_operator: 'equal_to', - values: ['open'], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'priority', - filter_operator: 'equal_to', - values: ['urgent'], - query_operator: 'OR' - }.with_indifferent_access, - { - attribute_key: 'display_id', - filter_operator: 'equal_to', - values: ['67890'], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'conversation_type', - filter_operator: 'equal_to', - values: ['platinum'], - custom_attribute_type: '', - query_operator: nil - }.with_indifferent_access - ] - end - - before do - conversation.update!( - status: 'open', - priority: 'urgent', - display_id: '12345', - additional_attributes: { 'browser_language': 'en' }, - custom_attributes: { conversation_type: 'platinum' } - ) - end - - it 'matches when all conditions in the chain are true' do - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end - - it 'does not match when the last condition is false' do - conversation.update!(custom_attributes: { conversation_type: 'silver' }) - params[:payload] = filter_payload - result = described_class.new(params, user_1).perform - expect(result[:conversations].length).to be 1 - end + it 'filters conversations by inbox membership for non-administrators' do + service = filter_service.new(params, user_1, account) + result = service.perform + expect(result[:conversations].count).to eq 1 end end end diff --git a/spec/services/conversations/permission_filter_service_spec.rb b/spec/services/conversations/permission_filter_service_spec.rb new file mode 100644 index 000000000..194387afc --- /dev/null +++ b/spec/services/conversations/permission_filter_service_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +RSpec.describe Conversations::PermissionFilterService do + let(:account) { create(:account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox) } + let!(:another_conversation) { create(:conversation, account: account, inbox: inbox) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:inbox) { create(:inbox, account: account) } + + # This inbox_member is used to establish the agent's access to the inbox + before { create(:inbox_member, user: agent, inbox: inbox) } + + describe '#perform' do + context 'when user is an administrator' do + it 'returns all conversations' do + result = described_class.new( + account.conversations, + admin, + account + ).perform + + expect(result).to include(conversation) + expect(result).to include(another_conversation) + expect(result.count).to eq(2) + end + end + + context 'when user is an agent' do + it 'returns all conversations with no further filtering' do + inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id) + + # The base implementation returns all conversations + # expecting the caller to filter by assigned inboxes + result = described_class.new( + account.conversations.where(inbox_id: inbox_ids), + agent, + account + ).perform + + expect(result).to include(conversation) + expect(result).to include(another_conversation) + expect(result.count).to eq(2) + end + end + end +end From d7de73ce5f702ae810cfa8cb19b99b2fa80c82c8 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 3 Apr 2025 03:49:58 +0530 Subject: [PATCH 02/71] fix: Signup form validation issue with captcha handling (#11232) # Pull Request Template ## Description This PR improves password validation in the signup form and ensures proper captcha handling during form submission. **Changes:** 1. Display an error message if the password is invalid. 2. Disable the account creation button when the password is invalid. Fixes https://linear.app/chatwoot/issue/CW-4199/signup-issues ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/d0ff04f12b98473e837b7f43af444f85?sid=04473cea-4717-4de8-b3fe-33ab0faed5e9 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../v3/views/auth/signup/components/Signup/Form.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/javascript/v3/views/auth/signup/components/Signup/Form.vue b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue index 6636d1e98..9cffea18d 100644 --- a/app/javascript/v3/views/auth/signup/components/Signup/Form.vue +++ b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue @@ -85,10 +85,10 @@ export default { if (!password.$error) { return ''; } - if (!password.minLength) { + if (password.minLength.$invalid) { return this.$t('REGISTER.PASSWORD.ERROR'); } - if (!password.isValidPassword) { + if (password.isValidPassword.$invalid) { return this.$t('REGISTER.PASSWORD.IS_INVALID_PASSWORD'); } return ''; @@ -96,6 +96,9 @@ export default { showGoogleOAuth() { return Boolean(window.chatwootConfig.googleOAuthClientId); }, + isFormValid() { + return !this.v$.$invalid && this.hasAValidCaptcha; + }, }, methods: { async submit() { @@ -120,6 +123,7 @@ export default { onRecaptchaVerified(token) { this.credentials.hCaptchaClientResponse = token; this.didCaptchaReset = false; + this.v$.$touch(); }, resetCaptcha() { if (!this.globalConfig.hCaptchaSiteKey) { @@ -198,7 +202,7 @@ export default { From c35edc9c490586562da4aec0454bb08668092c3f Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 3 Apr 2025 04:18:42 +0530 Subject: [PATCH 03/71] chore(i18n): Improvements in automation and macros (#11231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR includes, 1. **Sort Accounts List** – Orders the accounts list alphabetically for better organization. 2. **Add Missing Translations in Automation** – Includes missing translations for actions, events, and conditions dropdown. 3. **Fix Missing Translation in Macros** – Adds missing translations in the macros action select dropdown. 4. Translate "Automation System" Username – Ensures the "Automation System" username is properly translated. Fixes: https://linear.app/chatwoot/issue/CW-4198/issues-[converso] ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../sidebar/SidebarAccountSwitcher.vue | 8 +- .../helper/specs/macrosHelper.spec.js | 2 +- .../dashboard/i18n/locale/en/automation.json | 40 ++- .../dashboard/i18n/locale/en/macros.json | 15 + .../settings/automation/AddAutomationRule.vue | 39 ++- .../automation/EditAutomationRule.vue | 39 ++- .../settings/automation/constants.js | 285 +++++++----------- .../dashboard/settings/macros/MacroEditor.vue | 10 +- .../dashboard/settings/macros/MacroNode.vue | 2 +- .../dashboard/settings/macros/constants.js | 26 +- .../concerns/activity_message_handler.rb | 4 +- .../priority_activity_message_handler.rb | 2 +- config/locales/en.yml | 2 + 13 files changed, 258 insertions(+), 216 deletions(-) diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue index e3b20897f..6ef69b92c 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue @@ -26,6 +26,12 @@ const showAccountSwitcher = computed( () => userAccounts.value.length > 1 && currentAccount.value.name ); +const sortedCurrentUserAccounts = computed(() => { + return [...(currentUser.value.accounts || [])].sort((a, b) => + a.name.localeCompare(b.name) + ); +}); + const onChangeAccount = newId => { const accountUrl = `/app/accounts/${newId}/dashboard`; window.location.href = accountUrl; @@ -70,7 +76,7 @@ const emitNewAccount = () => { { expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).not.toEqual( MACRO_ACTION_TYPES[0].label ); - expect(resolveActionName('change_priority')).toEqual('Change Priority'); + expect(resolveActionName('change_priority')).toEqual('CHANGE_PRIORITY'); // Translated }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index bb4946416..86ec0b58b 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -126,6 +126,44 @@ "ATLEAST_ONE_CONDITION_REQUIRED": "At least one condition is required", "ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required" }, - "NONE_OPTION": "None" + "NONE_OPTION": "None", + "EVENTS": { + "CONVERSATION_CREATED": "Conversation Created", + "CONVERSATION_UPDATED": "Conversation Updated", + "MESSAGE_CREATED": "Message Created", + "CONVERSATION_OPENED": "Conversation Opened" + }, + "ACTIONS": { + "ASSIGN_AGENT": "Assign to Agent", + "ASSIGN_TEAM": "Assign a Team", + "ADD_LABEL": "Add a Label", + "REMOVE_LABEL": "Remove a Label", + "SEND_EMAIL_TO_TEAM": "Send an Email to Team", + "SEND_EMAIL_TRANSCRIPT": "Send an Email Transcript", + "MUTE_CONVERSATION": "Mute Conversation", + "SNOOZE_CONVERSATION": "Snooze Conversation", + "RESOLVE_CONVERSATION": "Resolve Conversation", + "SEND_WEBHOOK_EVENT": "Send Webhook Event", + "SEND_ATTACHMENT": "Send Attachment", + "SEND_MESSAGE": "Send a Message", + "CHANGE_PRIORITY": "Change Priority", + "ADD_SLA": "Add SLA" + }, + "ATTRIBUTES": { + "MESSAGE_TYPE": "Message Type", + "MESSAGE_CONTAINS": "Message Contains", + "EMAIL": "Email", + "INBOX": "Inbox", + "CONVERSATION_LANGUAGE": "Conversation Language", + "PHONE_NUMBER": "Phone Number", + "STATUS": "Status", + "BROWSER_LANGUAGE": "Browser Language", + "MAIL_SUBJECT": "Email Subject", + "COUNTRY_NAME": "Country", + "REFERER_LINK": "Referrer Link", + "ASSIGNEE_NAME": "Assignee", + "TEAM_NAME": "Team", + "PRIORITY": "Priority" + } } } diff --git a/app/javascript/dashboard/i18n/locale/en/macros.json b/app/javascript/dashboard/i18n/locale/en/macros.json index 95e02fe94..ed68d2798 100644 --- a/app/javascript/dashboard/i18n/locale/en/macros.json +++ b/app/javascript/dashboard/i18n/locale/en/macros.json @@ -83,6 +83,21 @@ "ACTION_PARAMETERS_REQUIRED": "Action parameters are required", "ATLEAST_ONE_CONDITION_REQUIRED": "At least one condition is required", "ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required" + }, + "ACTIONS": { + "ASSIGN_TEAM": "Assign a Team", + "ASSIGN_AGENT": "Assign an Agent", + "ADD_LABEL": "Add a Label", + "REMOVE_LABEL": "Remove a Label", + "REMOVE_ASSIGNED_TEAM": "Remove Assigned Team", + "SEND_EMAIL_TRANSCRIPT": "Send an Email Transcript", + "MUTE_CONVERSATION": "Mute Conversation", + "SNOOZE_CONVERSATION": "Snooze Conversation", + "RESOLVE_CONVERSATION": "Resolve Conversation", + "SEND_ATTACHMENT": "Send Attachment", + "SEND_MESSAGE": "Send a Message", + "CHANGE_PRIORITY": "Change Priority", + "ADD_PRIVATE_NOTE": "Add a Private Note" } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue index ceeb84ca4..c92d0b1c8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue @@ -82,7 +82,6 @@ export default { data() { return { automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key, - automationRuleEvents: AUTOMATION_RULE_EVENTS, automationMutated: false, show: true, showDeleteConfirmationModal: false, @@ -96,6 +95,12 @@ export default { accountId: 'getCurrentAccountId', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', }), + automationRuleEvents() { + return AUTOMATION_RULE_EVENTS.map(event => ({ + ...event, + value: this.$t(`AUTOMATION.EVENTS.${event.value}`), + })); + }, hasAutomationMutated() { if ( this.automation.conditions[0].values || @@ -105,10 +110,14 @@ export default { return false; }, automationActionTypes() { - const isSLAEnabled = this.isFeatureEnabled('sla'); - return isSLAEnabled + const actionTypes = this.isFeatureEnabled('sla') ? AUTOMATION_ACTION_TYPES - : AUTOMATION_ACTION_TYPES.filter(action => action.key !== 'add_sla'); + : AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla'); + + return actionTypes.map(action => ({ + ...action, + label: this.$t(`AUTOMATION.ACTIONS.${action.label}`), + })); }, }, mounted() { @@ -137,6 +146,26 @@ export default { this.$emit('saveAutomation', automation, this.mode); } }, + getTranslatedAttributes(type, event) { + return getAttributes(type, event).map(attribute => { + // Skip translation + // 1. If customAttributeType key is present then its rendering attributes from API + // 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title + const skipTranslation = + attribute.customAttributeType || + [ + 'contact_custom_attribute', + 'conversation_custom_attribute', + ].includes(attribute.key); + + return { + ...attribute, + name: skipTranslation + ? attribute.name + : this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`), + }; + }); + }, }, }; @@ -204,7 +233,7 @@ export default { :key="i" v-model="automation.conditions[i]" :filter-attributes=" - getAttributes(automationTypes, automation.event_name) + getTranslatedAttributes(automationTypes, automation.event_name) " :input-type=" getInputType( diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue index f2ec9d7c7..208fcf7ec 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue @@ -70,7 +70,6 @@ export default { data() { return { automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key, - automationRuleEvents: AUTOMATION_RULE_EVENTS, automationMutated: false, show: true, showDeleteConfirmationModal: false, @@ -84,6 +83,12 @@ export default { accountId: 'getCurrentAccountId', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', }), + automationRuleEvents() { + return AUTOMATION_RULE_EVENTS.map(event => ({ + ...event, + value: this.$t(`AUTOMATION.EVENTS.${event.value}`), + })); + }, hasAutomationMutated() { if ( this.automation.conditions[0].values || @@ -93,10 +98,14 @@ export default { return false; }, automationActionTypes() { - const isSLAEnabled = this.isFeatureEnabled('sla'); - return isSLAEnabled + const actionTypes = this.isFeatureEnabled('sla') ? AUTOMATION_ACTION_TYPES - : AUTOMATION_ACTION_TYPES.filter(action => action.key !== 'add_sla'); + : AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla'); + + return actionTypes.map(action => ({ + ...action, + label: this.$t(`AUTOMATION.ACTIONS.${action.label}`), + })); }, }, mounted() { @@ -127,6 +136,26 @@ export default { this.$emit('saveAutomation', automation, this.mode); } }, + getTranslatedAttributes(type, event) { + return getAttributes(type, event).map(attribute => { + // Skip translation + // 1. If customAttributeType key is present then its rendering attributes from API + // 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title + const skipTranslation = + attribute.customAttributeType || + [ + 'contact_custom_attribute', + 'conversation_custom_attribute', + ].includes(attribute.key); + + return { + ...attribute, + name: skipTranslation + ? attribute.name + : this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`), + }; + }); + }, }, }; @@ -187,7 +216,7 @@ export default { :key="i" v-model="automation.conditions[i]" :filter-attributes=" - getAttributes(automationTypes, automation.event_name) + getTranslatedAttributes(automationTypes, automation.event_name) " :input-type=" getInputType( diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index c8d67745c..18468e3ad 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -10,43 +10,37 @@ export const AUTOMATIONS = { conditions: [ { key: 'message_type', - name: 'Message Type', - attributeI18nKey: 'MESSAGE_TYPE', + name: 'MESSAGE_TYPE', inputType: 'search_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'content', - name: 'Message Content', - attributeI18nKey: 'MESSAGE_CONTAINS', + name: 'MESSAGE_CONTAINS', inputType: 'comma_separated_plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'email', - name: 'Email', - attributeI18nKey: 'EMAIL', + name: 'EMAIL', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'inbox_id', - name: 'Inbox', - attributeI18nKey: 'INBOX', + name: 'INBOX', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'conversation_language', - name: 'Conversation Language', - attributeI18nKey: 'CONVERSATION_LANGUAGE', + name: 'CONVERSATION_LANGUAGE', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'phone_number', - name: 'Phone Number', - attributeI18nKey: 'PHONE_NUMBER', + name: 'PHONE_NUMBER', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, @@ -54,64 +48,52 @@ export const AUTOMATIONS = { actions: [ { key: 'assign_agent', - name: 'Assign to agent', - attributeI18nKey: 'ASSIGN_AGENT', + name: 'ASSIGN_AGENT', }, { key: 'assign_team', - name: 'Assign a team', - attributeI18nKey: 'ASSIGN_TEAM', + name: 'ASSIGN_TEAM', }, { key: 'add_label', - name: 'Add a label', - attributeI18nKey: 'ADD_LABEL', + name: 'ADD_LABEL', }, { key: 'remove_label', - name: 'Remove a label', - attributeI18nKey: 'REMOVE_LABEL', + name: 'REMOVE_LABEL', }, { key: 'send_email_to_team', - name: 'Send an email to team', - attributeI18nKey: 'SEND_EMAIL_TO_TEAM', + name: 'SEND_EMAIL_TO_TEAM', }, { key: 'send_message', - name: 'Send a message', - attributeI18nKey: 'SEND_MESSAGE', + name: 'SEND_MESSAGE', }, { key: 'send_email_transcript', - name: 'Send an email transcript', - attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT', + name: 'SEND_EMAIL_TRANSCRIPT', }, { key: 'mute_conversation', - name: 'Mute conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'MUTE_CONVERSATION', }, { key: 'snooze_conversation', - name: 'Snooze conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'SNOOZE_CONVERSATION', }, { key: 'resolve_conversation', - name: 'Resolve conversation', - attributeI18nKey: 'RESOLVE_CONVERSATION', + name: 'RESOLVE_CONVERSATION', }, { key: 'send_webhook_event', - name: 'Send Webhook Event', - attributeI18nKey: 'SEND_WEBHOOK_EVENT', + name: 'SEND_WEBHOOK_EVENT', }, { key: 'send_attachment', - name: 'Send Attachment', - attributeI18nKey: 'SEND_ATTACHMENT', + name: 'SEND_ATTACHMENT', }, ], }, @@ -119,71 +101,61 @@ export const AUTOMATIONS = { conditions: [ { key: 'status', - name: 'Status', - attributeI18nKey: 'STATUS', + name: 'STATUS', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'browser_language', - name: 'Browser Language', - attributeI18nKey: 'BROWSER_LANGUAGE', + name: 'BROWSER_LANGUAGE', inputType: 'search_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'mail_subject', - name: 'Email Subject', - attributeI18nKey: 'MAIL_SUBJECT', + name: 'MAIL_SUBJECT', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'country_code', - name: 'Country', - attributeI18nKey: 'COUNTRY_NAME', + name: 'COUNTRY_NAME', inputType: 'search_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'phone_number', - name: 'Phone Number', - attributeI18nKey: 'PHONE_NUMBER', + name: 'PHONE_NUMBER', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, { key: 'referer', - name: 'Referrer Link', - attributeI18nKey: 'REFERER_LINK', + name: 'REFERER_LINK', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'email', - name: 'Email', - attributeI18nKey: 'EMAIL', + name: 'EMAIL', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'inbox_id', - name: 'Inbox', - attributeI18nKey: 'INBOX', + name: 'INBOX', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'conversation_language', - name: 'Conversation Language', - attributeI18nKey: 'CONVERSATION_LANGUAGE', + name: 'CONVERSATION_LANGUAGE', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'priority', - name: 'Priority', - attributeI18nKey: 'PRIORITY', + name: 'PRIORITY', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, @@ -191,58 +163,47 @@ export const AUTOMATIONS = { actions: [ { key: 'assign_agent', - name: 'Assign to agent', - attributeI18nKey: 'ASSIGN_AGENT', + name: 'ASSIGN_AGENT', }, { key: 'assign_team', - name: 'Assign a team', - attributeI18nKey: 'ASSIGN_TEAM', + name: 'ASSIGN_TEAM', }, { key: 'assign_agent', - name: 'Assign an agent', - attributeI18nKey: 'ASSIGN_AGENT', + name: 'ASSIGN_AGENT', }, { key: 'send_email_to_team', - name: 'Send an email to team', - attributeI18nKey: 'SEND_EMAIL_TO_TEAM', + name: 'SEND_EMAIL_TO_TEAM', }, { key: 'send_message', - name: 'Send a message', - attributeI18nKey: 'SEND_MESSAGE', + name: 'SEND_MESSAGE', }, { key: 'send_email_transcript', - name: 'Send an email transcript', - attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT', + name: 'SEND_EMAIL_TRANSCRIPT', }, { key: 'mute_conversation', - name: 'Mute conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'MUTE_CONVERSATION', }, { key: 'snooze_conversation', - name: 'Snooze conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'SNOOZE_CONVERSATION', }, { key: 'resolve_conversation', - name: 'Resolve conversation', - attributeI18nKey: 'RESOLVE_CONVERSATION', + name: 'RESOLVE_CONVERSATION', }, { key: 'send_webhook_event', - name: 'Send Webhook Event', - attributeI18nKey: 'SEND_WEBHOOK_EVENT', + name: 'SEND_WEBHOOK_EVENT', }, { key: 'send_attachment', - name: 'Send Attachment', - attributeI18nKey: 'SEND_ATTACHMENT', + name: 'SEND_ATTACHMENT', }, ], }, @@ -250,85 +211,73 @@ export const AUTOMATIONS = { conditions: [ { key: 'status', - name: 'Status', - attributeI18nKey: 'STATUS', + name: 'STATUS', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'browser_language', - name: 'Browser Language', - attributeI18nKey: 'BROWSER_LANGUAGE', + name: 'BROWSER_LANGUAGE', inputType: 'search_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'mail_subject', - name: 'Email Subject', - attributeI18nKey: 'MAIL_SUBJECT', + name: 'MAIL_SUBJECT', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'country_code', - name: 'Country', - attributeI18nKey: 'COUNTRY_NAME', + name: 'COUNTRY_NAME', inputType: 'search_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'referer', - name: 'Referrer Link', - attributeI18nKey: 'REFERER_LINK', + name: 'REFERER_LINK', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'phone_number', - name: 'Phone Number', - attributeI18nKey: 'PHONE_NUMBER', + name: 'PHONE_NUMBER', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, { key: 'assignee_id', - name: 'Assignee', - attributeI18nKey: 'ASSIGNEE_NAME', + name: 'ASSIGNEE_NAME', inputType: 'search_select', filterOperators: OPERATOR_TYPES_3, }, { key: 'team_id', - name: 'Team', - attributeI18nKey: 'TEAM_NAME', + name: 'TEAM_NAME', inputType: 'search_select', filterOperators: OPERATOR_TYPES_3, }, { key: 'email', - name: 'Email', - attributeI18nKey: 'EMAIL', + name: 'EMAIL', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'inbox_id', - name: 'Inbox', - attributeI18nKey: 'INBOX', + name: 'INBOX', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'conversation_language', - name: 'Conversation Language', - attributeI18nKey: 'CONVERSATION_LANGUAGE', + name: 'CONVERSATION_LANGUAGE', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'priority', - name: 'Priority', - attributeI18nKey: 'PRIORITY', + name: 'PRIORITY', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, @@ -336,58 +285,47 @@ export const AUTOMATIONS = { actions: [ { key: 'assign_agent', - name: 'Assign to agent', - attributeI18nKey: 'ASSIGN_AGENT', + name: 'ASSIGN_AGENT', }, { key: 'assign_team', - name: 'Assign a team', - attributeI18nKey: 'ASSIGN_TEAM', + name: 'ASSIGN_TEAM', }, { key: 'assign_agent', - name: 'Assign an agent', - attributeI18nKey: 'ASSIGN_AGENT', + name: 'ASSIGN_AGENT', }, { key: 'send_email_to_team', - name: 'Send an email to team', - attributeI18nKey: 'SEND_EMAIL_TO_TEAM', + name: 'SEND_EMAIL_TO_TEAM', }, { key: 'send_message', - name: 'Send a message', - attributeI18nKey: 'SEND_MESSAGE', + name: 'SEND_MESSAGE', }, { key: 'send_email_transcript', - name: 'Send an email transcript', - attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT', + name: 'SEND_EMAIL_TRANSCRIPT', }, { key: 'mute_conversation', - name: 'Mute conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'MUTE_CONVERSATION', }, { key: 'snooze_conversation', - name: 'Snooze conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'SNOOZE_CONVERSATION', }, { key: 'resolve_conversation', - name: 'Resolve conversation', - attributeI18nKey: 'RESOLVE_CONVERSATION', + name: 'RESOLVE_CONVERSATION', }, { key: 'send_webhook_event', - name: 'Send Webhook Event', - attributeI18nKey: 'SEND_WEBHOOK_EVENT', + name: 'SEND_WEBHOOK_EVENT', }, { key: 'send_attachment', - name: 'Send Attachment', - attributeI18nKey: 'SEND_ATTACHMENT', + name: 'SEND_ATTACHMENT', }, ], }, @@ -395,78 +333,67 @@ export const AUTOMATIONS = { conditions: [ { key: 'browser_language', - name: 'Browser Language', - attributeI18nKey: 'BROWSER_LANGUAGE', + name: 'BROWSER_LANGUAGE', inputType: 'search_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'email', - name: 'Email', - attributeI18nKey: 'EMAIL', + name: 'EMAIL', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'mail_subject', - name: 'Email Subject', - attributeI18nKey: 'MAIL_SUBJECT', + name: 'MAIL_SUBJECT', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'country_code', - name: 'Country', - attributeI18nKey: 'COUNTRY_NAME', + name: 'COUNTRY_NAME', inputType: 'search_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'referer', - name: 'Referrer Link', - attributeI18nKey: 'REFERER_LINK', + name: 'REFERER_LINK', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_2, }, { key: 'assignee_id', - name: 'Assignee', - attributeI18nKey: 'ASSIGNEE_NAME', + name: 'ASSIGNEE_NAME', inputType: 'search_select', filterOperators: OPERATOR_TYPES_3, }, { key: 'phone_number', - name: 'Phone Number', - attributeI18nKey: 'PHONE_NUMBER', + name: 'PHONE_NUMBER', inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, { key: 'team_id', - name: 'Team', - attributeI18nKey: 'TEAM_NAME', + name: 'TEAM_NAME', inputType: 'search_select', filterOperators: OPERATOR_TYPES_3, }, { key: 'inbox_id', - name: 'Inbox', - attributeI18nKey: 'INBOX', + name: 'INBOX', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'conversation_language', - name: 'Conversation Language', - attributeI18nKey: 'CONVERSATION_LANGUAGE', + name: 'CONVERSATION_LANGUAGE', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, { key: 'priority', - name: 'Priority', - attributeI18nKey: 'PRIORITY', + name: 'PRIORITY', inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, @@ -474,53 +401,43 @@ export const AUTOMATIONS = { actions: [ { key: 'assign_agent', - name: 'Assign to agent', - attributeI18nKey: 'ASSIGN_AGENT', + name: 'ASSIGN_AGENT', }, { key: 'assign_team', - name: 'Assign a team', - attributeI18nKey: 'ASSIGN_TEAM', + name: 'ASSIGN_TEAM', }, { key: 'assign_agent', - name: 'Assign an agent', - attributeI18nKey: 'ASSIGN_AGENT', + name: 'ASSIGN_AGENT', }, { key: 'send_email_to_team', - name: 'Send an email to team', - attributeI18nKey: 'SEND_EMAIL_TO_TEAM', + name: 'SEND_EMAIL_TO_TEAM', }, { key: 'send_message', - name: 'Send a message', - attributeI18nKey: 'SEND_MESSAGE', + name: 'SEND_MESSAGE', }, { key: 'send_email_transcript', - name: 'Send an email transcript', - attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT', + name: 'SEND_EMAIL_TRANSCRIPT', }, { key: 'mute_conversation', - name: 'Mute conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'MUTE_CONVERSATION', }, { key: 'snooze_conversation', - name: 'Snooze conversation', - attributeI18nKey: 'MUTE_CONVERSATION', + name: 'SNOOZE_CONVERSATION', }, { key: 'send_webhook_event', - name: 'Send Webhook Event', - attributeI18nKey: 'SEND_WEBHOOK_EVENT', + name: 'SEND_WEBHOOK_EVENT', }, { key: 'send_attachment', - name: 'Send Attachment', - attributeI18nKey: 'SEND_ATTACHMENT', + name: 'SEND_ATTACHMENT', }, ], }, @@ -529,91 +446,91 @@ export const AUTOMATIONS = { export const AUTOMATION_RULE_EVENTS = [ { key: 'conversation_created', - value: 'Conversation Created', + value: 'CONVERSATION_CREATED', }, { key: 'conversation_updated', - value: 'Conversation Updated', + value: 'CONVERSATION_UPDATED', }, { key: 'message_created', - value: 'Message Created', + value: 'MESSAGE_CREATED', }, { key: 'conversation_opened', - value: 'Conversation Opened', + value: 'CONVERSATION_OPENED', }, ]; export const AUTOMATION_ACTION_TYPES = [ { key: 'assign_agent', - label: 'Assign to agent', + label: 'ASSIGN_AGENT', inputType: 'search_select', }, { key: 'assign_team', - label: 'Assign a team', + label: 'ASSIGN_TEAM', inputType: 'search_select', }, { key: 'add_label', - label: 'Add a label', + label: 'ADD_LABEL', inputType: 'multi_select', }, { key: 'remove_label', - label: 'Remove a label', + label: 'REMOVE_LABEL', inputType: 'multi_select', }, { key: 'send_email_to_team', - label: 'Send an email to team', + label: 'SEND_EMAIL_TO_TEAM', inputType: 'team_message', }, { key: 'send_email_transcript', - label: 'Send an email transcript', + label: 'SEND_EMAIL_TRANSCRIPT', inputType: 'email', }, { key: 'mute_conversation', - label: 'Mute conversation', + label: 'MUTE_CONVERSATION', inputType: null, }, { key: 'snooze_conversation', - label: 'Snooze conversation', + label: 'SNOOZE_CONVERSATION', inputType: null, }, { key: 'resolve_conversation', - label: 'Resolve conversation', + label: 'RESOLVE_CONVERSATION', inputType: null, }, { key: 'send_webhook_event', - label: 'Send Webhook Event', + label: 'SEND_WEBHOOK_EVENT', inputType: 'url', }, { key: 'send_attachment', - label: 'Send Attachment', + label: 'SEND_ATTACHMENT', inputType: 'attachment', }, { key: 'send_message', - label: 'Send a message', + label: 'SEND_MESSAGE', inputType: 'textarea', }, { key: 'change_priority', - label: 'Change Priority', + label: 'CHANGE_PRIORITY', inputType: 'search_select', }, { key: 'add_sla', - label: 'Add SLA', + label: 'ADD_SLA', inputType: 'search_select', }, ]; diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue index a808adf23..c974ef4d6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue @@ -21,7 +21,13 @@ const { getMacroDropdownValues } = useMacros(); const macro = ref(null); const mode = ref('CREATE'); -const macroActionTypes = MACRO_ACTION_TYPES; + +const macroActionTypes = computed(() => { + return MACRO_ACTION_TYPES.map(type => ({ + ...type, + label: t(`MACROS.ACTIONS.${type.label}`), + })); +}); provide('macroActionTypes', macroActionTypes); @@ -38,7 +44,7 @@ const formatMacro = macroData => { const formattedActions = macroData.actions.map(action => { let actionParams = []; if (action.action_params.length) { - const inputType = macroActionTypes.find( + const inputType = macroActionTypes.value.find( item => item.key === action.action_name ).inputType; if (inputType === 'multi_select' || inputType === 'search_select') { diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue index e1fc2d2e7..e010b270c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue @@ -42,7 +42,7 @@ const showActionInput = computed(() => { actionData.value.action_name === 'send_message' ) return false; - const type = macroActionTypes.find( + const type = macroActionTypes.value.find( action => action.key === actionData.value.action_name ).inputType; return !!type; diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js b/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js index 111a3632d..6178065ec 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js @@ -1,67 +1,67 @@ export const MACRO_ACTION_TYPES = [ { key: 'assign_team', - label: 'Assign a team', + label: 'ASSIGN_TEAM', inputType: 'search_select', }, { key: 'assign_agent', - label: 'Assign an agent', + label: 'ASSIGN_AGENT', inputType: 'search_select', }, { key: 'add_label', - label: 'Add a label', + label: 'ADD_LABEL', inputType: 'multi_select', }, { key: 'remove_label', - label: 'Remove a label', + label: 'REMOVE_LABEL', inputType: 'multi_select', }, { key: 'remove_assigned_team', - label: 'Remove Assigned Team', + label: 'REMOVE_ASSIGNED_TEAM', inputType: null, }, { key: 'send_email_transcript', - label: 'Send an email transcript', + label: 'SEND_EMAIL_TRANSCRIPT', inputType: 'email', }, { key: 'mute_conversation', - label: 'Mute conversation', + label: 'MUTE_CONVERSATION', inputType: null, }, { key: 'snooze_conversation', - label: 'Snooze conversation', + label: 'SNOOZE_CONVERSATION', inputType: null, }, { key: 'resolve_conversation', - label: 'Resolve conversation', + label: 'RESOLVE_CONVERSATION', inputType: null, }, { key: 'send_attachment', - label: 'Send Attachment', + label: 'SEND_ATTACHMENT', inputType: 'attachment', }, { key: 'send_message', - label: 'Send a message', + label: 'SEND_MESSAGE', inputType: 'textarea', }, { key: 'add_private_note', - label: 'Add a private note', + label: 'ADD_PRIVATE_NOTE', inputType: 'textarea', }, { key: 'change_priority', - label: 'Change Priority', + label: 'CHANGE_PRIORITY', inputType: 'search_select', }, ]; diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb index d49442f10..a20eef565 100644 --- a/app/models/concerns/activity_message_handler.rb +++ b/app/models/concerns/activity_message_handler.rb @@ -68,7 +68,7 @@ module ActivityMessageHandler def automation_status_change_activity_content if Current.executed_by.instance_of?(AutomationRule) - I18n.t("conversations.activity.status.#{status}", user_name: 'Automation System') + I18n.t("conversations.activity.status.#{status}", user_name: I18n.t('automation.system_name')) elsif Current.executed_by.instance_of?(Contact) Current.executed_by = nil I18n.t('conversations.activity.status.system_auto_open') @@ -111,7 +111,7 @@ module ActivityMessageHandler end def activity_message_owner(user_name) - user_name = 'Automation System' if !user_name && Current.executed_by.present? + user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present? user_name end end diff --git a/app/models/concerns/priority_activity_message_handler.rb b/app/models/concerns/priority_activity_message_handler.rb index 748c41bdc..391173a94 100644 --- a/app/models/concerns/priority_activity_message_handler.rb +++ b/app/models/concerns/priority_activity_message_handler.rb @@ -7,7 +7,7 @@ module PriorityActivityMessageHandler old_priority, new_priority = previous_changes.values_at('priority')[0] return unless priority_change?(old_priority, new_priority) - user = Current.executed_by.instance_of?(AutomationRule) ? 'Automation System' : user_name + user = Current.executed_by.instance_of?(AutomationRule) ? I18n.t('automation.system_name') : user_name content = build_priority_change_content(user, old_priority, new_priority) ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content diff --git a/config/locales/en.yml b/config/locales/en.yml index 4a66a4bc2..a4341927e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -294,3 +294,5 @@ en: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' From 8bf2081affc25b2ff3a92f5aea1b201603e54693 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 2 Apr 2025 20:26:55 -0700 Subject: [PATCH 04/71] feat: Add webhook event support for macros (#11235) Fixes https://github.com/chatwoot/chatwoot/issues/5968 We will not support custom payload in V1. --- app/javascript/dashboard/i18n/locale/en/macros.json | 3 ++- .../dashboard/conversation/Macros/MacroPreview.vue | 2 +- .../routes/dashboard/settings/macros/constants.js | 5 +++++ app/models/macro.rb | 3 ++- app/services/macros/execution_service.rb | 5 +++++ spec/services/macros/execution_service_spec.rb | 10 +++++++++- 6 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/en/macros.json b/app/javascript/dashboard/i18n/locale/en/macros.json index ed68d2798..d22744190 100644 --- a/app/javascript/dashboard/i18n/locale/en/macros.json +++ b/app/javascript/dashboard/i18n/locale/en/macros.json @@ -97,7 +97,8 @@ "SEND_ATTACHMENT": "Send Attachment", "SEND_MESSAGE": "Send a Message", "CHANGE_PRIORITY": "Change Priority", - "ADD_PRIVATE_NOTE": "Add a Private Note" + "ADD_PRIVATE_NOTE": "Add a Private Note", + "SEND_WEBHOOK_EVENT": "Send Webhook Event" } } } diff --git a/app/javascript/dashboard/routes/dashboard/conversation/Macros/MacroPreview.vue b/app/javascript/dashboard/routes/dashboard/conversation/Macros/MacroPreview.vue index ac0feadd1..299531df2 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/Macros/MacroPreview.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/Macros/MacroPreview.vue @@ -65,7 +65,7 @@ const resolvedMacro = computed(() => { class="absolute -left-[0.21875rem] top-[0.2734375rem] w-2 h-2 rounded-full bg-n-solid-1 border-2 border-solid border-n-weak dark:border-slate-600" />

- {{ action.actionName }} + {{ $t(`MACROS.ACTIONS.${action.actionName}`) }}

{{ action.actionValue }}

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

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

+

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

+
+
+
+
+

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

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

Hello,

+ +

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

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

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

+{% else %} +

Reason for deletion: {{ meta.reason }}

+{% endif %} + +

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

+ +

Cancel Account Deletion

+ +

Thank you,
+Team Chatwoot

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