diff --git a/app/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb new file mode 100644 index 000000000..62565dd44 --- /dev/null +++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb @@ -0,0 +1,59 @@ +class V2::Reports::FirstResponseTimeDistributionBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + build_distribution + end + + private + + def build_distribution + results = fetch_aggregated_counts + format_results(results) + end + + def fetch_aggregated_counts + ReportingEvent + .joins('INNER JOIN inboxes ON reporting_events.inbox_id = inboxes.id') + .where(account_id: account.id, name: 'first_response') + .where(range_condition) + .group('inboxes.channel_type') + .select( + 'inboxes.channel_type', + bucket_case_statements + ) + end + + def bucket_case_statements + <<~SQL.squish + COUNT(CASE WHEN reporting_events.value < 3600 THEN 1 END) AS bucket_0_1h, + COUNT(CASE WHEN reporting_events.value >= 3600 AND reporting_events.value < 14400 THEN 1 END) AS bucket_1_4h, + COUNT(CASE WHEN reporting_events.value >= 14400 AND reporting_events.value < 28800 THEN 1 END) AS bucket_4_8h, + COUNT(CASE WHEN reporting_events.value >= 28800 AND reporting_events.value < 86400 THEN 1 END) AS bucket_8_24h, + COUNT(CASE WHEN reporting_events.value >= 86400 THEN 1 END) AS bucket_24h_plus + SQL + end + + def range_condition + range.present? ? { created_at: range } : {} + end + + def format_results(results) + results.each_with_object({}) do |row, hash| + hash[row.channel_type] = { + '0-1h' => row.bucket_0_1h, + '1-4h' => row.bucket_1_4h, + '4-8h' => row.bucket_4_8h, + '8-24h' => row.bucket_8_24h, + '24h+' => row.bucket_24h_plus + } + end + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 82576cf90..ddd629048 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -70,6 +70,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: builder.build end + def first_response_time_distribution + builder = V2::Reports::FirstResponseTimeDistributionBuilder.new( + account: Current.account, + params: first_response_time_distribution_params + ) + render json: builder.build + end + private def generate_csv(filename, template) @@ -156,4 +164,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController label_ids: params[:label_ids] } end + + def first_response_time_distribution_params + { + since: params[:since], + until: params[:until] + } + end end diff --git a/config/routes.rb b/config/routes.rb index fae66361c..79e5edd23 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -445,6 +445,7 @@ Rails.application.routes.draw do get :conversation_traffic get :bot_metrics get :inbox_label_matrix + get :first_response_time_distribution end end resource :year_in_review, only: [:show] diff --git a/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb b/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb new file mode 100644 index 000000000..de1dc4a53 --- /dev/null +++ b/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb @@ -0,0 +1,145 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::FirstResponseTimeDistributionBuilder do + let!(:account) { create(:account) } + let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) } + let!(:email_inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) } + let(:params) do + { + since: 1.week.ago.beginning_of_day.to_i.to_s, + until: Time.current.end_of_day.to_i.to_s + } + end + let(:builder) { described_class.new(account: account, params: params) } + + describe '#build' do + subject(:report) { builder.build } + + context 'when there are first response events across channels and time buckets' do + before do + # Web Widget: 0-1h bucket (30 minutes = 1800 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + # Web Widget: 1-4h bucket (2 hours = 7200 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 7_200, created_at: 2.days.ago) + # Web Widget: 4-8h bucket (6 hours = 21600 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 21_600, created_at: 3.days.ago) + # Email: 8-24h bucket (12 hours = 43200 seconds) + create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response', + value: 43_200, created_at: 2.days.ago) + # Email: 24h+ bucket (48 hours = 172800 seconds) + create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response', + value: 172_800, created_at: 1.day.ago) + end + + it 'returns correct distribution for web widget channel' do + expect(report['Channel::WebWidget']).to eq({ + '0-1h' => 1, + '1-4h' => 1, + '4-8h' => 1, + '8-24h' => 0, + '24h+' => 0 + }) + end + + it 'returns correct distribution for email channel' do + expect(report['Channel::Email']).to eq({ + '0-1h' => 0, + '1-4h' => 0, + '4-8h' => 0, + '8-24h' => 1, + '24h+' => 1 + }) + end + end + + context 'when filtering by date range' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.weeks.ago) + end + + it 'only counts events within the date range' do + expect(report['Channel::WebWidget']['0-1h']).to eq(1) + end + end + + context 'when there are no first response events' do + it 'returns an empty hash' do + expect(report).to eq({}) + end + end + + context 'when events belong to another account' do + let(:other_account) { create(:account) } + let(:other_inbox) { create(:inbox, account: other_account) } + + before do + create(:reporting_event, account: other_account, inbox: other_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + end + + it 'does not include events from other accounts' do + expect(report).to eq({}) + end + end + + context 'when events have different names' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'conversation_resolved', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'reply_time', + value: 1_800, created_at: 2.days.ago) + end + + it 'only counts first_response events' do + expect(report['Channel::WebWidget']['0-1h']).to eq(1) + end + end + + context 'when no date range params are provided' do + let(:params) { {} } + + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.months.ago) + end + + it 'returns all events without date filtering' do + expect(report['Channel::WebWidget']['0-1h']).to eq(2) + end + end + + context 'with boundary values for time buckets' do + before do + # Exactly at 1 hour boundary (should be in 1-4h bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 3_600, created_at: 2.days.ago) + # Just under 1 hour (should be in 0-1h bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 3_599, created_at: 2.days.ago) + # Exactly at 24 hour boundary (should be in 24h+ bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 86_400, created_at: 2.days.ago) + end + + it 'correctly assigns boundary values to buckets' do + expect(report['Channel::WebWidget']).to eq({ + '0-1h' => 1, + '1-4h' => 1, + '4-8h' => 0, + '8-24h' => 0, + '24h+' => 1 + }) + end + end + end +end diff --git a/spec/controllers/api/v2/accounts/reports_controller_spec.rb b/spec/controllers/api/v2/accounts/reports_controller_spec.rb index b62495e83..c92425c32 100644 --- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb @@ -248,4 +248,51 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do end end end + + describe 'GET /api/v2/accounts/{account.id}/reports/first_response_time_distribution' do + let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) } + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + headers: agent.create_new_auth_token, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + end + + it 'returns the first response time distribution' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + + body = response.parsed_body + expect(body).to be_a(Hash) + expect(body['Channel::WebWidget']).to include('0-1h', '1-4h', '4-8h', '8-24h', '24h+') + end + + it 'returns correct counts in buckets' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s }, + headers: admin.create_new_auth_token, as: :json + + body = response.parsed_body + expect(body['Channel::WebWidget']['0-1h']).to eq(1) + end + end + end end diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb index 8a64e6fd2..3b4d138f3 100644 --- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb @@ -46,7 +46,7 @@ describe Enterprise::Billing::TopupCheckoutService do it 'raises error for invalid credits' do expect do service.create_checkout_session(credits: 500) - end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) + end.to(raise_error { |error| expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') }) end it 'raises error when account is on free plan' do @@ -54,7 +54,7 @@ describe Enterprise::Billing::TopupCheckoutService do expect do service.create_checkout_session(credits: 1000) - end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) + end.to(raise_error { |error| expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') }) end end end diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb index 46d8e5e56..908d75088 100644 --- a/spec/jobs/send_reply_job_spec.rb +++ b/spec/jobs/send_reply_job_spec.rb @@ -33,8 +33,8 @@ RSpec.describe SendReplyJob do twitter_channel = create(:channel_twitter_profile) twitter_inbox = create(:inbox, channel: twitter_channel) message = create(:message, conversation: create(:conversation, inbox: twitter_inbox)) - allow(Twitter::SendOnTwitterService).to receive(:new).with(message: message).and_return(process_service) - expect(Twitter::SendOnTwitterService).to receive(:new).with(message: message) + allow(Twitter::SendOnTwitterService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Twitter::SendOnTwitterService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -42,8 +42,8 @@ RSpec.describe SendReplyJob do it 'calls ::Twilio::SendOnTwilioService when its twilio message' do twilio_channel = create(:channel_twilio_sms) message = create(:message, conversation: create(:conversation, inbox: twilio_channel.inbox)) - allow(Twilio::SendOnTwilioService).to receive(:new).with(message: message).and_return(process_service) - expect(Twilio::SendOnTwilioService).to receive(:new).with(message: message) + allow(Twilio::SendOnTwilioService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Twilio::SendOnTwilioService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -51,8 +51,8 @@ RSpec.describe SendReplyJob do it 'calls ::Telegram::SendOnTelegramService when its telegram message' do telegram_channel = create(:channel_telegram) message = create(:message, conversation: create(:conversation, inbox: telegram_channel.inbox)) - allow(Telegram::SendOnTelegramService).to receive(:new).with(message: message).and_return(process_service) - expect(Telegram::SendOnTelegramService).to receive(:new).with(message: message) + allow(Telegram::SendOnTelegramService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Telegram::SendOnTelegramService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -60,8 +60,8 @@ RSpec.describe SendReplyJob do it 'calls ::Line:SendOnLineService when its line message' do line_channel = create(:channel_line) message = create(:message, conversation: create(:conversation, inbox: line_channel.inbox)) - allow(Line::SendOnLineService).to receive(:new).with(message: message).and_return(process_service) - expect(Line::SendOnLineService).to receive(:new).with(message: message) + allow(Line::SendOnLineService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Line::SendOnLineService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -70,8 +70,8 @@ RSpec.describe SendReplyJob do stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook') whatsapp_channel = create(:channel_whatsapp, sync_templates: false) message = create(:message, conversation: create(:conversation, inbox: whatsapp_channel.inbox)) - allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message).and_return(process_service) - expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message) + allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -79,8 +79,8 @@ RSpec.describe SendReplyJob do it 'calls ::Sms::SendOnSmsService when its sms message' do sms_channel = create(:channel_sms) message = create(:message, conversation: create(:conversation, inbox: sms_channel.inbox)) - allow(Sms::SendOnSmsService).to receive(:new).with(message: message).and_return(process_service) - expect(Sms::SendOnSmsService).to receive(:new).with(message: message) + allow(Sms::SendOnSmsService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Sms::SendOnSmsService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -88,8 +88,8 @@ RSpec.describe SendReplyJob do it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do instagram_channel = create(:channel_instagram) message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox)) - allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service) - expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message) + allow(Instagram::SendOnInstagramService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Instagram::SendOnInstagramService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -112,8 +112,8 @@ RSpec.describe SendReplyJob do it 'calls ::Email::SendOnEmailService when its email message' do email_channel = create(:channel_email) message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox)) - allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service) - expect(Email::SendOnEmailService).to receive(:new).with(message: message) + allow(Email::SendOnEmailService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Email::SendOnEmailService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -121,8 +121,8 @@ RSpec.describe SendReplyJob do it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do webwidget_channel = create(:channel_widget) message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox)) - allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) - expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -130,8 +130,8 @@ RSpec.describe SendReplyJob do it 'calls ::Messages::SendEmailNotificationService when its api channel message' do api_channel = create(:channel_api) message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox)) - allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) - expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -139,8 +139,8 @@ RSpec.describe SendReplyJob do it 'calls ::Tiktok::SendOnTiktokService when its tiktok message' do tiktok_channel = create(:channel_tiktok) message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox)) - allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: message).and_return(process_service) - expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: message) + allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end