Compare commits

...
Author SHA1 Message Date
Shivam Mishra e47d7ae894 refactor: remove METRIC_TO_HIDDEN_KEY 2026-02-04 21:01:29 +05:30
Shivam MishraandGitHub e6df7f7dd7 Merge branch 'develop' into feat/disable-report-metrics 2026-02-04 20:48:33 +05:30
053b7774dd fix: Render all account limit fields (#13435)
## 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 <muhsinkeramam@gmail.com>
2026-02-04 20:21:07 +05:30
Shivam Mishra 0f5a25bad7 feat: hide report keys 2026-02-04 19:40:25 +05:30
Muhsin KelothandGitHub 8eaea7c72e 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.
2026-02-04 19:36:50 +05:30
Shivam Mishra 9f80c41e75 refactor: use useAccount composable 2026-02-04 19:20:14 +05:30
Shivam Mishra a4cee4f72d perf(reports): skip computing hidden metrics in summary
Instead of filtering metrics after computation, skip the expensive
database queries entirely for hidden metrics. This prevents timeouts
on accounts with large datasets.

- MetricBuilder now accepts hidden_metrics param and returns nil for
  hidden metrics (compacted from response)
- Reports controller passes hidden_metrics to builder params
2026-02-04 13:44:15 +05:30
Shivam Mishra a34a916b46 feat(reports): hide metrics in frontend reports UI
Add useHiddenMetrics composable to filter hidden metrics from
report keys. Update WootReports.vue to use the composable so
hidden metrics are not fetched or displayed in the reports UI.
2026-02-04 13:20:41 +05:30
Shivam Mishra b09a2c25fa feat(reports): filter hidden metrics from API responses
Apply MetricFilter concern to reports controllers:
- ReportsController: return empty array for hidden timeseries metrics,
  filter summary and bot_summary responses
- SummaryReportsController: filter hidden metrics from array responses
2026-02-04 13:20:28 +05:30
Shivam Mishra 7e65cc752e feat(reports): add MetricFilter concern for hiding metrics
Add Reports::MetricFilter concern that provides methods to filter
hidden metrics from API responses:
- filter_hidden_metrics_from_hash: filters hash responses
- filter_hidden_metrics_from_array: filters array responses
- filter_summary_with_previous: filters summary with nested previous data
- metric_hidden?: checks if a specific metric should be hidden

Includes alias support (avg_reply_time -> reply_time).
2026-02-04 13:20:14 +05:30
Shivam Mishra 0f02c0cd5c feat(account): add report_hidden_metrics setting
Add report_hidden_metrics to account settings schema to allow
hiding specific metrics from reports on a per-account basis.

Supported metrics: outgoing_messages_count, incoming_messages_count, reply_time
2026-02-04 13:19:57 +05:30
Tanmay Deep SharmaandGitHub 7ade9061a8 feat: display total FAQ count in Related FAQs dialog (#13433)
## 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?

<img width="717" height="268" alt="Screenshot 2026-02-04 at 1 47 36 AM"
src="https://github.com/user-attachments/assets/c3e927ce-6d09-499d-8d02-8a44e0c353e2"
/>


## 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

<!-- CURSOR_SUMMARY -->
---

> [!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 `(<count>)`.
> 
> <sup>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).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-04 11:27:51 +05:30
Sojan Jose 9eb3ee44a8 Revert "chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)"
This reverts commit ef6ba8aabd.
2026-02-03 21:09:42 -08:00
Sojan JoseandGitHub ef6ba8aabd chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)
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.
2026-02-03 14:29:26 -08:00
Vishnu NarayananandGitHub c884cdefde feat: add per-account daily rate limit for outbound emails (#13411)
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 |
2026-02-03 02:06:51 +05:30
c77d935e38 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 <sojan@pepalo.com>
2026-02-02 16:50:35 +05:30
Muhsin KelothandGitHub b686d14044 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.

<img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x"
src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a"
/>


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
2026-02-02 15:52:53 +05:30
Shivam MishraandGitHub 133fb1bcf6 feat: add mark pending action to automation (#13378) 2026-02-02 11:59:51 +05:30
PranavandGitHub e9e6de5690 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.
2026-01-30 12:49:31 -08:00
PranavandGitHub 329b749702 Add API documentation for inbox, agent, and team summary report (#13409)
- 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)
2026-01-30 22:48:10 +04:00
PranavandGitHub d8c5dda36c 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
2026-01-30 22:33:03 +04:00
5ec77aca64 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=<since>&until=<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 <muhsinkeramam@gmail.com>
2026-01-30 22:22:27 +04:00
69 changed files with 4113 additions and 126 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
parallelism: 20
steps:
- checkout
- node/install:
+2 -2
View File
@@ -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)
@@ -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
@@ -1,14 +1,16 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
HIDEABLE_METRICS = %w[incoming_messages_count outgoing_messages_count reply_time].freeze
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
incoming_messages_count: count_unless_hidden('incoming_messages_count'),
outgoing_messages_count: count_unless_hidden('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
reply_time: count_unless_hidden('reply_time')
}.compact
end
def bot_summary
@@ -24,6 +26,18 @@ class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::Ba
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def count_unless_hidden(metric)
return nil if metric_hidden?(metric)
count(metric)
end
def metric_hidden?(metric)
return false unless HIDEABLE_METRICS.include?(metric)
(params[:hidden_metrics] || []).include?(metric)
end
def builder_params(metric)
params.merge({ metric: metric })
end
@@ -0,0 +1,68 @@
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
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
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
def range_condition
range.present? ? { created_at: range } : {}
end
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|
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
@@ -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
@@ -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
@@ -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
@@ -1,10 +1,14 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
include Api::V2::Accounts::HeatmapHelper
include Reports::MetricFilter
before_action :check_authorization
def index
# Return empty timeseries array for hidden metrics (preserves expected response shape)
return render json: [] if metric_hidden?(params[:metric])
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
data = builder.timeseries
render json: data
@@ -70,6 +74,23 @@ 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
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)
@@ -95,7 +116,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:current][:since],
until: range[:current][:until],
timezone_offset: params[:timezone_offset]
timezone_offset: params[:timezone_offset],
hidden_metrics: hidden_metrics
})
end
@@ -103,7 +125,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until],
timezone_offset: params[:timezone_offset]
timezone_offset: params[:timezone_offset],
hidden_metrics: hidden_metrics
})
end
@@ -156,4 +179,19 @@ 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
def outgoing_messages_count_params
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end
@@ -1,4 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
include Reports::MetricFilter
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
@@ -40,7 +42,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
render json: builder.build
render json: filter_hidden_metrics_from_array(builder.build)
end
def permitted_params
@@ -0,0 +1,59 @@
module Reports::MetricFilter
extend ActiveSupport::Concern
HIDEABLE_METRICS = %w[outgoing_messages_count incoming_messages_count reply_time].freeze
# Alias mapping (canonical form)
METRIC_ALIASES = {
'avg_reply_time' => 'reply_time'
}.freeze
private
def filter_hidden_metrics_from_hash(data)
return data if hidden_metrics.blank?
data.reject { |key, _| metric_key_hidden?(key) }
end
def filter_hidden_metrics_from_array(data)
return data if hidden_metrics.blank?
data.map { |item| item.reject { |key, _| metric_key_hidden?(key) } }
end
# Filter summary data including nested 'previous' key (handles both symbol and string keys)
def filter_summary_with_previous(data)
return data if hidden_metrics.blank?
filtered = filter_hidden_metrics_from_hash(data)
previous_data = filtered[:previous] || filtered['previous']
if previous_data.present?
filtered_previous = filter_hidden_metrics_from_hash(previous_data)
# Preserve the original key type
if filtered.key?(:previous)
filtered[:previous] = filtered_previous
else
filtered['previous'] = filtered_previous
end
end
filtered
end
def hidden_metrics
@hidden_metrics ||= (Current.account.report_hidden_metrics || []) & HIDEABLE_METRICS
end
def metric_hidden?(metric_name)
# Normalize to canonical form (e.g., avg_reply_time -> reply_time)
normalized = METRIC_ALIASES[metric_name.to_s] || metric_name.to_s
hidden_metrics.include?(normalized)
end
# Check if a hash key should be hidden (handles both symbol and string keys, plus aliases)
def metric_key_hidden?(key)
key_str = key.to_s
normalized = METRIC_ALIASES[key_str] || key_str
hidden_metrics.include?(normalized)
end
end
@@ -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],
@@ -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 });
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@@ -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({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container"
class="flex w-full mb-2 message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -0,0 +1,29 @@
import { computed } from 'vue';
import { useAccount } from './useAccount';
// Alias mapping for consistency (canonical form is reply_time)
const METRIC_ALIASES = {
avg_reply_time: 'reply_time',
};
export function useHiddenMetrics() {
const { currentAccount } = useAccount();
const hiddenMetrics = computed(() => {
return currentAccount.value?.settings?.report_hidden_metrics || [];
});
const isMetricHidden = metricKey => {
const normalized = METRIC_ALIASES[metricKey] || metricKey;
return hiddenMetrics.value.includes(normalized);
};
const filterVisibleReportKeys = reportKeys => {
// Filter out hidden metrics from reportKeys object
return Object.fromEntries(
Object.entries(reportKeys).filter(([, value]) => !isMetricHidden(value))
);
};
return { hiddenMetrics, isMetricHidden, filterVisibleReportKeys };
}
@@ -127,6 +127,7 @@ const validateSingleAction = action => {
'resolve_conversation',
'remove_assigned_team',
'open_conversation',
'pending_conversation',
];
if (
@@ -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",
@@ -253,6 +253,8 @@
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
"BOT": "Bot",
"NATIVE_APP": "Native app",
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
"SEND_FAILED": "Couldn't send message! Try again",
"TRY_AGAIN": "retry",
"ASSIGNMENT": {
@@ -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',
@@ -1,6 +1,7 @@
<script>
import V4Button from 'dashboard/components-next/button/Button.vue';
import { useAlert, useTrack } from 'dashboard/composables';
import { useHiddenMetrics } from 'dashboard/composables/useHiddenMetrics';
import ReportFilterSelector from './components/FilterSelector.vue';
import { GROUP_BY_FILTER } from './constants';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -8,7 +9,7 @@ import { generateFileName } from 'dashboard/helper/downloadHelper';
import ReportContainer from './ReportContainer.vue';
import ReportHeader from './components/ReportHeader.vue';
const REPORTS_KEYS = {
const ALL_REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count',
INCOMING_MESSAGES: 'incoming_messages_count',
OUTGOING_MESSAGES: 'outgoing_messages_count',
@@ -26,6 +27,10 @@ export default {
ReportContainer,
V4Button,
},
setup() {
const { filterVisibleReportKeys } = useHiddenMetrics();
return { filterVisibleReportKeys };
},
data() {
return {
from: 0,
@@ -34,6 +39,11 @@ export default {
businessHours: false,
};
},
computed: {
reportKeys() {
return this.filterVisibleReportKeys(ALL_REPORTS_KEYS);
},
},
methods: {
fetchAllData() {
this.fetchAccountSummary();
@@ -47,18 +57,10 @@ export default {
}
},
fetchChartData() {
[
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
'REPLY_TIME',
].forEach(async key => {
Object.keys(this.reportKeys).forEach(async key => {
try {
await this.$store.dispatch('fetchAccountReport', {
metric: REPORTS_KEYS[key],
metric: this.reportKeys[key],
...this.getRequestPayload(),
});
} catch {
@@ -121,6 +123,6 @@ export default {
show-group-by-filter
@filter-change="onFilterChange"
/>
<ReportContainer :group-by="groupBy" />
<ReportContainer :group-by="groupBy" :report-keys="reportKeys" />
</div>
</template>
@@ -2,6 +2,7 @@
import ReportFilterSelector from './FilterSelector.vue';
import { formatTime } from '@chatwoot/utils';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useHiddenMetrics } from 'dashboard/composables/useHiddenMetrics';
import Table from 'dashboard/components/table/Table.vue';
import { generateFileName } from 'dashboard/helper/downloadHelper';
import {
@@ -44,6 +45,7 @@ import SummaryReportLink from './SummaryReportLink.vue';
const rowItems = useMapGetter([props.getterKey]) || [];
const reportMetrics = useMapGetter([props.summaryKey]) || [];
const { isMetricHidden } = useHiddenMetrics();
const getMetrics = id =>
reportMetrics.value.find(metrics => metrics.id === Number(id)) || {};
@@ -59,38 +61,42 @@ const defaulSpanRender = cellProps =>
cellProps.getValue()
);
const columns = computed(() => [
columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300,
cell: cellProps => h(SummaryReportLink, cellProps),
}),
columnHelper.accessor('conversationsCount', {
header: t('SUMMARY_REPORTS.CONVERSATIONS'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgFirstResponseTime', {
header: t('SUMMARY_REPORTS.AVG_FIRST_RESPONSE_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgResolutionTime', {
header: t('SUMMARY_REPORTS.AVG_RESOLUTION_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgReplyTime', {
header: t('SUMMARY_REPORTS.AVG_REPLY_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('resolutionsCount', {
header: t('SUMMARY_REPORTS.RESOLUTION_COUNT'),
width: 200,
cell: defaulSpanRender,
}),
]);
const columns = computed(() => {
const allColumns = [
columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300,
cell: cellProps => h(SummaryReportLink, cellProps),
}),
columnHelper.accessor('conversationsCount', {
header: t('SUMMARY_REPORTS.CONVERSATIONS'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgFirstResponseTime', {
header: t('SUMMARY_REPORTS.AVG_FIRST_RESPONSE_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgResolutionTime', {
header: t('SUMMARY_REPORTS.AVG_RESOLUTION_TIME'),
width: 200,
cell: defaulSpanRender,
}),
!isMetricHidden('reply_time') &&
columnHelper.accessor('avgReplyTime', {
header: t('SUMMARY_REPORTS.AVG_REPLY_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('resolutionsCount', {
header: t('SUMMARY_REPORTS.RESOLUTION_COUNT'),
width: 200,
cell: defaulSpanRender,
}),
];
return allColumns.filter(Boolean);
});
const renderAvgTime = value => (value ? formatTime(value) : '--');
@@ -1,6 +1,7 @@
<script>
import V4Button from 'dashboard/components-next/button/Button.vue';
import { useAlert, useTrack } from 'dashboard/composables';
import { useHiddenMetrics } from 'dashboard/composables/useHiddenMetrics';
import ReportFilters from './ReportFilters.vue';
import ReportContainer from '../ReportContainer.vue';
import { GROUP_BY_FILTER } from '../constants';
@@ -63,6 +64,10 @@ export default {
default: null,
},
},
setup() {
const { filterVisibleReportKeys } = useHiddenMetrics();
return { filterVisibleReportKeys };
},
data() {
return {
from: 0,
@@ -82,7 +87,7 @@ export default {
return this.type === 'agent';
},
reportKeys() {
return {
const allKeys = {
CONVERSATIONS: 'conversations_count',
...(!this.isAgentType && {
INCOMING_MESSAGES: 'incoming_messages_count',
@@ -93,6 +98,8 @@ export default {
RESOLUTION_COUNT: 'resolutions_count',
REPLY_TIME: 'reply_time',
};
// Filter out hidden metrics
return this.filterVisibleReportKeys(allKeys);
},
},
mounted() {
+1
View File
@@ -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
+1 -1
View File
@@ -54,7 +54,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
# Receive real-time notifications if you send a message to a user.
def im_send_msg
# This can be either an echo message or a message sent directly via tiktok application
::Tiktok::MessageService.new(channel: channel, content: content).perform
::Tiktok::MessageService.new(channel: channel, content: content, outgoing_echo: true).perform
end
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.
+50
View File
@@ -9,6 +9,56 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return
end
if message_echo_event?(params)
handle_message_echo(channel, params)
else
handle_message_events(channel, params)
end
end
# Detects if the webhook is an SMB message echo event (message sent from WhatsApp Business app)
# This is part of WhatsApp coexistence feature where businesses can respond from both
# Chatwoot and the WhatsApp Business app, with messages synced to Chatwoot.
#
# Regular message payload (field: "messages"):
# {
# "entry": [{
# "changes": [{
# "field": "messages",
# "value": {
# "contacts": [{ "wa_id": "919745786257", "profile": { "name": "Customer" } }],
# "messages": [{ "from": "919745786257", "id": "wamid...", "text": { "body": "Hello" } }]
# }
# }]
# }]
# }
#
# Echo message payload (field: "smb_message_echoes"):
# {
# "entry": [{
# "changes": [{
# "field": "smb_message_echoes",
# "value": {
# "message_echoes": [{ "from": "971545296927", "to": "919745786257", "id": "wamid...", "text": { "body": "Hi" } }]
# }
# }]
# }]
# }
#
# Key differences:
# - field: "smb_message_echoes" instead of "messages"
# - message_echoes[] instead of messages[]
# - "from" is the business number, "to" is the contact (reversed from regular messages)
# - No "contacts" array in echo payload
def message_echo_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
end
def handle_message_echo(channel, params)
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
end
def handle_message_events(channel, params)
case channel.provider
when 'whatsapp_cloud'
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
+1
View File
@@ -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
+9
View File
@@ -29,6 +29,7 @@ class Account < ApplicationRecord
include Featurable
include CacheKeys
include CaptainFeaturable
include AccountEmailRateLimitable
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
@@ -66,6 +67,13 @@ class Account < ApplicationRecord
'help_center_search': { 'type': %w[boolean null] }
},
'additionalProperties': false
},
'report_hidden_metrics': {
'type': %w[array null],
'items': {
'type': 'string',
'enum': %w[outgoing_messages_count incoming_messages_count reply_time]
}
}
},
'required': [],
@@ -87,6 +95,7 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :report_hidden_metrics
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
+2 -2
View File
@@ -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
@@ -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
+2 -1
View File
@@ -344,10 +344,11 @@ class Message < ApplicationRecord
# if the sender is not a user, it's not a human response
# if automation rule id is present, it's not a human response
# if campaign id is present, it's not a human response
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
outgoing? &&
content_attributes['automation_rule_id'].blank? &&
additional_attributes['campaign_id'].blank? &&
sender.is_a?(User)
(sender.is_a?(User) || content_attributes['external_echo'].present?)
end
def bot_response?
+4
View File
@@ -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
@@ -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
@@ -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}?")
+3 -3
View File
@@ -1,11 +1,10 @@
class Tiktok::MessageService
include Tiktok::MessagingHelpers
pattr_initialize [:channel!, :content!]
pattr_initialize [:channel!, :content!, :outgoing_echo]
def perform
if outgoing_message?
# Skip processing echo messages
message = find_message(tt_conversation_id, tt_message_id)
return if message.present?
end
@@ -39,7 +38,7 @@ class Tiktok::MessageService
updated_at: tt_message_time
)
message.sender = contact_inbox.contact if incoming_message?
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
message.status = :delivered if outgoing_message?
create_message_attachments(message)
@@ -91,6 +90,7 @@ class Tiktok::MessageService
attributes = {}
attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id
attributes[:is_unsupported] = true unless supported_message?
attributes[:external_echo] = true if outgoing_echo
attributes
end
+22 -2
View File
@@ -61,16 +61,36 @@ 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,
body: {
override_callback_uri: callback_url,
verify_token: verify_token
verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes]
}.to_json
)
handle_response(response, 'Webhook subscription failed')
handle_response(response, 'Webhook callback override failed')
end
def unsubscribe_waba_webhook(waba_id)
@@ -4,18 +4,23 @@
class Whatsapp::IncomingMessageBaseService
include ::Whatsapp::IncomingMessageServiceHelpers
pattr_initialize [:inbox!, :params!]
pattr_initialize [:inbox!, :params!, :outgoing_echo]
def perform
processed_params
if processed_params.try(:[], :statuses).present?
process_statuses
elsif processed_params.try(:[], :messages).present?
elsif messages_data.present?
process_messages
end
end
# Returns messages array for both regular messages and echo events
def messages_data
@processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
end
private
def process_messages
@@ -26,7 +31,7 @@ class Whatsapp::IncomingMessageBaseService
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
# business manager account. While we have not found the core reason yet, the following line ensure that
# there are no duplicate messages created.
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
cache_message_source_id_in_redis
set_contact
@@ -57,7 +62,7 @@ class Whatsapp::IncomingMessageBaseService
end
def create_messages
message = @processed_params[:messages].first
message = messages_data.first
log_error(message) && return if error_webhook_event?(message)
process_in_reply_to(message)
@@ -67,20 +72,44 @@ class Whatsapp::IncomingMessageBaseService
def create_contact_messages(message)
message['contacts'].each do |contact|
create_message(contact)
# Pass source_id from parent message since contact objects don't have :id
create_message(contact, source_id: message[:id])
attach_contact(contact)
@message.save!
end
end
def create_regular_message(message)
create_message(message)
create_message(message, source_id: message[:id])
attach_files
attach_location if message_type == 'location'
@message.save!
end
def set_contact
if outgoing_echo
set_contact_from_echo
else
set_contact_from_message
end
end
def set_contact_from_echo
# For echo messages, contact phone is in the 'to' field
phone_number = messages_data.first[:to]
waid = processed_waid(phone_number)
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
def set_contact_from_message
contact_params = @processed_params[:contacts]&.first
return if contact_params.blank?
@@ -89,7 +118,7 @@ class Whatsapp::IncomingMessageBaseService
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" }
).perform
@contact_inbox = contact_inbox
@@ -115,7 +144,7 @@ class Whatsapp::IncomingMessageBaseService
def attach_files
return if %w[text button interactive location contacts].include?(message_type)
attachment_payload = @processed_params[:messages].first[message_type.to_sym]
attachment_payload = messages_data.first[message_type.to_sym]
@message.content ||= attachment_payload[:caption]
attachment_file = download_attachment_file(attachment_payload)
@@ -133,7 +162,7 @@ class Whatsapp::IncomingMessageBaseService
end
def attach_location
location = @processed_params[:messages].first['location']
location = messages_data.first['location']
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
@message.attachments.new(
account_id: @message.account_id,
@@ -145,14 +174,17 @@ class Whatsapp::IncomingMessageBaseService
)
end
def create_message(message)
def create_message(message, source_id: nil)
@message = @conversation.messages.build(
content: message_content(message),
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: message[:id].to_s,
message_type: outgoing_echo ? :outgoing : :incoming,
# Set status to :delivered for echo messages to prevent SendReplyJob from trying to send them
status: outgoing_echo ? :delivered : :sent,
sender: outgoing_echo ? nil : @contact,
source_id: (source_id || message[:id]).to_s,
content_attributes: outgoing_echo ? { external_echo: true } : {},
in_reply_to_external_id: @in_reply_to_external_id
)
end
@@ -189,7 +221,7 @@ class Whatsapp::IncomingMessageBaseService
end
def contact_name_matches_phone_number?
phone_number = "+#{@processed_params[:messages].first[:from]}"
phone_number = "+#{messages_data.first[:from]}"
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
@contact.name == phone_number || @contact.name == formatted_phone_number
end
@@ -21,7 +21,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def message_type
@processed_params[:messages].first[:type]
messages_data.first[:type]
end
def message_content(message)
@@ -70,19 +70,19 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
Redis::Alfred.get(key)
end
def cache_message_source_id_in_redis
return if @processed_params.try(:[], :messages).blank?
return if messages_data.blank?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.setex(key, true)
end
def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.delete(key)
end
end
+10
View File
@@ -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 ------- #
+2
View File
@@ -445,6 +445,8 @@ Rails.application.routes.draw do
get :conversation_traffic
get :bot_metrics
get :inbox_label_matrix
get :first_response_time_distribution
get :outgoing_messages_count
end
end
resource :year_in_review, only: [:show]
@@ -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
+2 -1
View File
@@ -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"
@@ -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 }.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
@@ -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
+3
View File
@@ -49,4 +49,7 @@ module Redis::RedisKeys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
end
@@ -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
@@ -248,4 +248,178 @@ 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
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
@@ -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
@@ -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) }
@@ -161,10 +161,23 @@ 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' },
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,
@@ -179,18 +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' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
.to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_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(/Webhook subscription failed/)
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 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 callback override failed/)
end
end
end
+12
View File
@@ -225,6 +225,18 @@ 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'
outgoing_messages_count:
$ref: './resource/reports/outgoing_messages_count.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
@@ -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
@@ -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
@@ -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]
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -18,6 +18,6 @@
</head>
<body>
<redoc spec-url='/swagger/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js"> </script>
</body>
</html>
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'
+128 -2
View File
@@ -641,6 +641,23 @@
# Channel summary report (Available in 4.10.0+)
/api/v2/accounts/{account_id}/summary_reports/channel:
parameters:
- $ref: '#/components/parameters/account_id'
- in: query
name: since
schema:
type: string
description: The timestamp from where report should start (Unix timestamp).
- in: query
name: until
schema:
type: string
description: The timestamp from where report should stop (Unix timestamp).
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
@@ -657,9 +674,118 @@
name: business_hours
schema:
type: boolean
description: Whether to filter by business hours.
description: Whether to calculate metrics using business hours only.
get:
$ref: './application/reports/channel_summary.yml'
$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:
- $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'
# 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:
+740 -8
View File
@@ -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,417 @@
}
}
},
"/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": [
{
"$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"
}
}
}
}
}
}
},
"/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": [
{
@@ -11781,6 +12184,335 @@
}
}
},
"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
]
]
}
},
"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.",
"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": {
+740 -8
View File
@@ -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,417 @@
}
}
}
},
"/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": [
{
"$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"
}
}
}
}
}
}
},
"/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": {
@@ -10288,6 +10691,335 @@
}
}
},
"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
]
]
}
},
"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.",
"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": {
+329
View File
@@ -4424,6 +4424,335 @@
}
}
},
"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
]
]
}
},
"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.",
"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": {
+329
View File
@@ -3839,6 +3839,335 @@
}
}
},
"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
]
]
}
},
"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.",
"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": {
+329
View File
@@ -4600,6 +4600,335 @@
}
}
},
"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
]
]
}
},
"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.",
"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": {