From 8eaea7c72e3873edf23c6b4366a181eb8265f943 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 4 Feb 2026 19:36:50 +0530 Subject: [PATCH] feat: Add standalone outgoing messages count API endpoint (#13419) This PR adds a new standalone `GET /api/v2/accounts/:id/reports/outgoing_messages_count` endpoint that returns outgoing message counts grouped by agent, team, inbox, or label. --- .../outgoing_messages_count_builder.rb | 79 +++++++++++ .../api/v2/accounts/reports_controller.rb | 17 +++ config/routes.rb | 1 + .../v2/accounts/reports_controller_spec.rb | 127 ++++++++++++++++++ swagger/definitions/index.yml | 2 + .../reports/outgoing_messages_count.yml | 21 +++ .../reports/outgoing_messages_count.yml | 36 +++++ swagger/paths/index.yml | 17 +++ swagger/swagger.json | 108 +++++++++++++++ swagger/tag_groups/application_swagger.json | 108 +++++++++++++++ swagger/tag_groups/client_swagger.json | 33 +++++ swagger/tag_groups/other_swagger.json | 33 +++++ swagger/tag_groups/platform_swagger.json | 33 +++++ 13 files changed, 615 insertions(+) create mode 100644 app/builders/v2/reports/outgoing_messages_count_builder.rb create mode 100644 swagger/definitions/resource/reports/outgoing_messages_count.yml create mode 100644 swagger/paths/application/reports/outgoing_messages_count.yml diff --git a/app/builders/v2/reports/outgoing_messages_count_builder.rb b/app/builders/v2/reports/outgoing_messages_count_builder.rb new file mode 100644 index 000000000..ac0de59f2 --- /dev/null +++ b/app/builders/v2/reports/outgoing_messages_count_builder.rb @@ -0,0 +1,79 @@ +class V2::Reports::OutgoingMessagesCountBuilder + include DateRangeHelper + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def build + send("build_by_#{params[:group_by]}") + end + + private + + def base_messages + account.messages.outgoing.unscope(:order).where(created_at: range) + end + + def build_by_agent + counts = base_messages + .where(sender_type: 'User') + .where.not(sender_id: nil) + .group(:sender_id) + .count + + user_names = account.users.where(id: counts.keys).index_by(&:id) + + counts.map do |user_id, count| + user = user_names[user_id] + { id: user_id, name: user&.name, outgoing_messages_count: count } + end + end + + def build_by_team + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .where.not(conversations: { team_id: nil }) + .group('conversations.team_id') + .count + + team_names = account.teams.where(id: counts.keys).index_by(&:id) + + counts.map do |team_id, count| + team = team_names[team_id] + { id: team_id, name: team&.name, outgoing_messages_count: count } + end + end + + def build_by_inbox + counts = base_messages + .group(:inbox_id) + .count + + inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id) + + counts.map do |inbox_id, count| + inbox = inbox_names[inbox_id] + { id: inbox_id, name: inbox&.name, outgoing_messages_count: count } + end + end + + def build_by_label + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id + AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'") + .joins('INNER JOIN tags ON tags.id = taggings.tag_id') + .group('tags.name') + .count + + label_ids = account.labels.where(title: counts.keys).index_by(&:title) + + counts.map do |label_name, count| + label = label_ids[label_name] + { id: label&.id, name: label_name, outgoing_messages_count: count } + end + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index ddd629048..192b3619c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -78,6 +78,15 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: builder.build end + OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze + + def outgoing_messages_count + return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by]) + + builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params) + render json: builder.build + end + private def generate_csv(filename, template) @@ -171,4 +180,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController until: params[:until] } end + + def outgoing_messages_count_params + { + group_by: params[:group_by], + since: params[:since], + until: params[:until] + } + end end diff --git a/config/routes.rb b/config/routes.rb index 79e5edd23..cab069201 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -446,6 +446,7 @@ Rails.application.routes.draw do get :bot_metrics get :inbox_label_matrix get :first_response_time_distribution + get :outgoing_messages_count end end resource :year_in_review, only: [:show] diff --git a/spec/controllers/api/v2/accounts/reports_controller_spec.rb b/spec/controllers/api/v2/accounts/reports_controller_spec.rb index c92425c32..f7bf86978 100644 --- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb @@ -295,4 +295,131 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do end end end + + describe 'GET /api/v2/accounts/{account.id}/reports/outgoing_messages_count' do + let(:since_epoch) { 1.week.ago.to_i.to_s } + let(:until_epoch) { 1.day.from_now.to_i.to_s } + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch } + 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/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch }, + headers: agent.create_new_auth_token, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + let(:agent2) { create(:user, account: account, role: :agent) } + let(:team) { create(:team, account: account) } + let(:inbox2) { create(:inbox, account: account) } + + # Separate conversations for agent and team grouping because + # model callbacks clear assignee_id when team is set. + before do + conv_agent = create(:conversation, account: account, inbox: inbox, assignee: agent) + conv_agent2 = create(:conversation, account: account, inbox: inbox2, assignee: agent2) + conv_team = create(:conversation, account: account, inbox: inbox, team: team) + + create_list(:message, 3, account: account, conversation: conv_agent, inbox: inbox, message_type: :outgoing, sender: agent) + create_list(:message, 2, account: account, conversation: conv_agent2, inbox: inbox2, message_type: :outgoing, sender: agent2) + create_list(:message, 4, account: account, conversation: conv_team, inbox: inbox, message_type: :outgoing) + # incoming message should not be counted + create(:message, account: account, conversation: conv_agent, inbox: inbox, message_type: :incoming) + end + + it 'returns unprocessable_entity for invalid group_by' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'invalid', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns outgoing message counts grouped by agent' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + + agent_entry = data.find { |e| e['id'] == agent.id } + agent2_entry = data.find { |e| e['id'] == agent2.id } + expect(agent_entry['outgoing_messages_count']).to eq(3) + expect(agent2_entry['outgoing_messages_count']).to eq(2) + end + + it 'returns outgoing message counts grouped by team' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'team', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + expect(data.length).to eq(1) + expect(data.first['id']).to eq(team.id) + expect(data.first['outgoing_messages_count']).to eq(4) + end + + it 'returns outgoing message counts grouped by inbox' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'inbox', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + + inbox_entry = data.find { |e| e['id'] == inbox.id } + inbox2_entry = data.find { |e| e['id'] == inbox2.id } + expect(inbox_entry['outgoing_messages_count']).to eq(7) + expect(inbox2_entry['outgoing_messages_count']).to eq(2) + end + + it 'returns outgoing message counts grouped by label' do + label = create(:label, account: account, title: 'support') + conversation = account.conversations.first + conversation.label_list.add('support') + conversation.save! + + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'label', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + expect(data.length).to eq(1) + expect(data.first['id']).to eq(label.id) + expect(data.first['name']).to eq('support') + end + + it 'excludes bot messages when grouped by agent' do + bot = create(:agent_bot) + bot_conversation = create(:conversation, account: account, inbox: inbox) + create(:message, account: account, conversation: bot_conversation, inbox: inbox, + message_type: :outgoing, sender: bot) + + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + data = response.parsed_body + agent_entry = data.find { |e| e['id'] == agent.id } + # 3 from before block; bot message excluded (sender_type != 'User') + expect(agent_entry['outgoing_messages_count']).to eq(3) + end + end + end end diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index 1e64bf97b..1033da7dd 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -229,6 +229,8 @@ first_response_time_distribution: $ref: './resource/reports/first_response_time_distribution.yml' inbox_label_matrix: $ref: './resource/reports/inbox_label_matrix.yml' +outgoing_messages_count: + $ref: './resource/reports/outgoing_messages_count.yml' inbox_summary: $ref: './resource/reports/inbox_summary.yml' agent_summary: diff --git a/swagger/definitions/resource/reports/outgoing_messages_count.yml b/swagger/definitions/resource/reports/outgoing_messages_count.yml new file mode 100644 index 000000000..d7c55ed59 --- /dev/null +++ b/swagger/definitions/resource/reports/outgoing_messages_count.yml @@ -0,0 +1,21 @@ +type: array +description: Outgoing messages count report grouped by entity (agent, team, inbox, or label). +items: + type: object + properties: + id: + type: number + description: The ID of the grouped entity (agent, team, inbox, or label). + name: + type: string + description: The name of the grouped entity. + outgoing_messages_count: + type: number + description: The total number of outgoing messages for this entity in the given time range. +example: + - id: 1 + name: Agent One + outgoing_messages_count: 42 + - id: 2 + name: Agent Two + outgoing_messages_count: 18 diff --git a/swagger/paths/application/reports/outgoing_messages_count.yml b/swagger/paths/application/reports/outgoing_messages_count.yml new file mode 100644 index 000000000..af7eaae45 --- /dev/null +++ b/swagger/paths/application/reports/outgoing_messages_count.yml @@ -0,0 +1,36 @@ +tags: + - Reports +operationId: get-outgoing-messages-count +summary: Get outgoing messages count grouped by entity +security: + - userApiKey: [] +description: | + Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label). + When grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded. + + **Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above. +parameters: + - in: query + name: group_by + required: true + schema: + type: string + enum: + - agent + - team + - inbox + - label + description: The entity to group outgoing message counts by. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/outgoing_messages_count' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 24e460b4c..284bb7825 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -770,6 +770,23 @@ get: $ref: './application/reports/inbox_label_matrix.yml' +# Outgoing messages count report +/api/v2/accounts/{account_id}/reports/outgoing_messages_count: + 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). + get: + $ref: './application/reports/outgoing_messages_count.yml' + # Conversations Messages /accounts/{account_id}/conversations/{conversation_id}/messages: parameters: diff --git a/swagger/swagger.json b/swagger/swagger.json index 934864911..adde81e9f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -8274,6 +8274,81 @@ } } }, + "/api/v2/accounts/{account_id}/reports/outgoing_messages_count": { + "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)." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-outgoing-messages-count", + "summary": "Get outgoing messages count grouped by entity", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label).\nWhen grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "parameters": [ + { + "in": "query", + "name": "group_by", + "required": true, + "schema": { + "type": "string", + "enum": [ + "agent", + "team", + "inbox", + "label" + ] + }, + "description": "The entity to group outgoing message counts by." + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/outgoing_messages_count" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/accounts/{account_id}/conversations/{conversation_id}/messages": { "parameters": [ { @@ -12243,6 +12318,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 77a95da33..748722875 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -6816,6 +6816,81 @@ } } } + }, + "/api/v2/accounts/{account_id}/reports/outgoing_messages_count": { + "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)." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-outgoing-messages-count", + "summary": "Get outgoing messages count grouped by entity", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label).\nWhen grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "parameters": [ + { + "in": "query", + "name": "group_by", + "required": true, + "schema": { + "type": "string", + "enum": [ + "agent", + "team", + "inbox", + "label" + ] + }, + "description": "The entity to group outgoing message counts by." + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/outgoing_messages_count" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } } }, "components": { @@ -10750,6 +10825,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index a786aebea..ebeb4a9cb 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -4558,6 +4558,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index 12dd566c4..9aa9f5a7a 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -3973,6 +3973,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index fd74b12e5..a830a8d56 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -4734,6 +4734,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.",