feat: Add an API to support querying metrics by ChannelType (#13255)

This API gives you how many conversations exist per channel, broken down
by status in a given time period. The max time period is capped to 6
months for now.

**Input Params:**
- **since:** Unix timestamp (seconds) - start of date range
- **until:** Unix timestamp (seconds) - end of date range


**Response Payload:**

```json
{
  "Channel::Sms": {
    "resolved": 85,
    "snoozed": 10,
    "open": 5,
    "pending": 5,
    "total": 100
  },
  "Channel::Email": {
    "resolved": 72,
    "snoozed": 15,
    "open": 13,
    "pending": 13,
    "total": 100
  },
  "Channel::WebWidget": {
    "resolved": 90,
    "snoozed": 7,
    "open": 3,
    "pending": 3,
    "total": 100
  }
}
```

**Definitons:**
resolved = Number of conversations created within the selected time
period that are currently marked as resolved.
snoozed = Number of conversations created within the selected time
period that are currently marked as snoozed.
pending = Number of conversations created within the selected time
period that are currently marked as pending.
open = Number of conversations created within the selected time period
that are currently open.
total = Total number of conversations created within the selected time
period, across all statuses.
This commit is contained in:
Pranav
2026-01-12 23:18:47 -08:00
committed by GitHub
parent 9407cc2ad5
commit 0917e1a646
16 changed files with 686 additions and 10 deletions

View File

@@ -0,0 +1,38 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end

View File

@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end

View File

@@ -134,6 +134,8 @@ en:
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
stripe_customer_not_configured: Stripe customer not configured
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
reports:
date_range_too_long: Date range cannot exceed 6 months
profile:
mfa:
enabled: MFA enabled successfully

View File

@@ -418,6 +418,7 @@ Rails.application.routes.draw do
get :team
get :inbox
get :label
get :channel
end
end
resources :reports, only: [:index] do

View File

@@ -0,0 +1,92 @@
require 'rails_helper'
RSpec.describe V2::Reports::ChannelSummaryBuilder do
let!(:account) { create(:account) }
let!(:web_widget_inbox) { create(:inbox, account: account) }
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
let(:params) do
{
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
subject(:report) { builder.build }
context 'when there are conversations with different statuses across channels' do
before do
# Web widget conversations
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 3.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :pending, created_at: 1.day.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :snoozed, created_at: 1.day.ago)
# Email conversations
create(:conversation, account: account, inbox: email_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 1.day.ago)
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 3.days.ago)
end
it 'returns correct counts grouped by channel type' do
expect(report['Channel::WebWidget']).to eq(
open: 2,
resolved: 1,
pending: 1,
snoozed: 1,
total: 5
)
expect(report['Channel::Email']).to eq(
open: 1,
resolved: 2,
pending: 0,
snoozed: 0,
total: 3
)
end
end
context 'when conversations are outside the date range' do
before do
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.weeks.ago)
end
it 'only includes conversations within the date range' do
expect(report['Channel::WebWidget']).to eq(
open: 1,
resolved: 0,
pending: 0,
snoozed: 0,
total: 1
)
end
end
context 'when there are no conversations' do
it 'returns an empty hash' do
expect(report).to eq({})
end
end
context 'when a channel has only one status type' do
before do
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 1.day.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
end
it 'returns zeros for other statuses' do
expect(report['Channel::WebWidget']).to eq(
open: 0,
resolved: 2,
pending: 0,
snoozed: 0,
total: 2
)
end
end
end
end

View File

@@ -10,19 +10,14 @@ RSpec.describe 'API Base', type: :request do
let!(:conversation) { create(:conversation, account: account) }
it 'sets Current attributes for the request and then returns the response' do
# expect Current.account_user is set to the admin's account_user
allow(Current).to receive(:user=).and_call_original
allow(Current).to receive(:account=).and_call_original
allow(Current).to receive(:account_user=).and_call_original
# This test verifies that Current.user, Current.account, and Current.account_user
# are properly set during request processing. We verify this indirectly:
# - A successful response proves Current.account_user was set (required for authorization)
# - The correct conversation data proves Current.account was set (scopes the query)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(Current).to have_received(:user=).with(admin).at_least(:once)
expect(Current).to have_received(:account=).with(account).at_least(:once)
expect(Current).to have_received(:account_user=).with(admin.account_users.first).at_least(:once)
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end

View File

