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:
Pranav
2026-01-30 10:22:27 -08:00
committed by GitHub
parent 85324c82fa
commit 5ec77aca64
7 changed files with 291 additions and 24 deletions

View File

@@ -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