Merge branch 'develop' into feat/captain-editor-integration
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
4.9.1
|
||||
4.9.2
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
class V2::Reports::ChannelSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversations_by_channel_and_status
|
||||
account.conversations
|
||||
.joins(:inbox)
|
||||
.where(created_at: range)
|
||||
.group('inboxes.channel_type', 'conversations.status')
|
||||
.count
|
||||
.each_with_object({}) do |((channel_type, status), count), grouped|
|
||||
grouped[channel_type] ||= {}
|
||||
grouped[channel_type][status] = count
|
||||
end
|
||||
end
|
||||
|
||||
def build_channel_stats(status_counts)
|
||||
open_count = status_counts['open'] || 0
|
||||
resolved_count = status_counts['resolved'] || 0
|
||||
pending_count = status_counts['pending'] || 0
|
||||
snoozed_count = status_counts['snoozed'] || 0
|
||||
|
||||
{
|
||||
open: open_count,
|
||||
resolved: resolved_count,
|
||||
pending: pending_count,
|
||||
snoozed: snoozed_count,
|
||||
total: open_count + resolved_count + pending_count + snoozed_count
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||
end
|
||||
|
||||
def channel
|
||||
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
|
||||
|
||||
render_report_with(V2::Reports::ChannelSummaryBuilder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
|
||||
|
||||
since_time = Time.zone.at(permitted_params[:since].to_i)
|
||||
until_time = Time.zone.at(permitted_params[:until].to_i)
|
||||
(until_time - since_time) > 6.months
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Quote detection strategies
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { format, parseISO, isValid as isValidDate } from 'date-fns';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Extracts plain text from HTML content
|
||||
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
|
||||
return html.replace(/<[^>]*>/g, ' ');
|
||||
}
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(html);
|
||||
return tempDiv.textContent || tempDiv.innerText || '';
|
||||
};
|
||||
|
||||
|
||||
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
|
||||
});
|
||||
|
||||
describe('HTML sanitization', () => {
|
||||
it('removes onerror handlers from img tags in extractQuotes', () => {
|
||||
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onerror');
|
||||
expect(cleanedHtml).toContain('<p>Hello</p>');
|
||||
});
|
||||
|
||||
it('removes onerror handlers from img tags in hasQuotes', () => {
|
||||
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
// Should not throw and should safely check for quotes
|
||||
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('removes script tags in extractQuotes', () => {
|
||||
const maliciousHtml =
|
||||
'<p>Content</p><script>alert("xss")</script><p>More</p>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('<script');
|
||||
expect(cleanedHtml).not.toContain('alert');
|
||||
expect(cleanedHtml).toContain('<p>Content</p>');
|
||||
expect(cleanedHtml).toContain('<p>More</p>');
|
||||
});
|
||||
|
||||
it('removes onclick handlers in extractQuotes', () => {
|
||||
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onclick');
|
||||
expect(cleanedHtml).toContain('Click me');
|
||||
});
|
||||
|
||||
it('removes javascript: URLs in extractQuotes', () => {
|
||||
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
expect(cleanedHtml).not.toContain('javascript:');
|
||||
expect(cleanedHtml).toContain('Link');
|
||||
});
|
||||
|
||||
it('removes encoded payloads with event handlers in extractQuotes', () => {
|
||||
const maliciousHtml =
|
||||
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onerror');
|
||||
expect(cleanedHtml).not.toContain('eval');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
|
||||
expect(result).toContain('Line 1');
|
||||
expect(result).toContain('Line 2');
|
||||
});
|
||||
|
||||
it('sanitizes onerror handlers from img tags', () => {
|
||||
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Hello');
|
||||
});
|
||||
|
||||
it('sanitizes script tags', () => {
|
||||
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toContain('Safe');
|
||||
expect(result).toContain('Content');
|
||||
expect(result).not.toContain('alert');
|
||||
});
|
||||
|
||||
it('sanitizes onclick handlers', () => {
|
||||
const html = '<p onclick="alert(1)">Click me</p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Click me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailSenderName', () => {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.9.1'
|
||||
version: '4.9.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -134,6 +134,8 @@ en:
|
||||
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
||||
stripe_customer_not_configured: Stripe customer not configured
|
||||
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
|
||||
reports:
|
||||
date_range_too_long: Date range cannot exceed 6 months
|
||||
profile:
|
||||
mfa:
|
||||
enabled: MFA enabled successfully
|
||||
|
||||
@@ -424,6 +424,7 @@ Rails.application.routes.draw do
|
||||
get :team
|
||||
get :inbox
|
||||
get :label
|
||||
get :channel
|
||||
end
|
||||
end
|
||||
resources :reports, only: [:index] do
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.9.1",
|
||||
"version": "4.9.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe V2::Reports::ChannelSummaryBuilder do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:web_widget_inbox) { create(:inbox, account: account) }
|
||||
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
|
||||
let(:params) do
|
||||
{
|
||||
since: 1.week.ago.beginning_of_day,
|
||||
until: Time.current.end_of_day
|
||||
}
|
||||
end
|
||||
let(:builder) { described_class.new(account: account, params: params) }
|
||||
|
||||
describe '#build' do
|
||||
subject(:report) { builder.build }
|
||||
|
||||
context 'when there are conversations with different statuses across channels' do
|
||||
before do
|
||||
# Web widget conversations
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 3.days.ago)
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :pending, created_at: 1.day.ago)
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :snoozed, created_at: 1.day.ago)
|
||||
|
||||
# Email conversations
|
||||
create(:conversation, account: account, inbox: email_inbox, status: :open, created_at: 2.days.ago)
|
||||
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 1.day.ago)
|
||||
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 3.days.ago)
|
||||
end
|
||||
|
||||
it 'returns correct counts grouped by channel type' do
|
||||
expect(report['Channel::WebWidget']).to eq(
|
||||
open: 2,
|
||||
resolved: 1,
|
||||
pending: 1,
|
||||
snoozed: 1,
|
||||
total: 5
|
||||
)
|
||||
|
||||
expect(report['Channel::Email']).to eq(
|
||||
open: 1,
|
||||
resolved: 2,
|
||||
pending: 0,
|
||||
snoozed: 0,
|
||||
total: 3
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversations are outside the date range' do
|
||||
before do
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.weeks.ago)
|
||||
end
|
||||
|
||||
it 'only includes conversations within the date range' do
|
||||
expect(report['Channel::WebWidget']).to eq(
|
||||
open: 1,
|
||||
resolved: 0,
|
||||
pending: 0,
|
||||
snoozed: 0,
|
||||
total: 1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no conversations' do
|
||||
it 'returns an empty hash' do
|
||||
expect(report).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a channel has only one status type' do
|
||||
before do
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 1.day.ago)
|
||||
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'returns zeros for other statuses' do
|
||||
expect(report['Channel::WebWidget']).to eq(
|
||||
open: 0,
|
||||
resolved: 2,
|
||||
pending: 0,
|
||||
snoozed: 0,
|
||||
total: 2
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,19 +10,14 @@ RSpec.describe 'API Base', type: :request do
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
it 'sets Current attributes for the request and then returns the response' do
|
||||
# expect Current.account_user is set to the admin's account_user
|
||||
allow(Current).to receive(:user=).and_call_original
|
||||
allow(Current).to receive(:account=).and_call_original
|
||||
allow(Current).to receive(:account_user=).and_call_original
|
||||
|
||||
# This test verifies that Current.user, Current.account, and Current.account_user
|
||||
# are properly set during request processing. We verify this indirectly:
|
||||
# - A successful response proves Current.account_user was set (required for authorization)
|
||||
# - The correct conversation data proves Current.account was set (scopes the query)
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(Current).to have_received(:user=).with(admin).at_least(:once)
|
||||
expect(Current).to have_received(:account=).with(account).at_least(:once)
|
||||
expect(Current).to have_received(:account_user=).with(admin.account_users.first).at_least(:once)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
end
|
||||
|
||||
@@ -160,4 +160,68 @@ RSpec.describe 'Summary Reports API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/:account_id/summary_reports/channel' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/summary_reports/channel"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:params) do
|
||||
{
|
||||
since: start_of_today.to_s,
|
||||
until: end_of_today.to_s
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unauthorized for agents' do
|
||||
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'calls V2::Reports::ChannelSummaryBuilder with the right params if the user is an admin' do
|
||||
channel_summary_builder = double
|
||||
allow(V2::Reports::ChannelSummaryBuilder).to receive(:new).and_return(channel_summary_builder)
|
||||
allow(channel_summary_builder).to receive(:build)
|
||||
.and_return({
|
||||
'Channel::WebWidget' => { open: 5, resolved: 10, pending: 2, snoozed: 1, total: 18 }
|
||||
})
|
||||
|
||||
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(V2::Reports::ChannelSummaryBuilder).to have_received(:new).with(
|
||||
account: account,
|
||||
params: hash_including(since: start_of_today.to_s, until: end_of_today.to_s)
|
||||
)
|
||||
expect(channel_summary_builder).to have_received(:build)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['Channel::WebWidget']['open']).to eq(5)
|
||||
expect(json_response['Channel::WebWidget']['total']).to eq(18)
|
||||
end
|
||||
|
||||
it 'returns unprocessable_entity when date range exceeds 6 months' do
|
||||
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
|
||||
params: { since: 1.year.ago.to_i.to_s, until: Time.current.to_i.to_s },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.reports.date_range_too_long'))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -223,6 +223,8 @@ account_summary:
|
||||
$ref: './resource/reports/summary.yml'
|
||||
agent_conversation_metrics:
|
||||
$ref: './resource/reports/conversation/agent.yml'
|
||||
channel_summary:
|
||||
$ref: './resource/reports/channel_summary.yml'
|
||||
|
||||
contact_detail:
|
||||
$ref: ./resource/contact_detail.yml
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
type: object
|
||||
description: Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.
|
||||
additionalProperties:
|
||||
type: object
|
||||
description: Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)
|
||||
properties:
|
||||
open:
|
||||
type: number
|
||||
description: Number of open conversations
|
||||
resolved:
|
||||
type: number
|
||||
description: Number of resolved conversations
|
||||
pending:
|
||||
type: number
|
||||
description: Number of pending conversations
|
||||
snoozed:
|
||||
type: number
|
||||
description: Number of snoozed conversations
|
||||
total:
|
||||
type: number
|
||||
description: Total number of conversations
|
||||
example:
|
||||
Channel::WebWidget:
|
||||
open: 10
|
||||
resolved: 20
|
||||
pending: 5
|
||||
snoozed: 2
|
||||
total: 37
|
||||
Channel::Api:
|
||||
open: 5
|
||||
resolved: 15
|
||||
pending: 3
|
||||
snoozed: 1
|
||||
total: 24
|
||||
@@ -0,0 +1,30 @@
|
||||
tags:
|
||||
- Reports
|
||||
operationId: get-channel-summary-report
|
||||
summary: Get conversation statistics grouped by channel type
|
||||
security:
|
||||
- userApiKey: []
|
||||
description: |
|
||||
Get conversation counts grouped by channel type and status for a given date range.
|
||||
Returns statistics for each channel type including open, resolved, pending, snoozed, and total conversation counts.
|
||||
|
||||
**Note:** This API endpoint is available only in Chatwoot version 4.10.0 and above. The date range is limited to a maximum of 6 months.
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/channel_summary'
|
||||
'400':
|
||||
description: Date range exceeds 6 months limit
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
'403':
|
||||
description: Access denied
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
@@ -639,6 +639,28 @@
|
||||
get:
|
||||
$ref: './application/reports/conversation/agent.yml'
|
||||
|
||||
# Channel summary report (Available in 4.10.0+)
|
||||
/api/v2/accounts/{account_id}/summary_reports/channel:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/account_id'
|
||||
- in: query
|
||||
name: since
|
||||
schema:
|
||||
type: string
|
||||
description: The timestamp from where report should start (Unix timestamp).
|
||||
- in: query
|
||||
name: until
|
||||
schema:
|
||||
type: string
|
||||
description: The timestamp from where report should stop (Unix timestamp).
|
||||
- in: query
|
||||
name: business_hours
|
||||
schema:
|
||||
type: boolean
|
||||
description: Whether to filter by business hours.
|
||||
get:
|
||||
$ref: './application/reports/channel_summary.yml'
|
||||
|
||||
# Conversations Messages
|
||||
/accounts/{account_id}/conversations/{conversation_id}/messages:
|
||||
parameters:
|
||||
|
||||
@@ -7870,6 +7870,82 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/accounts/{account_id}/summary_reports/channel": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "since",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should start (Unix timestamp)."
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "until",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should stop (Unix timestamp)."
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "business_hours",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": "Whether to filter by business hours."
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reports"
|
||||
],
|
||||
"operationId": "get-channel-summary-report",
|
||||
"summary": "Get conversation statistics grouped by channel type",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get conversation counts grouped by channel type and status for a given date range.\nReturns statistics for each channel type including open, resolved, pending, snoozed, and total conversation counts.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.10.0 and above. The date range is limited to a maximum of 6 months.\n",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/channel_summary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Date range exceeds 6 months limit",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{account_id}/conversations/{conversation_id}/messages": {
|
||||
"parameters": [
|
||||
{
|
||||
@@ -11659,6 +11735,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_summary": {
|
||||
"type": "object",
|
||||
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
|
||||
"properties": {
|
||||
"open": {
|
||||
"type": "number",
|
||||
"description": "Number of open conversations"
|
||||
},
|
||||
"resolved": {
|
||||
"type": "number",
|
||||
"description": "Number of resolved conversations"
|
||||
},
|
||||
"pending": {
|
||||
"type": "number",
|
||||
"description": "Number of pending conversations"
|
||||
},
|
||||
"snoozed": {
|
||||
"type": "number",
|
||||
"description": "Number of snoozed conversations"
|
||||
},
|
||||
"total": {
|
||||
"type": "number",
|
||||
"description": "Total number of conversations"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"Channel::WebWidget": {
|
||||
"open": 10,
|
||||
"resolved": 20,
|
||||
"pending": 5,
|
||||
"snoozed": 2,
|
||||
"total": 37
|
||||
},
|
||||
"Channel::Api": {
|
||||
"open": 5,
|
||||
"resolved": 15,
|
||||
"pending": 3,
|
||||
"snoozed": 1,
|
||||
"total": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact_detail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -6412,6 +6412,82 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/accounts/{account_id}/summary_reports/channel": {
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "since",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should start (Unix timestamp)."
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "until",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The timestamp from where report should stop (Unix timestamp)."
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "business_hours",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": "Whether to filter by business hours."
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reports"
|
||||
],
|
||||
"operationId": "get-channel-summary-report",
|
||||
"summary": "Get conversation statistics grouped by channel type",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "Get conversation counts grouped by channel type and status for a given date range.\nReturns statistics for each channel type including open, resolved, pending, snoozed, and total conversation counts.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.10.0 and above. The date range is limited to a maximum of 6 months.\n",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/channel_summary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Date range exceeds 6 months limit",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/bad_request_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -10166,6 +10242,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_summary": {
|
||||
"type": "object",
|
||||
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
|
||||
"properties": {
|
||||
"open": {
|
||||
"type": "number",
|
||||
"description": "Number of open conversations"
|
||||
},
|
||||
"resolved": {
|
||||
"type": "number",
|
||||
"description": "Number of resolved conversations"
|
||||
},
|
||||
"pending": {
|
||||
"type": "number",
|
||||
"description": "Number of pending conversations"
|
||||
},
|
||||
"snoozed": {
|
||||
"type": "number",
|
||||
"description": "Number of snoozed conversations"
|
||||
},
|
||||
"total": {
|
||||
"type": "number",
|
||||
"description": "Total number of conversations"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"Channel::WebWidget": {
|
||||
"open": 10,
|
||||
"resolved": 20,
|
||||
"pending": 5,
|
||||
"snoozed": 2,
|
||||
"total": 37
|
||||
},
|
||||
"Channel::Api": {
|
||||
"open": 5,
|
||||
"resolved": 15,
|
||||
"pending": 3,
|
||||
"snoozed": 1,
|
||||
"total": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact_detail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -4378,6 +4378,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_summary": {
|
||||
"type": "object",
|
||||
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
|
||||
"properties": {
|
||||
"open": {
|
||||
"type": "number",
|
||||
"description": "Number of open conversations"
|
||||
},
|
||||
"resolved": {
|
||||
"type": "number",
|
||||
"description": "Number of resolved conversations"
|
||||
},
|
||||
"pending": {
|
||||
"type": "number",
|
||||
"description": "Number of pending conversations"
|
||||
},
|
||||
"snoozed": {
|
||||
"type": "number",
|
||||
"description": "Number of snoozed conversations"
|
||||
},
|
||||
"total": {
|
||||
"type": "number",
|
||||
"description": "Total number of conversations"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"Channel::WebWidget": {
|
||||
"open": 10,
|
||||
"resolved": 20,
|
||||
"pending": 5,
|
||||
"snoozed": 2,
|
||||
"total": 37
|
||||
},
|
||||
"Channel::Api": {
|
||||
"open": 5,
|
||||
"resolved": 15,
|
||||
"pending": 3,
|
||||
"snoozed": 1,
|
||||
"total": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact_detail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -3793,6 +3793,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_summary": {
|
||||
"type": "object",
|
||||
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
|
||||
"properties": {
|
||||
"open": {
|
||||
"type": "number",
|
||||
"description": "Number of open conversations"
|
||||
},
|
||||
"resolved": {
|
||||
"type": "number",
|
||||
"description": "Number of resolved conversations"
|
||||
},
|
||||
"pending": {
|
||||
"type": "number",
|
||||
"description": "Number of pending conversations"
|
||||
},
|
||||
"snoozed": {
|
||||
"type": "number",
|
||||
"description": "Number of snoozed conversations"
|
||||
},
|
||||
"total": {
|
||||
"type": "number",
|
||||
"description": "Total number of conversations"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"Channel::WebWidget": {
|
||||
"open": 10,
|
||||
"resolved": 20,
|
||||
"pending": 5,
|
||||
"snoozed": 2,
|
||||
"total": 37
|
||||
},
|
||||
"Channel::Api": {
|
||||
"open": 5,
|
||||
"resolved": 15,
|
||||
"pending": 3,
|
||||
"snoozed": 1,
|
||||
"total": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact_detail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -4554,6 +4554,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_summary": {
|
||||
"type": "object",
|
||||
"description": "Channel summary report containing conversation counts grouped by channel type and status. Available in version 4.10.0+.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"description": "Conversation statistics for a specific channel type (e.g., Channel::WebWidget, Channel::Api)",
|
||||
"properties": {
|
||||
"open": {
|
||||
"type": "number",
|
||||
"description": "Number of open conversations"
|
||||
},
|
||||
"resolved": {
|
||||
"type": "number",
|
||||
"description": "Number of resolved conversations"
|
||||
},
|
||||
"pending": {
|
||||
"type": "number",
|
||||
"description": "Number of pending conversations"
|
||||
},
|
||||
"snoozed": {
|
||||
"type": "number",
|
||||
"description": "Number of snoozed conversations"
|
||||
},
|
||||
"total": {
|
||||
"type": "number",
|
||||
"description": "Total number of conversations"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"Channel::WebWidget": {
|
||||
"open": 10,
|
||||
"resolved": 20,
|
||||
"pending": 5,
|
||||
"snoozed": 2,
|
||||
"total": 37
|
||||
},
|
||||
"Channel::Api": {
|
||||
"open": 5,
|
||||
"resolved": 15,
|
||||
"pending": 3,
|
||||
"snoozed": 1,
|
||||
"total": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact_detail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user