@@ -160,4 +160,68 @@ RSpec.describe 'Summary Reports API', type: :request do
end
end
end
describe 'GET /api/v2/accounts/:account_id/summary_reports/channel' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/summary_reports/channel"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:params) do
{
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}/summary_reports/channel",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'calls V2::Reports::ChannelSummaryBuilder with the right params if the user is an admin' do
channel_summary_builder = double
allow(V2::Reports::ChannelSummaryBuilder).to receive(:new).and_return(channel_summary_builder)
allow(channel_summary_builder).to receive(:build)
.and_return({
'Channel::WebWidget' => { open: 5, resolved: 10, pending: 2, snoozed: 1, total: 18 }
})
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::ChannelSummaryBuilder).to have_received(:new).with(
account: account,
params: hash_including(since: start_of_today.to_s, until: end_of_today.to_s)
)
expect(channel_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['Channel::WebWidget']['open']).to eq(5)
expect(json_response['Channel::WebWidget']['total']).to eq(18)
end
it 'returns unprocessable_entity when date range exceeds 6 months' do
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
params: { since: 1.year.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(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.reports.date_range_too_long'))
end
end
end
end

View File

@@ -223,6 +223,8 @@ account_summary:
$ref: './resource/reports/summary.yml'
agent_conversation_metrics:
$ref: './resource/reports/conversation/agent.yml'
channel_summary:
$ref: './resource/reports/channel_summary.yml'
contact_detail:
$ref: ./resource/contact_detail.yml

View File

@@ -0,0 +1,34 @@
type: object
description: Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.
additionalProperties:
type: object
description: Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)
properties:
open:
type: number
description: Number of open conversations
resolved:
type: number
description: Number of resolved conversations
pending:
type: number
description: Number of pending conversations
snoozed:
type: number
description: Number of snoozed conversations
total:
type: number
description: Total number of conversations
example:
Channel::WebWidget:
open: 10
resolved: 20
pending: 5
snoozed: 2
total: 37
Channel::Api:
open: 5
resolved: 15
pending: 3
snoozed: 1
total: 24

View File

@@ -0,0 +1,30 @@
tags:
- Reports
operationId: get-channel-summary-report
summary: Get conversation statistics grouped by channel type
security:
- userApiKey: []
description: |
Get conversation counts grouped by channel type and status for a given date range.
Returns statistics for each channel type including open, resolved, pending, snoozed, and total conversation counts.
**Note:** This API endpoint is available only in Chatwoot version 4.10.0 and above. The date range is limited to a maximum of 6 months.
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/channel_summary'
'400':
description: Date range exceeds 6 months limit
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'

View File

@@ -639,6 +639,28 @@
get:
$ref: './application/reports/conversation/agent.yml'
# Channel summary report (Available in 4.10.0+)
/api/v2/accounts/{account_id}/summary_reports/channel:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
name: since
schema:
type: string
description: The timestamp from where report should start (Unix timestamp).
- in: query
name: until
schema:
type: string
description: The timestamp from where report should stop (Unix timestamp).
- in: query
name: business_hours
schema:
type: boolean
description: Whether to filter by business hours.
get:
$ref: './application/reports/channel_summary.yml'
# Conversations Messages
/accounts/{account_id}/conversations/{conversation_id}/messages:
parameters:

View File

@@ -7870,6 +7870,82 @@
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/channel": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to filter by business hours."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-channel-summary-report",
"summary": "Get conversation statistics grouped by channel type",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation counts grouped by channel type and status for a given date range.\nReturns statistics for each channel type including open, resolved, pending, snoozed, and total conversation counts.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.10.0 and above. The date range is limited to a maximum of 6 months.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/channel_summary"
}
}
}
},
"400": {
"description": "Date range exceeds 6 months limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/accounts/{account_id}/conversations/{conversation_id}/messages": {
"parameters": [
{
@@ -11659,6 +11735,52 @@
}
}
},
"channel_summary": {
"type": "object",
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
"additionalProperties": {
"type": "object",
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"open": {
"type": "number",
"description": "Number of open conversations"
},
"resolved": {
"type": "number",
"description": "Number of resolved conversations"
},
"pending": {
"type": "number",
"description": "Number of pending conversations"
},
"snoozed": {
"type": "number",
"description": "Number of snoozed conversations"
},
"total": {
"type": "number",
"description": "Total number of conversations"
}
}
},
"example": {
"Channel::WebWidget": {
"open": 10,
"resolved": 20,
"pending": 5,
"snoozed": 2,
"total": 37
},
"Channel::Api": {
"open": 5,
"resolved": 15,
"pending": 3,
"snoozed": 1,
"total": 24
}
}
},
"contact_detail": {
"type": "object",
"properties": {

View File

@@ -6412,6 +6412,82 @@
}
}
}
},
"/api/v2/accounts/{account_id}/summary_reports/channel": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should start (Unix timestamp)."
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where report should stop (Unix timestamp)."
},
{
"in": "query",
"name": "business_hours",
"schema": {
"type": "boolean"
},
"description": "Whether to filter by business hours."
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-channel-summary-report",
"summary": "Get conversation statistics grouped by channel type",
"security": [
{
"userApiKey": []
}
],
"description": "Get conversation counts grouped by channel type and status for a given date range.\nReturns statistics for each channel type including open, resolved, pending, snoozed, and total conversation counts.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.10.0 and above. The date range is limited to a maximum of 6 months.\n",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/channel_summary"
}
}
}
},
"400": {
"description": "Date range exceeds 6 months limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
}
},
"components": {
@@ -10166,6 +10242,52 @@
}
}
},
"channel_summary": {
"type": "object",
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
"additionalProperties": {
"type": "object",
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"open": {
"type": "number",
"description": "Number of open conversations"
},
"resolved": {
"type": "number",
"description": "Number of resolved conversations"
},
"pending": {
"type": "number",
"description": "Number of pending conversations"
},
"snoozed": {
"type": "number",
"description": "Number of snoozed conversations"
},
"total": {
"type": "number",
"description": "Total number of conversations"
}
}
},
"example": {
"Channel::WebWidget": {
"open": 10,
"resolved": 20,
"pending": 5,
"snoozed": 2,
"total": 37
},
"Channel::Api": {
"open": 5,
"resolved": 15,
"pending": 3,
"snoozed": 1,
"total": 24
}
}
},
"contact_detail": {
"type": "object",
"properties": {

View File

@@ -4378,6 +4378,52 @@
}
}
},
"channel_summary": {
"type": "object",
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
"additionalProperties": {
"type": "object",
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"open": {
"type": "number",
"description": "Number of open conversations"
},
"resolved": {
"type": "number",
"description": "Number of resolved conversations"
},
"pending": {
"type": "number",
"description": "Number of pending conversations"
},
"snoozed": {
"type": "number",
"description": "Number of snoozed conversations"
},
"total": {
"type": "number",
"description": "Total number of conversations"
}
}
},
"example": {
"Channel::WebWidget": {
"open": 10,
"resolved": 20,
"pending": 5,
"snoozed": 2,
"total": 37
},
"Channel::Api": {
"open": 5,
"resolved": 15,
"pending": 3,
"snoozed": 1,
"total": 24
}
}
},
"contact_detail": {
"type": "object",
"properties": {

View File

@@ -3793,6 +3793,52 @@
}
}
},
"channel_summary": {
"type": "object",
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
"additionalProperties": {
"type": "object",
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"open": {
"type": "number",
"description": "Number of open conversations"
},
"resolved": {
"type": "number",
"description": "Number of resolved conversations"
},
"pending": {
"type": "number",
"description": "Number of pending conversations"
},
"snoozed": {
"type": "number",
"description": "Number of snoozed conversations"
},
"total": {
"type": "number",
"description": "Total number of conversations"
}
}
},
"example": {
"Channel::WebWidget": {
"open": 10,
"resolved": 20,
"pending": 5,
"snoozed": 2,
"total": 37
},
"Channel::Api": {
"open": 5,
"resolved": 15,
"pending": 3,
"snoozed": 1,
"total": 24
}
}
},
"contact_detail": {
"type": "object",
"properties": {

View File

@@ -4554,6 +4554,52 @@
}
}
},
"channel_summary": {
"type": "object",
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
"additionalProperties": {
"type": "object",
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
"properties": {
"open": {
"type": "number",
"description": "Number of open conversations"
},
"resolved": {
"type": "number",
"description": "Number of resolved conversations"
},
"pending": {
"type": "number",
"description": "Number of pending conversations"
},
"snoozed": {
"type": "number",
"description": "Number of snoozed conversations"
},
"total": {
"type": "number",
"description": "Total number of conversations"
}
}
},
"example": {
"Channel::WebWidget": {
"open": 10,
"resolved": 20,
"pending": 5,
"snoozed": 2,
"total": 37
},
"Channel::Api": {
"open": 5,
"resolved": 15,
"pending": 3,
"snoozed": 1,
"total": 24
}
}
},
"contact_detail": {
"type": "object",
"properties": {