From 6f45af605c9391a2494024e111dd27473e96355c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Fri, 30 Jan 2026 01:32:59 +0400 Subject: [PATCH 01/11] 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 From 81307d5aea2e78229198f9fb809c249f27050746 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:18:33 +0530 Subject: [PATCH 02/11] feat: search documentation tool for reply suggestions (#13340) Co-authored-by: Shivam Mishra --- .../app/services/captain/tools/base_tool.rb | 2 + .../services/captain/tools/instrumentation.rb | 10 ++++ .../search_reply_documentation_service.rb | 42 ++++++++++++++++ .../captain/reply_suggestion_service.rb | 24 ++++++++++ lib/captain/base_task_service.rb | 40 +++++++++------- lib/captain/reply_suggestion_service.rb | 2 + lib/captain/tool_instrumentation.rb | 48 +++++++++++++++++++ lib/chatwoot_app.rb | 4 ++ .../openai/openai_prompts/reply.liquid | 5 ++ spec/lib/captain/base_task_service_spec.rb | 5 -- .../captain/reply_suggestion_service_spec.rb | 2 + 11 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 enterprise/app/services/captain/tools/instrumentation.rb create mode 100644 enterprise/app/services/captain/tools/search_reply_documentation_service.rb create mode 100644 enterprise/lib/enterprise/captain/reply_suggestion_service.rb create mode 100644 lib/captain/tool_instrumentation.rb diff --git a/enterprise/app/services/captain/tools/base_tool.rb b/enterprise/app/services/captain/tools/base_tool.rb index dbc2902d8..1ec4aaffc 100644 --- a/enterprise/app/services/captain/tools/base_tool.rb +++ b/enterprise/app/services/captain/tools/base_tool.rb @@ -1,4 +1,6 @@ class Captain::Tools::BaseTool < RubyLLM::Tool + prepend Captain::Tools::Instrumentation + attr_accessor :assistant def initialize(assistant, user: nil) diff --git a/enterprise/app/services/captain/tools/instrumentation.rb b/enterprise/app/services/captain/tools/instrumentation.rb new file mode 100644 index 000000000..2288b239e --- /dev/null +++ b/enterprise/app/services/captain/tools/instrumentation.rb @@ -0,0 +1,10 @@ +module Captain::Tools::Instrumentation + extend ActiveSupport::Concern + include Integrations::LlmInstrumentation + + def execute(**args) + instrument_tool_call(name, args) do + super + end + end +end diff --git a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb new file mode 100644 index 000000000..d2c1df42f --- /dev/null +++ b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb @@ -0,0 +1,42 @@ +class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool + prepend Captain::Tools::Instrumentation + + description 'Search and retrieve documentation/FAQs from knowledge base' + + param :query, desc: 'Search Query', required: true + + def initialize(account:, assistant: nil) + @account = account + @assistant = assistant + super() + end + + def name + 'search_documentation' + end + + def execute(query:) + Rails.logger.info { "#{self.class.name}: #{query}" } + + responses = search_responses(query) + return 'No FAQs found for the given query' if responses.empty? + + responses.map { |response| format_response(response) }.join + end + + private + + def search_responses(query) + if @assistant.present? + @assistant.responses.approved.search(query, account_id: @account.id) + else + @account.captain_assistant_responses.approved.search(query, account_id: @account.id) + end + end + + def format_response(response) + result = "\nQuestion: #{response.question}\nAnswer: #{response.answer}\n" + result += "Source: #{response.documentable.external_link}\n" if response.documentable.present? && response.documentable.try(:external_link) + result + end +end diff --git a/enterprise/lib/enterprise/captain/reply_suggestion_service.rb b/enterprise/lib/enterprise/captain/reply_suggestion_service.rb new file mode 100644 index 000000000..503dd095a --- /dev/null +++ b/enterprise/lib/enterprise/captain/reply_suggestion_service.rb @@ -0,0 +1,24 @@ +module Enterprise::Captain::ReplySuggestionService + def make_api_call(model:, messages:, tools: []) + return super unless use_search_tool? + + super(model: model, messages: messages, tools: [build_search_tool]) + end + + private + + def use_search_tool? + ChatwootApp.chatwoot_cloud? || ChatwootApp.self_hosted_enterprise? + end + + def prompt_variables + return super unless use_search_tool? + + super.merge('has_search_tool' => true) + end + + def build_search_tool + assistant = conversation&.inbox&.captain_assistant + Captain::Tools::SearchReplyDocumentationService.new(account: account, assistant: assistant) + end +end diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index b0cf7d240..7b84a879d 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -1,5 +1,6 @@ class Captain::BaseTaskService include Integrations::LlmInstrumentation + include Captain::ToolInstrumentation # gpt-4o-mini supports 128,000 tokens # 1 token is approx 4 characters @@ -35,44 +36,52 @@ class Captain::BaseTaskService "#{endpoint}/v1" end - def make_api_call(model:, messages:) + def make_api_call(model:, messages:, tools: []) # Community edition prerequisite checks # Enterprise module handles these with more specific error messages (cloud vs self-hosted) return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled? return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured? instrumentation_params = build_instrumentation_params(model, messages) + instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call - response = instrument_llm_call(instrumentation_params) do - execute_ruby_llm_request(model: model, messages: messages) + response = send(instrumentation_method, instrumentation_params) do + execute_ruby_llm_request(model: model, messages: messages, tools: tools) end - # Build follow-up context for client-side refinement, when applicable - if build_follow_up_context? && response[:message].present? - response.merge(follow_up_context: build_follow_up_context(messages, response)) - else - response - end + return response unless build_follow_up_context? && response[:message].present? + + response.merge(follow_up_context: build_follow_up_context(messages, response)) end - def execute_ruby_llm_request(model:, messages:) + def execute_ruby_llm_request(model:, messages:, tools: []) Llm::Config.with_api_key(api_key, api_base: api_base) do |context| - chat = context.chat(model: model) - system_msg = messages.find { |m| m[:role] == 'system' } - chat.with_instructions(system_msg[:content]) if system_msg + chat = build_chat(context, model: model, messages: messages, tools: tools) conversation_messages = messages.reject { |m| m[:role] == 'system' } return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty? add_messages_if_needed(chat, conversation_messages) - response = chat.ask(conversation_messages.last[:content]) - build_ruby_llm_response(response, messages) + build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages) end rescue StandardError => e ChatwootExceptionTracker.new(e, account: account).capture_exception { error: e.message, request_messages: messages } end + def build_chat(context, model:, messages:, tools: []) + chat = context.chat(model: model) + system_msg = messages.find { |m| m[:role] == 'system' } + chat.with_instructions(system_msg[:content]) if system_msg + + if tools.any? + tools.each { |tool| chat = chat.with_tool(tool) } + chat.on_end_message { |message| record_generation(chat, message, model) } + end + + chat + end + def add_messages_if_needed(chat, conversation_messages) return if conversation_messages.length == 1 @@ -177,5 +186,4 @@ class Captain::BaseTaskService user_msg ? user_msg[:content] : nil end end - Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService') diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb index 8582258a8..2daf0615c 100644 --- a/lib/captain/reply_suggestion_service.rb +++ b/lib/captain/reply_suggestion_service.rb @@ -38,3 +38,5 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService 'reply_suggestion' end end + +Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService') diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb new file mode 100644 index 000000000..a2bacce1a --- /dev/null +++ b/lib/captain/tool_instrumentation.rb @@ -0,0 +1,48 @@ +module Captain::ToolInstrumentation + extend ActiveSupport::Concern + + private + + # Custom instrumentation for tool flows - outputs just the message (not full hash) + def instrument_tool_session(params) + return yield unless ChatwootApp.otel_enabled? + + response = nil + executed = false + tracer.in_span(params[:span_name]) do |span| + span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id] + span.set_attribute('langfuse.tags', [params[:feature_name]].to_json) + span.set_attribute('langfuse.observation.input', params[:messages].to_json) + + response = yield + executed = true + + # Output just the message for cleaner Langfuse display + span.set_attribute('langfuse.observation.output', response[:message] || response.to_json) + end + response + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: account).capture_exception + executed ? response : yield + end + + def record_generation(chat, message, model) + return unless ChatwootApp.otel_enabled? + return unless message.respond_to?(:role) && message.role.to_s == 'assistant' + + tracer.in_span("llm.#{event_name}.generation") do |span| + span.set_attribute('gen_ai.system', 'openai') + span.set_attribute('gen_ai.request.model', model) + span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens) + span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens) + span.set_attribute('langfuse.observation.input', format_chat_messages(chat)) + span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content) + end + rescue StandardError => e + Rails.logger.warn "Failed to record generation: #{e.message}" + end + + def format_chat_messages(chat) + chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json + end +end diff --git a/lib/chatwoot_app.rb b/lib/chatwoot_app.rb index 3afb7579e..c0aa41e1a 100644 --- a/lib/chatwoot_app.rb +++ b/lib/chatwoot_app.rb @@ -21,6 +21,10 @@ module ChatwootApp enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' end + def self.self_hosted_enterprise? + enterprise? && !chatwoot_cloud? && GlobalConfig.get_value('INSTALLATION_PRICING_PLAN') == 'enterprise' + end + def self.custom? @custom ||= root.join('custom').exist? end diff --git a/lib/integrations/openai/openai_prompts/reply.liquid b/lib/integrations/openai/openai_prompts/reply.liquid index 19db51a05..f9b95dbdf 100644 --- a/lib/integrations/openai/openai_prompts/reply.liquid +++ b/lib/integrations/openai/openai_prompts/reply.liquid @@ -31,5 +31,10 @@ General guidelines: - Move the conversation forward - Do not invent product details, policies, or links that weren't mentioned - Reply in the customer's language +{% if has_search_tool %} + +**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information. +**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation. +{% endif %} Output only the reply. diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index 1ea666b5d..b3c330252 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -161,11 +161,6 @@ RSpec.describe Captain::BaseTaskService do end end - it 'calls execute_ruby_llm_request with correct parameters' do - expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original - service.send(:make_api_call, model: model, messages: messages) - end - it 'instruments the LLM call' do expect(service).to receive(:instrument_llm_call).and_call_original service.send(:make_api_call, model: model, messages: messages) diff --git a/spec/lib/captain/reply_suggestion_service_spec.rb b/spec/lib/captain/reply_suggestion_service_spec.rb index 81c1f3854..a53825ee4 100644 --- a/spec/lib/captain/reply_suggestion_service_spec.rb +++ b/spec/lib/captain/reply_suggestion_service_spec.rb @@ -19,6 +19,8 @@ RSpec.describe Captain::ReplySuggestionService do mock_context = instance_double(RubyLLM::Context, chat: mock_chat) allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(mock_chat).to receive(:with_tool).and_return(mock_chat) + allow(mock_chat).to receive(:on_end_message).and_return(mock_chat) allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } } allow(mock_chat).to receive(:add_message) { |args| captured_messages << args } allow(mock_chat).to receive(:ask) do |msg| From 85324c82fa2e8836db87b9000d274911059667c4 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:35:32 +0530 Subject: [PATCH 03/11] fix: Formatting issue with reply preview content (#13399) --- .../dashboard/components-next/message/bubbles/Base.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index f66f272de..c40d63363 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt'; import { useMessageContext } from '../provider.js'; import { useI18n } from 'vue-i18n'; +import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; @@ -80,7 +81,7 @@ const replyToPreview = computed(() => { const { content, attachments } = inReplyTo.value; - if (content) return content; + if (content) return new MessageFormatter(content).formattedMessage; if (attachments?.length) { const firstAttachment = attachments[0]; const fileType = firstAttachment.fileType ?? firstAttachment.file_type; @@ -107,9 +108,10 @@ const replyToPreview = computed(() => { class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1" @click="scrollToMessage" > - - {{ replyToPreview }} - +
Date: Fri, 30 Jan 2026 10:22:27 -0800 Subject: [PATCH 04/11] feat: Add first response time distribution report endpoint (#13400) The index is already added in production. Adds a new reporting API that returns conversation counts grouped by channel type and first response time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+). - GET /api/v2/accounts/:id/reports/first_response_time_distribution - Uses SQL aggregation to handle large datasets efficiently - Adds composite index on reporting_events for query performance Tested on production workload. Request: GET `/api/v2/accounts/1/reports/first_response_time_distribution?since=&until=` Response payload: ``` { "Channel::WebWidget": { "0-1h": 120, "1-4h": 85, "4-8h": 32, "8-24h": 12, "24h+": 3 }, "Channel::Email": { "0-1h": 12, "1-4h": 28, "4-8h": 45, "8-24h": 35, "24h+": 10 }, "Channel::FacebookPage": { "0-1h": 50, "1-4h": 30, "4-8h": 15, "8-24h": 8, "24h+": 2 } } ``` --------- Co-authored-by: Muhsin Keloth --- ...irst_response_time_distribution_builder.rb | 59 +++++++ .../api/v2/accounts/reports_controller.rb | 15 ++ config/routes.rb | 1 + ...response_time_distribution_builder_spec.rb | 145 ++++++++++++++++++ .../v2/accounts/reports_controller_spec.rb | 47 ++++++ .../billing/topup_checkout_service_spec.rb | 4 +- spec/jobs/send_reply_job_spec.rb | 44 +++--- 7 files changed, 291 insertions(+), 24 deletions(-) create mode 100644 app/builders/v2/reports/first_response_time_distribution_builder.rb create mode 100644 spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb diff --git a/app/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb new file mode 100644 index 000000000..62565dd44 --- /dev/null +++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb @@ -0,0 +1,59 @@ +class V2::Reports::FirstResponseTimeDistributionBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + build_distribution + end + + private + + def build_distribution + results = fetch_aggregated_counts + format_results(results) + end + + def fetch_aggregated_counts + ReportingEvent + .joins('INNER JOIN inboxes ON reporting_events.inbox_id = inboxes.id') + .where(account_id: account.id, name: 'first_response') + .where(range_condition) + .group('inboxes.channel_type') + .select( + 'inboxes.channel_type', + bucket_case_statements + ) + end + + def bucket_case_statements + <<~SQL.squish + COUNT(CASE WHEN reporting_events.value < 3600 THEN 1 END) AS bucket_0_1h, + COUNT(CASE WHEN reporting_events.value >= 3600 AND reporting_events.value < 14400 THEN 1 END) AS bucket_1_4h, + COUNT(CASE WHEN reporting_events.value >= 14400 AND reporting_events.value < 28800 THEN 1 END) AS bucket_4_8h, + COUNT(CASE WHEN reporting_events.value >= 28800 AND reporting_events.value < 86400 THEN 1 END) AS bucket_8_24h, + COUNT(CASE WHEN reporting_events.value >= 86400 THEN 1 END) AS bucket_24h_plus + SQL + end + + def range_condition + range.present? ? { created_at: range } : {} + end + + def format_results(results) + results.each_with_object({}) do |row, hash| + hash[row.channel_type] = { + '0-1h' => row.bucket_0_1h, + '1-4h' => row.bucket_1_4h, + '4-8h' => row.bucket_4_8h, + '8-24h' => row.bucket_8_24h, + '24h+' => row.bucket_24h_plus + } + end + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 82576cf90..ddd629048 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -70,6 +70,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: builder.build end + def first_response_time_distribution + builder = V2::Reports::FirstResponseTimeDistributionBuilder.new( + account: Current.account, + params: first_response_time_distribution_params + ) + render json: builder.build + end + private def generate_csv(filename, template) @@ -156,4 +164,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController label_ids: params[:label_ids] } end + + def first_response_time_distribution_params + { + since: params[:since], + until: params[:until] + } + end end diff --git a/config/routes.rb b/config/routes.rb index fae66361c..79e5edd23 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -445,6 +445,7 @@ Rails.application.routes.draw do get :conversation_traffic get :bot_metrics get :inbox_label_matrix + get :first_response_time_distribution end end resource :year_in_review, only: [:show] diff --git a/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb b/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb new file mode 100644 index 000000000..de1dc4a53 --- /dev/null +++ b/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb @@ -0,0 +1,145 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::FirstResponseTimeDistributionBuilder do + let!(:account) { create(:account) } + let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) } + let!(:email_inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) } + 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 first response events across channels and time buckets' do + before do + # Web Widget: 0-1h bucket (30 minutes = 1800 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + # Web Widget: 1-4h bucket (2 hours = 7200 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 7_200, created_at: 2.days.ago) + # Web Widget: 4-8h bucket (6 hours = 21600 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 21_600, created_at: 3.days.ago) + # Email: 8-24h bucket (12 hours = 43200 seconds) + create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response', + value: 43_200, created_at: 2.days.ago) + # Email: 24h+ bucket (48 hours = 172800 seconds) + create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response', + value: 172_800, created_at: 1.day.ago) + end + + it 'returns correct distribution for web widget channel' do + expect(report['Channel::WebWidget']).to eq({ + '0-1h' => 1, + '1-4h' => 1, + '4-8h' => 1, + '8-24h' => 0, + '24h+' => 0 + }) + end + + it 'returns correct distribution for email channel' do + expect(report['Channel::Email']).to eq({ + '0-1h' => 0, + '1-4h' => 0, + '4-8h' => 0, + '8-24h' => 1, + '24h+' => 1 + }) + end + end + + context 'when filtering by date range' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.weeks.ago) + end + + it 'only counts events within the date range' do + expect(report['Channel::WebWidget']['0-1h']).to eq(1) + end + end + + context 'when there are no first response events' do + it 'returns an empty hash' do + expect(report).to eq({}) + end + end + + context 'when events belong to another account' do + let(:other_account) { create(:account) } + let(:other_inbox) { create(:inbox, account: other_account) } + + before do + create(:reporting_event, account: other_account, inbox: other_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + end + + it 'does not include events from other accounts' do + expect(report).to eq({}) + end + end + + context 'when events have different names' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'conversation_resolved', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'reply_time', + value: 1_800, created_at: 2.days.ago) + end + + it 'only counts first_response events' do + expect(report['Channel::WebWidget']['0-1h']).to eq(1) + end + end + + context 'when no date range params are provided' do + let(:params) { {} } + + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.months.ago) + end + + it 'returns all events without date filtering' do + expect(report['Channel::WebWidget']['0-1h']).to eq(2) + end + end + + context 'with boundary values for time buckets' do + before do + # Exactly at 1 hour boundary (should be in 1-4h bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 3_600, created_at: 2.days.ago) + # Just under 1 hour (should be in 0-1h bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 3_599, created_at: 2.days.ago) + # Exactly at 24 hour boundary (should be in 24h+ bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 86_400, created_at: 2.days.ago) + end + + it 'correctly assigns boundary values to buckets' do + expect(report['Channel::WebWidget']).to eq({ + '0-1h' => 1, + '1-4h' => 1, + '4-8h' => 0, + '8-24h' => 0, + '24h+' => 1 + }) + 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 b62495e83..c92425c32 100644 --- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb @@ -248,4 +248,51 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do end end end + + describe 'GET /api/v2/accounts/{account.id}/reports/first_response_time_distribution' do + let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) } + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution" + 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/first_response_time_distribution", + 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 + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + end + + it 'returns the first response time distribution' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + 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).to be_a(Hash) + expect(body['Channel::WebWidget']).to include('0-1h', '1-4h', '4-8h', '8-24h', '24h+') + end + + it 'returns correct counts in buckets' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s }, + headers: admin.create_new_auth_token, as: :json + + body = response.parsed_body + expect(body['Channel::WebWidget']['0-1h']).to eq(1) + end + end + end end diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb index 8a64e6fd2..3b4d138f3 100644 --- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb @@ -46,7 +46,7 @@ describe Enterprise::Billing::TopupCheckoutService do it 'raises error for invalid credits' do expect do service.create_checkout_session(credits: 500) - end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) + end.to(raise_error { |error| expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') }) end it 'raises error when account is on free plan' do @@ -54,7 +54,7 @@ describe Enterprise::Billing::TopupCheckoutService do expect do service.create_checkout_session(credits: 1000) - end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) + end.to(raise_error { |error| expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') }) end end end diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb index 46d8e5e56..908d75088 100644 --- a/spec/jobs/send_reply_job_spec.rb +++ b/spec/jobs/send_reply_job_spec.rb @@ -33,8 +33,8 @@ RSpec.describe SendReplyJob do twitter_channel = create(:channel_twitter_profile) twitter_inbox = create(:inbox, channel: twitter_channel) message = create(:message, conversation: create(:conversation, inbox: twitter_inbox)) - allow(Twitter::SendOnTwitterService).to receive(:new).with(message: message).and_return(process_service) - expect(Twitter::SendOnTwitterService).to receive(:new).with(message: message) + allow(Twitter::SendOnTwitterService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Twitter::SendOnTwitterService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -42,8 +42,8 @@ RSpec.describe SendReplyJob do it 'calls ::Twilio::SendOnTwilioService when its twilio message' do twilio_channel = create(:channel_twilio_sms) message = create(:message, conversation: create(:conversation, inbox: twilio_channel.inbox)) - allow(Twilio::SendOnTwilioService).to receive(:new).with(message: message).and_return(process_service) - expect(Twilio::SendOnTwilioService).to receive(:new).with(message: message) + allow(Twilio::SendOnTwilioService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Twilio::SendOnTwilioService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -51,8 +51,8 @@ RSpec.describe SendReplyJob do it 'calls ::Telegram::SendOnTelegramService when its telegram message' do telegram_channel = create(:channel_telegram) message = create(:message, conversation: create(:conversation, inbox: telegram_channel.inbox)) - allow(Telegram::SendOnTelegramService).to receive(:new).with(message: message).and_return(process_service) - expect(Telegram::SendOnTelegramService).to receive(:new).with(message: message) + allow(Telegram::SendOnTelegramService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Telegram::SendOnTelegramService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -60,8 +60,8 @@ RSpec.describe SendReplyJob do it 'calls ::Line:SendOnLineService when its line message' do line_channel = create(:channel_line) message = create(:message, conversation: create(:conversation, inbox: line_channel.inbox)) - allow(Line::SendOnLineService).to receive(:new).with(message: message).and_return(process_service) - expect(Line::SendOnLineService).to receive(:new).with(message: message) + allow(Line::SendOnLineService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Line::SendOnLineService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -70,8 +70,8 @@ RSpec.describe SendReplyJob do stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook') whatsapp_channel = create(:channel_whatsapp, sync_templates: false) message = create(:message, conversation: create(:conversation, inbox: whatsapp_channel.inbox)) - allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message).and_return(process_service) - expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message) + allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -79,8 +79,8 @@ RSpec.describe SendReplyJob do it 'calls ::Sms::SendOnSmsService when its sms message' do sms_channel = create(:channel_sms) message = create(:message, conversation: create(:conversation, inbox: sms_channel.inbox)) - allow(Sms::SendOnSmsService).to receive(:new).with(message: message).and_return(process_service) - expect(Sms::SendOnSmsService).to receive(:new).with(message: message) + allow(Sms::SendOnSmsService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Sms::SendOnSmsService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -88,8 +88,8 @@ RSpec.describe SendReplyJob do it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do instagram_channel = create(:channel_instagram) message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox)) - allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service) - expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message) + allow(Instagram::SendOnInstagramService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Instagram::SendOnInstagramService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -112,8 +112,8 @@ RSpec.describe SendReplyJob do it 'calls ::Email::SendOnEmailService when its email message' do email_channel = create(:channel_email) message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox)) - allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service) - expect(Email::SendOnEmailService).to receive(:new).with(message: message) + allow(Email::SendOnEmailService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Email::SendOnEmailService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -121,8 +121,8 @@ RSpec.describe SendReplyJob do it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do webwidget_channel = create(:channel_widget) message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox)) - allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) - expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -130,8 +130,8 @@ RSpec.describe SendReplyJob do it 'calls ::Messages::SendEmailNotificationService when its api channel message' do api_channel = create(:channel_api) message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox)) - allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) - expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -139,8 +139,8 @@ RSpec.describe SendReplyJob do it 'calls ::Tiktok::SendOnTiktokService when its tiktok message' do tiktok_channel = create(:channel_tiktok) message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox)) - allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: message).and_return(process_service) - expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: message) + allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) + expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: having_attributes(id: message.id)) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end From d8c5dda36c2171297cf78e2a29231ab1420ba3fe Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 30 Jan 2026 10:33:03 -0800 Subject: [PATCH 05/11] chore: Update report documentation (#13408) New API Documentation GET /api/v2/accounts/{account_id}/reports/first_response_time_distribution - Returns first response time distribution grouped by channel type - Shows conversation counts in time buckets: 0-1h, 1-4h, 4-8h, 8-24h, 24h+ - Parameters: since, until (Unix timestamps) GET /api/v2/accounts/{account_id}/reports/inbox_label_matrix - Returns a matrix of conversation counts for inbox-label combinations - Parameters: since, until, inbox_ids[], label_ids[] Fixes - Removed unused business_hours boolean parameter from /api/v2/accounts/{account_id}/summary_reports/channel - Updated ReDoc script from unstable @next to stable @2.1.5 version to fix empty swagger page --- swagger/definitions/index.yml | 4 + .../first_response_time_distribution.yml | 34 +++ .../resource/reports/inbox_label_matrix.yml | 50 ++++ swagger/index.html | 2 +- .../first_response_time_distribution.yml | 24 ++ .../reports/inbox_label_matrix.yml | 25 ++ swagger/paths/index.yml | 53 +++- swagger/swagger.json | 280 +++++++++++++++++- swagger/tag_groups/application_swagger.json | 280 +++++++++++++++++- swagger/tag_groups/client_swagger.json | 134 +++++++++ swagger/tag_groups/other_swagger.json | 134 +++++++++ swagger/tag_groups/platform_swagger.json | 134 +++++++++ 12 files changed, 1132 insertions(+), 22 deletions(-) create mode 100644 swagger/definitions/resource/reports/first_response_time_distribution.yml create mode 100644 swagger/definitions/resource/reports/inbox_label_matrix.yml create mode 100644 swagger/paths/application/reports/first_response_time_distribution.yml create mode 100644 swagger/paths/application/reports/inbox_label_matrix.yml diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index fd9cc1664..627b2cfb5 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -225,6 +225,10 @@ agent_conversation_metrics: $ref: './resource/reports/conversation/agent.yml' channel_summary: $ref: './resource/reports/channel_summary.yml' +first_response_time_distribution: + $ref: './resource/reports/first_response_time_distribution.yml' +inbox_label_matrix: + $ref: './resource/reports/inbox_label_matrix.yml' contact_detail: $ref: ./resource/contact_detail.yml diff --git a/swagger/definitions/resource/reports/first_response_time_distribution.yml b/swagger/definitions/resource/reports/first_response_time_distribution.yml new file mode 100644 index 000000000..790e5afe6 --- /dev/null +++ b/swagger/definitions/resource/reports/first_response_time_distribution.yml @@ -0,0 +1,34 @@ +type: object +description: First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets. +additionalProperties: + type: object + description: First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api) + properties: + 0-1h: + type: number + description: Number of conversations with first response time less than 1 hour + 1-4h: + type: number + description: Number of conversations with first response time between 1-4 hours + 4-8h: + type: number + description: Number of conversations with first response time between 4-8 hours + 8-24h: + type: number + description: Number of conversations with first response time between 8-24 hours + 24h+: + type: number + description: Number of conversations with first response time greater than 24 hours +example: + Channel::WebWidget: + 0-1h: 150 + 1-4h: 80 + 4-8h: 45 + 8-24h: 30 + 24h+: 15 + Channel::Api: + 0-1h: 75 + 1-4h: 40 + 4-8h: 20 + 8-24h: 10 + 24h+: 5 diff --git a/swagger/definitions/resource/reports/inbox_label_matrix.yml b/swagger/definitions/resource/reports/inbox_label_matrix.yml new file mode 100644 index 000000000..a9b4ebc59 --- /dev/null +++ b/swagger/definitions/resource/reports/inbox_label_matrix.yml @@ -0,0 +1,50 @@ +type: object +description: Inbox-label matrix report showing the count of conversations for each inbox-label combination. +properties: + inboxes: + type: array + description: List of inboxes included in the report + items: + type: object + properties: + id: + type: number + description: The inbox ID + name: + type: string + description: The inbox name + labels: + type: array + description: List of labels included in the report + items: + type: object + properties: + id: + type: number + description: The label ID + title: + type: string + description: The label title + matrix: + type: array + description: 2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j] + items: + type: array + items: + type: number +example: + inboxes: + - id: 1 + name: Website Chat + - id: 2 + name: Email Support + labels: + - id: 1 + title: bug + - id: 2 + title: feature-request + - id: 3 + title: urgent + matrix: + - [10, 5, 3] + - [8, 12, 2] diff --git a/swagger/index.html b/swagger/index.html index eb09d7768..e1546e56f 100644 --- a/swagger/index.html +++ b/swagger/index.html @@ -18,6 +18,6 @@ - + diff --git a/swagger/paths/application/reports/first_response_time_distribution.yml b/swagger/paths/application/reports/first_response_time_distribution.yml new file mode 100644 index 000000000..a5e092301 --- /dev/null +++ b/swagger/paths/application/reports/first_response_time_distribution.yml @@ -0,0 +1,24 @@ +tags: + - Reports +operationId: get-first-response-time-distribution +summary: Get first response time distribution by channel +security: + - userApiKey: [] +description: | + Get the distribution of first response times grouped by channel type. + Returns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type. + + **Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/first_response_time_distribution' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/application/reports/inbox_label_matrix.yml b/swagger/paths/application/reports/inbox_label_matrix.yml new file mode 100644 index 000000000..a99dea99d --- /dev/null +++ b/swagger/paths/application/reports/inbox_label_matrix.yml @@ -0,0 +1,25 @@ +tags: + - Reports +operationId: get-inbox-label-matrix +summary: Get inbox-label matrix report +security: + - userApiKey: [] +description: | + Get a matrix showing the count of conversations for each inbox-label combination. + Returns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations + in a specific inbox that have a specific label applied. + + **Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/inbox_label_matrix' + '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 2e7c5514e..55b916e4c 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -653,14 +653,57 @@ 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' +# First response time distribution report +/api/v2/accounts/{account_id}/reports/first_response_time_distribution: + 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/first_response_time_distribution.yml' + +# Inbox-label matrix report +/api/v2/accounts/{account_id}/reports/inbox_label_matrix: + 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: inbox_ids + schema: + type: array + items: + type: integer + description: Filter by specific inbox IDs. + - in: query + name: label_ids + schema: + type: array + items: + type: integer + description: Filter by specific label IDs. + get: + $ref: './application/reports/inbox_label_matrix.yml' + # Conversations Messages /accounts/{account_id}/conversations/{conversation_id}/messages: parameters: diff --git a/swagger/swagger.json b/swagger/swagger.json index ee33fe56f..4dd1cf8fe 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -7890,14 +7890,6 @@ "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": { @@ -7946,6 +7938,144 @@ } } }, + "/api/v2/accounts/{account_id}/reports/first_response_time_distribution": { + "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-first-response-time-distribution", + "summary": "Get first response time distribution by channel", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the distribution of first response times grouped by channel type.\nReturns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/first_response_time_distribution" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/inbox_label_matrix": { + "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": "inbox_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific inbox IDs." + }, + { + "in": "query", + "name": "label_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific label IDs." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-label-matrix", + "summary": "Get inbox-label matrix report", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get a matrix showing the count of conversations for each inbox-label combination.\nReturns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations\nin a specific inbox that have a specific label applied.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_label_matrix" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/accounts/{account_id}/conversations/{conversation_id}/messages": { "parameters": [ { @@ -11781,6 +11911,140 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index ef5ec5389..4d3dd9100 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -6433,14 +6433,6 @@ "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": { @@ -6488,6 +6480,144 @@ } } } + }, + "/api/v2/accounts/{account_id}/reports/first_response_time_distribution": { + "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-first-response-time-distribution", + "summary": "Get first response time distribution by channel", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the distribution of first response times grouped by channel type.\nReturns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/first_response_time_distribution" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/inbox_label_matrix": { + "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": "inbox_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific inbox IDs." + }, + { + "in": "query", + "name": "label_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific label IDs." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-label-matrix", + "summary": "Get inbox-label matrix report", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get a matrix showing the count of conversations for each inbox-label combination.\nReturns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations\nin a specific inbox that have a specific label applied.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_label_matrix" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } } }, "components": { @@ -10288,6 +10418,140 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index bcf4bb178..b9bab39ff 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -4424,6 +4424,140 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index 01d1adc46..c1c927e6d 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -3839,6 +3839,140 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index 2b81a67fd..478d8c49f 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -4600,6 +4600,140 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, "contact_detail": { "type": "object", "properties": { From 329b7497024fd2e038c7401432083925e565565d Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 30 Jan 2026 10:48:10 -0800 Subject: [PATCH 06/11] Add API documentation for inbox, agent, and team summary report (#13409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add API documentation for inbox, agent, and team summary report endpoints - These endpoints return conversation statistics grouped by inbox/agent/team for a given date range Endpoints documented: GET /api/v2/accounts/{account_id}/summary_reports/inbox │ Conversation stats grouped by inbox │ GET /api/v2/accounts/{account_id}/summary_reports/agent │ Conversation stats grouped by agent │ GET /api/v2/accounts/{account_id}/summary_reports/team │ Conversation stats grouped by team │ Query parameters (all endpoints): - since - Start timestamp (Unix) - until - End timestamp (Unix) - business_hours - Calculate metrics using business hours only Response fields: - id - Inbox/Agent/Team ID - conversations_count - Total conversations in date range - resolved_conversations_count - Resolved conversations in date range - avg_resolution_time - Average resolution time (seconds) - avg_first_response_time - Average first response time (seconds) - avg_reply_time - Average reply time (seconds) --- swagger/definitions/index.yml | 6 + .../resource/reports/agent_summary.yml | 39 ++ .../resource/reports/inbox_summary.yml | 39 ++ .../resource/reports/team_summary.yml | 39 ++ .../application/reports/agent_summary.yml | 23 ++ .../application/reports/inbox_summary.yml | 23 ++ .../application/reports/team_summary.yml | 23 ++ swagger/paths/index.yml | 66 ++++ swagger/swagger.json | 360 ++++++++++++++++++ swagger/tag_groups/application_swagger.json | 360 ++++++++++++++++++ swagger/tag_groups/client_swagger.json | 162 ++++++++ swagger/tag_groups/other_swagger.json | 162 ++++++++ swagger/tag_groups/platform_swagger.json | 162 ++++++++ 13 files changed, 1464 insertions(+) create mode 100644 swagger/definitions/resource/reports/agent_summary.yml create mode 100644 swagger/definitions/resource/reports/inbox_summary.yml create mode 100644 swagger/definitions/resource/reports/team_summary.yml create mode 100644 swagger/paths/application/reports/agent_summary.yml create mode 100644 swagger/paths/application/reports/inbox_summary.yml create mode 100644 swagger/paths/application/reports/team_summary.yml diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index 627b2cfb5..1e64bf97b 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -229,6 +229,12 @@ first_response_time_distribution: $ref: './resource/reports/first_response_time_distribution.yml' inbox_label_matrix: $ref: './resource/reports/inbox_label_matrix.yml' +inbox_summary: + $ref: './resource/reports/inbox_summary.yml' +agent_summary: + $ref: './resource/reports/agent_summary.yml' +team_summary: + $ref: './resource/reports/team_summary.yml' contact_detail: $ref: ./resource/contact_detail.yml diff --git a/swagger/definitions/resource/reports/agent_summary.yml b/swagger/definitions/resource/reports/agent_summary.yml new file mode 100644 index 000000000..47c632ddf --- /dev/null +++ b/swagger/definitions/resource/reports/agent_summary.yml @@ -0,0 +1,39 @@ +type: array +description: Agent summary report containing conversation statistics grouped by agent. +items: + type: object + properties: + id: + type: number + description: The agent (user) ID + conversations_count: + type: number + description: Number of conversations assigned to the agent during the date range + resolved_conversations_count: + type: number + description: Number of conversations resolved by the agent during the date range + avg_resolution_time: + type: number + nullable: true + description: Average time (in seconds) to resolve conversations. Null if no data available. + avg_first_response_time: + type: number + nullable: true + description: Average time (in seconds) for the first response. Null if no data available. + avg_reply_time: + type: number + nullable: true + description: Average time (in seconds) between replies. Null if no data available. +example: + - id: 1 + conversations_count: 150 + resolved_conversations_count: 120 + avg_resolution_time: 3600 + avg_first_response_time: 300 + avg_reply_time: 600 + - id: 2 + conversations_count: 75 + resolved_conversations_count: 60 + avg_resolution_time: 1800 + avg_first_response_time: 180 + avg_reply_time: 420 diff --git a/swagger/definitions/resource/reports/inbox_summary.yml b/swagger/definitions/resource/reports/inbox_summary.yml new file mode 100644 index 000000000..9a9adcf6b --- /dev/null +++ b/swagger/definitions/resource/reports/inbox_summary.yml @@ -0,0 +1,39 @@ +type: array +description: Inbox summary report containing conversation statistics grouped by inbox. +items: + type: object + properties: + id: + type: number + description: The inbox ID + conversations_count: + type: number + description: Number of conversations created in the inbox during the date range + resolved_conversations_count: + type: number + description: Number of conversations resolved in the inbox during the date range + avg_resolution_time: + type: number + nullable: true + description: Average time (in seconds) to resolve conversations. Null if no data available. + avg_first_response_time: + type: number + nullable: true + description: Average time (in seconds) for the first response. Null if no data available. + avg_reply_time: + type: number + nullable: true + description: Average time (in seconds) between replies. Null if no data available. +example: + - id: 1 + conversations_count: 150 + resolved_conversations_count: 120 + avg_resolution_time: 3600 + avg_first_response_time: 300 + avg_reply_time: 600 + - id: 2 + conversations_count: 75 + resolved_conversations_count: 60 + avg_resolution_time: 1800 + avg_first_response_time: 180 + avg_reply_time: 420 diff --git a/swagger/definitions/resource/reports/team_summary.yml b/swagger/definitions/resource/reports/team_summary.yml new file mode 100644 index 000000000..98f5895a9 --- /dev/null +++ b/swagger/definitions/resource/reports/team_summary.yml @@ -0,0 +1,39 @@ +type: array +description: Team summary report containing conversation statistics grouped by team. +items: + type: object + properties: + id: + type: number + description: The team ID + conversations_count: + type: number + description: Number of conversations assigned to the team during the date range + resolved_conversations_count: + type: number + description: Number of conversations resolved by the team during the date range + avg_resolution_time: + type: number + nullable: true + description: Average time (in seconds) to resolve conversations. Null if no data available. + avg_first_response_time: + type: number + nullable: true + description: Average time (in seconds) for the first response. Null if no data available. + avg_reply_time: + type: number + nullable: true + description: Average time (in seconds) between replies. Null if no data available. +example: + - id: 1 + conversations_count: 250 + resolved_conversations_count: 200 + avg_resolution_time: 2800 + avg_first_response_time: 240 + avg_reply_time: 500 + - id: 2 + conversations_count: 180 + resolved_conversations_count: 150 + avg_resolution_time: 2400 + avg_first_response_time: 200 + avg_reply_time: 450 diff --git a/swagger/paths/application/reports/agent_summary.yml b/swagger/paths/application/reports/agent_summary.yml new file mode 100644 index 000000000..ac899734f --- /dev/null +++ b/swagger/paths/application/reports/agent_summary.yml @@ -0,0 +1,23 @@ +tags: + - Reports +operationId: get-agent-summary-report +summary: Get conversation statistics grouped by agent +security: + - userApiKey: [] +description: | + Get conversation statistics grouped by agent for a given date range. + Returns metrics for each agent including conversation counts, resolution counts, + average first response time, average resolution time, and average reply time. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/agent_summary' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/application/reports/inbox_summary.yml b/swagger/paths/application/reports/inbox_summary.yml new file mode 100644 index 000000000..2c687f969 --- /dev/null +++ b/swagger/paths/application/reports/inbox_summary.yml @@ -0,0 +1,23 @@ +tags: + - Reports +operationId: get-inbox-summary-report +summary: Get conversation statistics grouped by inbox +security: + - userApiKey: [] +description: | + Get conversation statistics grouped by inbox for a given date range. + Returns metrics for each inbox including conversation counts, resolution counts, + average first response time, average resolution time, and average reply time. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/inbox_summary' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/application/reports/team_summary.yml b/swagger/paths/application/reports/team_summary.yml new file mode 100644 index 000000000..a6343eb11 --- /dev/null +++ b/swagger/paths/application/reports/team_summary.yml @@ -0,0 +1,23 @@ +tags: + - Reports +operationId: get-team-summary-report +summary: Get conversation statistics grouped by team +security: + - userApiKey: [] +description: | + Get conversation statistics grouped by team for a given date range. + Returns metrics for each team including conversation counts, resolution counts, + average first response time, average resolution time, and average reply time. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/team_summary' + '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 55b916e4c..24e460b4c 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -656,6 +656,72 @@ get: $ref: './application/reports/channel_summary.yml' +# Inbox summary report +/api/v2/accounts/{account_id}/summary_reports/inbox: + 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 calculate metrics using business hours only. + get: + $ref: './application/reports/inbox_summary.yml' + +# Agent summary report +/api/v2/accounts/{account_id}/summary_reports/agent: + 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 calculate metrics using business hours only. + get: + $ref: './application/reports/agent_summary.yml' + +# Team summary report +/api/v2/accounts/{account_id}/summary_reports/team: + 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 calculate metrics using business hours only. + get: + $ref: './application/reports/team_summary.yml' + # First response time distribution report /api/v2/accounts/{account_id}/reports/first_response_time_distribution: parameters: diff --git a/swagger/swagger.json b/swagger/swagger.json index 4dd1cf8fe..934864911 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -7938,6 +7938,204 @@ } } }, + "/api/v2/accounts/{account_id}/summary_reports/inbox": { + "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 calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-summary-report", + "summary": "Get conversation statistics grouped by inbox", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by inbox for a given date range.\nReturns metrics for each inbox including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/agent": { + "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 calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-agent-summary-report", + "summary": "Get conversation statistics grouped by agent", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by agent for a given date range.\nReturns metrics for each agent including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/team": { + "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 calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-team-summary-report", + "summary": "Get conversation statistics grouped by team", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by team for a given date range.\nReturns metrics for each team including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/team_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/api/v2/accounts/{account_id}/reports/first_response_time_distribution": { "parameters": [ { @@ -12045,6 +12243,168 @@ ] } }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 4d3dd9100..77a95da33 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -6481,6 +6481,204 @@ } } }, + "/api/v2/accounts/{account_id}/summary_reports/inbox": { + "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 calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-summary-report", + "summary": "Get conversation statistics grouped by inbox", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by inbox for a given date range.\nReturns metrics for each inbox including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/agent": { + "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 calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-agent-summary-report", + "summary": "Get conversation statistics grouped by agent", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by agent for a given date range.\nReturns metrics for each agent including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/team": { + "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 calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-team-summary-report", + "summary": "Get conversation statistics grouped by team", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by team for a given date range.\nReturns metrics for each team including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/team_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/api/v2/accounts/{account_id}/reports/first_response_time_distribution": { "parameters": [ { @@ -10552,6 +10750,168 @@ ] } }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index b9bab39ff..a786aebea 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -4558,6 +4558,168 @@ ] } }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index c1c927e6d..12dd566c4 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -3973,6 +3973,168 @@ ] } }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index 478d8c49f..fd74b12e5 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -4734,6 +4734,168 @@ ] } }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { From e9e6de56900a3d26bfb4b61fb7fba9a288885923 Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 30 Jan 2026 12:49:31 -0800 Subject: [PATCH 07/11] fix: Increase the parallelism config to fix flaky tests, revert bad commits (#13410) The specs break only in Circle CI, we have to figure out the root cause for the same. At the moment, I have increased the parallelism to fix this. --- .circleci/config.yml | 2 +- ...irst_response_time_distribution_builder.rb | 43 +++++++++++------- ...orting_events_for_response_distribution.rb | 11 +++++ db/schema.rb | 3 +- .../billing/topup_checkout_service_spec.rb | 4 +- spec/jobs/send_reply_job_spec.rb | 44 +++++++++---------- 6 files changed, 64 insertions(+), 43 deletions(-) create mode 100644 db/migrate/20260130061021_add_index_to_reporting_events_for_response_distribution.rb diff --git a/.circleci/config.yml b/.circleci/config.yml index 09bd5191d..c0320652b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 16 + parallelism: 20 steps: - checkout - node/install: diff --git a/app/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb index 62565dd44..971542596 100644 --- a/app/builders/v2/reports/first_response_time_distribution_builder.rb +++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb @@ -16,28 +16,27 @@ class V2::Reports::FirstResponseTimeDistributionBuilder def build_distribution results = fetch_aggregated_counts - format_results(results) + map_to_channel_types(results) end def fetch_aggregated_counts ReportingEvent - .joins('INNER JOIN inboxes ON reporting_events.inbox_id = inboxes.id') .where(account_id: account.id, name: 'first_response') .where(range_condition) - .group('inboxes.channel_type') + .group(:inbox_id) .select( - 'inboxes.channel_type', + :inbox_id, bucket_case_statements ) end def bucket_case_statements <<~SQL.squish - COUNT(CASE WHEN reporting_events.value < 3600 THEN 1 END) AS bucket_0_1h, - COUNT(CASE WHEN reporting_events.value >= 3600 AND reporting_events.value < 14400 THEN 1 END) AS bucket_1_4h, - COUNT(CASE WHEN reporting_events.value >= 14400 AND reporting_events.value < 28800 THEN 1 END) AS bucket_4_8h, - COUNT(CASE WHEN reporting_events.value >= 28800 AND reporting_events.value < 86400 THEN 1 END) AS bucket_8_24h, - COUNT(CASE WHEN reporting_events.value >= 86400 THEN 1 END) AS bucket_24h_plus + COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h, + COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h, + COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h, + COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h, + COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus SQL end @@ -45,15 +44,25 @@ class V2::Reports::FirstResponseTimeDistributionBuilder range.present? ? { created_at: range } : {} end - def format_results(results) + def inbox_channel_types + @inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h + end + + def map_to_channel_types(results) results.each_with_object({}) do |row, hash| - hash[row.channel_type] = { - '0-1h' => row.bucket_0_1h, - '1-4h' => row.bucket_1_4h, - '4-8h' => row.bucket_4_8h, - '8-24h' => row.bucket_8_24h, - '24h+' => row.bucket_24h_plus - } + channel_type = inbox_channel_types[row.inbox_id] + next unless channel_type + + hash[channel_type] ||= empty_buckets + hash[channel_type]['0-1h'] += row.bucket_0_1h + hash[channel_type]['1-4h'] += row.bucket_1_4h + hash[channel_type]['4-8h'] += row.bucket_4_8h + hash[channel_type]['8-24h'] += row.bucket_8_24h + hash[channel_type]['24h+'] += row.bucket_24h_plus end end + + def empty_buckets + { '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 } + end end diff --git a/db/migrate/20260130061021_add_index_to_reporting_events_for_response_distribution.rb b/db/migrate/20260130061021_add_index_to_reporting_events_for_response_distribution.rb new file mode 100644 index 000000000..b7807901c --- /dev/null +++ b/db/migrate/20260130061021_add_index_to_reporting_events_for_response_distribution.rb @@ -0,0 +1,11 @@ +class AddIndexToReportingEventsForResponseDistribution < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_index :reporting_events, + [:account_id, :name, :inbox_id, :created_at], + name: 'index_reporting_events_for_response_distribution', + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 148e7769c..fd4d18cb1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do +ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1115,6 +1115,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.datetime "event_start_time", precision: nil t.datetime "event_end_time", precision: nil t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at" + t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution" t.index ["account_id"], name: "index_reporting_events_on_account_id" t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id" t.index ["created_at"], name: "index_reporting_events_on_created_at" diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb index 3b4d138f3..8a64e6fd2 100644 --- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb @@ -46,7 +46,7 @@ describe Enterprise::Billing::TopupCheckoutService do it 'raises error for invalid credits' do expect do service.create_checkout_session(credits: 500) - end.to(raise_error { |error| expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') }) + end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) end it 'raises error when account is on free plan' do @@ -54,7 +54,7 @@ describe Enterprise::Billing::TopupCheckoutService do expect do service.create_checkout_session(credits: 1000) - end.to(raise_error { |error| expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') }) + end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) end end end diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb index 908d75088..46d8e5e56 100644 --- a/spec/jobs/send_reply_job_spec.rb +++ b/spec/jobs/send_reply_job_spec.rb @@ -33,8 +33,8 @@ RSpec.describe SendReplyJob do twitter_channel = create(:channel_twitter_profile) twitter_inbox = create(:inbox, channel: twitter_channel) message = create(:message, conversation: create(:conversation, inbox: twitter_inbox)) - allow(Twitter::SendOnTwitterService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Twitter::SendOnTwitterService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Twitter::SendOnTwitterService).to receive(:new).with(message: message).and_return(process_service) + expect(Twitter::SendOnTwitterService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -42,8 +42,8 @@ RSpec.describe SendReplyJob do it 'calls ::Twilio::SendOnTwilioService when its twilio message' do twilio_channel = create(:channel_twilio_sms) message = create(:message, conversation: create(:conversation, inbox: twilio_channel.inbox)) - allow(Twilio::SendOnTwilioService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Twilio::SendOnTwilioService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Twilio::SendOnTwilioService).to receive(:new).with(message: message).and_return(process_service) + expect(Twilio::SendOnTwilioService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -51,8 +51,8 @@ RSpec.describe SendReplyJob do it 'calls ::Telegram::SendOnTelegramService when its telegram message' do telegram_channel = create(:channel_telegram) message = create(:message, conversation: create(:conversation, inbox: telegram_channel.inbox)) - allow(Telegram::SendOnTelegramService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Telegram::SendOnTelegramService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Telegram::SendOnTelegramService).to receive(:new).with(message: message).and_return(process_service) + expect(Telegram::SendOnTelegramService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -60,8 +60,8 @@ RSpec.describe SendReplyJob do it 'calls ::Line:SendOnLineService when its line message' do line_channel = create(:channel_line) message = create(:message, conversation: create(:conversation, inbox: line_channel.inbox)) - allow(Line::SendOnLineService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Line::SendOnLineService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Line::SendOnLineService).to receive(:new).with(message: message).and_return(process_service) + expect(Line::SendOnLineService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -70,8 +70,8 @@ RSpec.describe SendReplyJob do stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook') whatsapp_channel = create(:channel_whatsapp, sync_templates: false) message = create(:message, conversation: create(:conversation, inbox: whatsapp_channel.inbox)) - allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message).and_return(process_service) + expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -79,8 +79,8 @@ RSpec.describe SendReplyJob do it 'calls ::Sms::SendOnSmsService when its sms message' do sms_channel = create(:channel_sms) message = create(:message, conversation: create(:conversation, inbox: sms_channel.inbox)) - allow(Sms::SendOnSmsService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Sms::SendOnSmsService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Sms::SendOnSmsService).to receive(:new).with(message: message).and_return(process_service) + expect(Sms::SendOnSmsService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -88,8 +88,8 @@ RSpec.describe SendReplyJob do it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do instagram_channel = create(:channel_instagram) message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox)) - allow(Instagram::SendOnInstagramService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Instagram::SendOnInstagramService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service) + expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -112,8 +112,8 @@ RSpec.describe SendReplyJob do it 'calls ::Email::SendOnEmailService when its email message' do email_channel = create(:channel_email) message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox)) - allow(Email::SendOnEmailService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Email::SendOnEmailService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service) + expect(Email::SendOnEmailService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -121,8 +121,8 @@ RSpec.describe SendReplyJob do it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do webwidget_channel = create(:channel_widget) message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox)) - allow(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -130,8 +130,8 @@ RSpec.describe SendReplyJob do it 'calls ::Messages::SendEmailNotificationService when its api channel message' do api_channel = create(:channel_api) message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox)) - allow(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Messages::SendEmailNotificationService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end @@ -139,8 +139,8 @@ RSpec.describe SendReplyJob do it 'calls ::Tiktok::SendOnTiktokService when its tiktok message' do tiktok_channel = create(:channel_tiktok) message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox)) - allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: having_attributes(id: message.id)).and_return(process_service) - expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: having_attributes(id: message.id)) + allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: message).and_return(process_service) + expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: message) expect(process_service).to receive(:perform) described_class.perform_now(message.id) end From 133fb1bcf621b80198d51e693def6e402552fcb5 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 2 Feb 2026 11:59:51 +0530 Subject: [PATCH 08/11] feat: add mark pending action to automation (#13378) --- .../dashboard/helper/validations.js | 1 + .../dashboard/i18n/locale/en/automation.json | 3 ++- .../settings/automation/constants.js | 21 +++++++++++++++++++ app/models/automation_rule.rb | 4 ++-- app/services/action_service.rb | 4 ++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/helper/validations.js b/app/javascript/dashboard/helper/validations.js index edebc4656..e425047a2 100644 --- a/app/javascript/dashboard/helper/validations.js +++ b/app/javascript/dashboard/helper/validations.js @@ -127,6 +127,7 @@ const validateSingleAction = action => { 'resolve_conversation', 'remove_assigned_team', 'open_conversation', + 'pending_conversation', ]; if ( diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 43245a1d5..341027299 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -150,7 +150,8 @@ "ADD_PRIVATE_NOTE": "Add a Private Note", "CHANGE_PRIORITY": "Change Priority", "ADD_SLA": "Add SLA", - "OPEN_CONVERSATION": "Open conversation" + "OPEN_CONVERSATION": "Open conversation", + "PENDING_CONVERSATION": "Mark conversation as pending" }, "MESSAGE_TYPES": { "INCOMING": "Incoming Message", diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index bc767040b..3acca3e2e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -116,6 +116,10 @@ export const AUTOMATIONS = { key: 'open_conversation', name: 'OPEN_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -232,6 +236,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -360,6 +368,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -482,6 +494,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'send_webhook_event', name: 'SEND_WEBHOOK_EVENT', @@ -668,6 +684,11 @@ export const AUTOMATION_ACTION_TYPES = [ label: 'OPEN_CONVERSATION', inputType: null, }, + { + key: 'pending_conversation', + label: 'PENDING_CONVERSATION', + inputType: null, + }, { key: 'send_webhook_event', label: 'SEND_WEBHOOK_EVENT', diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index 9dc4d97eb..8162abb91 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -41,8 +41,8 @@ class AutomationRule < ApplicationRecord def actions_attributes %w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation - send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript - add_private_note].freeze + send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority + send_email_transcript add_private_note].freeze end def file_base_data diff --git a/app/services/action_service.rb b/app/services/action_service.rb index a50b11193..80caac392 100644 --- a/app/services/action_service.rb +++ b/app/services/action_service.rb @@ -22,6 +22,10 @@ class ActionService @conversation.open! end + def pending_conversation(_params) + @conversation.pending! + end + def change_status(status) @conversation.update!(status: status[0]) end From b686d140440804aa0475ec6e84ae47b5597be90b Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 2 Feb 2026 14:22:53 +0400 Subject: [PATCH 09/11] feat: Handle external echo messages from native apps (#13371) When businesses use WhatsApp Business App (co-existence mode) or Instagram App or TikTok alongside Chatwoot, messages sent from the native apps were not synced properly back to Chatwoot. This left agents with an incomplete conversation history and no visibility into responses sent outside the dashboard. Additionally, if these echo messages did arrive, they appeared as "Sent by: Bot" in the UI since they had no sender, making it confusing for agents. This PR subscribes to WhatsApp `smb_message_echoes` webhook events and routes them through the existing service with an `outgoing_echo` flag, mirroring how Instagram already handles echoes. On the Instagram side, echo messages now also carry the `external_echo` content attribute and `delivered` status. On the frontend, messages with `externalEcho` are distinguished from bot messages showing a "Native app" avatar and an advisory note encouraging agents to reply from Chatwoot to maintain the service window. CleanShot 2026-01-29 at 13 37 57@2x Fixes https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo Fixes https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling --- .../instagram/base_message_builder.rb | 2 + .../components-next/message/Message.vue | 25 +++++++- .../i18n/locale/en/conversation.json | 2 + app/jobs/webhooks/tiktok_events_job.rb | 2 +- app/jobs/webhooks/whatsapp_events_job.rb | 50 ++++++++++++++++ app/models/message.rb | 3 +- app/services/tiktok/message_service.rb | 6 +- app/services/whatsapp/facebook_api_client.rb | 3 +- .../whatsapp/incoming_message_base_service.rb | 60 ++++++++++++++----- .../incoming_message_service_helpers.rb | 10 ++-- .../whatsapp/facebook_api_client_spec.rb | 6 +- 11 files changed, 141 insertions(+), 28 deletions(-) diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 818c217ca..8045e84c9 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: message_type, + status: @outgoing_echo ? :delivered : :sent, source_id: message_identifier, content: message_content, sender: @outgoing_echo ? nil : contact, @@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil } } + params[:content_attributes][:external_echo] = true if @outgoing_echo params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params end diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index c4ae45fef..0f6ab85a8 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue'; import { useTimeoutFn } from '@vueuse/core'; import { provideMessageContext } from './provider.js'; import { useTrack } from 'dashboard/composables'; +import { useMapGetter } from 'dashboard/composables/store'; import { emitter } from 'shared/helpers/mitt'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import { LocalStorage } from 'shared/helpers/localStorage'; import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; +import { getInboxIconByType } from 'dashboard/helper/inbox'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { MESSAGE_TYPES, @@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false); const showContextMenu = ref(false); const { t } = useI18n(); const route = useRoute(); +const inboxGetter = useMapGetter('inboxes/getInbox'); +const inbox = computed(() => inboxGetter.value(props.inboxId) || {}); /** * Computes the message variant based on props @@ -162,6 +166,10 @@ const variant = computed(() => { if (props.contentAttributes?.isUnsupported) return MESSAGE_VARIANTS.UNSUPPORTED; + if (props.contentAttributes?.externalEcho) { + return MESSAGE_VARIANTS.AGENT; + } + const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT; if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) { return MESSAGE_VARIANTS.BOT; @@ -424,6 +432,18 @@ function handleReplyTo() { } const avatarInfo = computed(() => { + if (props.contentAttributes?.externalEcho) { + const { name, avatar_url, channel_type, medium } = inbox.value; + const iconName = avatar_url + ? null + : getInboxIconByType(channel_type, medium); + return { + name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'), + src: avatar_url || '', + iconName, + }; + } + // If no sender, return bot info if (!props.sender) { return { @@ -451,6 +471,9 @@ const avatarInfo = computed(() => { }); const avatarTooltip = computed(() => { + if (props.contentAttributes?.externalEcho) { + return t('CONVERSATION.NATIVE_APP_ADVISORY'); + } if (avatarInfo.value.name === '') return ''; return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`; }); @@ -484,7 +507,7 @@ provideMessageContext({
"Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json + body: { override_callback_uri: callback_url, verify_token: verify_token, + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return( status: 200, @@ -184,7 +185,8 @@ describe Whatsapp::FacebookApiClient do stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json + body: { override_callback_uri: callback_url, verify_token: verify_token, + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json) end From c77d935e385cb0b72b8ac66a114c1253b0d104fd Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 2 Feb 2026 15:20:35 +0400 Subject: [PATCH 10/11] fix: Subscribe app to WABA before overriding webhook callback URL (#13279) #### Problem Meta requires the app to be subscribed to the WABA before `override_callback_uri` can be used. The current implementation tries to use `override_callback_uri` directly, which fails with: > Error 100: "Before override the current callback uri, your app must be subscribed to receive messages for WhatsApp Business Account" This causes embedded signup to fail silently, the inbox appears connected but never receives messages. #### Solution Split `subscribe_waba_webhook` into two sequential API calls: ```ruby def subscribe_waba_webhook(waba_id, callback_url, verify_token) # Step 1: Subscribe app to WABA first (required before override) subscribe_app_to_waba(waba_id) # Step 2: Override callback URL for this specific WABA override_waba_callback(waba_id, callback_url, verify_token) end ``` #### References - Subscribe app to WABA's webhooks: https://www.postman.com/meta/whatsapp-business-platform/request/ju40fld/subscribe-app-to-waba-s-webhooks - Override Callback URL (Embedded Signup): https://www.postman.com/meta/whatsapp-business-platform/request/l6a09ow/override-callback-url Co-authored-by: Sojan Jose --- app/services/whatsapp/facebook_api_client.rb | 21 ++++++++- .../whatsapp/facebook_api_client_spec.rb | 44 +++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 985c63f7f..fa09a4b44 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -61,6 +61,25 @@ class Whatsapp::FacebookApiClient end def subscribe_waba_webhook(waba_id, callback_url, verify_token) + # Step 1: Subscribe app to WABA first (required before override) + # Meta requires the app to be subscribed before using override_callback_uri + # See: https://github.com/chatwoot/chatwoot/issues/13097 + subscribe_app_to_waba(waba_id) + + # Step 2: Override callback URL for this specific WABA + override_waba_callback(waba_id, callback_url, verify_token) + end + + def subscribe_app_to_waba(waba_id) + response = HTTParty.post( + "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", + headers: request_headers + ) + + handle_response(response, 'App subscription to WABA failed') + end + + def override_waba_callback(waba_id, callback_url, verify_token) response = HTTParty.post( "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", headers: request_headers, @@ -71,7 +90,7 @@ class Whatsapp::FacebookApiClient }.to_json ) - handle_response(response, 'Webhook subscription failed') + handle_response(response, 'Webhook callback override failed') end def unsubscribe_waba_webhook(waba_id) diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index 51007332e..74fb2f6e2 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -161,6 +161,18 @@ describe Whatsapp::FacebookApiClient do context 'when successful' do before do + # Step 1: Subscribe app to WABA (no body) + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Step 2: Override callback URL (with body) stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, @@ -180,19 +192,45 @@ describe Whatsapp::FacebookApiClient do end end - context 'when failed' do + context 'when app subscription fails' do before do + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return(status: 400, body: { error: 'App subscription to WABA failed' }.to_json) + end + + it 'raises an error' do + expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/App subscription to WABA failed/) + end + end + + context 'when callback override fails' do + before do + # Step 1 succeeds + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Step 2 fails stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, body: { override_callback_uri: callback_url, verify_token: verify_token, subscribed_fields: %w[messages smb_message_echoes] }.to_json ) - .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json) + .to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json) end it 'raises an error' do - expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/) + expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook callback override failed/) end end end From c884cdefde7736b4de0f92da3594bbb9b8f4759d Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 3 Feb 2026 02:06:51 +0530 Subject: [PATCH 11/11] feat: add per-account daily rate limit for outbound emails (#13411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a daily cap on non-channel outbound emails to prevent abuse. Fixes https://linear.app/chatwoot/issue/CW-6418/ses-incident-jan-28 ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality not to work as expected) ## Summary - Adds a Redis-based daily counter to rate limit outbound emails per account, preventing email abuse - Covers continuity emails (WebWidget/API), conversation transcripts, and agent notifications - Email channel replies are excluded (paid feature, not abusable) - Adds account suspension check in `ConversationReplyMailer` to block already-queued emails for suspended accounts ## Limit Resolution Hierarchy 1. Per-account override (`account.limits['emails']`) — SuperAdmin configurable 2. Enterprise plan-based (`ACCOUNT_EMAILS_PLAN_LIMITS` InstallationConfig) 3. Global default (`ACCOUNT_EMAILS_LIMIT` InstallationConfig, default: 100) 4. Fallback (`ChatwootApp.max_limit` — effectively unlimited) ## Enforcement Points | Path | Where | Behavior | |------|-------|----------| | WebWidget/API continuity | `SendEmailNotificationService#should_send_email_notification?` | Silently skipped | | Widget transcript | `Widget::ConversationsController#transcript` | Returns 429 | | API transcript | `ConversationsController#transcript` | Returns 429 | | Agent notifications | `Notification::EmailNotificationService#perform` | Silently skipped | | Email channel replies | Not rate limited | Paid feature | | Suspended accounts | `ConversationReplyMailer` | Blocked at mailer level | --- .../v1/accounts/conversations_controller.rb | 2 + .../api/v1/widget/conversations_controller.rb | 19 ++++-- .../super_admin/app_configs_controller.rb | 2 +- app/jobs/conversation_reply_email_job.rb | 1 + app/mailers/conversation_reply_mailer.rb | 1 + app/models/account.rb | 1 + .../concerns/account_email_rate_limitable.rb | 49 +++++++++++++++ .../send_email_notification_service.rb | 2 + .../email_notification_service.rb | 15 +++-- config/installation_config.yml | 10 +++ enterprise/app/fields/account_limits_field.rb | 2 +- .../account/plan_usage_and_limits.rb | 19 +++++- lib/redis/redis_keys.rb | 3 + .../account_email_rate_limitable_spec.rb | 63 +++++++++++++++++++ .../send_email_notification_service_spec.rb | 14 +++++ 15 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 app/models/concerns/account_email_rate_limitable.rb create mode 100644 spec/models/concerns/account_email_rate_limitable_spec.rb diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e2b930ac9..b3151c8fa 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def transcript render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? + return head :too_many_requests unless @conversation.account.within_email_rate_limit? ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later + @conversation.account.increment_email_sent_count head :ok end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index fe5facc1a..96c15fde2 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def transcript - if conversation.present? && conversation.contact.present? && conversation.contact.email.present? - ConversationReplyMailer.with(account: conversation.account).conversation_transcript( - conversation, - conversation.contact.email - )&.deliver_later - end + return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit? + + send_transcript_email head :ok end @@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController private + def send_transcript_email + return if conversation.contact&.email.blank? + + ConversationReplyMailer.with(account: conversation.account).conversation_transcript( + conversation, + conversation.contact.email + )&.deliver_later + conversation.account.increment_email_sent_count + end + def trigger_typing_event(event) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact) end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index b910a9c9a..67d58aef1 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -42,7 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], - 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], + 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], diff --git a/app/jobs/conversation_reply_email_job.rb b/app/jobs/conversation_reply_email_job.rb index 5d186bf29..9d4c120c8 100644 --- a/app/jobs/conversation_reply_email_job.rb +++ b/app/jobs/conversation_reply_email_job.rb @@ -3,6 +3,7 @@ class ConversationReplyEmailJob < ApplicationJob def perform(conversation_id, last_queued_id) conversation = Conversation.find(conversation_id) + return unless conversation.account.active? if conversation.messages.incoming&.last&.content_type == 'incoming_email' ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 8dbe67bf8..7fee05596 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -38,6 +38,7 @@ class ConversationReplyMailer < ApplicationMailer return unless smtp_config_set_or_development? init_conversation_attributes(message.conversation) + @message = message prepare_mail(true) end diff --git a/app/models/account.rb b/app/models/account.rb index df79ee6c1..fead5f0f7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -29,6 +29,7 @@ class Account < ApplicationRecord include Featurable include CacheKeys include CaptainFeaturable + include AccountEmailRateLimitable SETTINGS_PARAMS_SCHEMA = { 'type': 'object', diff --git a/app/models/concerns/account_email_rate_limitable.rb b/app/models/concerns/account_email_rate_limitable.rb new file mode 100644 index 000000000..e967408fc --- /dev/null +++ b/app/models/concerns/account_email_rate_limitable.rb @@ -0,0 +1,49 @@ +module AccountEmailRateLimitable + extend ActiveSupport::Concern + + OUTBOUND_EMAIL_TTL = 25.hours.to_i + EMAIL_LIMIT_CONFIG_KEY = 'ACCOUNT_EMAILS_LIMIT'.freeze + + def email_rate_limit + account_limit || global_limit || default_limit + end + + def emails_sent_today + Redis::Alfred.get(email_count_cache_key).to_i + end + + def within_email_rate_limit? + return true if emails_sent_today < email_rate_limit + + Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}") + false + end + + def increment_email_sent_count + Redis::Alfred.incr(email_count_cache_key).tap do |count| + Redis::Alfred.expire(email_count_cache_key, OUTBOUND_EMAIL_TTL) if count == 1 + end + end + + private + + def email_count_cache_key + @email_count_cache_key ||= format( + Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, + account_id: id, + date: Time.zone.today.to_s + ) + end + + def account_limit + self[:limits]&.dig('emails')&.to_i + end + + def global_limit + GlobalConfig.get(EMAIL_LIMIT_CONFIG_KEY)[EMAIL_LIMIT_CONFIG_KEY]&.to_i + end + + def default_limit + ChatwootApp.max_limit.to_i + end +end diff --git a/app/services/messages/send_email_notification_service.rb b/app/services/messages/send_email_notification_service.rb index 25a77b0d5..dd4f5006e 100644 --- a/app/services/messages/send_email_notification_service.rb +++ b/app/services/messages/send_email_notification_service.rb @@ -13,6 +13,7 @@ class Messages::SendEmailNotificationService return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i) ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id) + message.account.increment_email_sent_count end private @@ -20,6 +21,7 @@ class Messages::SendEmailNotificationService def should_send_email_notification? return false unless message.email_notifiable_message? return false if message.conversation.contact.email.blank? + return false unless message.account.within_email_rate_limit? email_reply_enabled? end diff --git a/app/services/notification/email_notification_service.rb b/app/services/notification/email_notification_service.rb index fbec8b86f..6fc68560b 100644 --- a/app/services/notification/email_notification_service.rb +++ b/app/services/notification/email_notification_service.rb @@ -7,15 +7,22 @@ class Notification::EmailNotificationService # don't send emails if user is not confirmed return if notification.user.confirmed_at.nil? return unless user_subscribed_to_notification? + return unless notification.account.within_email_rate_limit? - # TODO : Clean up whatever happening over here - # Segregate the mailers properly - AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification - .notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor).deliver_later + send_notification_email + notification.account.increment_email_sent_count end private + # TODO : Clean up whatever happening over here + # Segregate the mailers properly + def send_notification_email + AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send( + notification.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor + ).deliver_later + end + def user_subscribed_to_notification? notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id) return true if notification_setting.public_send("email_#{notification.notification_type}?") diff --git a/config/installation_config.yml b/config/installation_config.yml index 946b81e8e..34cb736bf 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -107,6 +107,16 @@ value: description: 'The support email address for your installation' locked: false +- name: ACCOUNT_EMAILS_LIMIT + display_title: 'Account Email Sending Limit (Daily)' + description: 'Maximum number of non-channel emails an account can send per day' + value: 100 + locked: false +- name: ACCOUNT_EMAILS_PLAN_LIMITS + display_title: 'Account Email Plan Limits (Daily)' + description: 'Per-plan daily email sending limits as JSON' + value: + type: code # ------- End of Email Related Config ------- # # ------- Facebook Channel Related Config ------- # diff --git a/enterprise/app/fields/account_limits_field.rb b/enterprise/app/fields/account_limits_field.rb index b6aecd79f..2a46426b7 100644 --- a/enterprise/app/fields/account_limits_field.rb +++ b/enterprise/app/fields/account_limits_field.rb @@ -2,6 +2,6 @@ require 'administrate/field/base' class AccountLimitsField < Administrate::Field::Base def to_s - data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json + data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json end end diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb index ce03efa41..ee0803469 100644 --- a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -1,4 +1,4 @@ -module Enterprise::Account::PlanUsageAndLimits +module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength CAPTAIN_RESPONSES = 'captain_responses'.freeze CAPTAIN_DOCUMENTS = 'captain_documents'.freeze CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze @@ -32,6 +32,10 @@ module Enterprise::Account::PlanUsageAndLimits save end + def email_rate_limit + account_limit || plan_email_limit || global_limit || default_limit + end + def subscribed_features plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value return [] if plan_features.blank? @@ -68,6 +72,16 @@ module Enterprise::Account::PlanUsageAndLimits } end + def plan_email_limit + config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value + return nil if config.blank? || plan_name.blank? + + parsed = config.is_a?(String) ? JSON.parse(config) : config + parsed[plan_name.downcase]&.to_i + rescue StandardError + nil + end + def default_captain_limits max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access zero_limits = { documents: 0, responses: 0 }.with_indifferent_access @@ -119,7 +133,8 @@ module Enterprise::Account::PlanUsageAndLimits 'inboxes' => { 'type': 'number' }, 'agents' => { 'type': 'number' }, 'captain_responses' => { 'type': 'number' }, - 'captain_documents' => { 'type': 'number' } + 'captain_documents' => { 'type': 'number' }, + 'emails' => { 'type': 'number' } }, 'required' => [], 'additionalProperties' => false diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 973c2b188..8c9361ab5 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -49,4 +49,7 @@ module Redis::RedisKeys # Track conversation assignments to agents for rate limiting ASSIGNMENT_KEY = 'ASSIGNMENT::%d::AGENT::%d::CONVERSATION::%d'.freeze ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%d::AGENT::%d::*'.freeze + + ## Account Email Rate Limiting + ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze end diff --git a/spec/models/concerns/account_email_rate_limitable_spec.rb b/spec/models/concerns/account_email_rate_limitable_spec.rb new file mode 100644 index 000000000..919c5f621 --- /dev/null +++ b/spec/models/concerns/account_email_rate_limitable_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe AccountEmailRateLimitable do + let(:account) { create(:account) } + + describe '#email_rate_limit' do + it 'returns account-level override when set' do + account.update!(limits: { 'emails' => 50 }) + expect(account.email_rate_limit).to eq(50) + end + + it 'returns global config when no account override' do + InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200) + expect(account.email_rate_limit).to eq(200) + end + + it 'returns account override over global config' do + InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200) + account.update!(limits: { 'emails' => 50 }) + expect(account.email_rate_limit).to eq(50) + end + end + + describe '#within_email_rate_limit?' do + before do + account.update!(limits: { 'emails' => 2 }) + end + + it 'returns true when under limit' do + expect(account).to be_within_email_rate_limit + end + + it 'returns false when at limit' do + 2.times { account.increment_email_sent_count } + expect(account).not_to be_within_email_rate_limit + end + end + + describe '#increment_email_sent_count' do + it 'increments the counter' do + expect { account.increment_email_sent_count }.to change(account, :emails_sent_today).by(1) + end + + it 'sets TTL on first increment' do + key = format(Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, account_id: account.id, date: Time.zone.today.to_s) + allow(Redis::Alfred).to receive(:incr).and_return(1) + allow(Redis::Alfred).to receive(:expire) + + account.increment_email_sent_count + + expect(Redis::Alfred).to have_received(:expire).with(key, AccountEmailRateLimitable::OUTBOUND_EMAIL_TTL) + end + + it 'does not reset TTL on subsequent increments' do + allow(Redis::Alfred).to receive(:incr).and_return(2) + allow(Redis::Alfred).to receive(:expire) + + account.increment_email_sent_count + + expect(Redis::Alfred).not_to have_received(:expire) + end + end +end diff --git a/spec/services/messages/send_email_notification_service_spec.rb b/spec/services/messages/send_email_notification_service_spec.rb index 7c0970fe1..0c1563c79 100644 --- a/spec/services/messages/send_email_notification_service_spec.rb +++ b/spec/services/messages/send_email_notification_service_spec.rb @@ -99,6 +99,20 @@ describe Messages::SendEmailNotificationService do end end + context 'when account email rate limit is exceeded' do + let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + + before do + conversation.contact.update!(email: 'test@example.com') + allow_any_instance_of(Account).to receive(:within_email_rate_limit?).and_return(false) # rubocop:disable RSpec/AnyInstance + end + + it 'does not enqueue job' do + expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob) + end + end + context 'when channel does not support email notifications' do let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) } let(:conversation) { create(:conversation, account: account, inbox: inbox) }