From 6f45af605c9391a2494024e111dd27473e96355c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Fri, 30 Jan 2026 01:32:59 +0400 Subject: [PATCH] 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 --- .../v2/reports/inbox_label_matrix_builder.rb | 65 +++++++++ .../api/v2/accounts/reports_controller.rb | 17 +++ config/routes.rb | 1 + .../inbox_label_matrix_builder_spec.rb | 135 ++++++++++++++++++ .../v2/accounts/reports_controller_spec.rb | 52 +++++++ 5 files changed, 270 insertions(+) create mode 100644 app/builders/v2/reports/inbox_label_matrix_builder.rb create mode 100644 spec/builders/v2/reports/inbox_label_matrix_builder_spec.rb diff --git a/app/builders/v2/reports/inbox_label_matrix_builder.rb b/app/builders/v2/reports/inbox_label_matrix_builder.rb new file mode 100644 index 000000000..c3715019d --- /dev/null +++ b/app/builders/v2/reports/inbox_label_matrix_builder.rb @@ -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 diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 714aeb0c9..82576cf90 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 83aed5b79..fae66361c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/spec/builders/v2/reports/inbox_label_matrix_builder_spec.rb b/spec/builders/v2/reports/inbox_label_matrix_builder_spec.rb new file mode 100644 index 000000000..524f89e4a --- /dev/null +++ b/spec/builders/v2/reports/inbox_label_matrix_builder_spec.rb @@ -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 diff --git a/spec/controllers/api/v2/accounts/reports_controller_spec.rb b/spec/controllers/api/v2/accounts/reports_controller_spec.rb index 2d505822f..b62495e83 100644 --- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb @@ -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