feat: Add first response time distribution report endpoint (#13400)
The index is already added in production.
Adds a new reporting API that returns conversation counts grouped by
channel type and first response time buckets (0-1h, 1-4h, 4-8h, 8-24h,
24h+).
- GET /api/v2/accounts/:id/reports/first_response_time_distribution
- Uses SQL aggregation to handle large datasets efficiently
- Adds composite index on reporting_events for query performance
Tested on production workload.
Request: GET
`/api/v2/accounts/1/reports/first_response_time_distribution?since=<since>&until=<until>`
Response payload:
```
{
"Channel::WebWidget": {
"0-1h": 120,
"1-4h": 85,
"4-8h": 32,
"8-24h": 12,
"24h+": 3
},
"Channel::Email": {
"0-1h": 12,
"1-4h": 28,
"4-8h": 45,
"8-24h": 35,
"24h+": 10
},
"Channel::FacebookPage": {
"0-1h": 50,
"1-4h": 30,
"4-8h": 15,
"8-24h": 8,
"24h+": 2
}
}
```
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user