diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 2725c55a3..fb986d335 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -54,6 +54,13 @@ class V2::ReportBuilder } end + def bot_summary + { + bot_resolutions_count: bot_resolutions.count, + bot_handoffs_count: bot_handoffs.count + } + end + def conversation_metrics if params[:type].equal?(:account) live_conversations @@ -71,6 +78,8 @@ class V2::ReportBuilder avg_first_response_time avg_resolution_time reply_time resolutions_count + bot_resolutions_count + bot_handoffs_count reply_time].include?(params[:metric]) end @@ -123,6 +132,7 @@ class V2::ReportBuilder unattended: @open_conversations.unattended.count } metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) + metric[:pending] = @open_conversations.pending.count if params[:type].equal?(:account) metric end end diff --git a/app/builders/v2/reports/bot_metrics_builder.rb b/app/builders/v2/reports/bot_metrics_builder.rb new file mode 100644 index 000000000..c46daf978 --- /dev/null +++ b/app/builders/v2/reports/bot_metrics_builder.rb @@ -0,0 +1,54 @@ +class V2::Reports::BotMetricsBuilder + include DateRangeHelper + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def metrics + { + conversation_count: bot_conversations.count, + message_count: bot_messages.count, + resolution_rate: bot_resolution_rate.to_i, + handoff_rate: bot_handoff_rate.to_i + } + end + + private + + def bot_activated_inbox_ids + @bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id) + end + + def bot_conversations + @bot_conversations ||= account.conversations.where(inbox_id: bot_activated_inbox_ids).where(created_at: range) + end + + def bot_messages + @bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range) + end + + def bot_resolutions_count + account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, + created_at: range).distinct.count + end + + def bot_handoffs_count + account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, + created_at: range).distinct.count + end + + def bot_resolution_rate + return 0 if bot_conversations.count.zero? + + bot_resolutions_count.to_f / bot_conversations.count * 100 + end + + def bot_handoff_rate + return 0 if bot_conversations.count.zero? + + bot_handoffs_count.to_f / bot_conversations.count * 100 + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 260400feb..c67b74a43 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -14,6 +14,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: summary_metrics end + def bot_summary + summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary + summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary + render json: summary + end + def agents @report_data = generate_agents_report generate_csv('agents_report', 'api/v2/accounts/reports/agents') @@ -48,6 +54,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: conversation_metrics end + def bot_metrics + bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, params).metrics + render json: bot_metrics + end + private def generate_csv(filename, template) diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 2b0f43b3f..99f3fd36b 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -32,6 +32,14 @@ module ReportHelper (get_grouped_values resolutions).count end + def bot_resolutions_count + (get_grouped_values bot_resolutions).count + end + + def bot_handoffs_count + (get_grouped_values bot_handoffs).count + end + def conversations scope.conversations.where(account_id: account.id, created_at: range) end @@ -49,6 +57,16 @@ module ReportHelper conversations: { status: :resolved }, created_at: range).distinct end + def bot_resolutions + scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, + conversations: { status: :resolved }, created_at: range).distinct + end + + def bot_handoffs + scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, + created_at: range).distinct + end + def avg_first_response_time grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index c9a230fab..a6d476bc9 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -412,7 +412,8 @@ "LOADING_MESSAGE": "Loading conversation metrics...", "OPEN": "Open", "UNATTENDED": "Unattended", - "UNASSIGNED": "Unassigned" + "UNASSIGNED": "Unassigned", + "PENDING": "Pending" }, "CONVERSATION_HEATMAP": { "HEADER": "Conversation Traffic", diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js index f4eb5a6dc..cb4e5bb6b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js @@ -200,6 +200,7 @@ export const OVERVIEW_METRICS = { open: 'OPEN', unattended: 'UNATTENDED', unassigned: 'UNASSIGNED', + pending: 'PENDING', online: 'ONLINE', busy: 'BUSY', offline: 'OFFLINE', diff --git a/config/routes.rb b/config/routes.rb index 36b7f46a0..90317c7b2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -303,12 +303,14 @@ Rails.application.routes.draw do resources :reports, only: [:index] do collection do get :summary + get :bot_summary get :agents get :inboxes get :labels get :teams get :conversations get :conversation_traffic + get :bot_metrics end end end diff --git a/spec/builders/v2/report_builder_spec.rb b/spec/builders/v2/report_builder_spec.rb index 936c070c4..2ad62e2d6 100644 --- a/spec/builders/v2/report_builder_spec.rb +++ b/spec/builders/v2/report_builder_spec.rb @@ -6,8 +6,6 @@ describe V2::ReportBuilder do let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) } let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) } - # Update this spec to use travel_to - # This spec breaks in certain timezone describe '#timeseries' do before do travel_to(Time.zone.today) do @@ -128,6 +126,75 @@ describe V2::ReportBuilder do end end + it 'returns bot_resolutions count' do + travel_to(Time.zone.today) do + params = { + metric: 'bot_resolutions_count', + type: :account, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.end_of_day.to_time.to_i.to_s + } + + create(:agent_bot_inbox, inbox: account.inboxes.first) + conversations = account.conversations.where('created_at < ?', 1.day.ago) + conversations.each do |conversation| + conversation.messages.outgoing.all.update(sender: nil) + end + + perform_enqueued_jobs do + # Resolve all 5 conversations + conversations.each(&:resolved!) + + # Reopen 1 conversation + conversations.first.open! + end + + builder = described_class.new(account, params) + metrics = builder.timeseries + summary = builder.bot_summary + + # 4 conversations are resolved + expect(metrics[Time.zone.today]).to be 4 + expect(metrics[Time.zone.today - 2.days]).to be 0 + expect(summary[:bot_resolutions_count]).to be 4 + end + end + + it 'return bot_handoff count' do + travel_to(Time.zone.today) do + params = { + metric: 'bot_handoffs_count', + type: :account, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.end_of_day.to_time.to_i.to_s + } + + create(:agent_bot_inbox, inbox: account.inboxes.first) + conversations = account.conversations.where('created_at < ?', 1.day.ago) + conversations.each do |conversation| + conversation.pending! + conversation.messages.outgoing.all.update(sender: nil) + end + + perform_enqueued_jobs do + # Resolve all 5 conversations + conversations.each(&:bot_handoff!) + + # Reopen 1 conversation + conversations.first.open! + end + + builder = described_class.new(account, params) + metrics = builder.timeseries + summary = builder.bot_summary + + # 4 conversations are resolved + expect(metrics[Time.zone.today]).to be 5 + expect(metrics[Time.zone.today - 2.days]).to be 0 + expect(summary[:bot_handoffs_count]).to be 5 + end + end + it 'returns average first response time' do params = { metric: 'avg_first_response_time', diff --git a/spec/builders/v2/reports/bot_metrics_builder_spec.rb b/spec/builders/v2/reports/bot_metrics_builder_spec.rb new file mode 100644 index 000000000..53e52d595 --- /dev/null +++ b/spec/builders/v2/reports/bot_metrics_builder_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::BotMetricsBuilder do + subject(:bot_metrics_builder) { described_class.new(inbox.account, params) } + + let(:inbox) { create(:inbox) } + let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) } + let!(:unresolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) } + let(:since) { 1.week.ago.to_i.to_s } + let(:until_time) { Time.now.to_i.to_s } + let(:params) { { since: since, until: until_time } } + + before do + create(:agent_bot_inbox, inbox: inbox) + create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing') + create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved', conversation_id: resolved_conversation.id, + created_at: 2.days.ago) + create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff', + conversation_id: resolved_conversation.id, created_at: 2.days.ago) + create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff', + conversation_id: unresolved_conversation.id, created_at: 2.days.ago) + end + + describe '#metrics' do + context 'with valid params' do + it 'returns correct metrics' do + metrics = bot_metrics_builder.metrics + + expect(metrics[:conversation_count]).to eq(2) + expect(metrics[:message_count]).to eq(1) + expect(metrics[:resolution_rate]).to eq(50) + expect(metrics[:handoff_rate]).to eq(100) + end + end + + context 'with missing params' do + let(:params) { {} } + + it 'handles missing since and until params gracefully' do + expect { bot_metrics_builder.metrics }.not_to raise_error + end + end + end +end diff --git a/spec/controllers/api/v2/accounts/report_controller_spec.rb b/spec/controllers/api/v2/accounts/report_controller_spec.rb index 27a596243..fc0228c86 100644 --- a/spec/controllers/api/v2/accounts/report_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/report_controller_spec.rb @@ -191,6 +191,48 @@ RSpec.describe 'Reports API', type: :request do end end + describe 'GET /api/v2/accounts/:account_id/reports/bot_summary' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/bot_summary" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:params) do + super().merge( + type: :account, + since: start_of_today.to_s, + until: end_of_today.to_s + ) + end + + it 'returns unauthorized for agents' do + get "/api/v2/accounts/#{account.id}/reports/bot_summary", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns bot summary metrics' do + get "/api/v2/accounts/#{account.id}/reports/bot_summary", + params: params, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + + expect(json_response['bot_resolutions_count']).to eq(0) + expect(json_response['bot_handoffs_count']).to eq(0) + end + end + end + describe 'GET /api/v2/accounts/:account_id/reports/agents' do context 'when it is an unauthenticated user' do it 'returns unauthorized' do @@ -399,4 +441,41 @@ RSpec.describe 'Reports API', type: :request do end end end + + describe 'GET /api/v2/accounts/:account_id/reports/bot_metrics' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/bot_metrics" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:params) do + super().merge( + since: 7.days.ago.to_i.to_s, + until: end_of_today.to_s + ) + end + + it 'returns unauthorized if the user is an agent' do + get "/api/v2/accounts/#{account.id}/reports/bot_metrics", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns values' do + expect(V2::Reports::BotMetricsBuilder).to receive(:new).and_call_original + get "/api/v2/accounts/#{account.id}/reports/bot_metrics", + params: params, + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:success) + expect(response.parsed_body.keys).to match_array(%w[conversation_count message_count resolution_rate handoff_rate]) + end + end + end end diff --git a/spec/factories/agent_bots.rb b/spec/factories/agent_bots.rb index 8fc0a6bf4..286f70b5e 100644 --- a/spec/factories/agent_bots.rb +++ b/spec/factories/agent_bots.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :agent_bot do name { 'MyString' } description { 'MyString' } - outgoing_url { 'MyString' } + outgoing_url { 'localhost' } bot_config { {} } bot_type { 'webhook' }