diff --git a/app/builders/v2/reports/channel_summary_builder.rb b/app/builders/v2/reports/channel_summary_builder.rb new file mode 100644 index 000000000..2df8fc081 --- /dev/null +++ b/app/builders/v2/reports/channel_summary_builder.rb @@ -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 diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index f31a53c7e..98b3f05d7 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index aacabb1fc..d9ca2f1be 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index ed1e5e690..aa06d8f09 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -418,6 +418,7 @@ Rails.application.routes.draw do get :team get :inbox get :label + get :channel end end resources :reports, only: [:index] do diff --git a/spec/builders/v2/reports/channel_summary_builder_spec.rb b/spec/builders/v2/reports/channel_summary_builder_spec.rb new file mode 100644 index 000000000..4111282f3 --- /dev/null +++ b/spec/builders/v2/reports/channel_summary_builder_spec.rb @@ -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 diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb index 69e4f5cae..0034715eb 100644 --- a/spec/controllers/api/base_controller_spec.rb +++ b/spec/controllers/api/base_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb b/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb index f6c429e79..38c66eeb0 100644 --- a/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb @@ -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 diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index c24831f84..fd9cc1664 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -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 diff --git a/swagger/definitions/resource/reports/channel_summary.yml b/swagger/definitions/resource/reports/channel_summary.yml new file mode 100644 index 000000000..eaacf1c5f --- /dev/null +++ b/swagger/definitions/resource/reports/channel_summary.yml @@ -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 diff --git a/swagger/paths/application/reports/channel_summary.yml b/swagger/paths/application/reports/channel_summary.yml new file mode 100644 index 000000000..e5a3c278e --- /dev/null +++ b/swagger/paths/application/reports/channel_summary.yml @@ -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' diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 9d6ee2430..2e7c5514e 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -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: diff --git a/swagger/swagger.json b/swagger/swagger.json index c210ba716..ee33fe56f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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": { diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 59849ea5a..ef5ec5389 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -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": { diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index cf356e609..bcf4bb178 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -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": { diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index ed1073d0b..01d1adc46 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -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": { diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index 1bf31a212..2b81a67fd 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -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": {