Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1e387509 | ||
|
|
9eb3ee44a8 | ||
|
|
ef6ba8aabd | ||
|
|
a7c8a7bc26 | ||
|
|
b1e1448b8a | ||
|
|
c837dd5cb7 | ||
|
|
dd31bfa572 | ||
|
|
97a53f987b | ||
|
|
c884cdefde | ||
|
|
c77d935e38 | ||
|
|
b686d14044 | ||
|
|
133fb1bcf6 | ||
|
|
5e2fc6e339 | ||
|
|
e9e6de5690 | ||
|
|
329b749702 | ||
|
|
d8c5dda36c | ||
|
|
5ec77aca64 | ||
|
|
85324c82fa | ||
|
|
81307d5aea | ||
|
|
6f45af605c | ||
|
|
a32565d72b | ||
|
|
d94392a277 | ||
|
|
f2ff78ee47 | ||
|
|
a161fdd3e3 | ||
|
|
a7cd958087 | ||
|
|
b0784413e9 | ||
|
|
2e37ecacba | ||
|
|
195dde9fde | ||
|
|
0a96b31f7f | ||
|
|
eadd831c95 | ||
|
|
0dadaccb9e | ||
|
|
7d1f69632e | ||
|
|
05a5d81ad2 | ||
|
|
ab010ab71c | ||
|
|
69fabf0efd | ||
|
|
9e53743592 | ||
|
|
52d403d37c | ||
|
|
9ad83d101e | ||
|
|
6caa572c24 | ||
|
|
0d0abf6808 | ||
|
|
b167e24d2b | ||
|
|
ffe323261c | ||
|
|
657afe0ffe | ||
|
|
e244d0c56d | ||
|
|
59bf85d993 | ||
|
|
c8bbd6bbd8 | ||
|
|
a1a355bc90 | ||
|
|
b05311a169 | ||
|
|
77b286c41f | ||
|
|
3cf252a2fa | ||
|
|
491eaa5bce | ||
|
|
8e4483c722 | ||
|
|
dd98bb398e | ||
|
|
7061c3dde0 | ||
|
|
9ef476c6d8 | ||
|
|
88fc18617d | ||
|
|
f5a81964c3 | ||
|
|
251ef4f8bb | ||
|
|
45533f6bf8 | ||
|
|
1748c668cf | ||
|
|
be752af4c5 | ||
|
|
68529278e0 | ||
|
|
d27c78068c | ||
|
|
3eeab14136 | ||
|
|
5e06466f18 | ||
|
|
a0e4ce20f0 | ||
|
|
ac1b6d070e | ||
|
|
54bd800a82 | ||
|
|
3c67d93426 | ||
|
|
8ed8e809e3 | ||
|
|
c2e095adba | ||
|
|
d24b513028 | ||
|
|
87d703ad3a | ||
|
|
fa58046c68 | ||
|
|
965c316b77 | ||
|
|
9d0e2bc258 | ||
|
|
00ac2519a2 | ||
|
|
e59784dccc | ||
|
|
807a21daca | ||
|
|
4e9bdebe16 | ||
|
|
3ecd14e3ec | ||
|
|
9638163b6f | ||
|
|
1db6c7add3 | ||
|
|
35760ba464 | ||
|
|
c2d20abce3 | ||
|
|
06cfdf80d7 | ||
|
|
b9fa8fe038 | ||
|
|
1cf5bd50a6 | ||
|
|
e21ba3a0b5 | ||
|
|
e4e10caf0d | ||
|
|
d4e3320fdf | ||
|
|
0edcca3435 | ||
|
|
e833b67caa | ||
|
|
01c7252a99 | ||
|
|
9efd8b761a | ||
|
|
8764e463f4 | ||
|
|
8316c98d12 | ||
|
|
eecd0a1fc7 | ||
|
|
23afd1bc3f | ||
|
|
38f66afed9 | ||
|
|
1d4047a2e2 | ||
|
|
8cd45e6fdd | ||
|
|
458a374a8d | ||
|
|
ec614e17a2 | ||
|
|
f1f10cea88 | ||
|
|
baeb7826cf | ||
|
|
533ca3e919 | ||
|
|
e23f8ca0c2 | ||
|
|
099caa495a | ||
|
|
2043148305 | ||
|
|
35ca2d2acd | ||
|
|
204f28d301 | ||
|
|
da995000e1 | ||
|
|
478d84b8a1 | ||
|
|
19d5786cf4 | ||
|
|
10ed3c374e | ||
|
|
a6325b0ce9 | ||
|
|
9e3a79303e | ||
|
|
ba1ef0873f | ||
|
|
a5addd7549 | ||
|
|
9ef87ffd19 |
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 16
|
||||
parallelism: 20
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
@@ -279,10 +279,10 @@ jobs:
|
||||
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
||||
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
|
||||
|
||||
# Database setup
|
||||
# Database setup (match GitHub Actions flow)
|
||||
- run:
|
||||
name: Run DB migrations
|
||||
command: bundle exec rails db:chatwoot_prepare
|
||||
name: Create database + load schema
|
||||
command: bundle exec rake db:create db:schema:load
|
||||
|
||||
# Run backend tests (parallelized)
|
||||
- run:
|
||||
|
||||
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
# REDIS_VELMA_SIZE=10
|
||||
|
||||
@@ -2,6 +2,7 @@ plugins:
|
||||
- rubocop-performance
|
||||
- rubocop-rails
|
||||
- rubocop-rspec
|
||||
- rubocop-rspec_rails
|
||||
- rubocop-factory_bot
|
||||
|
||||
require:
|
||||
@@ -203,6 +204,12 @@ RSpec/MultipleExpectations:
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 14
|
||||
|
||||
RSpecRails/InferredSpecType:
|
||||
Enabled: false
|
||||
|
||||
RSpecRails/NegationBeValid:
|
||||
Enabled: false
|
||||
|
||||
# custom rules
|
||||
UseFromEmail:
|
||||
Enabled: true
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||
- **Test env**: Specs should run without `.env`. If present, temporarily rename it (e.g., `.env` -> `.env.bak`) while running specs and restore afterward.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ source 'https://rubygems.org'
|
||||
ruby '3.4.4'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||
gem 'rails', '~> 7.1'
|
||||
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'rails', '~> 7.2.0'
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -31,7 +33,9 @@ gem 'haikunator'
|
||||
# Template parsing safely
|
||||
gem 'liquid'
|
||||
# Parse Markdown to HTML
|
||||
gem 'commonmarker'
|
||||
# ref: https://github.com/gjtorikian/commonmarker/issues/358
|
||||
# can upgrade one this issue is fixed
|
||||
gem 'commonmarker', '~> 0.23.11'
|
||||
# Validate Data against JSON Schema
|
||||
gem 'json_schemer'
|
||||
# used in swagger build
|
||||
@@ -49,9 +53,7 @@ gem 'csv-safe'
|
||||
|
||||
##-- for active storage --##
|
||||
gem 'aws-sdk-s3', require: false
|
||||
# original gem isn't maintained actively
|
||||
# we wanted updated version of faraday which is a dependency for slack-ruby-client
|
||||
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
|
||||
gem 'azure-blob', require: false
|
||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||
gem 'image_processing'
|
||||
|
||||
@@ -89,9 +91,9 @@ gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
gem 'administrate'
|
||||
gem 'administrate-field-active_storage'
|
||||
gem 'administrate-field-belongs_to_search'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
|
||||
@@ -131,7 +133,7 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.3.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
gem 'sidekiq-cron', '>= 2.3.1'
|
||||
# for sidekiq healthcheck
|
||||
gem 'sidekiq_alive'
|
||||
|
||||
@@ -262,6 +264,7 @@ group :development, :test do
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'rubocop-rspec', require: false
|
||||
gem 'rubocop-rspec_rails', require: false
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
|
||||
+191
-192
@@ -1,110 +1,89 @@
|
||||
GIT
|
||||
remote: https://github.com/chatwoot/azure-storage-ruby
|
||||
revision: 9957cf899d33a285b5dfe15bdb875292398e392b
|
||||
branch: chatwoot
|
||||
specs:
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
azure-storage-common (2.0.4)
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects (~> 0.3.0)
|
||||
faraday-net_http_persistent (~> 2.0)
|
||||
net-http-persistent (~> 4.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/chatwoot/devise-secure_password
|
||||
revision: adcc85fe1babfe40feae73dbcae64d14fff86e69
|
||||
revision: 479987594b576dbf23aed8dbd962e78342bc683c
|
||||
branch: chatwoot
|
||||
specs:
|
||||
devise-secure_password (2.0.1)
|
||||
devise-secure_password (2.1.0)
|
||||
devise (>= 4.0.0, < 5.0.0)
|
||||
railties (>= 5.0.0, < 8.0.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actioncable (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailbox (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activestorage (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
actionview (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actionpack (7.2.2.2)
|
||||
actionview (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activestorage (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
actionview (7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activejob (7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activerecord (7.1.5.2)
|
||||
activemodel (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activemodel (7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
activerecord (7.2.2.2)
|
||||
activemodel (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.1.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activestorage (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.1.5.2)
|
||||
activesupport (7.2.2.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
acts-as-taggable-on (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
@@ -121,10 +100,10 @@ GEM
|
||||
administrate-field-active_storage (1.0.3)
|
||||
administrate (>= 0.2.2)
|
||||
rails (>= 7.0)
|
||||
administrate-field-belongs_to_search (0.9.0)
|
||||
administrate-field-belongs_to_search (0.10.0)
|
||||
administrate (>= 0.3, < 1.0)
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
rails (>= 4.2, < 8.0)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.7.0)
|
||||
ruby_llm (~> 1.8.2)
|
||||
@@ -141,8 +120,8 @@ GEM
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
aws-sdk-sns (~> 1, >= 1.61.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1198.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-partitions (1.1205.0)
|
||||
aws-sdk-core (3.241.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -150,11 +129,11 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sdk-kms (1.120.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-s3 (1.211.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-sns (1.70.0)
|
||||
@@ -162,20 +141,22 @@ GEM
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-blob (0.5.9.1)
|
||||
rexml
|
||||
barnes (0.0.9)
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (5.4.1)
|
||||
browser (5.3.1)
|
||||
builder (3.3.0)
|
||||
bullet (8.0.7)
|
||||
bullet (7.2.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundle-audit (0.1.0)
|
||||
@@ -184,17 +165,21 @@ GEM
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
commonmarker (0.23.11)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
csv (3.3.0)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
@@ -204,14 +189,15 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
datadog (2.19.0)
|
||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||
libdatadog (~> 18.1.0.1.0)
|
||||
libddwaf (~> 1.24.1.0.3)
|
||||
datadog (2.25.0)
|
||||
cgi
|
||||
datadog-ruby_core_source (~> 3.5, >= 3.5.0)
|
||||
libdatadog (~> 24.0.1.1.0)
|
||||
libddwaf (~> 1.30.0.0.0)
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.4.1)
|
||||
datadog-ruby_core_source (3.5.1)
|
||||
date (3.5.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -222,10 +208,10 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.1.0)
|
||||
activesupport (>= 7.0, < 8.1)
|
||||
devise (~> 4.0)
|
||||
railties (>= 7.0, < 8.1)
|
||||
devise-two-factor (6.3.0)
|
||||
activesupport (>= 7.0, < 8.2)
|
||||
devise (>= 4.0, < 5.0)
|
||||
railties (>= 7.0, < 8.2)
|
||||
rotp (~> 6.0)
|
||||
devise_token_auth (1.2.5)
|
||||
bcrypt (~> 3.0)
|
||||
@@ -244,31 +230,31 @@ GEM
|
||||
down (5.4.0)
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.1.0)
|
||||
dry-cli (1.4.1)
|
||||
dry-configurable (1.3.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-core (1.1.0)
|
||||
dry-core (1.2.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
zeitwerk (~> 2.6)
|
||||
dry-inflector (1.2.0)
|
||||
dry-inflector (1.3.1)
|
||||
dry-initializer (3.2.0)
|
||||
dry-logic (1.6.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-schema (1.14.1)
|
||||
dry-schema (1.15.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-configurable (~> 1.0, >= 1.0.1)
|
||||
dry-core (~> 1.1)
|
||||
dry-initializer (~> 3.2)
|
||||
dry-logic (~> 1.5)
|
||||
dry-logic (~> 1.6)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
dry-types (1.9.0)
|
||||
bigdecimal (>= 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
@@ -282,8 +268,9 @@ GEM
|
||||
ruby2_keywords
|
||||
email-provider-info (0.0.1)
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
erb (6.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.3.0)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
execjs (2.8.1)
|
||||
@@ -297,34 +284,27 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (1.0.0)
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday-mashify (1.0.2)
|
||||
faraday (~> 2.0)
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
faraday_middleware-aws-sigv4 (1.0.1)
|
||||
aws-sigv4 (~> 1.0)
|
||||
faraday (>= 2.0, < 3)
|
||||
fast-mcp (1.5.0)
|
||||
fast-mcp (1.6.0)
|
||||
addressable (~> 2.8)
|
||||
base64
|
||||
dry-schema (~> 1.14)
|
||||
json (~> 2.0)
|
||||
mime-types (~> 3.4)
|
||||
rack (~> 3.1)
|
||||
rack (>= 2.0, < 4.0)
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
@@ -441,19 +421,21 @@ GEM
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
httparty (0.24.0)
|
||||
httparty (0.24.2)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.6.0)
|
||||
irb (1.7.2)
|
||||
reline (>= 0.3.6)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
iso-639 (0.3.8)
|
||||
csv
|
||||
jbuilder (2.11.5)
|
||||
@@ -464,7 +446,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.13.2)
|
||||
json (2.12.0)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -479,7 +461,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.10.1)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -506,15 +488,15 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
libdatadog (18.1.0.1.0)
|
||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
||||
libddwaf (1.24.1.0.3)
|
||||
libdatadog (24.0.1.1.0)
|
||||
libdatadog (24.0.1.1.0-x86_64-linux)
|
||||
libddwaf (1.30.0.0.0)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
||||
libddwaf (1.30.0.0.0-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
||||
libddwaf (1.30.0.0.0-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
||||
libddwaf (1.30.0.0.0-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.28.0)
|
||||
lint_roller (1.1.0)
|
||||
@@ -531,7 +513,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.23.1)
|
||||
loofah (2.25.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -551,21 +533,19 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
minitest (5.27.0)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
multi_xml (0.8.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.3.0)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.6.0)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.20)
|
||||
date
|
||||
net-protocol
|
||||
@@ -582,14 +562,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.18.9)
|
||||
nokogiri (1.19.0)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-arm64-darwin)
|
||||
nokogiri (1.19.0-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-darwin)
|
||||
nokogiri (1.19.0-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -607,9 +587,8 @@ GEM
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
omniauth (2.1.2)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.3)
|
||||
@@ -661,6 +640,9 @@ GEM
|
||||
activerecord (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
pgvector (0.1.1)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
procore-sift (1.0.0)
|
||||
activerecord (>= 6.1)
|
||||
@@ -669,6 +651,9 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
@@ -676,7 +661,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack (3.1.19)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -694,53 +679,57 @@ GEM
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rackup (2.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (7.1.5.2)
|
||||
actioncable (= 7.1.5.2)
|
||||
actionmailbox (= 7.1.5.2)
|
||||
actionmailer (= 7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
actiontext (= 7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activemodel (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
rails (7.2.2.2)
|
||||
actioncable (= 7.2.2.2)
|
||||
actionmailbox (= 7.2.2.2)
|
||||
actionmailer (= 7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
actiontext (= 7.2.2.2)
|
||||
actionview (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activemodel (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activestorage (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.1.5.2)
|
||||
rails-dom-testing (2.2.0)
|
||||
railties (= 7.2.2.2)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
irb
|
||||
railties (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rake (13.3.1)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rdoc (7.1.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.22.2)
|
||||
redis-client (0.25.2)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.3.6)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -810,6 +799,10 @@ GEM
|
||||
rubocop-rspec (3.6.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rspec_rails (2.31.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rspec (~> 3.5)
|
||||
ruby-openai (7.3.1)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
@@ -832,9 +825,8 @@ GEM
|
||||
faraday-net_http (>= 1)
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
ruby_llm-schema (~> 0.2.1)
|
||||
zeitwerk (~> 2)
|
||||
ruby_llm-schema (0.2.5)
|
||||
ruby_llm-schema (0.1.0)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -854,9 +846,8 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
searchkick (5.5.2)
|
||||
activemodel (>= 7.1)
|
||||
hashie
|
||||
searchkick (6.0.3)
|
||||
activemodel (>= 7.2)
|
||||
securerandom (0.4.1)
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
@@ -885,16 +876,17 @@ GEM
|
||||
zeitwerk (~> 2.5)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.1)
|
||||
concurrent-ruby (< 2)
|
||||
sidekiq (7.3.9)
|
||||
base64
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
sidekiq-cron (1.12.0)
|
||||
fugit (~> 1.8)
|
||||
sidekiq-cron (2.3.1)
|
||||
cronex (>= 0.13.0)
|
||||
fugit (~> 1.8, >= 1.11.1)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6)
|
||||
sidekiq (>= 6.5.0)
|
||||
sidekiq_alive (2.5.0)
|
||||
gserver (~> 0.0.1)
|
||||
sidekiq (>= 5, < 9)
|
||||
@@ -934,12 +926,13 @@ GEM
|
||||
squasher (0.7.2)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (18.0.1)
|
||||
stringio (3.2.0)
|
||||
stripe (18.1.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.4.0)
|
||||
tidewave (0.2.0)
|
||||
fast-mcp (~> 1.5.0)
|
||||
thor (1.5.0)
|
||||
tidewave (0.4.1)
|
||||
fast-mcp (~> 1.6.0)
|
||||
rack (>= 2.0)
|
||||
rails (>= 7.1.0)
|
||||
tilt (2.3.0)
|
||||
@@ -948,7 +941,8 @@ GEM
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (7.6.0)
|
||||
tsort (0.2.0)
|
||||
twilio-ruby (5.77.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
@@ -964,21 +958,25 @@ GEM
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.4)
|
||||
uri (1.1.1)
|
||||
uri_template (0.7.0)
|
||||
useragent (0.16.11)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
vite_rails (3.0.17)
|
||||
railties (>= 5.1, < 8)
|
||||
vite_rails (3.0.20)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.8.0)
|
||||
vite_ruby (3.9.2)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
logger (~> 1.6)
|
||||
mutex_m
|
||||
rack-proxy (~> 0.6, >= 0.6.1)
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
@@ -995,7 +993,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
websocket-driver (0.7.7)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -1003,7 +1001,7 @@ GEM
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.6.17)
|
||||
zeitwerk (2.7.4)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -1020,16 +1018,16 @@ DEPENDENCIES
|
||||
active_record_query_trace
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
administrate
|
||||
administrate-field-active_storage
|
||||
administrate-field-belongs_to_search
|
||||
ai-agents (>= 0.7.0)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
aws-actionmailbox-ses (~> 0)
|
||||
aws-sdk-s3
|
||||
azure-storage-blob!
|
||||
azure-blob
|
||||
barnes
|
||||
bootsnap
|
||||
brakeman
|
||||
@@ -1038,7 +1036,7 @@ DEPENDENCIES
|
||||
bundle-audit
|
||||
byebug
|
||||
climate_control
|
||||
commonmarker
|
||||
commonmarker (~> 0.23.11)
|
||||
csv-safe
|
||||
database_cleaner
|
||||
datadog (~> 2.0)
|
||||
@@ -1108,10 +1106,10 @@ DEPENDENCIES
|
||||
puma
|
||||
pundit
|
||||
rack-attack (>= 6.7.0)
|
||||
rack-cors (= 2.0.0)
|
||||
rack-cors
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.1)
|
||||
rails (~> 7.2.0)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
@@ -1124,6 +1122,7 @@ DEPENDENCIES
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
rubocop-rspec_rails
|
||||
ruby-openai
|
||||
ruby_llm (>= 1.8.2)
|
||||
ruby_llm-schema
|
||||
@@ -1137,7 +1136,7 @@ DEPENDENCIES
|
||||
shopify_api
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq-cron (>= 2.3.1)
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,65 @@
|
||||
class V2::Reports::InboxLabelMatrixBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
{
|
||||
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
|
||||
matrix: build_matrix
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_inboxes
|
||||
@filtered_inboxes ||= begin
|
||||
inboxes = account.inboxes
|
||||
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
|
||||
inboxes.order(:name).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def filtered_labels
|
||||
@filtered_labels ||= begin
|
||||
labels = account.labels
|
||||
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
|
||||
labels.order(:title).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_filter
|
||||
filter = { account_id: account.id }
|
||||
filter[:created_at] = range if range.present?
|
||||
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
|
||||
filter
|
||||
end
|
||||
|
||||
def fetch_grouped_counts
|
||||
label_names = filtered_labels.map(&:title)
|
||||
return {} if label_names.empty?
|
||||
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
|
||||
.where(tags: { name: label_names })
|
||||
.group('conversations.inbox_id', 'tags.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def build_matrix
|
||||
counts = fetch_grouped_counts
|
||||
filtered_inboxes.map do |inbox|
|
||||
filtered_labels.map do |label|
|
||||
counts[[inbox.id, label.title]] || 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def create_issue
|
||||
issue = linear_processor_service.create_issue(permitted_params, Current.user)
|
||||
issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user)
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,6 +62,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: bot_metrics
|
||||
end
|
||||
|
||||
def inbox_label_matrix
|
||||
builder = V2::Reports::InboxLabelMatrixBuilder.new(
|
||||
account: Current.account,
|
||||
params: inbox_label_matrix_params
|
||||
)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def first_response_time_distribution
|
||||
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
|
||||
account: Current.account,
|
||||
params: first_response_time_distribution_params
|
||||
)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
@@ -139,4 +155,20 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
def conversation_metrics
|
||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||
end
|
||||
|
||||
def inbox_label_matrix_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
inbox_ids: params[:inbox_ids],
|
||||
label_ids: params[:label_ids]
|
||||
}
|
||||
end
|
||||
|
||||
def first_response_time_distribution_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
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],
|
||||
|
||||
@@ -20,7 +20,9 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
end
|
||||
|
||||
def instance_meta
|
||||
@metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
|
||||
migrations_paths = ActiveRecord::Migrator.migrations_paths
|
||||
migrations_context = ActiveRecord::MigrationContext.new(migrations_paths)
|
||||
@metrics['Database Migrations'] = migrations_context.needs_migration? ? 'pending' : 'completed'
|
||||
end
|
||||
|
||||
def chatwoot_version
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
@@ -80,7 +81,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
const { content, attachments } = inReplyTo.value;
|
||||
|
||||
if (content) return content;
|
||||
if (content) return new MessageFormatter(content).formattedMessage;
|
||||
if (attachments?.length) {
|
||||
const firstAttachment = attachments[0];
|
||||
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
|
||||
@@ -107,9 +108,10 @@ const replyToPreview = computed(() => {
|
||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<span class="break-all line-clamp-2">
|
||||
{{ replyToPreview }}
|
||||
</span>
|
||||
<div
|
||||
v-dompurify-html="replyToPreview"
|
||||
class="prose prose-bubble line-clamp-2"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ class Account < ApplicationRecord
|
||||
include Featurable
|
||||
include CacheKeys
|
||||
include CaptainFeaturable
|
||||
include AccountEmailRateLimitable
|
||||
|
||||
SETTINGS_PARAMS_SCHEMA = {
|
||||
'type': 'object',
|
||||
|
||||
@@ -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
|
||||
@@ -19,6 +19,11 @@ class InstallationConfig < ApplicationRecord
|
||||
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
|
||||
# FIX ME : fixes breakage of installation config. we need to migrate.
|
||||
# Fix configuration in application.rb
|
||||
#
|
||||
# Note: This whole thing is because we store the installation config serialized in YAML in Database
|
||||
# This serialized version stores HashWithIndifferentAccess, We could avoid all this complexity if we store the value as JSONB
|
||||
# We could also avoid this issue if we migrate the installation config to JSONB
|
||||
# We should do this migration at some point in time.
|
||||
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
|
||||
|
||||
before_validation :set_lock
|
||||
|
||||
@@ -18,7 +18,7 @@ class Integrations::Hook < ApplicationRecord
|
||||
include Reauthorizable
|
||||
|
||||
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
|
||||
before_validation :ensure_hook_type
|
||||
before_validation :ensure_hook_type, on: :create
|
||||
after_create :trigger_setup_if_crm
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
@@ -86,7 +86,9 @@ class Integrations::Hook < ApplicationRecord
|
||||
end
|
||||
|
||||
def ensure_hook_type
|
||||
self.hook_type = app.params[:hook_type] if app.present?
|
||||
return if app.blank?
|
||||
|
||||
self.hook_type = app.params[:hook_type]
|
||||
end
|
||||
|
||||
def validate_settings_json_schema
|
||||
|
||||
@@ -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?
|
||||
@@ -420,6 +421,8 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def reindex_for_search
|
||||
return unless respond_to?(:reindex)
|
||||
|
||||
reindex(mode: :async)
|
||||
end
|
||||
end
|
||||
|
||||
+3
-3
@@ -69,7 +69,7 @@ class User < ApplicationRecord
|
||||
|
||||
# TODO: remove in a future version once online status is moved to account users
|
||||
# remove the column availability from users
|
||||
enum availability: { online: 0, offline: 1, busy: 2 }
|
||||
enum :availability, { online: 0, offline: 1, busy: 2 }
|
||||
|
||||
# The validation below has been commented out as it does not
|
||||
# work because :validatable in devise overrides this.
|
||||
@@ -77,7 +77,7 @@ class User < ApplicationRecord
|
||||
|
||||
validates :email, presence: true
|
||||
|
||||
serialize :otp_backup_codes, type: Array
|
||||
serialize :otp_backup_codes, coder: JSON, type: Array
|
||||
|
||||
# Encrypt sensitive MFA fields
|
||||
encrypts :otp_secret, deterministic: true
|
||||
@@ -88,7 +88,7 @@ class User < ApplicationRecord
|
||||
accepts_nested_attributes_for :account_users
|
||||
|
||||
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
|
||||
alias_attribute :conversations, :assigned_conversations
|
||||
alias conversations assigned_conversations
|
||||
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
|
||||
has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
|
||||
dependent: :nullify, inverse_of: :review_notes_updated_by
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,7 +82,7 @@ class Crm::Leadsquared::SetupService
|
||||
end
|
||||
|
||||
def update_hook_settings(params)
|
||||
@hook.settings = @hook.settings.merge(params)
|
||||
@hook.settings = @hook.settings.merge(params.stringify_keys)
|
||||
@hook.save!
|
||||
end
|
||||
|
||||
|
||||
@@ -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}?")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ end
|
||||
module Chatwoot
|
||||
class Application < Rails::Application
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
config.load_defaults 7.0
|
||||
config.load_defaults 7.2
|
||||
|
||||
config.eager_load_paths << Rails.root.join('lib')
|
||||
config.eager_load_paths << Rails.root.join('enterprise/lib')
|
||||
|
||||
@@ -13,7 +13,8 @@ end
|
||||
|
||||
# Velma : Determined protector
|
||||
# used in rack attack
|
||||
$velma = ConnectionPool.new(size: 5, timeout: 1) do
|
||||
velma_size = ENV.fetch('REDIS_VELMA_SIZE', 10)
|
||||
$velma = ConnectionPool.new(size: velma_size, timeout: 1) do
|
||||
config = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
|
||||
Redis::Namespace.new('velma', redis: config, warning: true)
|
||||
end
|
||||
|
||||
@@ -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 ------- #
|
||||
|
||||
@@ -444,6 +444,8 @@ Rails.application.routes.draw do
|
||||
get :conversations_summary
|
||||
get :conversation_traffic
|
||||
get :bot_metrics
|
||||
get :inbox_label_matrix
|
||||
get :first_response_time_distribution
|
||||
end
|
||||
end
|
||||
resource :year_in_review, only: [:show]
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ google:
|
||||
|
||||
# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
|
||||
microsoft:
|
||||
service: AzureStorage
|
||||
service: AzureBlob
|
||||
storage_account_name: <%= ENV.fetch('AZURE_STORAGE_ACCOUNT_NAME', '') %>
|
||||
storage_access_key: <%= ENV.fetch('AZURE_STORAGE_ACCESS_KEY', '') %>
|
||||
container: <%= ENV.fetch('AZURE_STORAGE_CONTAINER', '') %>
|
||||
|
||||
@@ -12,8 +12,11 @@ class ArticleKeyConverter
|
||||
|
||||
def convert_key(id)
|
||||
verifier_name = 'ActiveStorage'
|
||||
key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base, iterations: 1000,
|
||||
hash_digest_class: OpenSSL::Digest::SHA1)
|
||||
secret_key_base = Rails.application.credentials.secret_key_base ||
|
||||
Rails.application.secrets.secret_key_base
|
||||
key_generator = ActiveSupport::KeyGenerator.new(secret_key_base,
|
||||
iterations: 1000,
|
||||
hash_digest_class: OpenSSL::Digest::SHA1)
|
||||
key_generator = ActiveSupport::CachingKeyGenerator.new(key_generator)
|
||||
secret = key_generator.generate_key(verifier_name.to_s)
|
||||
verifier = ActiveSupport::MessageVerifier.new(secret)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -29,6 +29,8 @@ RUN apk update && apk add --no-cache \
|
||||
tar \
|
||||
build-base \
|
||||
tzdata \
|
||||
yaml-dev \
|
||||
pkgconf \
|
||||
postgresql-dev \
|
||||
postgresql-client \
|
||||
git \
|
||||
|
||||
@@ -2,6 +2,6 @@ require 'administrate/field/base'
|
||||
|
||||
class AccountLimitsField < Administrate::Field::Base
|
||||
def to_s
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Captain::Tools::BaseTool < RubyLLM::Tool
|
||||
prepend Captain::Tools::Instrumentation
|
||||
|
||||
attr_accessor :assistant
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module Captain::Tools::Instrumentation
|
||||
extend ActiveSupport::Concern
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def execute(**args)
|
||||
instrument_tool_call(name, args) do
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
||||
prepend Captain::Tools::Instrumentation
|
||||
|
||||
description 'Search and retrieve documentation/FAQs from knowledge base'
|
||||
|
||||
param :query, desc: 'Search Query', required: true
|
||||
|
||||
def initialize(account:, assistant: nil)
|
||||
@account = account
|
||||
@assistant = assistant
|
||||
super()
|
||||
end
|
||||
|
||||
def name
|
||||
'search_documentation'
|
||||
end
|
||||
|
||||
def execute(query:)
|
||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||
|
||||
responses = search_responses(query)
|
||||
return 'No FAQs found for the given query' if responses.empty?
|
||||
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_responses(query)
|
||||
if @assistant.present?
|
||||
@assistant.responses.approved.search(query, account_id: @account.id)
|
||||
else
|
||||
@account.captain_assistant_responses.approved.search(query, account_id: @account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def format_response(response)
|
||||
result = "\nQuestion: #{response.question}\nAnswer: #{response.answer}\n"
|
||||
result += "Source: #{response.documentable.external_link}\n" if response.documentable.present? && response.documentable.try(:external_link)
|
||||
result
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module Enterprise::Captain::ReplySuggestionService
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
return super unless use_search_tool?
|
||||
|
||||
super(model: model, messages: messages, tools: [build_search_tool])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def use_search_tool?
|
||||
ChatwootApp.chatwoot_cloud? || ChatwootApp.self_hosted_enterprise?
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
return super unless use_search_tool?
|
||||
|
||||
super.merge('has_search_tool' => true)
|
||||
end
|
||||
|
||||
def build_search_tool
|
||||
assistant = conversation&.inbox&.captain_assistant
|
||||
Captain::Tools::SearchReplyDocumentationService.new(account: account, assistant: assistant)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::BaseTaskService
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::ToolInstrumentation
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
@@ -35,44 +36,52 @@ class Captain::BaseTaskService
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:)
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
# Community edition prerequisite checks
|
||||
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
|
||||
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
|
||||
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
|
||||
|
||||
instrumentation_params = build_instrumentation_params(model, messages)
|
||||
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages)
|
||||
response = send(instrumentation_method, instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
|
||||
end
|
||||
|
||||
# Build follow-up context for client-side refinement, when applicable
|
||||
if build_follow_up_context? && response[:message].present?
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
else
|
||||
response
|
||||
end
|
||||
return response unless build_follow_up_context? && response[:message].present?
|
||||
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:)
|
||||
def execute_ruby_llm_request(model:, messages:, tools: [])
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
chat = build_chat(context, model: model, messages: messages, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
|
||||
add_messages_if_needed(chat, conversation_messages)
|
||||
response = chat.ask(conversation_messages.last[:content])
|
||||
build_ruby_llm_response(response, messages)
|
||||
build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_chat(context, model:, messages:, tools: [])
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
|
||||
if tools.any?
|
||||
tools.each { |tool| chat = chat.with_tool(tool) }
|
||||
chat.on_end_message { |message| record_generation(chat, message, model) }
|
||||
end
|
||||
|
||||
chat
|
||||
end
|
||||
|
||||
def add_messages_if_needed(chat, conversation_messages)
|
||||
return if conversation_messages.length == 1
|
||||
|
||||
@@ -177,5 +186,4 @@ class Captain::BaseTaskService
|
||||
user_msg ? user_msg[:content] : nil
|
||||
end
|
||||
end
|
||||
|
||||
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
|
||||
|
||||
@@ -38,3 +38,5 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
|
||||
'reply_suggestion'
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
module Captain::ToolInstrumentation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# Custom instrumentation for tool flows - outputs just the message (not full hash)
|
||||
def instrument_tool_session(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
response = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute('langfuse.tags', [params[:feature_name]].to_json)
|
||||
span.set_attribute('langfuse.observation.input', params[:messages].to_json)
|
||||
|
||||
response = yield
|
||||
executed = true
|
||||
|
||||
# Output just the message for cleaner Langfuse display
|
||||
span.set_attribute('langfuse.observation.output', response[:message] || response.to_json)
|
||||
end
|
||||
response
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
executed ? response : yield
|
||||
end
|
||||
|
||||
def record_generation(chat, message, model)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
|
||||
|
||||
tracer.in_span("llm.#{event_name}.generation") do |span|
|
||||
span.set_attribute('gen_ai.system', 'openai')
|
||||
span.set_attribute('gen_ai.request.model', model)
|
||||
span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
|
||||
span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
|
||||
span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
|
||||
span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to record generation: #{e.message}"
|
||||
end
|
||||
|
||||
def format_chat_messages(chat)
|
||||
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,10 @@ module ChatwootApp
|
||||
enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
|
||||
end
|
||||
|
||||
def self.self_hosted_enterprise?
|
||||
enterprise? && !chatwoot_cloud? && GlobalConfig.get_value('INSTALLATION_PRICING_PLAN') == 'enterprise'
|
||||
end
|
||||
|
||||
def self.custom?
|
||||
@custom ||= root.join('custom').exist?
|
||||
end
|
||||
|
||||
@@ -31,5 +31,10 @@ General guidelines:
|
||||
- Move the conversation forward
|
||||
- Do not invent product details, policies, or links that weren't mentioned
|
||||
- Reply in the customer's language
|
||||
{% if has_search_tool %}
|
||||
|
||||
**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
|
||||
**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation.
|
||||
{% endif %}
|
||||
|
||||
Output only the reply.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ class Seeders::Reports::MessageCreator
|
||||
end
|
||||
|
||||
def create_messages
|
||||
message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5)
|
||||
message_count = rand(MESSAGES_PER_CONVERSATION..(MESSAGES_PER_CONVERSATION + 5))
|
||||
first_agent_reply = true
|
||||
|
||||
message_count.times do |i|
|
||||
|
||||
+2
-2
@@ -144,8 +144,8 @@
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"size-limit": "^8.2.4",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-ruby": "^5.0.0",
|
||||
"vite": "5.4.21",
|
||||
"vite-plugin-ruby": "^5.1.1",
|
||||
"vitest": "3.0.5"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
Generated
+9
-9
@@ -351,8 +351,8 @@ importers:
|
||||
specifier: 5.4.21
|
||||
version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite-plugin-ruby:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
vitest:
|
||||
specifier: 3.0.5
|
||||
version: 3.0.5(@types/node@22.7.0)(jsdom@27.2.0)(sass@1.79.3)(terser@5.33.0)
|
||||
@@ -4519,8 +4519,8 @@ packages:
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
||||
vite-plugin-ruby@5.0.0:
|
||||
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
|
||||
vite-plugin-ruby@5.1.1:
|
||||
resolution: {integrity: sha512-I1dXJq2ywdvTD2Cz5LYNcYLujqQ3eUxPoCjruRdfm2QBtHBY15NEeb6x5HuPM3T5S+y8S3p9fwRsieQQCjk0gg==}
|
||||
peerDependencies:
|
||||
vite: 5.4.21
|
||||
|
||||
@@ -6245,7 +6245,7 @@ snapshots:
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7647,7 +7647,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tootallnate/once': 2.0.0
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.0
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7661,7 +7661,7 @@ snapshots:
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.0
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -9599,9 +9599,9 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite-plugin-ruby@5.0.0(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||
vite-plugin-ruby@5.1.1(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||
dependencies:
|
||||
debug: 4.3.5
|
||||
debug: 4.4.3
|
||||
fast-glob: 3.3.2
|
||||
vite: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe V2::ReportBuilder do
|
||||
RSpec.describe V2::ReportBuilder do
|
||||
include ActiveJob::TestHelper
|
||||
let_it_be(:account) { create(:account) }
|
||||
let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) }
|
||||
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
|
||||
self.use_transactional_tests = false
|
||||
|
||||
def truncate_test_data
|
||||
connection = ActiveRecord::Base.connection
|
||||
connection.truncate_tables(*connection.tables)
|
||||
end
|
||||
|
||||
before { truncate_test_data }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:label_1) { create(:label, title: 'Label_1', account: account) }
|
||||
let!(:label_2) { create(:label, title: 'Label_2', account: account) }
|
||||
|
||||
describe '#timeseries' do
|
||||
# Use before_all to share expensive setup across all tests in this describe block
|
||||
# This runs once instead of 21 times, dramatically speeding up the suite
|
||||
before_all do
|
||||
before do
|
||||
travel_to(Time.zone.today) do
|
||||
user = create(:user, account: account)
|
||||
inbox = create(:inbox, account: account)
|
||||
@@ -21,7 +28,7 @@ describe V2::ReportBuilder do
|
||||
perform_enqueued_jobs do
|
||||
10.times do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: Time.zone.today)
|
||||
create_list(:message, 5, message_type: 'outgoing',
|
||||
account: account, inbox: inbox,
|
||||
@@ -37,7 +44,7 @@ describe V2::ReportBuilder do
|
||||
|
||||
5.times do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: (Time.zone.today - 2.days))
|
||||
create_list(:message, 3, message_type: 'outgoing',
|
||||
account: account, inbox: inbox,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,135 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe V2::Reports::InboxLabelMatrixBuilder do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') }
|
||||
let!(:inbox_two) { create(:inbox, account: account, name: 'Web Chat') }
|
||||
let!(:label_one) { create(:label, account: account, title: 'bug') }
|
||||
let!(:label_two) { create(:label, account: account, title: 'feature') }
|
||||
let(:params) do
|
||||
{
|
||||
since: 1.week.ago.beginning_of_day.to_i.to_s,
|
||||
until: Time.current.end_of_day.to_i.to_s
|
||||
}
|
||||
end
|
||||
let(:builder) { described_class.new(account: account, params: params) }
|
||||
|
||||
describe '#build' do
|
||||
subject(:report) { builder.build }
|
||||
|
||||
context 'when there are conversations with labels across inboxes' do
|
||||
before do
|
||||
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
|
||||
c1.update(label_list: [label_one.title])
|
||||
|
||||
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 3.days.ago)
|
||||
c2.update(label_list: [label_one.title, label_two.title])
|
||||
|
||||
c3 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
|
||||
c3.update(label_list: [label_two.title])
|
||||
end
|
||||
|
||||
it 'returns inboxes ordered by name' do
|
||||
expect(report[:inboxes]).to eq([
|
||||
{ id: inbox_one.id, name: 'Email Support' },
|
||||
{ id: inbox_two.id, name: 'Web Chat' }
|
||||
])
|
||||
end
|
||||
|
||||
it 'returns labels ordered by title' do
|
||||
expect(report[:labels]).to eq([
|
||||
{ id: label_one.id, title: 'bug' },
|
||||
{ id: label_two.id, title: 'feature' }
|
||||
])
|
||||
end
|
||||
|
||||
it 'returns correct conversation counts in the matrix' do
|
||||
# Email Support: bug=2, feature=1
|
||||
# Web Chat: bug=0, feature=1
|
||||
expect(report[:matrix]).to eq([[2, 1], [0, 1]])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by inbox_ids' do
|
||||
let(:params) do
|
||||
{
|
||||
since: 1.week.ago.beginning_of_day.to_i.to_s,
|
||||
until: Time.current.end_of_day.to_i.to_s,
|
||||
inbox_ids: [inbox_one.id]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
|
||||
c1.update(label_list: [label_one.title])
|
||||
|
||||
c2 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
|
||||
c2.update(label_list: [label_one.title])
|
||||
end
|
||||
|
||||
it 'only includes the specified inboxes and their counts' do
|
||||
expect(report[:inboxes]).to eq([{ id: inbox_one.id, name: 'Email Support' }])
|
||||
expect(report[:matrix]).to eq([[1, 0]])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by label_ids' do
|
||||
let(:params) do
|
||||
{
|
||||
since: 1.week.ago.beginning_of_day.to_i.to_s,
|
||||
until: Time.current.end_of_day.to_i.to_s,
|
||||
label_ids: [label_one.id]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
|
||||
c1.update(label_list: [label_one.title, label_two.title])
|
||||
end
|
||||
|
||||
it 'only includes the specified labels and their counts' do
|
||||
expect(report[:labels]).to eq([{ id: label_one.id, title: 'bug' }])
|
||||
expect(report[:matrix]).to eq([[1], [0]])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversations are outside the date range' do
|
||||
before do
|
||||
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
|
||||
c1.update(label_list: [label_one.title])
|
||||
|
||||
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.weeks.ago)
|
||||
c2.update(label_list: [label_one.title])
|
||||
end
|
||||
|
||||
it 'only counts conversations within the date range' do
|
||||
expect(report[:matrix]).to eq([[1, 0], [0, 0]])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no conversations with labels' do
|
||||
before do
|
||||
create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'returns a matrix of zeros' do
|
||||
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversations belong to another account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_inbox) { create(:inbox, account: other_account) }
|
||||
|
||||
before do
|
||||
c1 = create(:conversation, account: other_account, inbox: other_inbox, created_at: 2.days.ago)
|
||||
other_label = create(:label, account: other_account, title: 'bug')
|
||||
c1.update(label_list: [other_label.title])
|
||||
end
|
||||
|
||||
it 'does not include conversations from other accounts' do
|
||||
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,19 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
include ActiveJob::TestHelper
|
||||
self.use_transactional_tests = false
|
||||
|
||||
let_it_be(:account) { create(:account) }
|
||||
let_it_be(:label_1) { create(:label, title: 'label_1', account: account) }
|
||||
let_it_be(:label_2) { create(:label, title: 'label_2', account: account) }
|
||||
let_it_be(:label_3) { create(:label, title: 'label_3', account: account) }
|
||||
def truncate_test_data
|
||||
connection = ActiveRecord::Base.connection
|
||||
connection.truncate_tables(*connection.tables)
|
||||
end
|
||||
|
||||
before { truncate_test_data }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:label_1) { create(:label, title: 'label_1', account: account) }
|
||||
let!(:label_2) { create(:label, title: 'label_2', account: account) }
|
||||
let!(:label_3) { create(:label, title: 'label_3', account: account) }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
@@ -92,7 +100,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
# Create conversations with label_1
|
||||
3.times do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: Time.zone.today)
|
||||
create_list(:message, 2, message_type: 'outgoing',
|
||||
account: account, inbox: inbox,
|
||||
@@ -110,7 +118,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
# Create conversations with label_2
|
||||
2.times do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: Time.zone.today)
|
||||
create_list(:message, 1, message_type: 'outgoing',
|
||||
account: account, inbox: inbox,
|
||||
@@ -229,7 +237,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
perform_enqueued_jobs do
|
||||
# Conversation within range
|
||||
conversation_in_range = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: 2.days.ago)
|
||||
conversation_in_range.update_labels('label_1')
|
||||
conversation_in_range.label_list
|
||||
@@ -244,7 +252,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
|
||||
# Conversation outside range (too old)
|
||||
conversation_out_of_range = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: 1.week.ago)
|
||||
conversation_out_of_range.update_labels('label_1')
|
||||
conversation_out_of_range.label_list
|
||||
@@ -286,7 +294,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
|
||||
perform_enqueued_jobs do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: Time.zone.today)
|
||||
conversation.update_labels('label_1')
|
||||
conversation.label_list
|
||||
@@ -323,8 +331,8 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
let(:account2_builder) do
|
||||
described_class.new(account: account2, params: {
|
||||
business_hours: false,
|
||||
since: test_date.to_time.to_i.to_s,
|
||||
until: test_date.end_of_day.to_time.to_i.to_s,
|
||||
since: test_date.in_time_zone.to_i.to_s,
|
||||
until: test_date.end_of_day.in_time_zone.to_i.to_s,
|
||||
timezone_offset: 0
|
||||
})
|
||||
end
|
||||
@@ -343,7 +351,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
|
||||
perform_enqueued_jobs do
|
||||
conversation = create(:conversation, account: account2,
|
||||
inbox: inbox, assignee: user,
|
||||
inbox: inbox,
|
||||
created_at: test_date)
|
||||
conversation.update_labels(unique_label_name)
|
||||
conversation.label_list
|
||||
@@ -358,6 +366,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
# Second resolution
|
||||
conversation.resolved!
|
||||
end
|
||||
perform_enqueued_jobs
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
headers: administrator.create_new_auth_token
|
||||
end
|
||||
|
||||
expect(conversation.messages.activity.last.content).to eq("Assigned to #{user_1.name} by #{administrator.name}")
|
||||
expect(conversation.reload.assignee_id).to eq(user_1.id)
|
||||
end
|
||||
|
||||
it 'Assign the agent when he is not inbox member' do
|
||||
@@ -402,7 +402,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
headers: administrator.create_new_auth_token
|
||||
end
|
||||
|
||||
expect(conversation.messages.activity.last.content).not_to eq("Assigned to #{user_1.name} by #{administrator.name}")
|
||||
expect(conversation.reload.assignee_id).to be_nil
|
||||
end
|
||||
|
||||
it 'Assign the labels' do
|
||||
|
||||
@@ -196,4 +196,103 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/{account.id}/reports/inbox_label_matrix' do
|
||||
let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') }
|
||||
let!(:label_one) { create(:label, account: account, title: 'bug') }
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as admin' do
|
||||
before do
|
||||
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
|
||||
c1.update(label_list: [label_one.title])
|
||||
end
|
||||
|
||||
it 'returns the inbox label matrix' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
|
||||
params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
body = response.parsed_body
|
||||
expect(body['inboxes']).to be_an(Array)
|
||||
expect(body['labels']).to be_an(Array)
|
||||
expect(body['matrix']).to be_an(Array)
|
||||
end
|
||||
|
||||
it 'filters by inbox_ids and label_ids' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix",
|
||||
params: { inbox_ids: [inbox_one.id], label_ids: [label_one.id] },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
body = response.parsed_body
|
||||
expect(body['inboxes'].length).to eq(1)
|
||||
expect(body['labels'].length).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/{account.id}/reports/first_response_time_distribution' do
|
||||
let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) }
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as admin' do
|
||||
before do
|
||||
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
|
||||
value: 1_800, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'returns the first response time distribution' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution",
|
||||
params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
body = response.parsed_body
|
||||
expect(body).to be_a(Hash)
|
||||
expect(body['Channel::WebWidget']).to include('0-1h', '1-4h', '4-8h', '8-24h', '24h+')
|
||||
end
|
||||
|
||||
it 'returns correct counts in buckets' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution",
|
||||
params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
body = response.parsed_body
|
||||
expect(body['Channel::WebWidget']['0-1h']).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed' },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
}
|
||||
|
||||
subject(:job) { described_class.perform_later(account: account, params: params, user: agent) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
@@ -14,6 +8,8 @@ RSpec.describe BulkActionsJob do
|
||||
let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let(:conversation_ids) { Conversation.where(id: [conversation_1.id, conversation_2.id, conversation_3.id]).pluck(:display_id) }
|
||||
let(:params) { { type: 'Conversation', fields: { status: 'snoozed' }, ids: conversation_ids } }
|
||||
|
||||
before do
|
||||
Conversation.all.find_each do |conversation|
|
||||
@@ -38,10 +34,10 @@ RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', assignee_id: agent.id },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
ids: conversation_ids
|
||||
}
|
||||
|
||||
expect(Conversation.first.status).to eq('open')
|
||||
expect(conversation_1.status).to eq('open')
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
@@ -54,32 +50,32 @@ RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', assignee_id: agent.id },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
ids: conversation_ids
|
||||
}
|
||||
|
||||
expect(Conversation.first.assignee_id).to be_nil
|
||||
expect(conversation_1.assignee_id).to be_nil
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(Conversation.first.assignee_id).to eq(agent.id)
|
||||
expect(Conversation.second.assignee_id).to eq(agent.id)
|
||||
expect(Conversation.third.assignee_id).to eq(agent.id)
|
||||
expect(conversation_1.reload.assignee_id).to eq(agent.id)
|
||||
expect(conversation_2.reload.assignee_id).to eq(agent.id)
|
||||
expect(conversation_3.reload.assignee_id).to eq(agent.id)
|
||||
end
|
||||
|
||||
it 'bulk updates the snoozed_until' do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', snoozed_until: Time.zone.now },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
ids: conversation_ids
|
||||
}
|
||||
|
||||
expect(Conversation.first.snoozed_until).to be_nil
|
||||
expect(conversation_1.snoozed_until).to be_nil
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(Conversation.first.snoozed_until).to be_present
|
||||
expect(Conversation.second.snoozed_until).to be_present
|
||||
expect(Conversation.third.snoozed_until).to be_present
|
||||
expect(conversation_1.reload.snoozed_until).to be_present
|
||||
expect(conversation_2.reload.snoozed_until).to be_present
|
||||
expect(conversation_3.reload.snoozed_until).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -161,11 +161,6 @@ RSpec.describe Captain::BaseTaskService do
|
||||
end
|
||||
end
|
||||
|
||||
it 'calls execute_ruby_llm_request with correct parameters' do
|
||||
expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
|
||||
it 'instruments the LLM call' do
|
||||
expect(service).to receive(:instrument_llm_call).and_call_original
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
@@ -19,6 +19,8 @@ RSpec.describe Captain::ReplySuggestionService do
|
||||
mock_context = instance_double(RubyLLM::Context, chat: mock_chat)
|
||||
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_tool).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } }
|
||||
allow(mock_chat).to receive(:add_message) { |args| captured_messages << args }
|
||||
allow(mock_chat).to receive(:ask) do |msg|
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_context 'with smtp config' do
|
||||
before do
|
||||
# We need to use allow_any_instance_of here because smtp_config_set_or_development?
|
||||
# is defined in ApplicationMailer and needs to be stubbed for all mailer instances
|
||||
# rubocop:disable RSpec/AnyInstance
|
||||
allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true)
|
||||
# rubocop:enable RSpec/AnyInstance
|
||||
around do |example|
|
||||
# Set SMTP_ADDRESS so mailers build a Mail::Message in test without touching real SMTP.
|
||||
# Scoped to this shared context to avoid affecting other specs.
|
||||
with_modified_env('SMTP_ADDRESS' => 'smtp.example.com') { example.run }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -28,8 +28,7 @@ RSpec.describe ContactInbox do
|
||||
obj.reload
|
||||
|
||||
# ensure the column is nil in database
|
||||
results = ActiveRecord::Base.connection.execute('Select * from contact_inboxes;')
|
||||
expect(results.first['pubsub_token']).to be_nil
|
||||
expect(described_class.where(id: obj.id).pick(:pubsub_token)).to be_nil
|
||||
|
||||
new_token = obj.pubsub_token
|
||||
obj.update(source_id: '234234323')
|
||||
|
||||
@@ -20,7 +20,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
|
||||
it 'raises InvalidEmail with invalid message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
|
||||
expect(error.message).to eq(I18n.t('errors.signup.invalid_email'))
|
||||
end
|
||||
end
|
||||
@@ -32,7 +32,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
|
||||
it 'raises InvalidEmail with blocked domain message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
|
||||
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
|
||||
end
|
||||
end
|
||||
@@ -44,7 +44,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
|
||||
it 'raises InvalidEmail with blocked domain message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
|
||||
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
|
||||
end
|
||||
end
|
||||
@@ -56,7 +56,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
|
||||
it 'raises InvalidEmail with disposable message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
|
||||
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -42,6 +42,11 @@ describe Telegram::IncomingMessageService do
|
||||
}
|
||||
end
|
||||
|
||||
def contact_for(source_id = nil)
|
||||
source_id ||= message_params.dig('from', 'id')
|
||||
ContactInbox.find_by!(inbox: telegram_channel.inbox, source_id: source_id).contact
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when valid text message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
@@ -51,7 +56,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.content).to eq('test')
|
||||
end
|
||||
end
|
||||
@@ -64,9 +69,9 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23)
|
||||
expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan')
|
||||
expect(telegram_channel.inbox.messages.first.content).to eq('test')
|
||||
end
|
||||
end
|
||||
@@ -107,7 +112,7 @@ describe Telegram::IncomingMessageService do
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23,
|
||||
'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
|
||||
contact = Contact.all.first
|
||||
contact = contact_for
|
||||
expect(contact.name).to eq('Sojan Jose')
|
||||
expect(contact.additional_attributes['language_code']).to eq('en')
|
||||
message = telegram_channel.inbox.messages.first
|
||||
@@ -131,7 +136,7 @@ describe Telegram::IncomingMessageService do
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23,
|
||||
'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
|
||||
contact = Contact.all.first
|
||||
contact = contact_for
|
||||
expect(contact.name).to eq('Sojan Jose')
|
||||
# TODO: The language code is not present when we send the first message to the client.
|
||||
# Should we update it when the user replies?
|
||||
@@ -161,9 +166,9 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23)
|
||||
expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
|
||||
end
|
||||
end
|
||||
@@ -182,7 +187,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
@@ -207,7 +212,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
@@ -229,7 +234,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
|
||||
end
|
||||
end
|
||||
@@ -258,7 +263,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
|
||||
end
|
||||
end
|
||||
@@ -277,7 +282,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
|
||||
end
|
||||
end
|
||||
@@ -298,7 +303,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('file')
|
||||
end
|
||||
end
|
||||
@@ -336,7 +341,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location')
|
||||
end
|
||||
|
||||
@@ -355,7 +360,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
|
||||
attachment = telegram_channel.inbox.messages.first.attachments.first
|
||||
expect(attachment.file_type).to eq('location')
|
||||
@@ -388,8 +393,8 @@ describe Telegram::IncomingMessageService do
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(5_171_248)
|
||||
expect(contact_for(5_171_248).name).to eq('Sojan Jose')
|
||||
expect(contact_for(5_171_248).additional_attributes['social_telegram_user_id']).to eq(5_171_248)
|
||||
expect(telegram_channel.inbox.messages.first.content).to eq('Option 1')
|
||||
end
|
||||
end
|
||||
@@ -406,7 +411,7 @@ describe Telegram::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_for.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ require 'rails_helper'
|
||||
describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
describe '#perform' do
|
||||
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
|
||||
let(:sender_number) { '2423423243' }
|
||||
let(:params) do
|
||||
{
|
||||
phone_number: whatsapp_channel.phone_number,
|
||||
@@ -10,9 +11,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }],
|
||||
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }],
|
||||
messages: [{
|
||||
from: '2423423243',
|
||||
from: sender_number,
|
||||
image: {
|
||||
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||
mime_type: 'image/jpeg',
|
||||
@@ -48,7 +49,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect_contact_name
|
||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
|
||||
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be false
|
||||
expect(whatsapp_channel.authorization_error_count).to eq(1)
|
||||
@@ -63,9 +64,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }],
|
||||
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }],
|
||||
messages: [{
|
||||
from: '2423423243',
|
||||
from: sender_number,
|
||||
image: {
|
||||
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||
mime_type: 'image/jpeg',
|
||||
@@ -88,7 +89,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
it 'with attachment errors' do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: error_params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect_contact_name
|
||||
expect(whatsapp_channel.inbox.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
@@ -98,7 +99,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: { phone_number: whatsapp_channel.phone_number,
|
||||
object: 'whatsapp_business_account', entry: {} }).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).to eq(0)
|
||||
expect(Contact.all.first).to be_nil
|
||||
expect(Contact.find_by(phone_number: contact_phone_number)).to be_nil
|
||||
expect(whatsapp_channel.inbox.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
@@ -136,7 +137,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
end
|
||||
|
||||
def expect_contact_name
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(contact_from_number&.name).to eq('Sojan Jose')
|
||||
end
|
||||
|
||||
def expect_message_content
|
||||
@@ -146,4 +147,12 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
def expect_message_has_attachment
|
||||
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
|
||||
end
|
||||
|
||||
def contact_phone_number
|
||||
"+#{sender_number}"
|
||||
end
|
||||
|
||||
def contact_from_number
|
||||
Contact.find_by(phone_number: contact_phone_number)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -225,6 +225,16 @@ 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'
|
||||
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,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
@@ -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,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'
|
||||
+111
-2
@@ -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,101 @@
|
||||
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'
|
||||
|
||||
# Conversations Messages
|
||||
/accounts/{account_id}/conversations/{conversation_id}/messages:
|
||||
|
||||
+632
-8
@@ -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,342 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{account_id}/conversations/{conversation_id}/messages": {
|
||||
"parameters": [
|
||||
{
|
||||
@@ -11781,6 +12109,302 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -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,342 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -10288,6 +10616,302 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -4424,6 +4424,302 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -3839,6 +3839,302 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -4600,6 +4600,302 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user