From 6a4c0a15786f19869de1b0f3fa0b16eb2985b43d Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Tue, 26 Jul 2022 12:41:22 +0530 Subject: [PATCH] feat: Execute macro actions, for the conversation (#5066) --- .../api/v1/accounts/macros_controller.rb | 8 ++- app/jobs/macros_execution_job.rb | 14 +++++ app/models/automation_rule.rb | 9 +-- app/models/macro.rb | 18 +++++- app/policies/macro_policy.rb | 4 ++ app/services/action_service.rb | 57 ++++++++++++++++++ .../automation_rules/action_service.rb | 54 +---------------- app/services/macros/execution_service.rb | 38 ++++++++++++ config/routes.rb | 6 +- db/migrate/20220711090528_create_macros.rb | 2 +- db/schema.rb | 2 +- .../automation_rules_controller_spec.rb | 12 +--- .../api/v1/accounts/macros_controller_spec.rb | 59 ++++++++++++++++++- spec/factories/automation_rules.rb | 2 +- .../automation_rule_listener_spec.rb | 2 +- spec/models/automation_rule_spec.rb | 6 +- spec/models/macro_spec.rb | 28 +++++---- 17 files changed, 232 insertions(+), 89 deletions(-) create mode 100644 app/jobs/macros_execution_job.rb create mode 100644 app/services/action_service.rb create mode 100644 app/services/macros/execution_service.rb diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb index 9a37faa4b..60e1b62c2 100644 --- a/app/controllers/api/v1/accounts/macros_controller.rb +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -1,6 +1,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :fetch_macro, only: [:show, :update, :destroy] + before_action :fetch_macro, only: [:show, :update, :destroy, :execute] def index @macros = Macro.with_visibility(current_user, params) @@ -34,6 +34,12 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController end end + def execute + ::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user) + + head :ok + end + def permitted_params params.permit( :name, :account_id, :visibility, diff --git a/app/jobs/macros_execution_job.rb b/app/jobs/macros_execution_job.rb new file mode 100644 index 000000000..66dc43c26 --- /dev/null +++ b/app/jobs/macros_execution_job.rb @@ -0,0 +1,14 @@ +class MacrosExecutionJob < ApplicationJob + queue_as :medium + + def perform(macro, conversation_ids:, user:) + account = macro.account + conversations = account.conversations.where(display_id: conversation_ids.to_a) + + return if conversations.blank? + + conversations.each do |conversation| + ::Macros::ExecutionService.new(macro, conversation, user).perform + end + end +end diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index d76f2058e..f90e20d0c 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -30,7 +30,8 @@ class AutomationRule < ApplicationRecord scope :active, -> { where(active: true) } CONDITIONS_ATTRS = %w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id].freeze - ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_best_agents send_attachment].freeze + ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_best_agent send_webhook_event mute_conversation send_attachment + change_status resolve_conversation snooze_conversation send_email_transcript].freeze def file_base_data files.map do |file| @@ -49,7 +50,7 @@ class AutomationRule < ApplicationRecord private def json_conditions_format - return if conditions.nil? + return if conditions.blank? attributes = conditions.map { |obj, _| obj['attribute_key'] } conditions = attributes - CONDITIONS_ATTRS @@ -58,9 +59,9 @@ class AutomationRule < ApplicationRecord end def json_actions_format - return if actions.nil? + return if actions.blank? - attributes = actions.map { |obj, _| obj['attribute_key'] } + attributes = actions.map { |obj, _| obj['action_name'] } actions = attributes - ACTIONS_ATTRS errors.add(:actions, "Automation actions #{actions.join(',')} not supported.") if actions.any? diff --git a/app/models/macro.rb b/app/models/macro.rb index 2a6e44f5f..0b9c77d9f 100644 --- a/app/models/macro.rb +++ b/app/models/macro.rb @@ -5,7 +5,7 @@ # id :bigint not null, primary key # actions :jsonb not null # name :string not null -# visibility :integer default("user") +# visibility :integer default("personal") # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null @@ -31,6 +31,11 @@ class Macro < ApplicationRecord class_name: :User enum visibility: { personal: 0, global: 1 } + validate :json_actions_format + + ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_best_agent send_webhook_event mute_conversation change_status + resolve_conversation snooze_conversation].freeze + def set_visibility(user, params) self.visibility = params[:visibility] self.visibility = :personal if user.agent? @@ -46,4 +51,15 @@ class Macro < ApplicationRecord def self.current_page(params) params[:page] || 1 end + + private + + def json_actions_format + return if actions.blank? + + attributes = actions.map { |obj, _| obj['action_name'] } + actions = attributes - ACTIONS_ATTRS + + errors.add(:actions, "Macro execution actions #{actions.join(',')} not supported.") if actions.any? + end end diff --git a/app/policies/macro_policy.rb b/app/policies/macro_policy.rb index 3ad4cd2de..f1a83bc4a 100644 --- a/app/policies/macro_policy.rb +++ b/app/policies/macro_policy.rb @@ -18,4 +18,8 @@ class MacroPolicy < ApplicationPolicy def destroy? true end + + def execute? + true + end end diff --git a/app/services/action_service.rb b/app/services/action_service.rb new file mode 100644 index 000000000..2983b3050 --- /dev/null +++ b/app/services/action_service.rb @@ -0,0 +1,57 @@ +class ActionService + def initialize(conversation) + @conversation = conversation + end + + def mute_conversation(_params) + @conversation.mute! + end + + def snooze_conversation(_params) + @conversation.snoozed! + end + + def resolve_conversation(_params) + @conversation.resolved! + end + + def change_status(status) + @conversation.update!(status: status[0]) + end + + def add_label(labels) + return if labels.empty? + + @conversation.add_labels(labels) + end + + def assign_best_agent(agent_ids = []) + return unless agent_belongs_to_account?(agent_ids) + + @agent = @account.users.find_by(id: agent_ids) + + @conversation.update!(assignee_id: @agent.id) if @agent.present? + end + + def assign_team(team_ids = []) + return unless team_belongs_to_account?(team_ids) + + @conversation.update!(team_id: team_ids[0]) + end + + private + + def agent_belongs_to_account?(agent_ids) + @account.agents.pluck(:id).include?(agent_ids[0]) + end + + def team_belongs_to_account?(team_ids) + @account.team_ids.include?(team_ids[0]) + end + + def conversation_a_tweet? + return false if @conversation.additional_attributes.blank? + + @conversation.additional_attributes['type'] == 'tweet' + end +end diff --git a/app/services/automation_rules/action_service.rb b/app/services/automation_rules/action_service.rb index 1d2e0d4da..c28285fe7 100644 --- a/app/services/automation_rules/action_service.rb +++ b/app/services/automation_rules/action_service.rb @@ -1,8 +1,8 @@ -class AutomationRules::ActionService +class AutomationRules::ActionService < ActionService def initialize(rule, account, conversation) + super(conversation) @rule = rule @account = account - @conversation = conversation Current.executed_by = rule end @@ -41,22 +41,6 @@ class AutomationRules::ActionService end end - def mute_conversation(_params) - @conversation.mute! - end - - def snooze_conversation(_params) - @conversation.snoozed! - end - - def resolve_conversation(_params) - @conversation.resolved! - end - - def change_status(status) - @conversation.update!(status: status[0]) - end - def send_webhook_event(webhook_url) payload = @conversation.webhook_data.merge(event: "automation_event.#{@rule.event_name}") WebhookJob.perform_later(webhook_url[0], payload) @@ -70,26 +54,6 @@ class AutomationRules::ActionService mb.perform end - def assign_team(team_ids = []) - return unless team_belongs_to_account?(team_ids) - - @conversation.update!(team_id: team_ids[0]) - end - - def assign_best_agent(agent_ids = []) - return unless agent_belongs_to_account?(agent_ids) - - @agent = @account.users.find_by(id: agent_ids) - - @conversation.update!(assignee_id: @agent.id) if @agent.present? - end - - def add_label(labels) - return if labels.empty? - - @conversation.add_labels(labels) - end - def send_email_to_team(params) teams = Team.where(id: params[0][:team_ids]) @@ -97,18 +61,4 @@ class AutomationRules::ActionService TeamNotifications::AutomationNotificationMailer.conversation_creation(@conversation, team, params[0][:message])&.deliver_now end end - - def agent_belongs_to_account?(agent_ids) - @account.agents.pluck(:id).include?(agent_ids[0]) - end - - def team_belongs_to_account?(team_ids) - @account.team_ids.include?(team_ids[0]) - end - - def conversation_a_tweet? - return false if @conversation.additional_attributes.blank? - - @conversation.additional_attributes['type'] == 'tweet' - end end diff --git a/app/services/macros/execution_service.rb b/app/services/macros/execution_service.rb new file mode 100644 index 000000000..f9a32ef68 --- /dev/null +++ b/app/services/macros/execution_service.rb @@ -0,0 +1,38 @@ +class Macros::ExecutionService < ActionService + def initialize(macro, conversation, user) + super(conversation) + @macro = macro + @account = macro.account + Current.user = user + end + + def perform + @macro.actions.each do |action| + action = action.with_indifferent_access + begin + send(action[:action_name], action[:action_params]) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @account).capture_exception + end + end + ensure + Current.reset + end + + private + + def send_webhook_event(webhook_url) + payload = @conversation.webhook_data.merge(event: "macro_event.#{@macro.name}") + WebhookJob.perform_later(webhook_url[0], payload) + end + + def send_message(message) + return if conversation_a_tweet? + + params = { content: message[0], private: false, content_attributes: { macro_id: @macro.id } } + mb = Messages::MessageBuilder.new(nil, @conversation, params) + mb.perform + end + + def send_email_to_team(_params); end +end diff --git a/config/routes.rb b/config/routes.rb index 2d7d3d8e3..44b5ce7ea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,7 +57,11 @@ Rails.application.routes.draw do post :clone post :attach_file, on: :collection end - resources :macros, only: [:index, :create, :show, :update, :destroy] + resources :macros, only: [:index, :create, :show, :update, :destroy] do + member do + post :execute + end + end resources :campaigns, only: [:index, :create, :show, :update, :destroy] resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy] namespace :channels do diff --git a/db/migrate/20220711090528_create_macros.rb b/db/migrate/20220711090528_create_macros.rb index fcde5f9e3..35c0825a7 100644 --- a/db/migrate/20220711090528_create_macros.rb +++ b/db/migrate/20220711090528_create_macros.rb @@ -6,7 +6,7 @@ class CreateMacros < ActiveRecord::Migration[6.1] t.integer :visibility, default: 0 t.references :created_by, null: false, index: true, foreign_key: { to_table: :users } t.references :updated_by, null: false, index: true, foreign_key: { to_table: :users } - t.jsonb :actions, null: false, default: '{}' + t.jsonb :actions, null: false, default: {} t.timestamps t.index :account_id, name: 'index_macros_on_account_id' end diff --git a/db/schema.rb b/db/schema.rb index 3b62a868b..d56a6d0d0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -570,7 +570,7 @@ ActiveRecord::Schema.define(version: 2022_07_20_080126) do t.integer "visibility", default: 0 t.bigint "created_by_id", null: false t.bigint "updated_by_id", null: false - t.jsonb "actions", default: "{}", null: false + t.jsonb "actions", default: {}, null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.index ["account_id"], name: "index_macros_on_account_id" diff --git a/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb b/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb index d905bf771..2c8041ab5 100644 --- a/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb @@ -71,14 +71,6 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do { 'action_name': :add_label, 'action_params': %w[support priority_customer] - }, - { - 'action_name': :assign_best_administrator, - 'action_params': [1] - }, - { - 'action_name': :update_additional_attributes, - 'action_params': [{ intiated_at: '2021-12-03 17:25:26.844536 +0530' }] } ] } @@ -283,8 +275,8 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do ], 'actions': [ { - 'action_name': :update_additional_attributes, - 'action_params': [{ intiated_at: '2021-12-03 17:25:26.844536 +0530' }] + 'action_name': :add_label, + 'action_params': %w[support priority_customer] } ] } diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb index 2da70ed1b..d605dd508 100644 --- a/spec/controllers/api/v1/accounts/macros_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do + include ActiveJob::TestHelper + let(:account) { create(:account) } let(:administrator) { create(:user, account: account, role: :administrator) } let(:agent) { create(:user, account: account, role: :agent) } @@ -80,7 +82,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do 'action_params': ['Welcome to the chatwoot platform.'] }, { - 'action_name': :resolved + 'action_name': :resolve_conversation } ], visibility: 'global', @@ -173,4 +175,59 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/macros/{macro.id}/execute' do + let!(:macro) { create(:macro, account: account, created_by: administrator, updated_by: administrator) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account, identifier: '123') } + let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :open) } + let(:team) { create(:team, account: account) } + let(:user_1) { create(:user, role: 0) } + + before do + create(:team_member, user: user_1, team: team) + create(:account_user, user: user_1, account: account) + macro.update!(actions: + [ + { + 'action_name' => 'send_email_to_team', 'action_params' => [{ + 'message' => 'Please pay attention to this conversation, its from high priority customer', + 'team_ids' => [team.id] + }] + }, + { 'action_name' => 'assign_team', 'action_params' => [team.id] }, + { 'action_name' => 'add_label', 'action_params' => %w[support priority_customer] }, + { 'action_name' => 'snooze_conversation' }, + { 'action_name' => 'assign_best_agent', 'action_params' => [user_1.id] }, + { 'action_name' => 'send_message', 'action_params' => ['Send this message.'] } + ]) + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/macros/#{macro.id}/execute" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'execute the macro' do + expect(conversation.messages).to be_empty + expect(conversation.assignee).to be_nil + expect(conversation.labels).to be_empty + + perform_enqueued_jobs do + post "/api/v1/accounts/#{account.id}/macros/#{macro.id}/execute", + params: { conversation_ids: [conversation.display_id] }, + headers: administrator.create_new_auth_token + end + + expect(conversation.reload.status).to eql('snoozed') + expect(conversation.messages.chat.last.content).to eq('Send this message.') + expect(conversation.label_list).to match_array(%w[support priority_customer]) + expect(conversation.messages.activity.last.content).to eq("Assigned to #{user_1.name} by #{administrator.name}") + end + end + end end diff --git a/spec/factories/automation_rules.rb b/spec/factories/automation_rules.rb index 1b37e40d2..9c7a80254 100644 --- a/spec/factories/automation_rules.rb +++ b/spec/factories/automation_rules.rb @@ -12,7 +12,7 @@ FactoryBot.define do }, { 'action_name' => 'assign_team', 'action_params' => [1] }, { 'action_name' => 'add_label', 'action_params' => %w[support priority_customer] }, - { 'action_name' => 'assign_best_agents', 'action_params' => [1, 2, 3, 4] } + { 'action_name' => 'assign_best_agent', 'action_params' => [1, 2, 3, 4] } ] end end diff --git a/spec/listeners/automation_rule_listener_spec.rb b/spec/listeners/automation_rule_listener_spec.rb index ee13a01ba..9d4c03159 100644 --- a/spec/listeners/automation_rule_listener_spec.rb +++ b/spec/listeners/automation_rule_listener_spec.rb @@ -39,7 +39,7 @@ describe AutomationRuleListener do { 'action_name' => 'mute_conversation', 'action_params' => nil }, { 'action_name' => 'change_status', 'action_params' => ['snoozed'] }, { 'action_name' => 'send_message', 'action_params' => ['Send this message.'] }, - { 'action_name' => 'send_attachments' } + { 'action_name' => 'send_attachment' } ]) file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') automation_rule.files.attach(file) diff --git a/spec/models/automation_rule_spec.rb b/spec/models/automation_rule_spec.rb index 501e19637..7a68306d6 100644 --- a/spec/models/automation_rule_spec.rb +++ b/spec/models/automation_rule_spec.rb @@ -37,12 +37,8 @@ RSpec.describe AutomationRule, type: :model do action_params: %w[support priority_customer] }, { - action_name: :assign_best_administrator, + action_name: :assign_best_agent, action_params: [1] - }, - { - action_name: :update_additional_attributes, - action_params: [{ intiated_at: '2021-12-03 17:25:26.844536 +0530' }] } ] }.with_indifferent_access diff --git a/spec/models/macro_spec.rb b/spec/models/macro_spec.rb index d58a7d9da..2879e38ae 100644 --- a/spec/models/macro_spec.rb +++ b/spec/models/macro_spec.rb @@ -10,9 +10,17 @@ RSpec.describe Macro, type: :model do it { is_expected.to belong_to(:updated_by) } end + describe 'validations' do + it 'validation action name' do + macro = FactoryBot.build(:macro, account: account, created_by: admin, updated_by: admin, actions: [{ action_name: :update_last_seen }]) + expect(macro).not_to be_valid + expect(macro.errors.full_messages).to eq(['Actions Macro execution actions update_last_seen not supported.']) + end + end + describe '#set_visibility' do let(:agent) { create(:user, account: account, role: :agent) } - let(:macro) { create(:macro, account: account, created_by: admin, updated_by: admin) } + let(:macro) { create(:macro, account: account, created_by: admin, updated_by: admin, actions: []) } context 'when user is administrator' do it 'set visibility with params' do @@ -47,15 +55,15 @@ RSpec.describe Macro, type: :model do let(:agent_2) { create(:user, account: account, role: :agent) } before do - create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global) - create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global) - create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal) - create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal) - create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) - create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) - create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) - create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) - create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global, actions: []) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global, actions: []) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal, actions: []) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal, actions: []) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal, actions: []) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal, actions: []) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal, actions: []) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal, actions: []) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal, actions: []) end context 'when user is administrator' do