feat: Add inbox-label matrix report endpoint (#13394)

This PR added new API endpoint GET
/api/v2/accounts/:account_id/reports/inbox_label_matrix that returns
conversation counts grouped by inbox and label in a matrix format.
Supports optional filtering by date range, inbox_ids, and label_ids.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Muhsin Keloth
2026-01-30 01:32:59 +04:00
committed by GitHub
parent a32565d72b
commit 6f45af605c
5 changed files with 270 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end

View File

@@ -62,6 +62,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def inbox_label_matrix
builder = V2::Reports::InboxLabelMatrixBuilder.new(
account: Current.account,
params: inbox_label_matrix_params
)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -139,4 +147,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def inbox_label_matrix_params
{
since: params[:since],
until: params[:until],
inbox_ids: params[:inbox_ids],
label_ids: params[:label_ids]
}
end
end

View File

@@ -444,6 +444,7 @@ Rails.application.routes.draw do
get :conversations_summary
get :conversation_traffic
get :bot_metrics
get :inbox_label_matrix
end
end
resource :year_in_review, only: [:show]

View File

@@ -0,0 +1,135 @@
require 'rails_helper'
RSpec.describe V2::Reports::InboxLabelMatrixBuilder do
let!(:account) { create(:account) }
let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') }
let!(:inbox_two) { create(:inbox, account: account, name: 'Web Chat') }
let!(:label_one) { create(:label, account: account, title: 'bug') }
let!(:label_two) { create(:label, account: account, title: 'feature') }
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 conversations with labels across inboxes' do
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 3.days.ago)
c2.update(label_list: [label_one.title, label_two.title])
c3 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
c3.update(label_list: [label_two.title])
end
it 'returns inboxes ordered by name' do
expect(report[:inboxes]).to eq([
{ id: inbox_one.id, name: 'Email Support' },
{ id: inbox_two.id, name: 'Web Chat' }
])
end
it 'returns labels ordered by title' do
expect(report[:labels]).to eq([
{ id: label_one.id, title: 'bug' },
{ id: label_two.id, title: 'feature' }
])
end
it 'returns correct conversation counts in the matrix' do
# Email Support: bug=2, feature=1
# Web Chat: bug=0, feature=1
expect(report[:matrix]).to eq([[2, 1], [0, 1]])
end
end
context 'when filtering by inbox_ids' do
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s,
inbox_ids: [inbox_one.id]
}
end
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
c2.update(label_list: [label_one.title])
end
it 'only includes the specified inboxes and their counts' do
expect(report[:inboxes]).to eq([{ id: inbox_one.id, name: 'Email Support' }])
expect(report[:matrix]).to eq([[1, 0]])
end
end
context 'when filtering by label_ids' do
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s,
label_ids: [label_one.id]
}
end
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title, label_two.title])
end
it 'only includes the specified labels and their counts' do
expect(report[:labels]).to eq([{ id: label_one.id, title: 'bug' }])
expect(report[:matrix]).to eq([[1], [0]])
end
end
context 'when conversations are outside the date range' do
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.weeks.ago)
c2.update(label_list: [label_one.title])
end
it 'only counts conversations within the date range' do
expect(report[:matrix]).to eq([[1, 0], [0, 0]])
end
end
context 'when there are no conversations with labels' do
before do
create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
end
it 'returns a matrix of zeros' do
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
end
end
context 'when conversations belong to another account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
before do
c1 = create(:conversation, account: other_account, inbox: other_inbox, created_at: 2.days.ago)
other_label = create(:label, account: other_account, title: 'bug')
c1.update(label_list: [other_label.title])
end
it 'does not include conversations from other accounts' do
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
end
end
end
end

View File

@@ -196,4 +196,56 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
end
end
end
describe 'GET /api/v2/accounts/{account.id}/reports/inbox_label_matrix' do
let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') }
let!(:label_one) { create(:label, account: account, title: 'bug') }
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix"
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/inbox_label_matrix",
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
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
end
it 'returns the inbox label matrix' do
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
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['inboxes']).to be_an(Array)
expect(body['labels']).to be_an(Array)
expect(body['matrix']).to be_an(Array)
end
it 'filters by inbox_ids and label_ids' do
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
params: { inbox_ids: [inbox_one.id], label_ids: [label_one.id] },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
body = response.parsed_body
expect(body['inboxes'].length).to eq(1)
expect(body['labels'].length).to eq(1)
end
end
end
end