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 001/118] 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 002/118] 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 003/118] 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 004/118] 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 005/118] 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 006/118] 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 007/118] 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 008/118] 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 009/118] 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 010/118] 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) } From ef6ba8aabd0e52e9cee1b61c8497671e057fa9d8 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 3 Feb 2026 14:29:26 -0800 Subject: [PATCH 011/118] chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade rails to 7.2.2 so that we can proceed with the rails 8 upgrade afterwards # Changelog - `.circleci/config.yml` — align CI DB setup with GitHub Actions (`db:create` + `db:schema:load`) to avoid trigger-dependent prep steps. - `.rubocop.yml` — add `rubocop-rspec_rails` and disable new cops that don't match existing spec style. - `AGENTS.md` — document that specs should run without `.env` (rename temporarily when present). - `Gemfile` — upgrade to Rails 7.2, switch Azure storage gem, pin `commonmarker`, bump `sidekiq-cron`, add `rubocop-rspec_rails`, and relax some gem pins. - `Gemfile.lock` — dependency lockfile updates from the Rails 7.2 and gem changes. - `app/controllers/api/v1/accounts/integrations/linear_controller.rb` — stringify params before passing to the Linear service to keep key types stable. - `app/controllers/super_admin/instance_statuses_controller.rb` — use `MigrationContext` API for migration status in Rails 7.2. - `app/models/installation_config.rb` — add commentary on YAML serialization and future JSONB migration (no behavior change). - `app/models/integrations/hook.rb` — ensure hook type is set on create only and guard against missing app. - `app/models/user.rb` — update enum syntax for Rails 7.2 deprecation, serialize OTP backup codes with JSON, and use Ruby `alias`. - `app/services/crm/leadsquared/setup_service.rb` — stringify hook settings keys before merge to keep JSON shape consistent. - `app/services/macros/execution_service.rb` — remove macro-specific assignee activity workaround; rely on standard assignment handlers. - `config/application.rb` — load Rails 7.2 defaults. - `config/storage.yml` — update Azure Active Storage service name to `AzureBlob`. - `db/migrate/20230515051424_update_article_image_keys.rb` — use credentials `secret_key_base` with fallback to legacy secrets. - `docker/Dockerfile` — add `yaml-dev` and `pkgconf` packages for native extensions (Ruby 3.4 / psych). - `lib/seeders/reports/message_creator.rb` — add parentheses for clarity in range calculation. - `package.json` — pin Vite version and bump `vite-plugin-ruby`. - `pnpm-lock.yaml` — lockfile changes from JS dependency updates. - `spec/builders/v2/report_builder_spec.rb` — disable transactional fixtures; truncate tables per example via Rails `truncate_tables` so after_commit callbacks run with clean isolation; keep builder spec metadata minimal. - `spec/builders/v2/reports/label_summary_builder_spec.rb` — disable transactional fixtures + truncate tables via Rails `truncate_tables`; revert to real `resolved!`/`open!`/`resolved!` flow for multiple resolution events; align date range to `Time.zone` to avoid offset gaps; keep builder spec metadata minimal. - `spec/controllers/api/v1/accounts/macros_controller_spec.rb` — assert `assignee_id` instead of activity message to avoid transaction-timing flakes. - `spec/services/telegram/incoming_message_service_spec.rb` — reference the contact tied to the created conversation instead of `Contact.all.first` to avoid order-dependent failures when other specs leave data behind. - `spec/mailers/administrator_notifications/shared/smtp_config_shared.rb` — use `with_modified_env` instead of stubbing mailer internals. - `spec/services/account/sign_up_email_validation_service_spec.rb` — compare error `class.name` for parallel/reload-safe assertions. --- .circleci/config.yml | 6 +- .rubocop.yml | 7 + AGENTS.md | 1 + Gemfile | 23 +- Gemfile.lock | 383 +++++++++--------- .../integrations/linear_controller.rb | 2 +- .../instance_statuses_controller.rb | 4 +- app/models/installation_config.rb | 5 + app/models/integrations/hook.rb | 6 +- app/models/message.rb | 2 + app/models/user.rb | 6 +- app/services/crm/leadsquared/setup_service.rb | 2 +- config/application.rb | 2 +- config/storage.yml | 2 +- ...0230515051424_update_article_image_keys.rb | 7 +- docker/Dockerfile | 2 + lib/seeders/reports/message_creator.rb | 2 +- package.json | 4 +- pnpm-lock.yaml | 18 +- spec/builders/v2/report_builder_spec.rb | 25 +- .../v2/reports/label_summary_builder_spec.rb | 33 +- .../api/v1/accounts/macros_controller_spec.rb | 4 +- spec/jobs/bulk_actions_job_spec.rb | 32 +- .../shared/smtp_config_shared.rb | 10 +- spec/models/contact_inbox_spec.rb | 3 +- .../sign_up_email_validation_service_spec.rb | 8 +- .../telegram/incoming_message_service_spec.rb | 45 +- ...ing_message_whatsapp_cloud_service_spec.rb | 25 +- 28 files changed, 359 insertions(+), 310 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c0320652b..624e4a1ac 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -279,10 +279,10 @@ jobs: echo -en "\nINSTALLATION_ENV=circleci" >> ".env" echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" - # Database setup + # Database setup (match GitHub Actions flow) - run: - name: Run DB migrations - command: bundle exec rails db:chatwoot_prepare + name: Create database + load schema + command: bundle exec rake db:create db:schema:load # Run backend tests (parallelized) - run: diff --git a/.rubocop.yml b/.rubocop.yml index d87f08bfd..25d684cbe 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,6 +2,7 @@ plugins: - rubocop-performance - rubocop-rails - rubocop-rspec + - rubocop-rspec_rails - rubocop-factory_bot require: @@ -203,6 +204,12 @@ RSpec/MultipleExpectations: RSpec/MultipleMemoizedHelpers: Max: 14 +RSpecRails/InferredSpecType: + Enabled: false + +RSpecRails/NegationBeValid: + Enabled: false + # custom rules UseFromEmail: Enabled: true diff --git a/AGENTS.md b/AGENTS.md index 474fe6e7f..add9d5040 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ - **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) - **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used - Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) +- **Test env**: Specs should run without `.env`. If present, temporarily rename it (e.g., `.env` -> `.env.bak`) while running specs and restore afterward. ## Code Style diff --git a/Gemfile b/Gemfile index 1ae6cf093..f0fb62413 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,10 @@ source 'https://rubygems.org' ruby '3.4.4' ##-- base gems for rails --## -gem 'rack-cors', '2.0.0', require: 'rack/cors' -gem 'rails', '~> 7.1' + +gem 'rack-cors', require: 'rack/cors' +gem 'rails', '~> 7.2.0' + # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -31,7 +33,9 @@ gem 'haikunator' # Template parsing safely gem 'liquid' # Parse Markdown to HTML -gem 'commonmarker' +# ref: https://github.com/gjtorikian/commonmarker/issues/358 +# can upgrade one this issue is fixed +gem 'commonmarker', '~> 0.23.11' # Validate Data against JSON Schema gem 'json_schemer' # used in swagger build @@ -49,9 +53,7 @@ gem 'csv-safe' ##-- for active storage --## gem 'aws-sdk-s3', require: false -# original gem isn't maintained actively -# we wanted updated version of faraday which is a dependency for slack-ruby-client -gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false +gem 'azure-blob', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'image_processing' @@ -89,9 +91,9 @@ gem 'jwt' gem 'pundit' # super admin -gem 'administrate', '>= 0.20.1' -gem 'administrate-field-active_storage', '>= 1.0.3' -gem 'administrate-field-belongs_to_search', '>= 0.9.0' +gem 'administrate' +gem 'administrate-field-active_storage' +gem 'administrate-field-belongs_to_search' ##--- gems for pubsub service ---## # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ @@ -131,7 +133,7 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false ##-- background job processing --## gem 'sidekiq', '>= 7.3.1' # We want cron jobs -gem 'sidekiq-cron', '>= 1.12.0' +gem 'sidekiq-cron', '>= 2.3.1' # for sidekiq healthcheck gem 'sidekiq_alive' @@ -262,6 +264,7 @@ group :development, :test do gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false + gem 'rubocop-rspec_rails', require: false gem 'rubocop-factory_bot', require: false gem 'seed_dump' gem 'shoulda-matchers' diff --git a/Gemfile.lock b/Gemfile.lock index 1cdfabee0..6b485cf29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,110 +1,89 @@ -GIT - remote: https://github.com/chatwoot/azure-storage-ruby - revision: 9957cf899d33a285b5dfe15bdb875292398e392b - branch: chatwoot - specs: - azure-storage-blob (2.0.3) - azure-storage-common (~> 2.0) - nokogiri (~> 1, >= 1.10.8) - azure-storage-common (2.0.4) - faraday (~> 2.0) - faraday-follow_redirects (~> 0.3.0) - faraday-net_http_persistent (~> 2.0) - net-http-persistent (~> 4.0) - nokogiri (~> 1, >= 1.10.8) - GIT remote: https://github.com/chatwoot/devise-secure_password - revision: adcc85fe1babfe40feae73dbcae64d14fff86e69 + revision: 479987594b576dbf23aed8dbd962e78342bc683c branch: chatwoot specs: - devise-secure_password (2.0.1) + devise-secure_password (2.1.0) devise (>= 4.0.0, < 5.0.0) railties (>= 5.0.0, < 8.0.0) GEM remote: https://rubygems.org/ specs: - actioncable (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) + actioncable (7.2.2.2) + actionpack (= 7.2.2.2) + activesupport (= 7.2.2.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.5.2) - actionpack (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activesupport (= 7.1.5.2) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.2.2) + actionpack (= 7.2.2.2) + activejob (= 7.2.2.2) + activerecord (= 7.2.2.2) + activestorage (= 7.2.2.2) + activesupport (= 7.2.2.2) + mail (>= 2.8.0) + actionmailer (7.2.2.2) + actionpack (= 7.2.2.2) + actionview (= 7.2.2.2) + activejob (= 7.2.2.2) + activesupport (= 7.2.2.2) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.5.2) - actionview (= 7.1.5.2) - activesupport (= 7.1.5.2) + actionpack (7.2.2.2) + actionview (= 7.2.2.2) + activesupport (= 7.2.2.2) nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.2) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.5.2) - actionpack (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + useragent (~> 0.16) + actiontext (7.2.2.2) + actionpack (= 7.2.2.2) + activerecord (= 7.2.2.2) + activestorage (= 7.2.2.2) + activesupport (= 7.2.2.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.5.2) - activesupport (= 7.1.5.2) + actionview (7.2.2.2) + activesupport (= 7.2.2.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) active_record_query_trace (1.8) - activejob (7.1.5.2) - activesupport (= 7.1.5.2) + activejob (7.2.2.2) + activesupport (= 7.2.2.2) globalid (>= 0.3.6) - activemodel (7.1.5.2) - activesupport (= 7.1.5.2) - activerecord (7.1.5.2) - activemodel (= 7.1.5.2) - activesupport (= 7.1.5.2) + activemodel (7.2.2.2) + activesupport (= 7.2.2.2) + activerecord (7.2.2.2) + activemodel (= 7.2.2.2) + activesupport (= 7.2.2.2) timeout (>= 0.4.0) activerecord-import (2.1.0) activerecord (>= 4.2) - activestorage (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activesupport (= 7.1.5.2) + activestorage (7.2.2.2) + actionpack (= 7.2.2.2) + activejob (= 7.2.2.2) + activerecord (= 7.2.2.2) + activesupport (= 7.2.2.2) marcel (~> 1.0) - activesupport (7.1.5.2) + activesupport (7.2.2.2) base64 benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) logger (>= 1.4.2) minitest (>= 5.1) - mutex_m securerandom (>= 0.3) - tzinfo (~> 2.0) + tzinfo (~> 2.0, >= 2.0.5) acts-as-taggable-on (12.0.0) activerecord (>= 7.1, < 8.1) zeitwerk (>= 2.4, < 3.0) @@ -121,10 +100,10 @@ GEM administrate-field-active_storage (1.0.3) administrate (>= 0.2.2) rails (>= 7.0) - administrate-field-belongs_to_search (0.9.0) + administrate-field-belongs_to_search (0.10.0) administrate (>= 0.3, < 1.0) jbuilder (~> 2) - rails (>= 4.2, < 7.2) + rails (>= 4.2, < 8.0) selectize-rails (~> 0.6) ai-agents (0.7.0) ruby_llm (~> 1.8.2) @@ -141,8 +120,8 @@ GEM aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-sns (~> 1, >= 1.61.0) aws-eventstream (1.4.0) - aws-partitions (1.1198.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1205.0) + aws-sdk-core (3.241.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -150,11 +129,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.120.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.208.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.211.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-sns (1.70.0) @@ -162,20 +141,22 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) + azure-blob (0.5.9.1) + rexml barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.1) - bigdecimal (3.2.2) + benchmark (0.5.0) + bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) brakeman (5.4.1) browser (5.3.1) builder (3.3.0) - bullet (8.0.7) + bullet (7.2.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) @@ -184,17 +165,21 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + cgi (0.5.1) childprocess (5.1.0) logger (~> 1.5) climate_control (1.2.0) coderay (1.1.3) - commonmarker (0.23.10) - concurrent-ruby (1.3.5) - connection_pool (2.5.3) + commonmarker (0.23.11) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crack (1.0.0) bigdecimal rexml crass (1.0.6) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) csv (3.3.0) csv-safe (3.3.1) csv (~> 3.0) @@ -204,14 +189,15 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - datadog (2.19.0) - datadog-ruby_core_source (~> 3.4, >= 3.4.1) - libdatadog (~> 18.1.0.1.0) - libddwaf (~> 1.24.1.0.3) + datadog (2.25.0) + cgi + datadog-ruby_core_source (~> 3.5, >= 3.5.0) + libdatadog (~> 24.0.1.1.0) + libddwaf (~> 1.30.0.0.0) logger msgpack - datadog-ruby_core_source (3.4.1) - date (3.4.1) + datadog-ruby_core_source (3.5.1) + date (3.5.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -222,10 +208,10 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (6.1.0) - activesupport (>= 7.0, < 8.1) - devise (~> 4.0) - railties (>= 7.0, < 8.1) + devise-two-factor (6.3.0) + activesupport (>= 7.0, < 8.2) + devise (>= 4.0, < 5.0) + railties (>= 7.0, < 8.2) rotp (~> 6.0) devise_token_auth (1.2.5) bcrypt (~> 3.0) @@ -244,31 +230,31 @@ GEM down (5.4.0) addressable (~> 2.8) drb (2.2.3) - dry-cli (1.1.0) + dry-cli (1.4.1) dry-configurable (1.3.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-core (1.1.0) + dry-core (1.2.0) concurrent-ruby (~> 1.0) logger zeitwerk (~> 2.6) - dry-inflector (1.2.0) + dry-inflector (1.3.1) dry-initializer (3.2.0) dry-logic (1.6.0) bigdecimal concurrent-ruby (~> 1.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.14.1) + dry-schema (1.15.0) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) dry-core (~> 1.1) dry-initializer (~> 3.2) - dry-logic (~> 1.5) + dry-logic (~> 1.6) dry-types (~> 1.8) zeitwerk (~> 2.6) - dry-types (1.8.3) - bigdecimal (~> 3.0) + dry-types (1.9.0) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -282,8 +268,9 @@ GEM ruby2_keywords email-provider-info (0.0.1) email_reply_trimmer (0.1.13) - erubi (1.13.0) - et-orbi (1.2.11) + erb (6.0.1) + erubi (1.13.1) + et-orbi (1.3.0) tzinfo event_stream_parser (1.0.0) execjs (2.8.1) @@ -297,34 +284,27 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.13.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-follow_redirects (0.3.0) - faraday (>= 1, < 3) - faraday-mashify (1.0.0) + faraday (2.9.0) + faraday-net_http (>= 2.0, < 3.2) + faraday-mashify (1.0.2) faraday (~> 2.0) hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.4.0) - net-http (>= 0.5.0) - faraday-net_http_persistent (2.1.0) - faraday (~> 2.5) - net-http-persistent (~> 4.0) + faraday-net_http (3.1.0) + net-http faraday-retry (2.2.1) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) faraday (>= 2.0, < 3) - fast-mcp (1.5.0) + fast-mcp (1.6.0) addressable (~> 2.8) base64 dry-schema (~> 1.14) json (~> 2.0) mime-types (~> 3.4) - rack (~> 3.1) + rack (>= 2.0, < 4.0) fcm (1.0.8) faraday (>= 1.0.0, < 3.0) googleauth (~> 1) @@ -441,19 +421,21 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.24.0) + httparty (0.24.2) csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - io-console (0.6.0) - irb (1.7.2) - reline (>= 0.3.6) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) iso-639 (0.3.8) csv jbuilder (2.11.5) @@ -464,7 +446,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.13.2) + json (2.12.0) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -479,7 +461,7 @@ GEM judoscale-sidekiq (1.8.2) judoscale-ruby (= 1.8.2) sidekiq (>= 5.0) - jwt (2.10.1) + jwt (2.8.1) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -506,15 +488,15 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - libdatadog (18.1.0.1.0) - libdatadog (18.1.0.1.0-x86_64-linux) - libddwaf (1.24.1.0.3) + libdatadog (24.0.1.1.0) + libdatadog (24.0.1.1.0-x86_64-linux) + libddwaf (1.30.0.0.0) ffi (~> 1.0) - libddwaf (1.24.1.0.3-arm64-darwin) + libddwaf (1.30.0.0.0-arm64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-darwin) + libddwaf (1.30.0.0.0-x86_64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-linux) + libddwaf (1.30.0.0.0-x86_64-linux) ffi (~> 1.0) line-bot-api (1.28.0) lint_roller (1.1.0) @@ -531,7 +513,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.23.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -551,21 +533,19 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) + minitest (5.27.0) mock_redis (0.36.0) ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.8.0) + multi_xml (0.8.1) bigdecimal (>= 3.1, < 5) multipart-post (2.3.0) mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) - net-http (0.6.0) + net-http (0.4.1) uri - net-http-persistent (4.0.2) - connection_pool (~> 2.2) net-imap (0.4.20) date net-protocol @@ -582,14 +562,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.18.9) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.9-arm64-darwin) + nokogiri (1.19.0-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-darwin) + nokogiri (1.19.0-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) + nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -607,9 +587,8 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.4) + omniauth (2.1.2) hashie (>= 3.4.6) - logger rack (>= 2.2.3) rack-protection omniauth-google-oauth2 (1.1.3) @@ -661,6 +640,9 @@ GEM activerecord (>= 5.2) activesupport (>= 5.2) pgvector (0.1.1) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) prism (1.4.0) procore-sift (1.0.0) activerecord (>= 6.1) @@ -669,6 +651,9 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) + psych (5.3.1) + date + stringio public_suffix (6.0.2) puma (6.4.3) nio4r (~> 2.0) @@ -676,7 +661,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.3) + rack (3.1.19) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -694,53 +679,57 @@ GEM rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) rack-timeout (0.6.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) - rails (7.1.5.2) - actioncable (= 7.1.5.2) - actionmailbox (= 7.1.5.2) - actionmailer (= 7.1.5.2) - actionpack (= 7.1.5.2) - actiontext (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activemodel (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + rails (7.2.2.2) + actioncable (= 7.2.2.2) + actionmailbox (= 7.2.2.2) + actionmailer (= 7.2.2.2) + actionpack (= 7.2.2.2) + actiontext (= 7.2.2.2) + actionview (= 7.2.2.2) + activejob (= 7.2.2.2) + activemodel (= 7.2.2.2) + activerecord (= 7.2.2.2) + activestorage (= 7.2.2.2) + activesupport (= 7.2.2.2) bundler (>= 1.15.0) - railties (= 7.1.5.2) - rails-dom-testing (2.2.0) + railties (= 7.2.2.2) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.1) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) - irb + railties (7.2.2.2) + actionpack (= 7.2.2.2) + activesupport (= 7.2.2.2) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) + rake (13.3.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) + rdoc (7.1.0) + erb + psych (>= 4.0.0) + tsort redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.22.2) + redis-client (0.25.2) connection_pool redis-namespace (1.10.0) redis (>= 4) regexp_parser (2.10.0) - reline (0.3.6) + reline (0.6.3) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -810,6 +799,10 @@ GEM rubocop-rspec (3.6.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec_rails (2.31.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) ruby-openai (7.3.1) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) @@ -832,9 +825,8 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) - ruby_llm-schema (0.2.5) + ruby_llm-schema (0.1.0) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -854,9 +846,8 @@ GEM parser scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) - searchkick (5.5.2) - activemodel (>= 7.1) - hashie + searchkick (6.0.3) + activemodel (>= 7.2) securerandom (0.4.1) seed_dump (3.3.1) activerecord (>= 4) @@ -885,16 +876,17 @@ GEM zeitwerk (~> 2.5) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.3.1) - concurrent-ruby (< 2) + sidekiq (7.3.9) + base64 connection_pool (>= 2.3.0) logger rack (>= 2.2.4) redis-client (>= 0.22.2) - sidekiq-cron (1.12.0) - fugit (~> 1.8) + sidekiq-cron (2.3.1) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) - sidekiq (>= 6) + sidekiq (>= 6.5.0) sidekiq_alive (2.5.0) gserver (~> 0.0.1) sidekiq (>= 5, < 9) @@ -934,12 +926,13 @@ GEM squasher (0.7.2) stackprof (0.2.25) statsd-ruby (1.5.0) - stripe (18.0.1) + stringio (3.2.0) + stripe (18.1.0) telephone_number (1.4.20) test-prof (1.2.1) - thor (1.4.0) - tidewave (0.2.0) - fast-mcp (~> 1.5.0) + thor (1.5.0) + tidewave (0.4.1) + fast-mcp (~> 1.6.0) rack (>= 2.0) rails (>= 7.1.0) tilt (2.3.0) @@ -948,7 +941,8 @@ GEM i18n timeout (0.4.3) trailblazer-option (0.1.2) - twilio-ruby (7.6.0) + tsort (0.2.0) + twilio-ruby (5.77.0) faraday (>= 0.9, < 3.0) jwt (>= 1.5, < 3.0) nokogiri (>= 1.6, < 2.0) @@ -964,21 +958,25 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) + unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.17.0) - uri (1.0.4) + uri (1.1.1) uri_template (0.7.0) + useragent (0.16.11) valid_email2 (5.2.6) activemodel (>= 3.2) mail (~> 2.5) version_gem (1.1.4) - vite_rails (3.0.17) - railties (>= 5.1, < 8) + vite_rails (3.0.20) + railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.8.0) + vite_ruby (3.9.2) dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m rack-proxy (~> 0.6, >= 0.6.1) zeitwerk (~> 2.2) warden (1.2.9) @@ -995,7 +993,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.7) + websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -1003,7 +1001,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.17) + zeitwerk (2.7.4) PLATFORMS arm64-darwin-20 @@ -1020,16 +1018,16 @@ DEPENDENCIES active_record_query_trace activerecord-import acts-as-taggable-on - administrate (>= 0.20.1) - administrate-field-active_storage (>= 1.0.3) - administrate-field-belongs_to_search (>= 0.9.0) + administrate + administrate-field-active_storage + administrate-field-belongs_to_search ai-agents (>= 0.7.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) aws-actionmailbox-ses (~> 0) aws-sdk-s3 - azure-storage-blob! + azure-blob barnes bootsnap brakeman @@ -1038,7 +1036,7 @@ DEPENDENCIES bundle-audit byebug climate_control - commonmarker + commonmarker (~> 0.23.11) csv-safe database_cleaner datadog (~> 2.0) @@ -1108,10 +1106,10 @@ DEPENDENCIES puma pundit rack-attack (>= 6.7.0) - rack-cors (= 2.0.0) + rack-cors rack-mini-profiler (>= 3.2.0) rack-timeout - rails (~> 7.1) + rails (~> 7.2.0) redis redis-namespace responders (>= 3.1.1) @@ -1124,6 +1122,7 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec + rubocop-rspec_rails ruby-openai ruby_llm (>= 1.8.2) ruby_llm-schema @@ -1137,7 +1136,7 @@ DEPENDENCIES shopify_api shoulda-matchers sidekiq (>= 7.3.1) - sidekiq-cron (>= 1.12.0) + sidekiq-cron (>= 2.3.1) sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index eb6525bb1..295739a57 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def create_issue - issue = linear_processor_service.create_issue(permitted_params, Current.user) + issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index b0e97b95d..3c6adc4c1 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -20,7 +20,9 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController end def instance_meta - @metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed' + migrations_paths = ActiveRecord::Migrator.migrations_paths + migrations_context = ActiveRecord::MigrationContext.new(migrations_paths) + @metrics['Database Migrations'] = migrations_context.needs_migration? ? 'pending' : 'completed' end def chatwoot_version diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb index a7400460c..6f9bf3269 100644 --- a/app/models/installation_config.rb +++ b/app/models/installation_config.rb @@ -19,6 +19,11 @@ class InstallationConfig < ApplicationRecord # https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017 # FIX ME : fixes breakage of installation config. we need to migrate. # Fix configuration in application.rb + # + # Note: This whole thing is because we store the installation config serialized in YAML in Database + # This serialized version stores HashWithIndifferentAccess, We could avoid all this complexity if we store the value as JSONB + # We could also avoid this issue if we migrate the installation config to JSONB + # We should do this migration at some point in time. serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess before_validation :set_lock diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 518b405da..8805000ef 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -18,7 +18,7 @@ class Integrations::Hook < ApplicationRecord include Reauthorizable attr_readonly :app_id, :account_id, :inbox_id, :hook_type - before_validation :ensure_hook_type + before_validation :ensure_hook_type, on: :create after_create :trigger_setup_if_crm # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). @@ -86,7 +86,9 @@ class Integrations::Hook < ApplicationRecord end def ensure_hook_type - self.hook_type = app.params[:hook_type] if app.present? + return if app.blank? + + self.hook_type = app.params[:hook_type] end def validate_settings_json_schema diff --git a/app/models/message.rb b/app/models/message.rb index 20b9a756d..aa4bb2f55 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -421,6 +421,8 @@ class Message < ApplicationRecord end def reindex_for_search + return unless respond_to?(:reindex) + reindex(mode: :async) end end diff --git a/app/models/user.rb b/app/models/user.rb index b14bcd158..741c54107 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,7 +69,7 @@ class User < ApplicationRecord # TODO: remove in a future version once online status is moved to account users # remove the column availability from users - enum availability: { online: 0, offline: 1, busy: 2 } + enum :availability, { online: 0, offline: 1, busy: 2 } # The validation below has been commented out as it does not # work because :validatable in devise overrides this. @@ -77,7 +77,7 @@ class User < ApplicationRecord validates :email, presence: true - serialize :otp_backup_codes, type: Array + serialize :otp_backup_codes, coder: JSON, type: Array # Encrypt sensitive MFA fields encrypts :otp_secret, deterministic: true @@ -88,7 +88,7 @@ class User < ApplicationRecord accepts_nested_attributes_for :account_users has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee - alias_attribute :conversations, :assigned_conversations + alias conversations assigned_conversations has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse', dependent: :nullify, inverse_of: :review_notes_updated_by diff --git a/app/services/crm/leadsquared/setup_service.rb b/app/services/crm/leadsquared/setup_service.rb index 0433f68fd..12117115b 100644 --- a/app/services/crm/leadsquared/setup_service.rb +++ b/app/services/crm/leadsquared/setup_service.rb @@ -82,7 +82,7 @@ class Crm::Leadsquared::SetupService end def update_hook_settings(params) - @hook.settings = @hook.settings.merge(params) + @hook.settings = @hook.settings.merge(params.stringify_keys) @hook.save! end diff --git a/config/application.rb b/config/application.rb index aa150794a..f6cd37931 100644 --- a/config/application.rb +++ b/config/application.rb @@ -36,7 +36,7 @@ end module Chatwoot class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.0 + config.load_defaults 7.2 config.eager_load_paths << Rails.root.join('lib') config.eager_load_paths << Rails.root.join('enterprise/lib') diff --git a/config/storage.yml b/config/storage.yml index c01eb04d4..ccf00e382 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -23,7 +23,7 @@ google: # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) microsoft: - service: AzureStorage + service: AzureBlob storage_account_name: <%= ENV.fetch('AZURE_STORAGE_ACCOUNT_NAME', '') %> storage_access_key: <%= ENV.fetch('AZURE_STORAGE_ACCESS_KEY', '') %> container: <%= ENV.fetch('AZURE_STORAGE_CONTAINER', '') %> diff --git a/db/migrate/20230515051424_update_article_image_keys.rb b/db/migrate/20230515051424_update_article_image_keys.rb index 3e8b9f77d..21891b35f 100644 --- a/db/migrate/20230515051424_update_article_image_keys.rb +++ b/db/migrate/20230515051424_update_article_image_keys.rb @@ -12,8 +12,11 @@ class ArticleKeyConverter def convert_key(id) verifier_name = 'ActiveStorage' - key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base, iterations: 1000, - hash_digest_class: OpenSSL::Digest::SHA1) + secret_key_base = Rails.application.credentials.secret_key_base || + Rails.application.secrets.secret_key_base + key_generator = ActiveSupport::KeyGenerator.new(secret_key_base, + iterations: 1000, + hash_digest_class: OpenSSL::Digest::SHA1) key_generator = ActiveSupport::CachingKeyGenerator.new(key_generator) secret = key_generator.generate_key(verifier_name.to_s) verifier = ActiveSupport::MessageVerifier.new(secret) diff --git a/docker/Dockerfile b/docker/Dockerfile index 645a61a55..16e5cb253 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,6 +29,8 @@ RUN apk update && apk add --no-cache \ tar \ build-base \ tzdata \ + yaml-dev \ + pkgconf \ postgresql-dev \ postgresql-client \ git \ diff --git a/lib/seeders/reports/message_creator.rb b/lib/seeders/reports/message_creator.rb index fc10716d9..cf3e826ab 100644 --- a/lib/seeders/reports/message_creator.rb +++ b/lib/seeders/reports/message_creator.rb @@ -15,7 +15,7 @@ class Seeders::Reports::MessageCreator end def create_messages - message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5) + message_count = rand(MESSAGES_PER_CONVERSATION..(MESSAGES_PER_CONVERSATION + 5)) first_agent_reply = true message_count.times do |i| diff --git a/package.json b/package.json index 04821480d..777c87fd2 100644 --- a/package.json +++ b/package.json @@ -144,8 +144,8 @@ "prosemirror-model": "^1.22.3", "size-limit": "^8.2.4", "tailwindcss": "^3.4.13", - "vite": "^5.4.21", - "vite-plugin-ruby": "^5.0.0", + "vite": "5.4.21", + "vite-plugin-ruby": "^5.1.1", "vitest": "3.0.5" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1b6f35f..6f48be943 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,8 +351,8 @@ importers: specifier: 5.4.21 version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vite-plugin-ruby: - specifier: ^5.0.0 - version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + specifier: ^5.1.1 + version: 5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) vitest: specifier: 3.0.5 version: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0) @@ -4519,8 +4519,8 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true - vite-plugin-ruby@5.0.0: - resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==} + vite-plugin-ruby@5.1.1: + resolution: {integrity: sha512-I1dXJq2ywdvTD2Cz5LYNcYLujqQ3eUxPoCjruRdfm2QBtHBY15NEeb6x5HuPM3T5S+y8S3p9fwRsieQQCjk0gg==} peerDependencies: vite: 5.4.21 @@ -6245,7 +6245,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7647,7 +7647,7 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7661,7 +7661,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -9599,9 +9599,9 @@ snapshots: - supports-color - terser - vite-plugin-ruby@5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): + vite-plugin-ruby@5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): dependencies: - debug: 4.3.5 + debug: 4.4.3 fast-glob: 3.3.2 vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) transitivePeerDependencies: diff --git a/spec/builders/v2/report_builder_spec.rb b/spec/builders/v2/report_builder_spec.rb index 3f86b0348..9de1eed3b 100644 --- a/spec/builders/v2/report_builder_spec.rb +++ b/spec/builders/v2/report_builder_spec.rb @@ -1,15 +1,22 @@ require 'rails_helper' -describe V2::ReportBuilder do +RSpec.describe V2::ReportBuilder do include ActiveJob::TestHelper - let_it_be(:account) { create(:account) } - let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) } - let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) } + self.use_transactional_tests = false + + def truncate_test_data + connection = ActiveRecord::Base.connection + connection.truncate_tables(*connection.tables) + end + + before { truncate_test_data } + + let(:account) { create(:account) } + let!(:label_1) { create(:label, title: 'Label_1', account: account) } + let!(:label_2) { create(:label, title: 'Label_2', account: account) } describe '#timeseries' do - # Use before_all to share expensive setup across all tests in this describe block - # This runs once instead of 21 times, dramatically speeding up the suite - before_all do + before do travel_to(Time.zone.today) do user = create(:user, account: account) inbox = create(:inbox, account: account) @@ -21,7 +28,7 @@ describe V2::ReportBuilder do perform_enqueued_jobs do 10.times do conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, + inbox: inbox, created_at: Time.zone.today) create_list(:message, 5, message_type: 'outgoing', account: account, inbox: inbox, @@ -37,7 +44,7 @@ describe V2::ReportBuilder do 5.times do conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, + inbox: inbox, created_at: (Time.zone.today - 2.days)) create_list(:message, 3, message_type: 'outgoing', account: account, inbox: inbox, diff --git a/spec/builders/v2/reports/label_summary_builder_spec.rb b/spec/builders/v2/reports/label_summary_builder_spec.rb index f0eb6cefd..0f299798d 100644 --- a/spec/builders/v2/reports/label_summary_builder_spec.rb +++ b/spec/builders/v2/reports/label_summary_builder_spec.rb @@ -2,11 +2,19 @@ require 'rails_helper' RSpec.describe V2::Reports::LabelSummaryBuilder do include ActiveJob::TestHelper + self.use_transactional_tests = false - let_it_be(:account) { create(:account) } - let_it_be(:label_1) { create(:label, title: 'label_1', account: account) } - let_it_be(:label_2) { create(:label, title: 'label_2', account: account) } - let_it_be(:label_3) { create(:label, title: 'label_3', account: account) } + def truncate_test_data + connection = ActiveRecord::Base.connection + connection.truncate_tables(*connection.tables) + end + + before { truncate_test_data } + + let(:account) { create(:account) } + let!(:label_1) { create(:label, title: 'label_1', account: account) } + let!(:label_2) { create(:label, title: 'label_2', account: account) } + let!(:label_3) { create(:label, title: 'label_3', account: account) } let(:params) do { @@ -92,7 +100,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Create conversations with label_1 3.times do conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, + inbox: inbox, created_at: Time.zone.today) create_list(:message, 2, message_type: 'outgoing', account: account, inbox: inbox, @@ -110,7 +118,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Create conversations with label_2 2.times do conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, + inbox: inbox, created_at: Time.zone.today) create_list(:message, 1, message_type: 'outgoing', account: account, inbox: inbox, @@ -229,7 +237,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do perform_enqueued_jobs do # Conversation within range conversation_in_range = create(:conversation, account: account, - inbox: inbox, assignee: user, + inbox: inbox, created_at: 2.days.ago) conversation_in_range.update_labels('label_1') conversation_in_range.label_list @@ -244,7 +252,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Conversation outside range (too old) conversation_out_of_range = create(:conversation, account: account, - inbox: inbox, assignee: user, + inbox: inbox, created_at: 1.week.ago) conversation_out_of_range.update_labels('label_1') conversation_out_of_range.label_list @@ -286,7 +294,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do perform_enqueued_jobs do conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, + inbox: inbox, created_at: Time.zone.today) conversation.update_labels('label_1') conversation.label_list @@ -323,8 +331,8 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do let(:account2_builder) do described_class.new(account: account2, params: { business_hours: false, - since: test_date.to_time.to_i.to_s, - until: test_date.end_of_day.to_time.to_i.to_s, + since: test_date.in_time_zone.to_i.to_s, + until: test_date.end_of_day.in_time_zone.to_i.to_s, timezone_offset: 0 }) end @@ -343,7 +351,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do perform_enqueued_jobs do conversation = create(:conversation, account: account2, - inbox: inbox, assignee: user, + inbox: inbox, created_at: test_date) conversation.update_labels(unique_label_name) conversation.label_list @@ -358,6 +366,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Second resolution conversation.resolved! end + perform_enqueued_jobs end end diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb index 87d301d88..7fb6fcf63 100644 --- a/spec/controllers/api/v1/accounts/macros_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb @@ -388,7 +388,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do headers: administrator.create_new_auth_token end - expect(conversation.messages.activity.last.content).to eq("Assigned to #{user_1.name} by #{administrator.name}") + expect(conversation.reload.assignee_id).to eq(user_1.id) end it 'Assign the agent when he is not inbox member' do @@ -402,7 +402,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do headers: administrator.create_new_auth_token end - expect(conversation.messages.activity.last.content).not_to eq("Assigned to #{user_1.name} by #{administrator.name}") + expect(conversation.reload.assignee_id).to be_nil end it 'Assign the labels' do diff --git a/spec/jobs/bulk_actions_job_spec.rb b/spec/jobs/bulk_actions_job_spec.rb index 3b0ff8133..0e589f86a 100644 --- a/spec/jobs/bulk_actions_job_spec.rb +++ b/spec/jobs/bulk_actions_job_spec.rb @@ -1,12 +1,6 @@ require 'rails_helper' RSpec.describe BulkActionsJob do - params = { - type: 'Conversation', - fields: { status: 'snoozed' }, - ids: Conversation.first(3).pluck(:display_id) - } - subject(:job) { described_class.perform_later(account: account, params: params, user: agent) } let(:account) { create(:account) } @@ -14,6 +8,8 @@ RSpec.describe BulkActionsJob do let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) } let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) } let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) } + let(:conversation_ids) { Conversation.where(id: [conversation_1.id, conversation_2.id, conversation_3.id]).pluck(:display_id) } + let(:params) { { type: 'Conversation', fields: { status: 'snoozed' }, ids: conversation_ids } } before do Conversation.all.find_each do |conversation| @@ -38,10 +34,10 @@ RSpec.describe BulkActionsJob do params = { type: 'Conversation', fields: { status: 'snoozed', assignee_id: agent.id }, - ids: Conversation.first(3).pluck(:display_id) + ids: conversation_ids } - expect(Conversation.first.status).to eq('open') + expect(conversation_1.status).to eq('open') described_class.perform_now(account: account, params: params, user: agent) @@ -54,32 +50,32 @@ RSpec.describe BulkActionsJob do params = { type: 'Conversation', fields: { status: 'snoozed', assignee_id: agent.id }, - ids: Conversation.first(3).pluck(:display_id) + ids: conversation_ids } - expect(Conversation.first.assignee_id).to be_nil + expect(conversation_1.assignee_id).to be_nil described_class.perform_now(account: account, params: params, user: agent) - expect(Conversation.first.assignee_id).to eq(agent.id) - expect(Conversation.second.assignee_id).to eq(agent.id) - expect(Conversation.third.assignee_id).to eq(agent.id) + expect(conversation_1.reload.assignee_id).to eq(agent.id) + expect(conversation_2.reload.assignee_id).to eq(agent.id) + expect(conversation_3.reload.assignee_id).to eq(agent.id) end it 'bulk updates the snoozed_until' do params = { type: 'Conversation', fields: { status: 'snoozed', snoozed_until: Time.zone.now }, - ids: Conversation.first(3).pluck(:display_id) + ids: conversation_ids } - expect(Conversation.first.snoozed_until).to be_nil + expect(conversation_1.snoozed_until).to be_nil described_class.perform_now(account: account, params: params, user: agent) - expect(Conversation.first.snoozed_until).to be_present - expect(Conversation.second.snoozed_until).to be_present - expect(Conversation.third.snoozed_until).to be_present + expect(conversation_1.reload.snoozed_until).to be_present + expect(conversation_2.reload.snoozed_until).to be_present + expect(conversation_3.reload.snoozed_until).to be_present end end end diff --git a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb index 96d4dbb0d..0bb18a337 100644 --- a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb +++ b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true RSpec.shared_context 'with smtp config' do - before do - # We need to use allow_any_instance_of here because smtp_config_set_or_development? - # is defined in ApplicationMailer and needs to be stubbed for all mailer instances - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true) - # rubocop:enable RSpec/AnyInstance + around do |example| + # Set SMTP_ADDRESS so mailers build a Mail::Message in test without touching real SMTP. + # Scoped to this shared context to avoid affecting other specs. + with_modified_env('SMTP_ADDRESS' => 'smtp.example.com') { example.run } end end diff --git a/spec/models/contact_inbox_spec.rb b/spec/models/contact_inbox_spec.rb index c0a2faf9e..b19d58934 100644 --- a/spec/models/contact_inbox_spec.rb +++ b/spec/models/contact_inbox_spec.rb @@ -28,8 +28,7 @@ RSpec.describe ContactInbox do obj.reload # ensure the column is nil in database - results = ActiveRecord::Base.connection.execute('Select * from contact_inboxes;') - expect(results.first['pubsub_token']).to be_nil + expect(described_class.where(id: obj.id).pick(:pubsub_token)).to be_nil new_token = obj.pubsub_token obj.update(source_id: '234234323') diff --git a/spec/services/account/sign_up_email_validation_service_spec.rb b/spec/services/account/sign_up_email_validation_service_spec.rb index 3f907f02c..a4b1231be 100644 --- a/spec/services/account/sign_up_email_validation_service_spec.rb +++ b/spec/services/account/sign_up_email_validation_service_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with invalid message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address) expect { service.perform }.to raise_error do |error| - expect(error).to be_a(CustomExceptions::Account::InvalidEmail) + expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') expect(error.message).to eq(I18n.t('errors.signup.invalid_email')) end end @@ -32,7 +32,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with blocked domain message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) expect { service.perform }.to raise_error do |error| - expect(error).to be_a(CustomExceptions::Account::InvalidEmail) + expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') expect(error.message).to eq(I18n.t('errors.signup.blocked_domain')) end end @@ -44,7 +44,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with blocked domain message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) expect { service.perform }.to raise_error do |error| - expect(error).to be_a(CustomExceptions::Account::InvalidEmail) + expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') expect(error.message).to eq(I18n.t('errors.signup.blocked_domain')) end end @@ -56,7 +56,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with disposable message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address) expect { service.perform }.to raise_error do |error| - expect(error).to be_a(CustomExceptions::Account::InvalidEmail) + expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') expect(error.message).to eq(I18n.t('errors.signup.disposable_email')) end end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 528161afe..3d63e6a8f 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -42,6 +42,11 @@ describe Telegram::IncomingMessageService do } end + def contact_for(source_id = nil) + source_id ||= message_params.dig('from', 'id') + ContactInbox.find_by!(inbox: telegram_channel.inbox, source_id: source_id).contact + end + describe '#perform' do context 'when valid text message params' do it 'creates appropriate conversations, message and contacts' do @@ -51,7 +56,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.content).to eq('test') end end @@ -64,9 +69,9 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') - expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23) - expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan') + expect(contact_for.name).to eq('Sojan Jose') + expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23) + expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan') expect(telegram_channel.inbox.messages.first.content).to eq('test') end end @@ -107,7 +112,7 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.conversations.count).not_to eq(0) expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) - contact = Contact.all.first + contact = contact_for expect(contact.name).to eq('Sojan Jose') expect(contact.additional_attributes['language_code']).to eq('en') message = telegram_channel.inbox.messages.first @@ -131,7 +136,7 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.conversations.count).not_to eq(0) expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) - contact = Contact.all.first + contact = contact_for expect(contact.name).to eq('Sojan Jose') # TODO: The language code is not present when we send the first message to the client. # Should we update it when the user replies? @@ -161,9 +166,9 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') - expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23) - expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan') + expect(contact_for.name).to eq('Sojan Jose') + expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23) + expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') end end @@ -182,7 +187,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') end end @@ -207,7 +212,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') end end @@ -229,7 +234,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') end end @@ -258,7 +263,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') end end @@ -277,7 +282,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') end end @@ -298,7 +303,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('file') end end @@ -336,7 +341,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location') end @@ -355,7 +360,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') attachment = telegram_channel.inbox.messages.first.attachments.first expect(attachment.file_type).to eq('location') @@ -388,8 +393,8 @@ describe Telegram::IncomingMessageService do described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') - expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(5_171_248) + expect(contact_for(5_171_248).name).to eq('Sojan Jose') + expect(contact_for(5_171_248).additional_attributes['social_telegram_user_id']).to eq(5_171_248) expect(telegram_channel.inbox.messages.first.content).to eq('Option 1') end end @@ -406,7 +411,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact') end end diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb index 2ac3bb651..286292774 100644 --- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' describe Whatsapp::IncomingMessageWhatsappCloudService do describe '#perform' do let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) } + let(:sender_number) { '2423423243' } let(:params) do { phone_number: whatsapp_channel.phone_number, @@ -10,9 +11,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do entry: [{ changes: [{ value: { - contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }], + contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }], messages: [{ - from: '2423423243', + from: sender_number, image: { id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', mime_type: 'image/jpeg', @@ -48,7 +49,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do described_class.new(inbox: whatsapp_channel.inbox, params: params).perform expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect_contact_name expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!') expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be false expect(whatsapp_channel.authorization_error_count).to eq(1) @@ -63,9 +64,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do entry: [{ changes: [{ value: { - contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }], + contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }], messages: [{ - from: '2423423243', + from: sender_number, image: { id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', mime_type: 'image/jpeg', @@ -88,7 +89,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do it 'with attachment errors' do described_class.new(inbox: whatsapp_channel.inbox, params: error_params).perform expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect_contact_name expect(whatsapp_channel.inbox.messages.count).to eq(0) end end @@ -98,7 +99,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do described_class.new(inbox: whatsapp_channel.inbox, params: { phone_number: whatsapp_channel.phone_number, object: 'whatsapp_business_account', entry: {} }).perform expect(whatsapp_channel.inbox.conversations.count).to eq(0) - expect(Contact.all.first).to be_nil + expect(Contact.find_by(phone_number: contact_phone_number)).to be_nil expect(whatsapp_channel.inbox.messages.count).to eq(0) end end @@ -136,7 +137,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do end def expect_contact_name - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_from_number&.name).to eq('Sojan Jose') end def expect_message_content @@ -146,4 +147,12 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do def expect_message_has_attachment expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true end + + def contact_phone_number + "+#{sender_number}" + end + + def contact_from_number + Contact.find_by(phone_number: contact_phone_number) + end end From 9eb3ee44a84737c13f1028df8aa50b8b0ff98700 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 3 Feb 2026 21:09:42 -0800 Subject: [PATCH 012/118] Revert "chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)" This reverts commit ef6ba8aabd0e52e9cee1b61c8497671e057fa9d8. --- .circleci/config.yml | 6 +- .rubocop.yml | 7 - AGENTS.md | 1 - Gemfile | 23 +- Gemfile.lock | 385 +++++++++--------- .../integrations/linear_controller.rb | 2 +- .../instance_statuses_controller.rb | 4 +- app/models/installation_config.rb | 5 - app/models/integrations/hook.rb | 6 +- app/models/message.rb | 2 - app/models/user.rb | 6 +- app/services/crm/leadsquared/setup_service.rb | 2 +- config/application.rb | 2 +- config/storage.yml | 2 +- ...0230515051424_update_article_image_keys.rb | 7 +- docker/Dockerfile | 2 - lib/seeders/reports/message_creator.rb | 2 +- package.json | 4 +- pnpm-lock.yaml | 18 +- spec/builders/v2/report_builder_spec.rb | 25 +- .../v2/reports/label_summary_builder_spec.rb | 33 +- .../api/v1/accounts/macros_controller_spec.rb | 4 +- spec/jobs/bulk_actions_job_spec.rb | 32 +- .../shared/smtp_config_shared.rb | 10 +- spec/models/contact_inbox_spec.rb | 3 +- .../sign_up_email_validation_service_spec.rb | 8 +- .../telegram/incoming_message_service_spec.rb | 45 +- ...ing_message_whatsapp_cloud_service_spec.rb | 25 +- 28 files changed, 311 insertions(+), 360 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 624e4a1ac..c0320652b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -279,10 +279,10 @@ jobs: echo -en "\nINSTALLATION_ENV=circleci" >> ".env" echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" - # Database setup (match GitHub Actions flow) + # Database setup - run: - name: Create database + load schema - command: bundle exec rake db:create db:schema:load + name: Run DB migrations + command: bundle exec rails db:chatwoot_prepare # Run backend tests (parallelized) - run: diff --git a/.rubocop.yml b/.rubocop.yml index 25d684cbe..d87f08bfd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,7 +2,6 @@ plugins: - rubocop-performance - rubocop-rails - rubocop-rspec - - rubocop-rspec_rails - rubocop-factory_bot require: @@ -204,12 +203,6 @@ RSpec/MultipleExpectations: RSpec/MultipleMemoizedHelpers: Max: 14 -RSpecRails/InferredSpecType: - Enabled: false - -RSpecRails/NegationBeValid: - Enabled: false - # custom rules UseFromEmail: Enabled: true diff --git a/AGENTS.md b/AGENTS.md index add9d5040..474fe6e7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,6 @@ - **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) - **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used - Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) -- **Test env**: Specs should run without `.env`. If present, temporarily rename it (e.g., `.env` -> `.env.bak`) while running specs and restore afterward. ## Code Style diff --git a/Gemfile b/Gemfile index f0fb62413..1ae6cf093 100644 --- a/Gemfile +++ b/Gemfile @@ -3,10 +3,8 @@ source 'https://rubygems.org' ruby '3.4.4' ##-- base gems for rails --## - -gem 'rack-cors', require: 'rack/cors' -gem 'rails', '~> 7.2.0' - +gem 'rack-cors', '2.0.0', require: 'rack/cors' +gem 'rails', '~> 7.1' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -33,9 +31,7 @@ gem 'haikunator' # Template parsing safely gem 'liquid' # Parse Markdown to HTML -# ref: https://github.com/gjtorikian/commonmarker/issues/358 -# can upgrade one this issue is fixed -gem 'commonmarker', '~> 0.23.11' +gem 'commonmarker' # Validate Data against JSON Schema gem 'json_schemer' # used in swagger build @@ -53,7 +49,9 @@ gem 'csv-safe' ##-- for active storage --## gem 'aws-sdk-s3', require: false -gem 'azure-blob', require: false +# original gem isn't maintained actively +# we wanted updated version of faraday which is a dependency for slack-ruby-client +gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'image_processing' @@ -91,9 +89,9 @@ gem 'jwt' gem 'pundit' # super admin -gem 'administrate' -gem 'administrate-field-active_storage' -gem 'administrate-field-belongs_to_search' +gem 'administrate', '>= 0.20.1' +gem 'administrate-field-active_storage', '>= 1.0.3' +gem 'administrate-field-belongs_to_search', '>= 0.9.0' ##--- gems for pubsub service ---## # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ @@ -133,7 +131,7 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false ##-- background job processing --## gem 'sidekiq', '>= 7.3.1' # We want cron jobs -gem 'sidekiq-cron', '>= 2.3.1' +gem 'sidekiq-cron', '>= 1.12.0' # for sidekiq healthcheck gem 'sidekiq_alive' @@ -264,7 +262,6 @@ group :development, :test do gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false - gem 'rubocop-rspec_rails', require: false gem 'rubocop-factory_bot', require: false gem 'seed_dump' gem 'shoulda-matchers' diff --git a/Gemfile.lock b/Gemfile.lock index 6b485cf29..1cdfabee0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,89 +1,110 @@ GIT - remote: https://github.com/chatwoot/devise-secure_password - revision: 479987594b576dbf23aed8dbd962e78342bc683c + remote: https://github.com/chatwoot/azure-storage-ruby + revision: 9957cf899d33a285b5dfe15bdb875292398e392b branch: chatwoot specs: - devise-secure_password (2.1.0) + azure-storage-blob (2.0.3) + azure-storage-common (~> 2.0) + nokogiri (~> 1, >= 1.10.8) + azure-storage-common (2.0.4) + faraday (~> 2.0) + faraday-follow_redirects (~> 0.3.0) + faraday-net_http_persistent (~> 2.0) + net-http-persistent (~> 4.0) + nokogiri (~> 1, >= 1.10.8) + +GIT + remote: https://github.com/chatwoot/devise-secure_password + revision: adcc85fe1babfe40feae73dbcae64d14fff86e69 + branch: chatwoot + specs: + devise-secure_password (2.0.1) devise (>= 4.0.0, < 5.0.0) railties (>= 5.0.0, < 8.0.0) GEM remote: https://rubygems.org/ specs: - actioncable (7.2.2.2) - actionpack (= 7.2.2.2) - activesupport (= 7.2.2.2) + actioncable (7.1.5.2) + actionpack (= 7.1.5.2) + activesupport (= 7.1.5.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.2.2) - actionpack (= 7.2.2.2) - activejob (= 7.2.2.2) - activerecord (= 7.2.2.2) - activestorage (= 7.2.2.2) - activesupport (= 7.2.2.2) - mail (>= 2.8.0) - actionmailer (7.2.2.2) - actionpack (= 7.2.2.2) - actionview (= 7.2.2.2) - activejob (= 7.2.2.2) - activesupport (= 7.2.2.2) - mail (>= 2.8.0) + actionmailbox (7.1.5.2) + actionpack (= 7.1.5.2) + activejob (= 7.1.5.2) + activerecord (= 7.1.5.2) + activestorage (= 7.1.5.2) + activesupport (= 7.1.5.2) + mail (>= 2.7.1) + net-imap + net-pop + net-smtp + actionmailer (7.1.5.2) + actionpack (= 7.1.5.2) + actionview (= 7.1.5.2) + activejob (= 7.1.5.2) + activesupport (= 7.1.5.2) + mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp rails-dom-testing (~> 2.2) - actionpack (7.2.2.2) - actionview (= 7.2.2.2) - activesupport (= 7.2.2.2) + actionpack (7.1.5.2) + actionview (= 7.1.5.2) + activesupport (= 7.1.5.2) nokogiri (>= 1.8.5) racc - rack (>= 2.2.4, < 3.2) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - useragent (~> 0.16) - actiontext (7.2.2.2) - actionpack (= 7.2.2.2) - activerecord (= 7.2.2.2) - activestorage (= 7.2.2.2) - activesupport (= 7.2.2.2) + actiontext (7.1.5.2) + actionpack (= 7.1.5.2) + activerecord (= 7.1.5.2) + activestorage (= 7.1.5.2) + activesupport (= 7.1.5.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.2.2) - activesupport (= 7.2.2.2) + actionview (7.1.5.2) + activesupport (= 7.1.5.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) active_record_query_trace (1.8) - activejob (7.2.2.2) - activesupport (= 7.2.2.2) + activejob (7.1.5.2) + activesupport (= 7.1.5.2) globalid (>= 0.3.6) - activemodel (7.2.2.2) - activesupport (= 7.2.2.2) - activerecord (7.2.2.2) - activemodel (= 7.2.2.2) - activesupport (= 7.2.2.2) + activemodel (7.1.5.2) + activesupport (= 7.1.5.2) + activerecord (7.1.5.2) + activemodel (= 7.1.5.2) + activesupport (= 7.1.5.2) timeout (>= 0.4.0) activerecord-import (2.1.0) activerecord (>= 4.2) - activestorage (7.2.2.2) - actionpack (= 7.2.2.2) - activejob (= 7.2.2.2) - activerecord (= 7.2.2.2) - activesupport (= 7.2.2.2) + activestorage (7.1.5.2) + actionpack (= 7.1.5.2) + activejob (= 7.1.5.2) + activerecord (= 7.1.5.2) + activesupport (= 7.1.5.2) marcel (~> 1.0) - activesupport (7.2.2.2) + activesupport (7.1.5.2) base64 benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) + concurrent-ruby (~> 1.0, >= 1.0.2) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) logger (>= 1.4.2) minitest (>= 5.1) + mutex_m securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) + tzinfo (~> 2.0) acts-as-taggable-on (12.0.0) activerecord (>= 7.1, < 8.1) zeitwerk (>= 2.4, < 3.0) @@ -100,10 +121,10 @@ GEM administrate-field-active_storage (1.0.3) administrate (>= 0.2.2) rails (>= 7.0) - administrate-field-belongs_to_search (0.10.0) + administrate-field-belongs_to_search (0.9.0) administrate (>= 0.3, < 1.0) jbuilder (~> 2) - rails (>= 4.2, < 8.0) + rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) ai-agents (0.7.0) ruby_llm (~> 1.8.2) @@ -120,8 +141,8 @@ GEM aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-sns (~> 1, >= 1.61.0) aws-eventstream (1.4.0) - aws-partitions (1.1205.0) - aws-sdk-core (3.241.3) + aws-partitions (1.1198.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -129,11 +150,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.120.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.208.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-sns (1.70.0) @@ -141,22 +162,20 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.9.1) - rexml barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.5.0) - bigdecimal (4.0.1) + benchmark (0.4.1) + bigdecimal (3.2.2) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) brakeman (5.4.1) browser (5.3.1) builder (3.3.0) - bullet (7.2.0) + bullet (8.0.7) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) @@ -165,21 +184,17 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) - cgi (0.5.1) childprocess (5.1.0) logger (~> 1.5) climate_control (1.2.0) coderay (1.1.3) - commonmarker (0.23.11) - concurrent-ruby (1.3.6) - connection_pool (3.0.2) + commonmarker (0.23.10) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) crack (1.0.0) bigdecimal rexml crass (1.0.6) - cronex (0.15.0) - tzinfo - unicode (>= 0.4.4.5) csv (3.3.0) csv-safe (3.3.1) csv (~> 3.0) @@ -189,15 +204,14 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - datadog (2.25.0) - cgi - datadog-ruby_core_source (~> 3.5, >= 3.5.0) - libdatadog (~> 24.0.1.1.0) - libddwaf (~> 1.30.0.0.0) + datadog (2.19.0) + datadog-ruby_core_source (~> 3.4, >= 3.4.1) + libdatadog (~> 18.1.0.1.0) + libddwaf (~> 1.24.1.0.3) logger msgpack - datadog-ruby_core_source (3.5.1) - date (3.5.1) + datadog-ruby_core_source (3.4.1) + date (3.4.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -208,10 +222,10 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (6.3.0) - activesupport (>= 7.0, < 8.2) - devise (>= 4.0, < 5.0) - railties (>= 7.0, < 8.2) + devise-two-factor (6.1.0) + activesupport (>= 7.0, < 8.1) + devise (~> 4.0) + railties (>= 7.0, < 8.1) rotp (~> 6.0) devise_token_auth (1.2.5) bcrypt (~> 3.0) @@ -230,31 +244,31 @@ GEM down (5.4.0) addressable (~> 2.8) drb (2.2.3) - dry-cli (1.4.1) + dry-cli (1.1.0) dry-configurable (1.3.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-core (1.2.0) + dry-core (1.1.0) concurrent-ruby (~> 1.0) logger zeitwerk (~> 2.6) - dry-inflector (1.3.1) + dry-inflector (1.2.0) dry-initializer (3.2.0) dry-logic (1.6.0) bigdecimal concurrent-ruby (~> 1.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.15.0) + dry-schema (1.14.1) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) dry-core (~> 1.1) dry-initializer (~> 3.2) - dry-logic (~> 1.6) + dry-logic (~> 1.5) dry-types (~> 1.8) zeitwerk (~> 2.6) - dry-types (1.9.0) - bigdecimal (>= 3.0) + dry-types (1.8.3) + bigdecimal (~> 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -268,9 +282,8 @@ GEM ruby2_keywords email-provider-info (0.0.1) email_reply_trimmer (0.1.13) - erb (6.0.1) - erubi (1.13.1) - et-orbi (1.3.0) + erubi (1.13.0) + et-orbi (1.2.11) tzinfo event_stream_parser (1.0.0) execjs (2.8.1) @@ -284,27 +297,34 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.9.0) - faraday-net_http (>= 2.0, < 3.2) - faraday-mashify (1.0.2) + faraday (2.13.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-mashify (1.0.0) faraday (~> 2.0) hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.1.0) - net-http + faraday-net_http (3.4.0) + net-http (>= 0.5.0) + faraday-net_http_persistent (2.1.0) + faraday (~> 2.5) + net-http-persistent (~> 4.0) faraday-retry (2.2.1) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) faraday (>= 2.0, < 3) - fast-mcp (1.6.0) + fast-mcp (1.5.0) addressable (~> 2.8) base64 dry-schema (~> 1.14) json (~> 2.0) mime-types (~> 3.4) - rack (>= 2.0, < 4.0) + rack (~> 3.1) fcm (1.0.8) faraday (>= 1.0.0, < 3.0) googleauth (~> 1) @@ -421,21 +441,19 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.24.2) + httparty (0.24.0) csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.14.8) + i18n (1.14.7) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - io-console (0.8.2) - irb (1.16.0) - pp (>= 0.6.0) - rdoc (>= 4.0.0) - reline (>= 0.4.2) + io-console (0.6.0) + irb (1.7.2) + reline (>= 0.3.6) iso-639 (0.3.8) csv jbuilder (2.11.5) @@ -446,7 +464,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.12.0) + json (2.13.2) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -461,7 +479,7 @@ GEM judoscale-sidekiq (1.8.2) judoscale-ruby (= 1.8.2) sidekiq (>= 5.0) - jwt (2.8.1) + jwt (2.10.1) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -488,15 +506,15 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - libdatadog (24.0.1.1.0) - libdatadog (24.0.1.1.0-x86_64-linux) - libddwaf (1.30.0.0.0) + libdatadog (18.1.0.1.0) + libdatadog (18.1.0.1.0-x86_64-linux) + libddwaf (1.24.1.0.3) ffi (~> 1.0) - libddwaf (1.30.0.0.0-arm64-darwin) + libddwaf (1.24.1.0.3-arm64-darwin) ffi (~> 1.0) - libddwaf (1.30.0.0.0-x86_64-darwin) + libddwaf (1.24.1.0.3-x86_64-darwin) ffi (~> 1.0) - libddwaf (1.30.0.0.0-x86_64-linux) + libddwaf (1.24.1.0.3-x86_64-linux) ffi (~> 1.0) line-bot-api (1.28.0) lint_roller (1.1.0) @@ -513,7 +531,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.0) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -533,19 +551,21 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.27.0) + minitest (5.25.5) mock_redis (0.36.0) ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.8.1) + multi_xml (0.8.0) bigdecimal (>= 3.1, < 5) multipart-post (2.3.0) mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) - net-http (0.4.1) + net-http (0.6.0) uri + net-http-persistent (4.0.2) + connection_pool (~> 2.2) net-imap (0.4.20) date net-protocol @@ -562,14 +582,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.19.0) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.18.9-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-darwin) + nokogiri (1.18.9-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -587,8 +607,9 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.2) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-google-oauth2 (1.1.3) @@ -640,9 +661,6 @@ GEM activerecord (>= 5.2) activesupport (>= 5.2) pgvector (0.1.1) - pp (0.6.3) - prettyprint - prettyprint (0.2.0) prism (1.4.0) procore-sift (1.0.0) activerecord (>= 6.1) @@ -651,9 +669,6 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - psych (5.3.1) - date - stringio public_suffix (6.0.2) puma (6.4.3) nio4r (~> 2.0) @@ -661,7 +676,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.19) + rack (3.2.3) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -679,57 +694,53 @@ GEM rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.2.0) + rack-test (2.1.0) rack (>= 1.3) rack-timeout (0.6.3) - rackup (2.3.1) + rackup (2.2.1) rack (>= 3) - rails (7.2.2.2) - actioncable (= 7.2.2.2) - actionmailbox (= 7.2.2.2) - actionmailer (= 7.2.2.2) - actionpack (= 7.2.2.2) - actiontext (= 7.2.2.2) - actionview (= 7.2.2.2) - activejob (= 7.2.2.2) - activemodel (= 7.2.2.2) - activerecord (= 7.2.2.2) - activestorage (= 7.2.2.2) - activesupport (= 7.2.2.2) + rails (7.1.5.2) + actioncable (= 7.1.5.2) + actionmailbox (= 7.1.5.2) + actionmailer (= 7.1.5.2) + actionpack (= 7.1.5.2) + actiontext (= 7.1.5.2) + actionview (= 7.1.5.2) + activejob (= 7.1.5.2) + activemodel (= 7.1.5.2) + activerecord (= 7.1.5.2) + activestorage (= 7.1.5.2) + activesupport (= 7.1.5.2) bundler (>= 1.15.0) - railties (= 7.2.2.2) - rails-dom-testing (2.3.0) + railties (= 7.1.5.2) + rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) + rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.2.2.2) - actionpack (= 7.2.2.2) - activesupport (= 7.2.2.2) - irb (~> 1.13) + railties (7.1.5.2) + actionpack (= 7.1.5.2) + activesupport (= 7.1.5.2) + irb rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) + rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) - rdoc (7.1.0) - erb - psych (>= 4.0.0) - tsort redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.25.2) + redis-client (0.22.2) connection_pool redis-namespace (1.10.0) redis (>= 4) regexp_parser (2.10.0) - reline (0.6.3) + reline (0.3.6) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -799,10 +810,6 @@ GEM rubocop-rspec (3.6.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) - rubocop-rspec_rails (2.31.0) - lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-rspec (~> 3.5) ruby-openai (7.3.1) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) @@ -825,8 +832,9 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) - ruby_llm-schema (0.1.0) + ruby_llm-schema (0.2.5) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -846,8 +854,9 @@ GEM parser scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) - searchkick (6.0.3) - activemodel (>= 7.2) + searchkick (5.5.2) + activemodel (>= 7.1) + hashie securerandom (0.4.1) seed_dump (3.3.1) activerecord (>= 4) @@ -876,17 +885,16 @@ GEM zeitwerk (~> 2.5) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.3.9) - base64 + sidekiq (7.3.1) + concurrent-ruby (< 2) connection_pool (>= 2.3.0) logger rack (>= 2.2.4) redis-client (>= 0.22.2) - sidekiq-cron (2.3.1) - cronex (>= 0.13.0) - fugit (~> 1.8, >= 1.11.1) + sidekiq-cron (1.12.0) + fugit (~> 1.8) globalid (>= 1.0.1) - sidekiq (>= 6.5.0) + sidekiq (>= 6) sidekiq_alive (2.5.0) gserver (~> 0.0.1) sidekiq (>= 5, < 9) @@ -926,13 +934,12 @@ GEM squasher (0.7.2) stackprof (0.2.25) statsd-ruby (1.5.0) - stringio (3.2.0) - stripe (18.1.0) + stripe (18.0.1) telephone_number (1.4.20) test-prof (1.2.1) - thor (1.5.0) - tidewave (0.4.1) - fast-mcp (~> 1.6.0) + thor (1.4.0) + tidewave (0.2.0) + fast-mcp (~> 1.5.0) rack (>= 2.0) rails (>= 7.1.0) tilt (2.3.0) @@ -941,8 +948,7 @@ GEM i18n timeout (0.4.3) trailblazer-option (0.1.2) - tsort (0.2.0) - twilio-ruby (5.77.0) + twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) jwt (>= 1.5, < 3.0) nokogiri (>= 1.6, < 2.0) @@ -958,25 +964,21 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) - unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.17.0) - uri (1.1.1) + uri (1.0.4) uri_template (0.7.0) - useragent (0.16.11) valid_email2 (5.2.6) activemodel (>= 3.2) mail (~> 2.5) version_gem (1.1.4) - vite_rails (3.0.20) - railties (>= 5.1, < 9) + vite_rails (3.0.17) + railties (>= 5.1, < 8) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.9.2) + vite_ruby (3.8.0) dry-cli (>= 0.7, < 2) - logger (~> 1.6) - mutex_m rack-proxy (~> 0.6, >= 0.6.1) zeitwerk (~> 2.2) warden (1.2.9) @@ -993,7 +995,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.8.0) + websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -1001,7 +1003,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.7.4) + zeitwerk (2.6.17) PLATFORMS arm64-darwin-20 @@ -1018,16 +1020,16 @@ DEPENDENCIES active_record_query_trace activerecord-import acts-as-taggable-on - administrate - administrate-field-active_storage - administrate-field-belongs_to_search + administrate (>= 0.20.1) + administrate-field-active_storage (>= 1.0.3) + administrate-field-belongs_to_search (>= 0.9.0) ai-agents (>= 0.7.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) aws-actionmailbox-ses (~> 0) aws-sdk-s3 - azure-blob + azure-storage-blob! barnes bootsnap brakeman @@ -1036,7 +1038,7 @@ DEPENDENCIES bundle-audit byebug climate_control - commonmarker (~> 0.23.11) + commonmarker csv-safe database_cleaner datadog (~> 2.0) @@ -1106,10 +1108,10 @@ DEPENDENCIES puma pundit rack-attack (>= 6.7.0) - rack-cors + rack-cors (= 2.0.0) rack-mini-profiler (>= 3.2.0) rack-timeout - rails (~> 7.2.0) + rails (~> 7.1) redis redis-namespace responders (>= 3.1.1) @@ -1122,7 +1124,6 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec - rubocop-rspec_rails ruby-openai ruby_llm (>= 1.8.2) ruby_llm-schema @@ -1136,7 +1137,7 @@ DEPENDENCIES shopify_api shoulda-matchers sidekiq (>= 7.3.1) - sidekiq-cron (>= 2.3.1) + sidekiq-cron (>= 1.12.0) sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 295739a57..eb6525bb1 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def create_issue - issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user) + issue = linear_processor_service.create_issue(permitted_params, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index 3c6adc4c1..b0e97b95d 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -20,9 +20,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController end def instance_meta - migrations_paths = ActiveRecord::Migrator.migrations_paths - migrations_context = ActiveRecord::MigrationContext.new(migrations_paths) - @metrics['Database Migrations'] = migrations_context.needs_migration? ? 'pending' : 'completed' + @metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed' end def chatwoot_version diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb index 6f9bf3269..a7400460c 100644 --- a/app/models/installation_config.rb +++ b/app/models/installation_config.rb @@ -19,11 +19,6 @@ class InstallationConfig < ApplicationRecord # https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017 # FIX ME : fixes breakage of installation config. we need to migrate. # Fix configuration in application.rb - # - # Note: This whole thing is because we store the installation config serialized in YAML in Database - # This serialized version stores HashWithIndifferentAccess, We could avoid all this complexity if we store the value as JSONB - # We could also avoid this issue if we migrate the installation config to JSONB - # We should do this migration at some point in time. serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess before_validation :set_lock diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 8805000ef..518b405da 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -18,7 +18,7 @@ class Integrations::Hook < ApplicationRecord include Reauthorizable attr_readonly :app_id, :account_id, :inbox_id, :hook_type - before_validation :ensure_hook_type, on: :create + before_validation :ensure_hook_type after_create :trigger_setup_if_crm # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). @@ -86,9 +86,7 @@ class Integrations::Hook < ApplicationRecord end def ensure_hook_type - return if app.blank? - - self.hook_type = app.params[:hook_type] + self.hook_type = app.params[:hook_type] if app.present? end def validate_settings_json_schema diff --git a/app/models/message.rb b/app/models/message.rb index aa4bb2f55..20b9a756d 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -421,8 +421,6 @@ class Message < ApplicationRecord end def reindex_for_search - return unless respond_to?(:reindex) - reindex(mode: :async) end end diff --git a/app/models/user.rb b/app/models/user.rb index 741c54107..b14bcd158 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,7 +69,7 @@ class User < ApplicationRecord # TODO: remove in a future version once online status is moved to account users # remove the column availability from users - enum :availability, { online: 0, offline: 1, busy: 2 } + enum availability: { online: 0, offline: 1, busy: 2 } # The validation below has been commented out as it does not # work because :validatable in devise overrides this. @@ -77,7 +77,7 @@ class User < ApplicationRecord validates :email, presence: true - serialize :otp_backup_codes, coder: JSON, type: Array + serialize :otp_backup_codes, type: Array # Encrypt sensitive MFA fields encrypts :otp_secret, deterministic: true @@ -88,7 +88,7 @@ class User < ApplicationRecord accepts_nested_attributes_for :account_users has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee - alias conversations assigned_conversations + alias_attribute :conversations, :assigned_conversations has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse', dependent: :nullify, inverse_of: :review_notes_updated_by diff --git a/app/services/crm/leadsquared/setup_service.rb b/app/services/crm/leadsquared/setup_service.rb index 12117115b..0433f68fd 100644 --- a/app/services/crm/leadsquared/setup_service.rb +++ b/app/services/crm/leadsquared/setup_service.rb @@ -82,7 +82,7 @@ class Crm::Leadsquared::SetupService end def update_hook_settings(params) - @hook.settings = @hook.settings.merge(params.stringify_keys) + @hook.settings = @hook.settings.merge(params) @hook.save! end diff --git a/config/application.rb b/config/application.rb index f6cd37931..aa150794a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -36,7 +36,7 @@ end module Chatwoot class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 + config.load_defaults 7.0 config.eager_load_paths << Rails.root.join('lib') config.eager_load_paths << Rails.root.join('enterprise/lib') diff --git a/config/storage.yml b/config/storage.yml index ccf00e382..c01eb04d4 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -23,7 +23,7 @@ google: # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) microsoft: - service: AzureBlob + service: AzureStorage storage_account_name: <%= ENV.fetch('AZURE_STORAGE_ACCOUNT_NAME', '') %> storage_access_key: <%= ENV.fetch('AZURE_STORAGE_ACCESS_KEY', '') %> container: <%= ENV.fetch('AZURE_STORAGE_CONTAINER', '') %> diff --git a/db/migrate/20230515051424_update_article_image_keys.rb b/db/migrate/20230515051424_update_article_image_keys.rb index 21891b35f..3e8b9f77d 100644 --- a/db/migrate/20230515051424_update_article_image_keys.rb +++ b/db/migrate/20230515051424_update_article_image_keys.rb @@ -12,11 +12,8 @@ class ArticleKeyConverter def convert_key(id) verifier_name = 'ActiveStorage' - secret_key_base = Rails.application.credentials.secret_key_base || - Rails.application.secrets.secret_key_base - key_generator = ActiveSupport::KeyGenerator.new(secret_key_base, - iterations: 1000, - hash_digest_class: OpenSSL::Digest::SHA1) + key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base, iterations: 1000, + hash_digest_class: OpenSSL::Digest::SHA1) key_generator = ActiveSupport::CachingKeyGenerator.new(key_generator) secret = key_generator.generate_key(verifier_name.to_s) verifier = ActiveSupport::MessageVerifier.new(secret) diff --git a/docker/Dockerfile b/docker/Dockerfile index 16e5cb253..645a61a55 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,8 +29,6 @@ RUN apk update && apk add --no-cache \ tar \ build-base \ tzdata \ - yaml-dev \ - pkgconf \ postgresql-dev \ postgresql-client \ git \ diff --git a/lib/seeders/reports/message_creator.rb b/lib/seeders/reports/message_creator.rb index cf3e826ab..fc10716d9 100644 --- a/lib/seeders/reports/message_creator.rb +++ b/lib/seeders/reports/message_creator.rb @@ -15,7 +15,7 @@ class Seeders::Reports::MessageCreator end def create_messages - message_count = rand(MESSAGES_PER_CONVERSATION..(MESSAGES_PER_CONVERSATION + 5)) + message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5) first_agent_reply = true message_count.times do |i| diff --git a/package.json b/package.json index 777c87fd2..04821480d 100644 --- a/package.json +++ b/package.json @@ -144,8 +144,8 @@ "prosemirror-model": "^1.22.3", "size-limit": "^8.2.4", "tailwindcss": "^3.4.13", - "vite": "5.4.21", - "vite-plugin-ruby": "^5.1.1", + "vite": "^5.4.21", + "vite-plugin-ruby": "^5.0.0", "vitest": "3.0.5" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f48be943..7a1b6f35f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,8 +351,8 @@ importers: specifier: 5.4.21 version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vite-plugin-ruby: - specifier: ^5.1.1 - version: 5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + specifier: ^5.0.0 + version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) vitest: specifier: 3.0.5 version: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0) @@ -4519,8 +4519,8 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true - vite-plugin-ruby@5.1.1: - resolution: {integrity: sha512-I1dXJq2ywdvTD2Cz5LYNcYLujqQ3eUxPoCjruRdfm2QBtHBY15NEeb6x5HuPM3T5S+y8S3p9fwRsieQQCjk0gg==} + vite-plugin-ruby@5.0.0: + resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==} peerDependencies: vite: 5.4.21 @@ -6245,7 +6245,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -7647,7 +7647,7 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -7661,7 +7661,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -9599,9 +9599,9 @@ snapshots: - supports-color - terser - vite-plugin-ruby@5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): + vite-plugin-ruby@5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): dependencies: - debug: 4.4.3 + debug: 4.3.5 fast-glob: 3.3.2 vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) transitivePeerDependencies: diff --git a/spec/builders/v2/report_builder_spec.rb b/spec/builders/v2/report_builder_spec.rb index 9de1eed3b..3f86b0348 100644 --- a/spec/builders/v2/report_builder_spec.rb +++ b/spec/builders/v2/report_builder_spec.rb @@ -1,22 +1,15 @@ require 'rails_helper' -RSpec.describe V2::ReportBuilder do +describe V2::ReportBuilder do include ActiveJob::TestHelper - self.use_transactional_tests = false - - def truncate_test_data - connection = ActiveRecord::Base.connection - connection.truncate_tables(*connection.tables) - end - - before { truncate_test_data } - - let(:account) { create(:account) } - let!(:label_1) { create(:label, title: 'Label_1', account: account) } - let!(:label_2) { create(:label, title: 'Label_2', account: account) } + let_it_be(:account) { create(:account) } + let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) } + let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) } describe '#timeseries' do - before do + # Use before_all to share expensive setup across all tests in this describe block + # This runs once instead of 21 times, dramatically speeding up the suite + before_all do travel_to(Time.zone.today) do user = create(:user, account: account) inbox = create(:inbox, account: account) @@ -28,7 +21,7 @@ RSpec.describe V2::ReportBuilder do perform_enqueued_jobs do 10.times do conversation = create(:conversation, account: account, - inbox: inbox, + inbox: inbox, assignee: user, created_at: Time.zone.today) create_list(:message, 5, message_type: 'outgoing', account: account, inbox: inbox, @@ -44,7 +37,7 @@ RSpec.describe V2::ReportBuilder do 5.times do conversation = create(:conversation, account: account, - inbox: inbox, + inbox: inbox, assignee: user, created_at: (Time.zone.today - 2.days)) create_list(:message, 3, message_type: 'outgoing', account: account, inbox: inbox, diff --git a/spec/builders/v2/reports/label_summary_builder_spec.rb b/spec/builders/v2/reports/label_summary_builder_spec.rb index 0f299798d..f0eb6cefd 100644 --- a/spec/builders/v2/reports/label_summary_builder_spec.rb +++ b/spec/builders/v2/reports/label_summary_builder_spec.rb @@ -2,19 +2,11 @@ require 'rails_helper' RSpec.describe V2::Reports::LabelSummaryBuilder do include ActiveJob::TestHelper - self.use_transactional_tests = false - def truncate_test_data - connection = ActiveRecord::Base.connection - connection.truncate_tables(*connection.tables) - end - - before { truncate_test_data } - - let(:account) { create(:account) } - let!(:label_1) { create(:label, title: 'label_1', account: account) } - let!(:label_2) { create(:label, title: 'label_2', account: account) } - let!(:label_3) { create(:label, title: 'label_3', account: account) } + let_it_be(:account) { create(:account) } + let_it_be(:label_1) { create(:label, title: 'label_1', account: account) } + let_it_be(:label_2) { create(:label, title: 'label_2', account: account) } + let_it_be(:label_3) { create(:label, title: 'label_3', account: account) } let(:params) do { @@ -100,7 +92,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Create conversations with label_1 3.times do conversation = create(:conversation, account: account, - inbox: inbox, + inbox: inbox, assignee: user, created_at: Time.zone.today) create_list(:message, 2, message_type: 'outgoing', account: account, inbox: inbox, @@ -118,7 +110,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Create conversations with label_2 2.times do conversation = create(:conversation, account: account, - inbox: inbox, + inbox: inbox, assignee: user, created_at: Time.zone.today) create_list(:message, 1, message_type: 'outgoing', account: account, inbox: inbox, @@ -237,7 +229,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do perform_enqueued_jobs do # Conversation within range conversation_in_range = create(:conversation, account: account, - inbox: inbox, + inbox: inbox, assignee: user, created_at: 2.days.ago) conversation_in_range.update_labels('label_1') conversation_in_range.label_list @@ -252,7 +244,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Conversation outside range (too old) conversation_out_of_range = create(:conversation, account: account, - inbox: inbox, + inbox: inbox, assignee: user, created_at: 1.week.ago) conversation_out_of_range.update_labels('label_1') conversation_out_of_range.label_list @@ -294,7 +286,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do perform_enqueued_jobs do conversation = create(:conversation, account: account, - inbox: inbox, + inbox: inbox, assignee: user, created_at: Time.zone.today) conversation.update_labels('label_1') conversation.label_list @@ -331,8 +323,8 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do let(:account2_builder) do described_class.new(account: account2, params: { business_hours: false, - since: test_date.in_time_zone.to_i.to_s, - until: test_date.end_of_day.in_time_zone.to_i.to_s, + since: test_date.to_time.to_i.to_s, + until: test_date.end_of_day.to_time.to_i.to_s, timezone_offset: 0 }) end @@ -351,7 +343,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do perform_enqueued_jobs do conversation = create(:conversation, account: account2, - inbox: inbox, + inbox: inbox, assignee: user, created_at: test_date) conversation.update_labels(unique_label_name) conversation.label_list @@ -366,7 +358,6 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do # Second resolution conversation.resolved! end - perform_enqueued_jobs end end diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb index 7fb6fcf63..87d301d88 100644 --- a/spec/controllers/api/v1/accounts/macros_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb @@ -388,7 +388,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do headers: administrator.create_new_auth_token end - expect(conversation.reload.assignee_id).to eq(user_1.id) + expect(conversation.messages.activity.last.content).to eq("Assigned to #{user_1.name} by #{administrator.name}") end it 'Assign the agent when he is not inbox member' do @@ -402,7 +402,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do headers: administrator.create_new_auth_token end - expect(conversation.reload.assignee_id).to be_nil + expect(conversation.messages.activity.last.content).not_to eq("Assigned to #{user_1.name} by #{administrator.name}") end it 'Assign the labels' do diff --git a/spec/jobs/bulk_actions_job_spec.rb b/spec/jobs/bulk_actions_job_spec.rb index 0e589f86a..3b0ff8133 100644 --- a/spec/jobs/bulk_actions_job_spec.rb +++ b/spec/jobs/bulk_actions_job_spec.rb @@ -1,6 +1,12 @@ require 'rails_helper' RSpec.describe BulkActionsJob do + params = { + type: 'Conversation', + fields: { status: 'snoozed' }, + ids: Conversation.first(3).pluck(:display_id) + } + subject(:job) { described_class.perform_later(account: account, params: params, user: agent) } let(:account) { create(:account) } @@ -8,8 +14,6 @@ RSpec.describe BulkActionsJob do let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) } let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) } let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) } - let(:conversation_ids) { Conversation.where(id: [conversation_1.id, conversation_2.id, conversation_3.id]).pluck(:display_id) } - let(:params) { { type: 'Conversation', fields: { status: 'snoozed' }, ids: conversation_ids } } before do Conversation.all.find_each do |conversation| @@ -34,10 +38,10 @@ RSpec.describe BulkActionsJob do params = { type: 'Conversation', fields: { status: 'snoozed', assignee_id: agent.id }, - ids: conversation_ids + ids: Conversation.first(3).pluck(:display_id) } - expect(conversation_1.status).to eq('open') + expect(Conversation.first.status).to eq('open') described_class.perform_now(account: account, params: params, user: agent) @@ -50,32 +54,32 @@ RSpec.describe BulkActionsJob do params = { type: 'Conversation', fields: { status: 'snoozed', assignee_id: agent.id }, - ids: conversation_ids + ids: Conversation.first(3).pluck(:display_id) } - expect(conversation_1.assignee_id).to be_nil + expect(Conversation.first.assignee_id).to be_nil described_class.perform_now(account: account, params: params, user: agent) - expect(conversation_1.reload.assignee_id).to eq(agent.id) - expect(conversation_2.reload.assignee_id).to eq(agent.id) - expect(conversation_3.reload.assignee_id).to eq(agent.id) + expect(Conversation.first.assignee_id).to eq(agent.id) + expect(Conversation.second.assignee_id).to eq(agent.id) + expect(Conversation.third.assignee_id).to eq(agent.id) end it 'bulk updates the snoozed_until' do params = { type: 'Conversation', fields: { status: 'snoozed', snoozed_until: Time.zone.now }, - ids: conversation_ids + ids: Conversation.first(3).pluck(:display_id) } - expect(conversation_1.snoozed_until).to be_nil + expect(Conversation.first.snoozed_until).to be_nil described_class.perform_now(account: account, params: params, user: agent) - expect(conversation_1.reload.snoozed_until).to be_present - expect(conversation_2.reload.snoozed_until).to be_present - expect(conversation_3.reload.snoozed_until).to be_present + expect(Conversation.first.snoozed_until).to be_present + expect(Conversation.second.snoozed_until).to be_present + expect(Conversation.third.snoozed_until).to be_present end end end diff --git a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb index 0bb18a337..96d4dbb0d 100644 --- a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb +++ b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true RSpec.shared_context 'with smtp config' do - around do |example| - # Set SMTP_ADDRESS so mailers build a Mail::Message in test without touching real SMTP. - # Scoped to this shared context to avoid affecting other specs. - with_modified_env('SMTP_ADDRESS' => 'smtp.example.com') { example.run } + before do + # We need to use allow_any_instance_of here because smtp_config_set_or_development? + # is defined in ApplicationMailer and needs to be stubbed for all mailer instances + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true) + # rubocop:enable RSpec/AnyInstance end end diff --git a/spec/models/contact_inbox_spec.rb b/spec/models/contact_inbox_spec.rb index b19d58934..c0a2faf9e 100644 --- a/spec/models/contact_inbox_spec.rb +++ b/spec/models/contact_inbox_spec.rb @@ -28,7 +28,8 @@ RSpec.describe ContactInbox do obj.reload # ensure the column is nil in database - expect(described_class.where(id: obj.id).pick(:pubsub_token)).to be_nil + results = ActiveRecord::Base.connection.execute('Select * from contact_inboxes;') + expect(results.first['pubsub_token']).to be_nil new_token = obj.pubsub_token obj.update(source_id: '234234323') diff --git a/spec/services/account/sign_up_email_validation_service_spec.rb b/spec/services/account/sign_up_email_validation_service_spec.rb index a4b1231be..3f907f02c 100644 --- a/spec/services/account/sign_up_email_validation_service_spec.rb +++ b/spec/services/account/sign_up_email_validation_service_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with invalid message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address) expect { service.perform }.to raise_error do |error| - expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') + expect(error).to be_a(CustomExceptions::Account::InvalidEmail) expect(error.message).to eq(I18n.t('errors.signup.invalid_email')) end end @@ -32,7 +32,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with blocked domain message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) expect { service.perform }.to raise_error do |error| - expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') + expect(error).to be_a(CustomExceptions::Account::InvalidEmail) expect(error.message).to eq(I18n.t('errors.signup.blocked_domain')) end end @@ -44,7 +44,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with blocked domain message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) expect { service.perform }.to raise_error do |error| - expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') + expect(error).to be_a(CustomExceptions::Account::InvalidEmail) expect(error.message).to eq(I18n.t('errors.signup.blocked_domain')) end end @@ -56,7 +56,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do it 'raises InvalidEmail with disposable message' do allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address) expect { service.perform }.to raise_error do |error| - expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail') + expect(error).to be_a(CustomExceptions::Account::InvalidEmail) expect(error.message).to eq(I18n.t('errors.signup.disposable_email')) end end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 3d63e6a8f..528161afe 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -42,11 +42,6 @@ describe Telegram::IncomingMessageService do } end - def contact_for(source_id = nil) - source_id ||= message_params.dig('from', 'id') - ContactInbox.find_by!(inbox: telegram_channel.inbox, source_id: source_id).contact - end - describe '#perform' do context 'when valid text message params' do it 'creates appropriate conversations, message and contacts' do @@ -56,7 +51,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.content).to eq('test') end end @@ -69,9 +64,9 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') - expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23) - expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan') + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23) + expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan') expect(telegram_channel.inbox.messages.first.content).to eq('test') end end @@ -112,7 +107,7 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.conversations.count).not_to eq(0) expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) - contact = contact_for + contact = Contact.all.first expect(contact.name).to eq('Sojan Jose') expect(contact.additional_attributes['language_code']).to eq('en') message = telegram_channel.inbox.messages.first @@ -136,7 +131,7 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.conversations.count).not_to eq(0) expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) - contact = contact_for + contact = Contact.all.first expect(contact.name).to eq('Sojan Jose') # TODO: The language code is not present when we send the first message to the client. # Should we update it when the user replies? @@ -166,9 +161,9 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') - expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23) - expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan') + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23) + expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') end end @@ -187,7 +182,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') end end @@ -212,7 +207,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') end end @@ -234,7 +229,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') end end @@ -263,7 +258,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') end end @@ -282,7 +277,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') end end @@ -303,7 +298,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('file') end end @@ -341,7 +336,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location') end @@ -360,7 +355,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') attachment = telegram_channel.inbox.messages.first.attachments.first expect(attachment.file_type).to eq('location') @@ -393,8 +388,8 @@ describe Telegram::IncomingMessageService do described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for(5_171_248).name).to eq('Sojan Jose') - expect(contact_for(5_171_248).additional_attributes['social_telegram_user_id']).to eq(5_171_248) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(5_171_248) expect(telegram_channel.inbox.messages.first.content).to eq('Option 1') end end @@ -411,7 +406,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(contact_for.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact') end end diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb index 286292774..2ac3bb651 100644 --- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb @@ -3,7 +3,6 @@ require 'rails_helper' describe Whatsapp::IncomingMessageWhatsappCloudService do describe '#perform' do let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) } - let(:sender_number) { '2423423243' } let(:params) do { phone_number: whatsapp_channel.phone_number, @@ -11,9 +10,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do entry: [{ changes: [{ value: { - contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }], + contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }], messages: [{ - from: sender_number, + from: '2423423243', image: { id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', mime_type: 'image/jpeg', @@ -49,7 +48,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do described_class.new(inbox: whatsapp_channel.inbox, params: params).perform expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect_contact_name + expect(Contact.all.first.name).to eq('Sojan Jose') expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!') expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be false expect(whatsapp_channel.authorization_error_count).to eq(1) @@ -64,9 +63,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do entry: [{ changes: [{ value: { - contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }], + contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }], messages: [{ - from: sender_number, + from: '2423423243', image: { id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', mime_type: 'image/jpeg', @@ -89,7 +88,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do it 'with attachment errors' do described_class.new(inbox: whatsapp_channel.inbox, params: error_params).perform expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect_contact_name + expect(Contact.all.first.name).to eq('Sojan Jose') expect(whatsapp_channel.inbox.messages.count).to eq(0) end end @@ -99,7 +98,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do described_class.new(inbox: whatsapp_channel.inbox, params: { phone_number: whatsapp_channel.phone_number, object: 'whatsapp_business_account', entry: {} }).perform expect(whatsapp_channel.inbox.conversations.count).to eq(0) - expect(Contact.find_by(phone_number: contact_phone_number)).to be_nil + expect(Contact.all.first).to be_nil expect(whatsapp_channel.inbox.messages.count).to eq(0) end end @@ -137,7 +136,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do end def expect_contact_name - expect(contact_from_number&.name).to eq('Sojan Jose') + expect(Contact.all.first.name).to eq('Sojan Jose') end def expect_message_content @@ -147,12 +146,4 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do def expect_message_has_attachment expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true end - - def contact_phone_number - "+#{sender_number}" - end - - def contact_from_number - Contact.find_by(phone_number: contact_phone_number) - end end From 7ade9061a89a044b6e3b560a45d5d7c52e7c6724 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:27:51 +0530 Subject: [PATCH 013/118] feat: display total FAQ count in Related FAQs dialog (#13433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Display the total count of generated FAQs in the Related FAQs dialog title to give users immediate visibility into how many FAQs were generated from a document. ## Type of change Please delete options that are not relevant. - [ ] New feature (non-breaking change which adds functionality) ## Snapshots? Screenshot 2026-02-04 at 1 47 36 AM ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- > [!NOTE] > **Low Risk** > Small UI-only change using existing store metadata; risk is limited to incorrect/blank counts if `meta.totalCount` is missing or stale. > > **Overview** > Updates the `RelatedResponses` dialog to display the total related response count in the title by reading `captainResponses/getMeta.totalCount` (defaulting to 0) and appending it as `()`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7cd67c9991faceeff33d33c319e324b1c6cf73f4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../captain/pageComponents/document/RelatedResponses.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue b/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue index 6e00eda9b..9c95fd2b4 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue @@ -18,7 +18,9 @@ const dialogRef = ref(null); const uiFlags = useMapGetter('captainResponses/getUIFlags'); const responses = useMapGetter('captainResponses/getRecords'); +const meta = useMapGetter('captainResponses/getMeta'); const isFetching = computed(() => uiFlags.value.fetchingList); +const totalCount = computed(() => meta.value.totalCount || 0); const handleClose = () => { emit('close'); @@ -37,7 +39,7 @@ defineExpose({ dialogRef }); Date: Wed, 4 Feb 2026 19:36:50 +0530 Subject: [PATCH 014/118] feat: Add standalone outgoing messages count API endpoint (#13419) This PR adds a new standalone `GET /api/v2/accounts/:id/reports/outgoing_messages_count` endpoint that returns outgoing message counts grouped by agent, team, inbox, or label. --- .../outgoing_messages_count_builder.rb | 79 +++++++++++ .../api/v2/accounts/reports_controller.rb | 17 +++ config/routes.rb | 1 + .../v2/accounts/reports_controller_spec.rb | 127 ++++++++++++++++++ swagger/definitions/index.yml | 2 + .../reports/outgoing_messages_count.yml | 21 +++ .../reports/outgoing_messages_count.yml | 36 +++++ swagger/paths/index.yml | 17 +++ swagger/swagger.json | 108 +++++++++++++++ swagger/tag_groups/application_swagger.json | 108 +++++++++++++++ swagger/tag_groups/client_swagger.json | 33 +++++ swagger/tag_groups/other_swagger.json | 33 +++++ swagger/tag_groups/platform_swagger.json | 33 +++++ 13 files changed, 615 insertions(+) create mode 100644 app/builders/v2/reports/outgoing_messages_count_builder.rb create mode 100644 swagger/definitions/resource/reports/outgoing_messages_count.yml create mode 100644 swagger/paths/application/reports/outgoing_messages_count.yml diff --git a/app/builders/v2/reports/outgoing_messages_count_builder.rb b/app/builders/v2/reports/outgoing_messages_count_builder.rb new file mode 100644 index 000000000..ac0de59f2 --- /dev/null +++ b/app/builders/v2/reports/outgoing_messages_count_builder.rb @@ -0,0 +1,79 @@ +class V2::Reports::OutgoingMessagesCountBuilder + include DateRangeHelper + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def build + send("build_by_#{params[:group_by]}") + end + + private + + def base_messages + account.messages.outgoing.unscope(:order).where(created_at: range) + end + + def build_by_agent + counts = base_messages + .where(sender_type: 'User') + .where.not(sender_id: nil) + .group(:sender_id) + .count + + user_names = account.users.where(id: counts.keys).index_by(&:id) + + counts.map do |user_id, count| + user = user_names[user_id] + { id: user_id, name: user&.name, outgoing_messages_count: count } + end + end + + def build_by_team + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .where.not(conversations: { team_id: nil }) + .group('conversations.team_id') + .count + + team_names = account.teams.where(id: counts.keys).index_by(&:id) + + counts.map do |team_id, count| + team = team_names[team_id] + { id: team_id, name: team&.name, outgoing_messages_count: count } + end + end + + def build_by_inbox + counts = base_messages + .group(:inbox_id) + .count + + inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id) + + counts.map do |inbox_id, count| + inbox = inbox_names[inbox_id] + { id: inbox_id, name: inbox&.name, outgoing_messages_count: count } + end + end + + def build_by_label + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id + AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'") + .joins('INNER JOIN tags ON tags.id = taggings.tag_id') + .group('tags.name') + .count + + label_ids = account.labels.where(title: counts.keys).index_by(&:title) + + counts.map do |label_name, count| + label = label_ids[label_name] + { id: label&.id, name: label_name, outgoing_messages_count: count } + end + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index ddd629048..192b3619c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -78,6 +78,15 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: builder.build end + OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze + + def outgoing_messages_count + return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by]) + + builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params) + render json: builder.build + end + private def generate_csv(filename, template) @@ -171,4 +180,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController until: params[:until] } end + + def outgoing_messages_count_params + { + group_by: params[:group_by], + since: params[:since], + until: params[:until] + } + end end diff --git a/config/routes.rb b/config/routes.rb index 79e5edd23..cab069201 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -446,6 +446,7 @@ Rails.application.routes.draw do get :bot_metrics get :inbox_label_matrix get :first_response_time_distribution + get :outgoing_messages_count end end resource :year_in_review, only: [:show] diff --git a/spec/controllers/api/v2/accounts/reports_controller_spec.rb b/spec/controllers/api/v2/accounts/reports_controller_spec.rb index c92425c32..f7bf86978 100644 --- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb @@ -295,4 +295,131 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do end end end + + describe 'GET /api/v2/accounts/{account.id}/reports/outgoing_messages_count' do + let(:since_epoch) { 1.week.ago.to_i.to_s } + let(:until_epoch) { 1.day.from_now.to_i.to_s } + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch } + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch }, + headers: agent.create_new_auth_token, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + let(:agent2) { create(:user, account: account, role: :agent) } + let(:team) { create(:team, account: account) } + let(:inbox2) { create(:inbox, account: account) } + + # Separate conversations for agent and team grouping because + # model callbacks clear assignee_id when team is set. + before do + conv_agent = create(:conversation, account: account, inbox: inbox, assignee: agent) + conv_agent2 = create(:conversation, account: account, inbox: inbox2, assignee: agent2) + conv_team = create(:conversation, account: account, inbox: inbox, team: team) + + create_list(:message, 3, account: account, conversation: conv_agent, inbox: inbox, message_type: :outgoing, sender: agent) + create_list(:message, 2, account: account, conversation: conv_agent2, inbox: inbox2, message_type: :outgoing, sender: agent2) + create_list(:message, 4, account: account, conversation: conv_team, inbox: inbox, message_type: :outgoing) + # incoming message should not be counted + create(:message, account: account, conversation: conv_agent, inbox: inbox, message_type: :incoming) + end + + it 'returns unprocessable_entity for invalid group_by' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'invalid', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns outgoing message counts grouped by agent' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + + agent_entry = data.find { |e| e['id'] == agent.id } + agent2_entry = data.find { |e| e['id'] == agent2.id } + expect(agent_entry['outgoing_messages_count']).to eq(3) + expect(agent2_entry['outgoing_messages_count']).to eq(2) + end + + it 'returns outgoing message counts grouped by team' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'team', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + expect(data.length).to eq(1) + expect(data.first['id']).to eq(team.id) + expect(data.first['outgoing_messages_count']).to eq(4) + end + + it 'returns outgoing message counts grouped by inbox' do + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'inbox', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + + inbox_entry = data.find { |e| e['id'] == inbox.id } + inbox2_entry = data.find { |e| e['id'] == inbox2.id } + expect(inbox_entry['outgoing_messages_count']).to eq(7) + expect(inbox2_entry['outgoing_messages_count']).to eq(2) + end + + it 'returns outgoing message counts grouped by label' do + label = create(:label, account: account, title: 'support') + conversation = account.conversations.first + conversation.label_list.add('support') + conversation.save! + + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'label', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data).to be_an(Array) + expect(data.length).to eq(1) + expect(data.first['id']).to eq(label.id) + expect(data.first['name']).to eq('support') + end + + it 'excludes bot messages when grouped by agent' do + bot = create(:agent_bot) + bot_conversation = create(:conversation, account: account, inbox: inbox) + create(:message, account: account, conversation: bot_conversation, inbox: inbox, + message_type: :outgoing, sender: bot) + + get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count", + params: { group_by: 'agent', since: since_epoch, until: until_epoch }, + headers: admin.create_new_auth_token, as: :json + + data = response.parsed_body + agent_entry = data.find { |e| e['id'] == agent.id } + # 3 from before block; bot message excluded (sender_type != 'User') + expect(agent_entry['outgoing_messages_count']).to eq(3) + end + end + end end diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index 1e64bf97b..1033da7dd 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -229,6 +229,8 @@ first_response_time_distribution: $ref: './resource/reports/first_response_time_distribution.yml' inbox_label_matrix: $ref: './resource/reports/inbox_label_matrix.yml' +outgoing_messages_count: + $ref: './resource/reports/outgoing_messages_count.yml' inbox_summary: $ref: './resource/reports/inbox_summary.yml' agent_summary: diff --git a/swagger/definitions/resource/reports/outgoing_messages_count.yml b/swagger/definitions/resource/reports/outgoing_messages_count.yml new file mode 100644 index 000000000..d7c55ed59 --- /dev/null +++ b/swagger/definitions/resource/reports/outgoing_messages_count.yml @@ -0,0 +1,21 @@ +type: array +description: Outgoing messages count report grouped by entity (agent, team, inbox, or label). +items: + type: object + properties: + id: + type: number + description: The ID of the grouped entity (agent, team, inbox, or label). + name: + type: string + description: The name of the grouped entity. + outgoing_messages_count: + type: number + description: The total number of outgoing messages for this entity in the given time range. +example: + - id: 1 + name: Agent One + outgoing_messages_count: 42 + - id: 2 + name: Agent Two + outgoing_messages_count: 18 diff --git a/swagger/paths/application/reports/outgoing_messages_count.yml b/swagger/paths/application/reports/outgoing_messages_count.yml new file mode 100644 index 000000000..af7eaae45 --- /dev/null +++ b/swagger/paths/application/reports/outgoing_messages_count.yml @@ -0,0 +1,36 @@ +tags: + - Reports +operationId: get-outgoing-messages-count +summary: Get outgoing messages count grouped by entity +security: + - userApiKey: [] +description: | + Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label). + When grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded. + + **Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above. +parameters: + - in: query + name: group_by + required: true + schema: + type: string + enum: + - agent + - team + - inbox + - label + description: The entity to group outgoing message counts by. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/outgoing_messages_count' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 24e460b4c..284bb7825 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -770,6 +770,23 @@ get: $ref: './application/reports/inbox_label_matrix.yml' +# Outgoing messages count report +/api/v2/accounts/{account_id}/reports/outgoing_messages_count: + parameters: + - $ref: '#/components/parameters/account_id' + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start (Unix timestamp). + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop (Unix timestamp). + get: + $ref: './application/reports/outgoing_messages_count.yml' + # Conversations Messages /accounts/{account_id}/conversations/{conversation_id}/messages: parameters: diff --git a/swagger/swagger.json b/swagger/swagger.json index 934864911..adde81e9f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -8274,6 +8274,81 @@ } } }, + "/api/v2/accounts/{account_id}/reports/outgoing_messages_count": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-outgoing-messages-count", + "summary": "Get outgoing messages count grouped by entity", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label).\nWhen grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "parameters": [ + { + "in": "query", + "name": "group_by", + "required": true, + "schema": { + "type": "string", + "enum": [ + "agent", + "team", + "inbox", + "label" + ] + }, + "description": "The entity to group outgoing message counts by." + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/outgoing_messages_count" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/accounts/{account_id}/conversations/{conversation_id}/messages": { "parameters": [ { @@ -12243,6 +12318,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 77a95da33..748722875 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -6816,6 +6816,81 @@ } } } + }, + "/api/v2/accounts/{account_id}/reports/outgoing_messages_count": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-outgoing-messages-count", + "summary": "Get outgoing messages count grouped by entity", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the count of outgoing messages grouped by a specified entity (agent, team, inbox, or label).\nWhen grouped by agent, messages sent by bots (AgentBot, Captain::Assistant) are excluded.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "parameters": [ + { + "in": "query", + "name": "group_by", + "required": true, + "schema": { + "type": "string", + "enum": [ + "agent", + "team", + "inbox", + "label" + ] + }, + "description": "The entity to group outgoing message counts by." + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/outgoing_messages_count" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } } }, "components": { @@ -10750,6 +10825,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index a786aebea..ebeb4a9cb 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -4558,6 +4558,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index 12dd566c4..9aa9f5a7a 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -3973,6 +3973,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index fd74b12e5..a830a8d56 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -4734,6 +4734,39 @@ ] } }, + "outgoing_messages_count": { + "type": "array", + "description": "Outgoing messages count report grouped by entity (agent, team, inbox, or label).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the grouped entity (agent, team, inbox, or label)." + }, + "name": { + "type": "string", + "description": "The name of the grouped entity." + }, + "outgoing_messages_count": { + "type": "number", + "description": "The total number of outgoing messages for this entity in the given time range." + } + } + }, + "example": [ + { + "id": 1, + "name": "Agent One", + "outgoing_messages_count": 42 + }, + { + "id": 2, + "name": "Agent Two", + "outgoing_messages_count": 18 + } + ] + }, "inbox_summary": { "type": "array", "description": "Inbox summary report containing conversation statistics grouped by inbox.", From 053b7774ddf1a3f1e98a73fd97f5606b999dda03 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 4 Feb 2026 06:51:07 -0800 Subject: [PATCH 015/118] fix: Render all account limit fields (#13435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug Explanation - The Super Admin limits form renders inputs by iterating the keys of `account.limits`. - When `account.limits` was present, `AccountLimitsField#to_s` returned only that hash (no defaults). - On save, `SuperAdmin::AccountsController` compacts the limits hash, removing blank keys. - Result: if only one key (e.g., `agents`) was saved, the other keys were missing from the hash and their fields disappeared on the next render. ## Fix - Always start from a defaults hash of all expected limit keys and merge in any saved overrides. - This keeps the UI stable and ensures all limit inputs remain visible even when the stored hash is partial. - Upgraded meta_request to `0.8.5` to stop a dev‑only `SystemStackError` caused by JSON‑encoding ActiveRecord::Transaction in Rails 7.2. No production behavior changes. ## Reproduction Steps 1. In Super Admin, edit an account and set only `agents` in the limits; leave other limit fields blank and save. 2. Re-open the same account in Super Admin. 3. Observe that only `agents` is rendered and other limit fields are missing. ## Testing - Tested on UI --------- Co-authored-by: Muhsin Keloth --- Gemfile.lock | 4 ++-- enterprise/app/fields/account_limits_field.rb | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1cdfabee0..b7b7301d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -541,9 +541,9 @@ GEM net-smtp marcel (1.0.4) maxminddb (0.1.22) - meta_request (0.8.3) + meta_request (0.8.5) rack-contrib (>= 1.1, < 3) - railties (>= 3.0.0, < 8) + railties (>= 3.0.0, < 9) method_source (1.1.0) mime-types (3.4.1) mime-types-data (~> 3.2015) diff --git a/enterprise/app/fields/account_limits_field.rb b/enterprise/app/fields/account_limits_field.rb index 2a46426b7..97c5b97fd 100644 --- a/enterprise/app/fields/account_limits_field.rb +++ b/enterprise/app/fields/account_limits_field.rb @@ -2,6 +2,9 @@ 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, emails: nil }.to_json + defaults = { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil } + overrides = (data.presence || {}).to_h.symbolize_keys.compact + + defaults.merge(overrides).to_json end end From 04e747cc02fd913e3ffe2c9370713214762e37b2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 6 Feb 2026 13:27:51 +0530 Subject: [PATCH 016/118] chore: temporarily disable `ProcessStaleContactsJob` (#13462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We've been seeing orphan conversations (conversations whose contact has been deleted) on high-frequency accounts. These orphans cause 500 errors when the API attempts to render conversation data, since the jbuilder partials expect a valid contact association. The `ProcessStaleContactsJob` triggers `RemoveStaleContactsService`, which uses `delete_all` to remove contacts. While the query only selects contacts without conversations, `delete_all` bypasses ActiveRecord callbacks and does not re-verify at deletion time. We suspect this creates a window where a new conversation can be created for a contact between query evaluation and the actual delete, leaving the conversation orphaned. **This is unverified — disabling the job is the experiment to confirm or rule out this theory.** This PR disables the job for a monitoring period. If orphan conversations stop appearing, we'll have confirmation and can work on a proper fix for the service. If they continue, we'll investigate other deletion paths. Stale contacts will accumulate while the job is disabled, but this is a controlled tradeoff — they are inert records with no user-facing impact, and can be cleaned up in bulk once we re-enable or fix the job. --- config/schedule.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/config/schedule.yml b/config/schedule.yml index 96e4cfc4f..4a264e587 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -33,12 +33,13 @@ remove_stale_redis_keys_job.rb: class: 'Internal::RemoveStaleRedisKeysJob' queue: scheduled_jobs -#executed daily at 0430 UTC -# which will be IST 10:00 AM -process_stale_contacts_job: - cron: '30 04 * * *' - class: 'Internal::ProcessStaleContactsJob' - queue: housekeeping +# DISABLED: investigating if this job is the source of orphan conversations +# #executed daily at 0430 UTC +# # which will be IST 10:00 AM +# process_stale_contacts_job: +# cron: '30 04 * * *' +# class: 'Internal::ProcessStaleContactsJob' +# queue: housekeeping # executed daily at 0100 UTC # to delete accounts marked for deletion From 0d3b59fd9cb05fb19efd5222e22d01bad095d310 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:22:30 +0530 Subject: [PATCH 017/118] feat: Refactor reports filters (#13443) --- .../components/ui/DatePicker/DatePicker.vue | 302 ++++++-- .../DatePicker/components/CalendarAction.vue | 8 +- .../components/CalendarDateRange.vue | 29 +- .../DatePicker/components/CalendarFooter.vue | 1 - .../DatePicker/components/CalendarMonth.vue | 2 +- .../ui/DatePicker/components/CalendarYear.vue | 2 +- .../components/DatePickerButton.vue | 89 ++- .../ui/DatePicker/helpers/DatePickerHelper.js | 73 +- .../components/ui/Dropdown/DropdownSearch.vue | 4 +- .../dashboard/i18n/locale/en/datePicker.json | 5 + .../dashboard/i18n/locale/en/report.json | 27 +- .../dashboard/settings/reports/BotReports.vue | 12 +- .../settings/reports/CsatResponses.vue | 3 + .../dashboard/settings/reports/Index.vue | 12 +- .../settings/reports/ReportContainer.vue | 2 +- .../reports/components/Csat/CsatFilters.vue | 65 +- .../reports/components/FilterSelector.vue | 228 ------ .../reports/components/Filters/Agents.vue | 47 -- .../components/Filters/DateGroupBy.vue | 66 -- .../reports/components/Filters/DateRange.vue | 51 -- .../reports/components/Filters/Inboxes.vue | 42 -- .../reports/components/Filters/Labels.vue | 66 -- .../reports/components/Filters/Ratings.vue | 40 - .../reports/components/Filters/Teams.vue | 42 -- .../Filters/v3/ActiveFilterChip.vue | 10 +- .../components/OverviewReportFilters.vue | 102 +++ .../reports/components/ReportFilters.vue | 689 +++++++++--------- .../reports/components/SLA/SLAFilter.vue | 324 ++++---- .../components/SLA/SLAReportFilters.vue | 147 ++-- .../reports/components/SummaryReports.vue | 4 +- .../reports/components/WootReports.vue | 115 +-- .../specs/Filters/FiltersAgents.spec.js | 57 -- .../specs/Filters/FiltersDateGroupBy.spec.js | 47 -- .../specs/Filters/FiltersDateRange.spec.js | 41 -- .../specs/Filters/FiltersInboxes.spec.js | 65 -- .../specs/Filters/FiltersLabels.spec.js | 67 -- .../specs/Filters/FiltersRatings.spec.js | 43 -- .../specs/Filters/FiltersTeams.spec.js | 65 -- .../reports/helpers/reportFilterHelper.js | 69 ++ .../helpers/reportFilterHelper.spec.js | 350 +++++++++ .../dashboard/store/modules/labels.js | 2 +- 41 files changed, 1678 insertions(+), 1737 deletions(-) delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/FilterSelector.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateGroupBy.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Inboxes.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Labels.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Ratings.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Teams.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/OverviewReportFilters.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersAgents.spec.js delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateGroupBy.spec.js delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateRange.spec.js delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersInboxes.spec.js delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersLabels.spec.js delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersRatings.spec.js delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersTeams.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/helpers/reportFilterHelper.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/helpers/reportFilterHelper.spec.js diff --git a/app/javascript/dashboard/components/ui/DatePicker/DatePicker.vue b/app/javascript/dashboard/components/ui/DatePicker/DatePicker.vue index 886bc30a0..58a31fba8 100644 --- a/app/javascript/dashboard/components/ui/DatePicker/DatePicker.vue +++ b/app/javascript/dashboard/components/ui/DatePicker/DatePicker.vue @@ -1,11 +1,14 @@ diff --git a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarDateRange.vue b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarDateRange.vue index 1aea65cfa..3b1e0d1e2 100644 --- a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarDateRange.vue +++ b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarDateRange.vue @@ -18,24 +18,25 @@ const setDateRange = range => { diff --git a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarFooter.vue b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarFooter.vue index 830f17ead..5aa45342f 100644 --- a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarFooter.vue +++ b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarFooter.vue @@ -23,7 +23,6 @@ const onClickApply = () => { /> diff --git a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarMonth.vue b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarMonth.vue index c3ddb5bfd..500cefbdc 100644 --- a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarMonth.vue +++ b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarMonth.vue @@ -78,7 +78,7 @@ const selectMonth = index => { 'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': index !== activeMonthIndex, }" - @click="selectMonth(index)" + @click.stop="selectMonth(index)" > {{ month }} diff --git a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarYear.vue b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarYear.vue index 13a786e27..c90a2ab45 100644 --- a/app/javascript/dashboard/components/ui/DatePicker/components/CalendarYear.vue +++ b/app/javascript/dashboard/components/ui/DatePicker/components/CalendarYear.vue @@ -77,7 +77,7 @@ const selectYear = year => { 'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear, 'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear, }" - @click="selectYear(year)" + @click.stop="selectYear(year)" > {{ year }} diff --git a/app/javascript/dashboard/components/ui/DatePicker/components/DatePickerButton.vue b/app/javascript/dashboard/components/ui/DatePicker/components/DatePickerButton.vue index 950a9f020..5885fb47b 100644 --- a/app/javascript/dashboard/components/ui/DatePicker/components/DatePickerButton.vue +++ b/app/javascript/dashboard/components/ui/DatePicker/components/DatePickerButton.vue @@ -2,6 +2,8 @@ import { computed } from 'vue'; import { dateRanges } from '../helpers/DatePickerHelper'; import { format, isSameYear, isValid } from 'date-fns'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; const props = defineProps({ selectedStartDate: Date, @@ -10,9 +12,21 @@ const props = defineProps({ type: String, default: '', }, + showMonthNavigation: { + type: Boolean, + default: false, + }, + canNavigateNext: { + type: Boolean, + default: false, + }, + navigationLabel: { + type: String, + default: null, + }, }); -const emit = defineEmits(['open']); +const emit = defineEmits(['open', 'navigateMonth']); const formatDateRange = computed(() => { const startDate = props.selectedStartDate; @@ -22,19 +36,15 @@ const formatDateRange = computed(() => { return 'Select a date range'; } - const formatString = isSameYear(startDate, endDate) - ? 'MMM d' // Same year: "Apr 1" - : 'MMM d yyyy'; // Different years: "Apr 1 2025" + const crossesYears = !isSameYear(startDate, endDate); - if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) { - // Both dates are in the current year - return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`; + // Always show years when crossing year boundaries + if (crossesYears) { + return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`; } - // At least one date is not in the current year - return `${format(startDate, formatString)} - ${format( - endDate, - formatString - )}`; + + // For same year, always show the year for clarity + return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`; }); const activeDateRange = computed( @@ -47,17 +57,46 @@ const openDatePicker = () => { diff --git a/app/javascript/dashboard/components/ui/DatePicker/helpers/DatePickerHelper.js b/app/javascript/dashboard/components/ui/DatePicker/helpers/DatePickerHelper.js index 85e41603f..e0e9276ee 100644 --- a/app/javascript/dashboard/components/ui/DatePicker/helpers/DatePickerHelper.js +++ b/app/javascript/dashboard/components/ui/DatePicker/helpers/DatePickerHelper.js @@ -10,6 +10,8 @@ import { isSameMonth, format, startOfWeek, + endOfWeek, + addWeeks, addDays, eachDayOfInterval, endOfMonth, @@ -34,13 +36,27 @@ export const dateRanges = [ { label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS', value: 'last3months', + separator: true, }, { label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS', value: 'last6months', }, { label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' }, - { label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' }, + { + label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK', + value: 'thisWeek', + separator: true, + }, + { + label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE', + value: 'monthToDate', + }, + { + label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', + value: 'custom', + separator: true, + }, ]; export const DATE_RANGE_TYPES = { @@ -49,6 +65,8 @@ export const DATE_RANGE_TYPES = { LAST_3_MONTHS: 'last3months', LAST_6_MONTHS: 'last6months', LAST_YEAR: 'lastYear', + THIS_WEEK: 'thisWeek', + MONTH_TO_DATE: 'monthToDate', CUSTOM_RANGE: 'custom', }; @@ -210,6 +228,14 @@ export const getActiveDateRange = (range, currentDate) => { start: startOfDay(subMonths(currentDate, 12)), end: endOfDay(currentDate), }), + thisWeek: () => ({ + start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })), + end: endOfDay(currentDate), + }), + monthToDate: () => ({ + start: startOfDay(startOfMonth(currentDate)), + end: endOfDay(currentDate), + }), custom: () => ({ start: currentDate, end: currentDate }), }; @@ -217,3 +243,48 @@ export const getActiveDateRange = (range, currentDate) => { ranges[range] || (() => ({ start: currentDate, end: currentDate })) )(); }; + +export const isNavigableRange = rangeType => + rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE || + rangeType === DATE_RANGE_TYPES.THIS_WEEK; + +const WEEK_START = 1; // Monday + +const getWeekRangeAtOffset = (offset, currentDate) => { + if (offset === 0) { + return { + start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })), + end: endOfDay(currentDate), + }; + } + const targetWeek = addWeeks(currentDate, offset); + return { + start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })), + end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })), + }; +}; + +const getMonthRangeAtOffset = (offset, currentDate) => { + if (offset === 0) { + return { + start: startOfDay(startOfMonth(currentDate)), + end: endOfDay(currentDate), + }; + } + const targetMonth = addMonths(currentDate, offset); + return { + start: startOfDay(startOfMonth(targetMonth)), + end: endOfDay(endOfMonth(targetMonth)), + }; +}; + +export const getRangeAtOffset = ( + rangeType, + offset, + currentDate = new Date() +) => { + if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) { + return getWeekRangeAtOffset(offset, currentDate); + } + return getMonthRangeAtOffset(offset, currentDate); +}; diff --git a/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue b/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue index 7e5bf569e..6be49b99e 100644 --- a/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue +++ b/app/javascript/dashboard/components/ui/Dropdown/DropdownSearch.vue @@ -34,8 +34,8 @@ const value = defineModel({
diff --git a/app/javascript/dashboard/i18n/locale/en/datePicker.json b/app/javascript/dashboard/i18n/locale/en/datePicker.json index c7ef06880..95d304cc6 100644 --- a/app/javascript/dashboard/i18n/locale/en/datePicker.json +++ b/app/javascript/dashboard/i18n/locale/en/datePicker.json @@ -1,5 +1,8 @@ { "DATE_PICKER": { + "PREVIOUS_PERIOD": "Previous period", + "NEXT_PERIOD": "Next period", + "WEEK_NUMBER": "Week #{weekNumber}", "APPLY_BUTTON": "Apply", "CLEAR_BUTTON": "Clear", "DATE_RANGE_INPUT": { @@ -13,6 +16,8 @@ "LAST_3_MONTHS": "Last 3 months", "LAST_6_MONTHS": "Last 6 months", "LAST_YEAR": "Last year", + "THIS_WEEK": "This week", + "MONTH_TO_DATE": "This month", "CUSTOM_RANGE": "Custom date range" } } diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 504caff0f..2ffa0ef11 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -128,11 +128,16 @@ }, "AGENT_REPORTS": { "HEADER": "Agents Overview", - "DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent’s name to learn more.", + "DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.", "LOADING_CHART": "Loading chart data...", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "DOWNLOAD_AGENT_REPORTS": "Download agent reports", "FILTER_DROPDOWN_LABEL": "Select Agent", + "FILTERS": { + "INPUT_PLACEHOLDER": { + "AGENTS": "Search agents" + } + }, "METRICS": { "CONVERSATIONS": { "NAME": "Conversations", @@ -201,6 +206,11 @@ "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "DOWNLOAD_LABEL_REPORTS": "Download label reports", "FILTER_DROPDOWN_LABEL": "Select Label", + "FILTERS": { + "INPUT_PLACEHOLDER": { + "LABELS": "Search labels" + } + }, "METRICS": { "CONVERSATIONS": { "NAME": "Conversations", @@ -271,6 +281,11 @@ "FILTER_DROPDOWN_LABEL": "Select Inbox", "ALL_INBOXES": "All Inboxes", "SEARCH_INBOX": "Search Inbox", + "FILTERS": { + "INPUT_PLACEHOLDER": { + "INBOXES": "Search inboxes" + } + }, "METRICS": { "CONVERSATIONS": { "NAME": "Conversations", @@ -334,11 +349,19 @@ }, "TEAM_REPORTS": { "HEADER": "Team Overview", - "DESCRIPTION": "Get a snapshot of your team’s performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.", + "DESCRIPTION": "Get a snapshot of your team's performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.", "LOADING_CHART": "Loading chart data...", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "DOWNLOAD_TEAM_REPORTS": "Download team reports", "FILTER_DROPDOWN_LABEL": "Select Team", + "FILTERS": { + "ADD_FILTER": "Add filter", + "CLEAR_ALL": "Clear all", + "NO_FILTER": "No filters available", + "INPUT_PLACEHOLDER": { + "TEAMS": "Search teams" + } + }, "METRICS": { "CONVERSATIONS": { "NAME": "Conversations", diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue index 11800a029..03b7290d6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue @@ -1,7 +1,7 @@ - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue deleted file mode 100644 index 353c09bb8..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateGroupBy.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateGroupBy.vue deleted file mode 100644 index 37139865f..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateGroupBy.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue deleted file mode 100644 index 9caab26dd..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Inboxes.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Inboxes.vue deleted file mode 100644 index 476c7a0a3..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Inboxes.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Labels.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Labels.vue deleted file mode 100644 index ede9e020c..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Labels.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Ratings.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Ratings.vue deleted file mode 100644 index 207de214f..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Ratings.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Teams.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Teams.vue deleted file mode 100644 index 606cdb2af..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Teams.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/v3/ActiveFilterChip.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/v3/ActiveFilterChip.vue index bd0a80984..e37a3bc2b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/v3/ActiveFilterChip.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/v3/ActiveFilterChip.vue @@ -8,8 +8,8 @@ const props = defineProps({ required: true, }, id: { - type: Number, - required: true, + type: [Number, null], + default: null, }, type: { type: String, @@ -35,6 +35,10 @@ const props = defineProps({ type: Boolean, default: false, }, + showClearFilter: { + type: Boolean, + default: true, + }, }); const emit = defineEmits([ @@ -60,7 +64,7 @@ const closeDropdown = () => emit('closeDropdown'); +import { ref, onMounted } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper'; +import subDays from 'date-fns/subDays'; +import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue'; +import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue'; +import { + generateReportURLParams, + parseReportURLParams, +} from '../helpers/reportFilterHelper'; +import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper'; + +const emit = defineEmits(['filterChange']); + +const route = useRoute(); +const router = useRouter(); + +const customDateRange = ref([subDays(new Date(), 6), new Date()]); +const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS); +const businessHoursSelected = ref(false); + +const updateURLParams = () => { + const params = generateReportURLParams({ + from: getUnixStartOfDay(customDateRange.value[0]), + to: getUnixEndOfDay(customDateRange.value[1]), + businessHours: businessHoursSelected.value, + range: selectedDateRange.value, + }); + + router.replace({ query: { ...params } }); +}; + +const emitChange = () => { + updateURLParams(); + emit('filterChange', { + from: getUnixStartOfDay(customDateRange.value[0]), + to: getUnixEndOfDay(customDateRange.value[1]), + businessHours: businessHoursSelected.value, + }); +}; + +const onDateRangeChange = value => { + const [startDate, endDate, rangeType] = value; + customDateRange.value = [startDate, endDate]; + selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE; + emitChange(); +}; + +const onBusinessHoursToggle = () => { + emitChange(); +}; + +const initializeFromURL = () => { + const urlParams = parseReportURLParams(route.query); + + // Set the range type first + if (urlParams.range) { + selectedDateRange.value = urlParams.range; + } + + // Restore dates from URL if available + if (urlParams.from && urlParams.to) { + customDateRange.value = [ + new Date(urlParams.from * 1000), + new Date(urlParams.to * 1000), + ]; + } + + if (urlParams.businessHours) { + businessHoursSelected.value = urlParams.businessHours; + } +}; + +onMounted(() => { + initializeFromURL(); + emitChange(); +}); + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue index 02b286d07..4b86aefba 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue @@ -1,349 +1,382 @@ - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAFilter.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAFilter.vue index abe5674eb..599c7ca5c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAFilter.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SLA/SLAFilter.vue @@ -1,155 +1,201 @@ -