Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55d089f01 | ||
|
|
d0e84373ee | ||
|
|
62152321bc | ||
|
|
c6714b20c2 | ||
|
|
0f2263ec94 | ||
|
|
784e0468c3 | ||
|
|
75c9b38099 | ||
|
|
f8c155dd9e | ||
|
|
b9cf410b1f | ||
|
|
e6d96b14ac | ||
|
|
f0b3f025ff | ||
|
|
5b32761d72 | ||
|
|
7146471270 | ||
|
|
8d6ea64848 | ||
|
|
27b0a22019 | ||
|
|
611635d3d7 | ||
|
|
02178eb930 | ||
|
|
a1413d1656 | ||
|
|
a1747eeb62 | ||
|
|
f90a324ddf | ||
|
|
3f5d4f46f8 | ||
|
|
7ae916e2fc | ||
|
|
c623d6079b | ||
|
|
f0876e9877 | ||
|
|
92b0c2d4f5 | ||
|
|
0a80076387 | ||
|
|
a59f53d00d | ||
|
|
143f394a18 | ||
|
|
8e98fd26b5 | ||
|
|
4c85a1c6c0 | ||
|
|
6e28c4fcf8 | ||
|
|
7f9d711845 | ||
|
|
31436412fb | ||
|
|
84d2135a04 | ||
|
|
5bd739739e | ||
|
|
3b42b6c3bb | ||
|
|
d3b8edd522 | ||
|
|
ac7cac33a8 | ||
|
|
4d98b1871a | ||
|
|
4f214dd77a | ||
|
|
94640628eb | ||
|
|
c9c9a011fe | ||
|
|
747a856181 | ||
|
|
be5248ad68 | ||
|
|
a9ead61a89 | ||
|
|
c5b72239c6 | ||
|
|
824b5a8113 | ||
|
|
913ff2821a | ||
|
|
05c004a3cb | ||
|
|
bd4c572aea | ||
|
|
7e672e5c71 | ||
|
|
1622ba1b7f | ||
|
|
d59f2d7bd1 | ||
|
|
959f7447c3 | ||
|
|
39a6485c2a | ||
|
|
bcaca9d3d3 | ||
|
|
4de56468b7 | ||
|
|
6f84899beb | ||
|
|
bf78dde488 | ||
|
|
1df0159f65 | ||
|
|
b2a1e3282c |
+47
-238
@@ -3,7 +3,6 @@ orbs:
|
|||||||
node: circleci/node@6.1.0
|
node: circleci/node@6.1.0
|
||||||
qlty-orb: qltysh/qlty-orb@0.0
|
qlty-orb: qltysh/qlty-orb@0.0
|
||||||
|
|
||||||
# Shared defaults for setup steps
|
|
||||||
defaults: &defaults
|
defaults: &defaults
|
||||||
working_directory: ~/build
|
working_directory: ~/build
|
||||||
machine:
|
machine:
|
||||||
@@ -13,147 +12,21 @@ defaults: &defaults
|
|||||||
RAILS_LOG_TO_STDOUT: false
|
RAILS_LOG_TO_STDOUT: false
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
LOG_LEVEL: warn
|
LOG_LEVEL: warn
|
||||||
|
parallelism: 4
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Separate job for linting (no parallelism needed)
|
build:
|
||||||
lint:
|
|
||||||
<<: *defaults
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
|
|
||||||
# Install minimal system dependencies for linting
|
|
||||||
- run:
|
|
||||||
name: Install System Dependencies
|
|
||||||
command: |
|
|
||||||
sudo apt-get update
|
|
||||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
|
||||||
libpq-dev \
|
|
||||||
build-essential \
|
|
||||||
git \
|
|
||||||
curl \
|
|
||||||
libssl-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
libreadline-dev \
|
|
||||||
libyaml-dev \
|
|
||||||
openjdk-11-jdk \
|
|
||||||
jq \
|
|
||||||
software-properties-common \
|
|
||||||
ca-certificates \
|
|
||||||
imagemagick \
|
|
||||||
libxml2-dev \
|
|
||||||
libxslt1-dev \
|
|
||||||
file \
|
|
||||||
g++ \
|
|
||||||
gcc \
|
|
||||||
autoconf \
|
|
||||||
gnupg2 \
|
|
||||||
patch \
|
|
||||||
ruby-dev \
|
|
||||||
liblzma-dev \
|
|
||||||
libgmp-dev \
|
|
||||||
libncurses5-dev \
|
|
||||||
libffi-dev \
|
|
||||||
libgdbm6 \
|
|
||||||
libgdbm-dev \
|
|
||||||
libvips
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install RVM and Ruby 3.4.4
|
|
||||||
command: |
|
|
||||||
sudo apt-get install -y gpg
|
|
||||||
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
|
||||||
\curl -sSL https://get.rvm.io | bash -s stable
|
|
||||||
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
|
||||||
source ~/.rvm/scripts/rvm
|
|
||||||
rvm install "3.4.4"
|
|
||||||
rvm use 3.4.4 --default
|
|
||||||
gem install bundler -v 2.5.16
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install Application Dependencies
|
|
||||||
command: |
|
|
||||||
source ~/.rvm/scripts/rvm
|
|
||||||
bundle install
|
|
||||||
|
|
||||||
- node/install:
|
|
||||||
node-version: '24.13'
|
|
||||||
- node/install-pnpm
|
|
||||||
- node/install-packages:
|
|
||||||
pkg-manager: pnpm
|
|
||||||
override-ci-command: pnpm i
|
|
||||||
|
|
||||||
# Swagger verification
|
|
||||||
- run:
|
|
||||||
name: Verify swagger API specification
|
|
||||||
command: |
|
|
||||||
bundle exec rake swagger:build
|
|
||||||
if [[ `git status swagger/swagger.json --porcelain` ]]
|
|
||||||
then
|
|
||||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p ~/tmp
|
|
||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
|
||||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
|
||||||
|
|
||||||
# Bundle audit
|
|
||||||
- run:
|
|
||||||
name: Bundle audit
|
|
||||||
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
|
||||||
|
|
||||||
# Rubocop linting
|
|
||||||
- run:
|
|
||||||
name: Rubocop
|
|
||||||
command: bundle exec rubocop --parallel
|
|
||||||
|
|
||||||
# ESLint linting
|
|
||||||
- run:
|
|
||||||
name: eslint
|
|
||||||
command: pnpm run eslint
|
|
||||||
|
|
||||||
# Separate job for frontend tests
|
|
||||||
frontend-tests:
|
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '24.13'
|
node-version: '23.7'
|
||||||
- node/install-pnpm
|
- node/install-pnpm
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
|
- run: node --version
|
||||||
- run:
|
- run: pnpm --version
|
||||||
name: Run frontend tests (with coverage)
|
|
||||||
command: pnpm run test:coverage
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Move coverage files if they exist
|
|
||||||
command: |
|
|
||||||
if [ -d "coverage" ]; then
|
|
||||||
mkdir -p ~/build/coverage
|
|
||||||
cp -r coverage ~/build/coverage/frontend || true
|
|
||||||
fi
|
|
||||||
when: always
|
|
||||||
|
|
||||||
- persist_to_workspace:
|
|
||||||
root: ~/build
|
|
||||||
paths:
|
|
||||||
- coverage
|
|
||||||
|
|
||||||
# Backend tests with parallelization
|
|
||||||
backend-tests:
|
|
||||||
<<: *defaults
|
|
||||||
parallelism: 18
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
- node/install:
|
|
||||||
node-version: '24.13'
|
|
||||||
- node/install-pnpm
|
|
||||||
- node/install-packages:
|
|
||||||
pkg-manager: pnpm
|
|
||||||
override-ci-command: pnpm i
|
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Add PostgreSQL repository and update
|
name: Add PostgreSQL repository and update
|
||||||
command: |
|
command: |
|
||||||
@@ -218,48 +91,19 @@ jobs:
|
|||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
# Install and configure OpenSearch
|
# Swagger verification
|
||||||
- run:
|
- run:
|
||||||
name: Install OpenSearch
|
name: Verify swagger API specification
|
||||||
command: |
|
command: |
|
||||||
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
|
bundle exec rake swagger:build
|
||||||
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
|
if [[ `git status swagger/swagger.json --porcelain` ]]
|
||||||
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
|
then
|
||||||
sudo mv opensearch-2.11.0 /opt/opensearch
|
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||||
|
exit 1
|
||||||
- run:
|
fi
|
||||||
name: Configure and Start OpenSearch
|
mkdir -p ~/tmp
|
||||||
command: |
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||||
# Configure OpenSearch for single-node testing
|
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||||
cat > /opt/opensearch/config/opensearch.yml \<< EOF
|
|
||||||
cluster.name: chatwoot-test
|
|
||||||
node.name: node-1
|
|
||||||
network.host: 0.0.0.0
|
|
||||||
http.port: 9200
|
|
||||||
discovery.type: single-node
|
|
||||||
plugins.security.disabled: true
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Set ownership and permissions
|
|
||||||
sudo chown -R $USER:$USER /opt/opensearch
|
|
||||||
|
|
||||||
# Start OpenSearch in background
|
|
||||||
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Wait for OpenSearch to be ready
|
|
||||||
command: |
|
|
||||||
echo "Waiting for OpenSearch to start..."
|
|
||||||
for i in {1..30}; do
|
|
||||||
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
|
|
||||||
echo "OpenSearch is ready!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Waiting... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "OpenSearch failed to start"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# Configure environment and database
|
# Configure environment and database
|
||||||
- run:
|
- run:
|
||||||
@@ -277,98 +121,63 @@ jobs:
|
|||||||
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
|
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
|
||||||
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
|
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
|
||||||
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
||||||
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
|
|
||||||
|
|
||||||
# Database setup
|
# Database setup
|
||||||
- run:
|
- run:
|
||||||
name: Run DB migrations
|
name: Run DB migrations
|
||||||
command: bundle exec rails db:chatwoot_prepare
|
command: bundle exec rails db:chatwoot_prepare
|
||||||
|
|
||||||
# Run backend tests (parallelized)
|
# Bundle audit
|
||||||
|
- run:
|
||||||
|
name: Bundle audit
|
||||||
|
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
||||||
|
|
||||||
|
# Rubocop linting
|
||||||
|
- run:
|
||||||
|
name: Rubocop
|
||||||
|
command: bundle exec rubocop
|
||||||
|
|
||||||
|
# ESLint linting
|
||||||
|
- run:
|
||||||
|
name: eslint
|
||||||
|
command: pnpm run eslint
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Run frontend tests (with coverage)
|
||||||
|
command: |
|
||||||
|
mkdir -p ~/build/coverage/frontend
|
||||||
|
pnpm run test:coverage
|
||||||
|
|
||||||
|
# Run backend tests
|
||||||
- run:
|
- run:
|
||||||
name: Run backend tests
|
name: Run backend tests
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/tmp/test-results/rspec
|
mkdir -p ~/tmp/test-results/rspec
|
||||||
mkdir -p ~/tmp/test-artifacts
|
mkdir -p ~/tmp/test-artifacts
|
||||||
mkdir -p ~/build/coverage/backend
|
mkdir -p ~/build/coverage/backend
|
||||||
|
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||||
# Use round-robin distribution (same as GitHub Actions) for better test isolation
|
|
||||||
# This prevents tests with similar timing from being grouped on the same runner
|
|
||||||
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
|
||||||
TESTS=""
|
|
||||||
|
|
||||||
for i in "${!SPEC_FILES[@]}"; do
|
|
||||||
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
|
|
||||||
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||||
--format RspecJunitFormatter \
|
--format RspecJunitFormatter \
|
||||||
--out ~/tmp/test-results/rspec.xml \
|
--out ~/tmp/test-results/rspec.xml \
|
||||||
-- $TESTS
|
-- ${TESTFILES}
|
||||||
no_output_timeout: 30m
|
no_output_timeout: 30m
|
||||||
|
|
||||||
# Store test results for better splitting in future runs
|
# Qlty coverage publish
|
||||||
- store_test_results:
|
- qlty-orb/coverage_publish:
|
||||||
path: ~/tmp/test-results
|
files: |
|
||||||
|
coverage/coverage.json
|
||||||
|
coverage/lcov.info
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Move coverage files if they exist
|
name: List coverage directory contents
|
||||||
command: |
|
command: |
|
||||||
if [ -d "coverage" ]; then
|
ls -R ~/build/coverage
|
||||||
mkdir -p ~/build/coverage
|
|
||||||
cp -r coverage ~/build/coverage/backend || true
|
|
||||||
fi
|
|
||||||
when: always
|
|
||||||
|
|
||||||
- persist_to_workspace:
|
- persist_to_workspace:
|
||||||
root: ~/build
|
root: ~/build
|
||||||
paths:
|
paths:
|
||||||
- coverage
|
- coverage
|
||||||
|
|
||||||
# Collect coverage from all jobs
|
|
||||||
coverage:
|
|
||||||
<<: *defaults
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
- attach_workspace:
|
|
||||||
at: ~/build
|
|
||||||
|
|
||||||
# Qlty coverage publish
|
|
||||||
- qlty-orb/coverage_publish:
|
|
||||||
files: |
|
|
||||||
coverage/frontend/lcov.info
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: List coverage directory contents
|
|
||||||
command: |
|
|
||||||
ls -R ~/build/coverage || echo "No coverage directory"
|
|
||||||
|
|
||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: coverage
|
path: coverage
|
||||||
destination: coverage
|
destination: coverage
|
||||||
|
|
||||||
build:
|
|
||||||
<<: *defaults
|
|
||||||
steps:
|
|
||||||
- run:
|
|
||||||
name: Legacy build aggregator
|
|
||||||
command: |
|
|
||||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
|
||||||
|
|
||||||
workflows:
|
|
||||||
version: 2
|
|
||||||
build:
|
|
||||||
jobs:
|
|
||||||
- lint
|
|
||||||
- frontend-tests
|
|
||||||
- backend-tests
|
|
||||||
- coverage:
|
|
||||||
requires:
|
|
||||||
- frontend-tests
|
|
||||||
- backend-tests
|
|
||||||
- build:
|
|
||||||
requires:
|
|
||||||
- lint
|
|
||||||
- coverage
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
dockerfile: .devcontainer/Dockerfile.base
|
dockerfile: .devcontainer/Dockerfile.base
|
||||||
args:
|
args:
|
||||||
VARIANT: 'ubuntu-22.04'
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: '24.13.0'
|
NODE_VERSION: '23.7.0'
|
||||||
RUBY_VERSION: '3.4.4'
|
RUBY_VERSION: '3.4.4'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: '1000'
|
USER_UID: '1000'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
dockerfile: .devcontainer/Dockerfile
|
dockerfile: .devcontainer/Dockerfile
|
||||||
args:
|
args:
|
||||||
VARIANT: 'ubuntu-22.04'
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: '24.13.0'
|
NODE_VERSION: '23.7.0'
|
||||||
RUBY_VERSION: '3.4.4'
|
RUBY_VERSION: '3.4.4'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: '1000'
|
USER_UID: '1000'
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ MAILER_INBOUND_EMAIL_DOMAIN=
|
|||||||
# mandrill for Mandrill
|
# mandrill for Mandrill
|
||||||
# postmark for Postmark
|
# postmark for Postmark
|
||||||
# sendgrid for Sendgrid
|
# sendgrid for Sendgrid
|
||||||
# ses for Amazon SES
|
|
||||||
RAILS_INBOUND_EMAIL_SERVICE=
|
RAILS_INBOUND_EMAIL_SERVICE=
|
||||||
# Use one of the following based on the email ingress service
|
# Use one of the following based on the email ingress service
|
||||||
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
||||||
@@ -115,10 +114,6 @@ RAILS_INBOUND_EMAIL_PASSWORD=
|
|||||||
MAILGUN_INGRESS_SIGNING_KEY=
|
MAILGUN_INGRESS_SIGNING_KEY=
|
||||||
MANDRILL_INGRESS_API_KEY=
|
MANDRILL_INGRESS_API_KEY=
|
||||||
|
|
||||||
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
|
|
||||||
# Configure only if the rails_inbound_email_service = ses
|
|
||||||
ACTION_MAILBOX_SES_SNS_TOPIC=
|
|
||||||
|
|
||||||
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
||||||
# Inbound webhook URL format:
|
# Inbound webhook URL format:
|
||||||
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
||||||
@@ -220,7 +215,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
|||||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||||
# DD_TRACE_AGENT_URL=
|
# DD_TRACE_AGENT_URL=
|
||||||
|
|
||||||
|
|
||||||
# MaxMindDB API key to download GeoLite2 City database
|
# MaxMindDB API key to download GeoLite2 City database
|
||||||
# IP_LOOKUP_API_KEY=
|
# IP_LOOKUP_API_KEY=
|
||||||
|
|
||||||
@@ -262,8 +256,6 @@ AZURE_APP_SECRET=
|
|||||||
## Change these values to fine tune performance
|
## Change these values to fine tune performance
|
||||||
# control the concurrency setting of sidekiq
|
# control the concurrency setting of sidekiq
|
||||||
# SIDEKIQ_CONCURRENCY=10
|
# SIDEKIQ_CONCURRENCY=10
|
||||||
# Enable verbose logging each time a job is dequeued in Sidekiq
|
|
||||||
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
|
||||||
|
|
||||||
|
|
||||||
# AI powered features
|
# AI powered features
|
||||||
@@ -274,6 +266,3 @@ AZURE_APP_SECRET=
|
|||||||
# Set to true if you want to remove stale contact inboxes
|
# Set to true if you want to remove stale contact inboxes
|
||||||
# contact_inboxes with no conversation older than 90 days will be removed
|
# contact_inboxes with no conversation older than 90 days will be removed
|
||||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||||
|
|
||||||
# REDIS_ALFRED_SIZE=10
|
|
||||||
# REDIS_VELMA_SIZE=10
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 23
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
name: Run Chatwoot CE spec
|
name: Run Chatwoot CE spec
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -10,58 +8,11 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Separate linting jobs for faster feedback
|
test:
|
||||||
lint-backend:
|
runs-on: ubuntu-22.04
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
- name: Run Rubocop
|
|
||||||
run: bundle exec rubocop --parallel
|
|
||||||
|
|
||||||
lint-frontend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install pnpm dependencies
|
|
||||||
run: pnpm i
|
|
||||||
- name: Run ESLint
|
|
||||||
run: pnpm run eslint
|
|
||||||
|
|
||||||
# Frontend tests run in parallel with backend
|
|
||||||
frontend-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: 'pnpm'
|
|
||||||
- name: Install pnpm dependencies
|
|
||||||
run: pnpm i
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: pnpm run test:coverage
|
|
||||||
|
|
||||||
# Backend tests with parallelization
|
|
||||||
backend-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
ci_node_total: [16]
|
|
||||||
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg15
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ''
|
POSTGRES_PASSWORD: ''
|
||||||
@@ -69,6 +20,8 @@ jobs:
|
|||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
# needed because the postgres container does not provide a healthcheck
|
||||||
|
# tmpfs makes DB faster by using RAM
|
||||||
options: >-
|
options: >-
|
||||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@@ -76,7 +29,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: --entrypoint redis-server
|
options: --entrypoint redis-server
|
||||||
@@ -90,11 +43,11 @@ jobs:
|
|||||||
|
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
bundler-cache: true
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 23
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
@@ -111,36 +64,19 @@ jobs:
|
|||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: bundle exec rake db:schema:load
|
run: bundle exec rake db:schema:load
|
||||||
|
|
||||||
- name: Run backend tests (parallelized)
|
- name: Run frontend tests
|
||||||
|
run: pnpm run test:coverage
|
||||||
|
|
||||||
|
# Run rails tests
|
||||||
|
- name: Run backend tests
|
||||||
run: |
|
run: |
|
||||||
# Get all spec files and split them using round-robin distribution
|
bundle exec rspec --profile=10 --format documentation
|
||||||
# This ensures slow tests are distributed evenly across all nodes
|
|
||||||
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
|
||||||
TESTS=""
|
|
||||||
|
|
||||||
for i in "${!SPEC_FILES[@]}"; do
|
|
||||||
# Assign spec to this node if: index % total == node_index
|
|
||||||
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
|
|
||||||
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -n "$TESTS" ]; then
|
|
||||||
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
|
||||||
fi
|
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --openssl-legacy-provider
|
NODE_OPTIONS: --openssl-legacy-provider
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload rails log folder
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: rspec-results-${{ matrix.ci_node_index }}
|
name: rails-log-folder
|
||||||
path: tmp/rspec_results.json
|
|
||||||
|
|
||||||
- name: Upload rails log folder
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: rails-log-folder-${{ matrix.ci_node_index }}
|
|
||||||
path: log
|
path: log
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 23
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: pnpm
|
- name: pnpm
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: false
|
push: false
|
||||||
load: false
|
load: false
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -94,13 +94,8 @@ yarn-debug.log*
|
|||||||
.vscode
|
.vscode
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
.codex/
|
|
||||||
.claude/
|
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
|
||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
|
||||||
local/
|
|
||||||
Procfile.worktree
|
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
|||||||
"**/target/**",
|
"**/target/**",
|
||||||
"**/templates/**",
|
"**/templates/**",
|
||||||
"**/testdata/**",
|
"**/testdata/**",
|
||||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||||
]
|
]
|
||||||
|
|
||||||
test_patterns = [
|
test_patterns = [
|
||||||
|
|||||||
+1
-12
@@ -7,8 +7,6 @@ plugins:
|
|||||||
require:
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
- ./rubocop/attachment_download.rb
|
|
||||||
- ./rubocop/one_class_per_file.rb
|
|
||||||
|
|
||||||
Layout/LineLength:
|
Layout/LineLength:
|
||||||
Max: 150
|
Max: 150
|
||||||
@@ -42,12 +40,6 @@ Style/SymbolArray:
|
|||||||
Style/OpenStructUse:
|
Style/OpenStructUse:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Chatwoot/AttachmentDownload:
|
|
||||||
Enabled: true
|
|
||||||
Exclude:
|
|
||||||
- 'spec/**/*'
|
|
||||||
- 'test/**/*'
|
|
||||||
|
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
@@ -95,7 +87,7 @@ Metrics/ModuleLength:
|
|||||||
Rails/HelperInstanceVariable:
|
Rails/HelperInstanceVariable:
|
||||||
Exclude:
|
Exclude:
|
||||||
- enterprise/app/helpers/captain/chat_helper.rb
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
- enterprise/app/helpers/captain/chat_response_helper.rb
|
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -213,9 +205,6 @@ UseFromEmail:
|
|||||||
CustomCopLocation:
|
CustomCopLocation:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
Style/OneClassPerFile:
|
|
||||||
Enabled: true
|
|
||||||
|
|
||||||
AllCops:
|
AllCops:
|
||||||
NewCops: enable
|
NewCops: enable
|
||||||
Exclude:
|
Exclude:
|
||||||
|
|||||||
@@ -4,20 +4,12 @@
|
|||||||
|
|
||||||
- **Setup**: `bundle install && pnpm install`
|
- **Setup**: `bundle install && pnpm install`
|
||||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
||||||
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
|
|
||||||
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
|
|
||||||
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
|
|
||||||
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
|
|
||||||
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
|
|
||||||
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||||
- **Run Project**: `overmind start -f Procfile.dev`
|
- **Run Project**: `overmind start -f Procfile.dev`
|
||||||
- **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.)
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -45,27 +37,11 @@
|
|||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- MVP focus: Least code change, happy-path only
|
||||||
- No unnecessary defensive programming
|
- No unnecessary defensive programming
|
||||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
|
||||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
|
||||||
- Break down complex tasks into small, testable units
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
- Remove dead/unreachable/unused code
|
- Remove dead/unreachable/unused code
|
||||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
|
||||||
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
|
||||||
|
|
||||||
## Codex Worktree Workflow
|
|
||||||
|
|
||||||
- Use a separate git worktree + branch per task to keep changes isolated.
|
|
||||||
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
|
|
||||||
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
|
|
||||||
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
|
|
||||||
|
|
||||||
## Commit Messages
|
|
||||||
|
|
||||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
|
||||||
- Example: `feat(auth): add user authentication`
|
|
||||||
- Don't reference Claude in commit messages
|
- Don't reference Claude in commit messages
|
||||||
|
|
||||||
## Project-Specific
|
## Project-Specific
|
||||||
@@ -97,8 +73,3 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
|
||||||
|
|
||||||
## Branding / White-labeling note
|
|
||||||
|
|
||||||
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ gem 'telephone_number'
|
|||||||
gem 'time_diff'
|
gem 'time_diff'
|
||||||
gem 'tzinfo-data'
|
gem 'tzinfo-data'
|
||||||
gem 'valid_email2'
|
gem 'valid_email2'
|
||||||
gem 'email-provider-info'
|
gem 'octokit'
|
||||||
# compress javascript config.assets.js_compressor
|
# compress javascript config.assets.js_compressor
|
||||||
gem 'uglifier'
|
gem 'uglifier'
|
||||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||||
@@ -55,9 +55,6 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
|
|||||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||||
gem 'image_processing'
|
gem 'image_processing'
|
||||||
|
|
||||||
##-- for actionmailbox --##
|
|
||||||
gem 'aws-actionmailbox-ses', '~> 0'
|
|
||||||
|
|
||||||
##-- gems for database --#
|
##-- gems for database --#
|
||||||
gem 'groupdate'
|
gem 'groupdate'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
@@ -162,7 +159,7 @@ gem 'working_hours'
|
|||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
|
|
||||||
# Subscriptions, Billing
|
# Subscriptions, Billing
|
||||||
gem 'stripe', '~> 18.0'
|
gem 'stripe'
|
||||||
|
|
||||||
## - helper gems --##
|
## - helper gems --##
|
||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
@@ -191,18 +188,11 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents'
|
gem 'ai-agents', '>= 0.4.3'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm', '>= 1.8.2'
|
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
gem 'cld3', '~> 3.7'
|
|
||||||
|
|
||||||
# OpenTelemetry for LLM observability
|
|
||||||
gem 'opentelemetry-sdk'
|
|
||||||
gem 'opentelemetry-exporter-otlp'
|
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
@@ -217,7 +207,7 @@ group :production do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'annotaterb'
|
gem 'annotate'
|
||||||
gem 'bullet'
|
gem 'bullet'
|
||||||
gem 'letter_opener'
|
gem 'letter_opener'
|
||||||
gem 'scss_lint', require: false
|
gem 'scss_lint', require: false
|
||||||
|
|||||||
+53
-86
@@ -126,41 +126,31 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.9.0)
|
ai-agents (0.4.3)
|
||||||
ruby_llm (~> 1.9.1)
|
ruby_llm (~> 1.3)
|
||||||
annotaterb (4.20.0)
|
annotate (3.2.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 3.2, < 8.0)
|
||||||
activesupport (>= 6.0.0)
|
rake (>= 10.4, < 14.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
activerecord (>= 5.0, < 7.7)
|
activerecord (>= 5.0, < 7.7)
|
||||||
activesupport (>= 5.0, < 7.7)
|
activesupport (>= 5.0, < 7.7)
|
||||||
aws-actionmailbox-ses (0.1.0)
|
aws-eventstream (1.2.0)
|
||||||
actionmailbox (>= 7.1.0)
|
aws-partitions (1.760.0)
|
||||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
aws-sdk-core (3.171.1)
|
||||||
aws-sdk-sns (~> 1, >= 1.61.0)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-eventstream (1.4.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-partitions (1.1198.0)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-core (3.240.0)
|
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
|
||||||
aws-sigv4 (~> 1.9)
|
|
||||||
base64
|
|
||||||
bigdecimal
|
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
aws-sdk-kms (1.64.0)
|
||||||
aws-sdk-kms (1.118.0)
|
aws-sdk-core (~> 3, >= 3.165.0)
|
||||||
aws-sdk-core (~> 3, >= 3.239.1)
|
|
||||||
aws-sigv4 (~> 1.5)
|
|
||||||
aws-sdk-s3 (1.208.0)
|
|
||||||
aws-sdk-core (~> 3, >= 3.234.0)
|
|
||||||
aws-sdk-kms (~> 1)
|
|
||||||
aws-sigv4 (~> 1.5)
|
|
||||||
aws-sdk-sns (1.70.0)
|
|
||||||
aws-sdk-core (~> 3, >= 3.188.0)
|
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sdk-s3 (1.122.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.165.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.4)
|
||||||
|
aws-sigv4 (1.5.2)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
@@ -186,7 +176,6 @@ GEM
|
|||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
cld3 (3.7.0)
|
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
@@ -281,7 +270,6 @@ GEM
|
|||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
http (>= 3.0)
|
http (>= 3.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
email-provider-info (0.0.1)
|
|
||||||
email_reply_trimmer (0.1.13)
|
email_reply_trimmer (0.1.13)
|
||||||
erubi (1.13.0)
|
erubi (1.13.0)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
@@ -298,7 +286,7 @@ GEM
|
|||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (3.2.0)
|
faker (3.2.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.14.1)
|
faraday (2.13.1)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
@@ -309,12 +297,12 @@ GEM
|
|||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2)
|
||||||
faraday-net_http (3.4.2)
|
faraday-net_http (3.4.0)
|
||||||
net-http (~> 0.5)
|
net-http (>= 0.5.0)
|
||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
faraday (~> 2.5)
|
faraday (~> 2.5)
|
||||||
net-http-persistent (~> 4.0)
|
net-http-persistent (~> 4.0)
|
||||||
faraday-retry (2.4.0)
|
faraday-retry (2.2.1)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday_middleware-aws-sigv4 (1.0.1)
|
faraday_middleware-aws-sigv4 (1.0.1)
|
||||||
aws-sigv4 (~> 1.0)
|
aws-sigv4 (~> 1.0)
|
||||||
@@ -442,8 +430,7 @@ GEM
|
|||||||
http-cookie (1.0.5)
|
http-cookie (1.0.5)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
http-form_data (2.3.0)
|
http-form_data (2.3.0)
|
||||||
httparty (0.24.0)
|
httparty (0.21.0)
|
||||||
csv
|
|
||||||
mini_mime (>= 1.0.0)
|
mini_mime (>= 1.0.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
@@ -465,7 +452,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.18.1)
|
json (2.13.2)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@@ -540,11 +527,11 @@ GEM
|
|||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.1.0)
|
marcel (1.0.4)
|
||||||
maxminddb (0.1.22)
|
maxminddb (0.1.22)
|
||||||
meta_request (0.8.5)
|
meta_request (0.8.3)
|
||||||
rack-contrib (>= 1.1, < 3)
|
rack-contrib (>= 1.1, < 3)
|
||||||
railties (>= 3.0.0, < 9)
|
railties (>= 3.0.0, < 8)
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
mime-types (3.4.1)
|
mime-types (3.4.1)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
@@ -557,14 +544,13 @@ GEM
|
|||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.8.0)
|
multi_xml (0.6.0)
|
||||||
bigdecimal (>= 3.1, < 5)
|
multipart-post (2.3.0)
|
||||||
multipart-post (2.4.1)
|
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
neighbor (0.2.3)
|
neighbor (0.2.3)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
net-http (0.9.1)
|
net-http (0.6.0)
|
||||||
uri (>= 0.11.1)
|
uri
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.20)
|
net-imap (0.4.20)
|
||||||
@@ -583,14 +569,14 @@ GEM
|
|||||||
newrelic_rpm (9.6.0)
|
newrelic_rpm (9.6.0)
|
||||||
base64
|
base64
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.3)
|
||||||
nokogiri (1.19.1)
|
nokogiri (1.18.9)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm64-darwin)
|
nokogiri (1.18.9-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-darwin)
|
nokogiri (1.18.9-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (1.1.0)
|
oauth (1.1.0)
|
||||||
oauth-tty (~> 1.0, >= 1.0.1)
|
oauth-tty (~> 1.0, >= 1.0.1)
|
||||||
@@ -605,10 +591,13 @@ GEM
|
|||||||
rack (>= 1.2, < 4)
|
rack (>= 1.2, < 4)
|
||||||
snaky_hash (~> 2.0)
|
snaky_hash (~> 2.0)
|
||||||
version_gem (~> 1.1)
|
version_gem (~> 1.1)
|
||||||
|
octokit (10.0.0)
|
||||||
|
faraday (>= 1, < 3)
|
||||||
|
sawyer (~> 0.9)
|
||||||
oj (3.16.10)
|
oj (3.16.10)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.4)
|
omniauth (2.1.3)
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
logger
|
logger
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
@@ -631,25 +620,6 @@ GEM
|
|||||||
faraday (>= 1.0, < 3)
|
faraday (>= 1.0, < 3)
|
||||||
multi_json (>= 1.0)
|
multi_json (>= 1.0)
|
||||||
openssl (3.2.0)
|
openssl (3.2.0)
|
||||||
opentelemetry-api (1.7.0)
|
|
||||||
opentelemetry-common (0.23.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-exporter-otlp (0.31.1)
|
|
||||||
google-protobuf (>= 3.18)
|
|
||||||
googleapis-common-protos-types (~> 1.3)
|
|
||||||
opentelemetry-api (~> 1.1)
|
|
||||||
opentelemetry-common (~> 0.20)
|
|
||||||
opentelemetry-sdk (~> 1.10)
|
|
||||||
opentelemetry-semantic_conventions
|
|
||||||
opentelemetry-registry (0.4.0)
|
|
||||||
opentelemetry-api (~> 1.1)
|
|
||||||
opentelemetry-sdk (1.10.0)
|
|
||||||
opentelemetry-api (~> 1.1)
|
|
||||||
opentelemetry-common (~> 0.20)
|
|
||||||
opentelemetry-registry (~> 0.2)
|
|
||||||
opentelemetry-semantic_conventions
|
|
||||||
opentelemetry-semantic_conventions (1.36.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
@@ -677,7 +647,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.5)
|
rack (3.2.3)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@@ -686,7 +656,7 @@ GEM
|
|||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-mini-profiler (3.2.0)
|
rack-mini-profiler (3.2.0)
|
||||||
rack (>= 1.2.0)
|
rack (>= 1.2.0)
|
||||||
rack-protection (4.2.1)
|
rack-protection (4.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
logger (>= 1.6.0)
|
logger (>= 1.6.0)
|
||||||
rack (>= 3.0.0, < 4)
|
rack (>= 3.0.0, < 4)
|
||||||
@@ -825,7 +795,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.9.2)
|
ruby_llm (1.5.1)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@@ -833,9 +803,8 @@ GEM
|
|||||||
faraday-net_http (>= 1)
|
faraday-net_http (>= 1)
|
||||||
faraday-retry (>= 1)
|
faraday-retry (>= 1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
ruby_llm-schema (~> 0.2.1)
|
|
||||||
zeitwerk (~> 2)
|
zeitwerk (~> 2)
|
||||||
ruby_llm-schema (0.2.5)
|
ruby_llm-schema (0.1.0)
|
||||||
ruby_parser (3.20.0)
|
ruby_parser (3.20.0)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sass (3.7.4)
|
sass (3.7.4)
|
||||||
@@ -851,6 +820,9 @@ GEM
|
|||||||
sprockets (> 3.0)
|
sprockets (> 3.0)
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
tilt
|
tilt
|
||||||
|
sawyer (0.9.2)
|
||||||
|
addressable (>= 2.3.5)
|
||||||
|
faraday (>= 0.17.3, < 3)
|
||||||
scout_apm (5.3.3)
|
scout_apm (5.3.3)
|
||||||
parser
|
parser
|
||||||
scss_lint (0.60.0)
|
scss_lint (0.60.0)
|
||||||
@@ -935,7 +907,7 @@ GEM
|
|||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (18.0.1)
|
stripe (8.5.0)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
@@ -969,7 +941,7 @@ GEM
|
|||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.17.0)
|
uniform_notifier (1.17.0)
|
||||||
uri (1.1.1)
|
uri (1.0.4)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
@@ -1004,7 +976,7 @@ GEM
|
|||||||
working_hours (1.4.1)
|
working_hours (1.4.1)
|
||||||
activesupport (>= 3.2)
|
activesupport (>= 3.2)
|
||||||
tzinfo
|
tzinfo
|
||||||
zeitwerk (2.7.4)
|
zeitwerk (2.6.17)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
arm64-darwin-20
|
arm64-darwin-20
|
||||||
@@ -1024,11 +996,10 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents
|
ai-agents (>= 0.4.3)
|
||||||
annotaterb
|
annotate
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
aws-actionmailbox-ses (~> 0)
|
|
||||||
aws-sdk-s3
|
aws-sdk-s3
|
||||||
azure-storage-blob!
|
azure-storage-blob!
|
||||||
barnes
|
barnes
|
||||||
@@ -1038,7 +1009,6 @@ DEPENDENCIES
|
|||||||
bullet
|
bullet
|
||||||
bundle-audit
|
bundle-audit
|
||||||
byebug
|
byebug
|
||||||
cld3 (~> 3.7)
|
|
||||||
climate_control
|
climate_control
|
||||||
commonmarker
|
commonmarker
|
||||||
csv-safe
|
csv-safe
|
||||||
@@ -1052,7 +1022,6 @@ DEPENDENCIES
|
|||||||
dotenv-rails (>= 3.0.0)
|
dotenv-rails (>= 3.0.0)
|
||||||
down
|
down
|
||||||
elastic-apm
|
elastic-apm
|
||||||
email-provider-info
|
|
||||||
email_reply_trimmer
|
email_reply_trimmer
|
||||||
facebook-messenger
|
facebook-messenger
|
||||||
factory_bot_rails (>= 6.4.3)
|
factory_bot_rails (>= 6.4.3)
|
||||||
@@ -1094,14 +1063,13 @@ DEPENDENCIES
|
|||||||
net-smtp (~> 0.3.4)
|
net-smtp (~> 0.3.4)
|
||||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||||
newrelic_rpm
|
newrelic_rpm
|
||||||
|
octokit
|
||||||
omniauth (>= 2.1.2)
|
omniauth (>= 2.1.2)
|
||||||
omniauth-google-oauth2 (>= 1.1.3)
|
omniauth-google-oauth2 (>= 1.1.3)
|
||||||
omniauth-oauth2
|
omniauth-oauth2
|
||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
omniauth-saml
|
omniauth-saml
|
||||||
opensearch-ruby
|
opensearch-ruby
|
||||||
opentelemetry-exporter-otlp
|
|
||||||
opentelemetry-sdk
|
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
@@ -1127,7 +1095,6 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
ruby_llm (>= 1.8.2)
|
|
||||||
ruby_llm-schema
|
ruby_llm-schema
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
@@ -1148,7 +1115,7 @@ DEPENDENCIES
|
|||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
stackprof
|
stackprof
|
||||||
stripe (~> 18.0)
|
stripe
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
tidewave
|
tidewave
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ ___
|
|||||||
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
|
||||||
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
|
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
|
||||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
|
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
|
||||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
|
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
|
||||||
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
|||||||
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
|
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
|
||||||
|
|
||||||
|
|
||||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.11.1
|
4.4.0
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.5.0
|
3.4.3
|
||||||
|
|||||||
@@ -36,10 +36,6 @@
|
|||||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||||
"value": "none"
|
"value": "none"
|
||||||
},
|
|
||||||
"NODE_OPTIONS": {
|
|
||||||
"description": "Increase V8 heap for Vite build to avoid OOM",
|
|
||||||
"value": "--max-old-space-size=4096"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"formation": {
|
"formation": {
|
||||||
|
|||||||
@@ -103,5 +103,3 @@ class ContactInboxBuilder
|
|||||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
|
||||||
|
|||||||
@@ -112,25 +112,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
return if story_reply_attributes.blank?
|
return if story_reply_attributes.blank?
|
||||||
|
|
||||||
@message.save_story_info(story_reply_attributes)
|
@message.save_story_info(story_reply_attributes)
|
||||||
create_story_reply_attachment(story_reply_attributes['url'])
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_story_reply_attachment(story_url)
|
|
||||||
return if story_url.blank?
|
|
||||||
|
|
||||||
attachment = @message.attachments.new(
|
|
||||||
file_type: :ig_story,
|
|
||||||
account_id: @message.account_id,
|
|
||||||
external_url: story_url
|
|
||||||
)
|
|
||||||
attachment.save!
|
|
||||||
begin
|
|
||||||
attach_file(attachment, story_url)
|
|
||||||
rescue Down::Error, StandardError => e
|
|
||||||
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
|
|
||||||
end
|
|
||||||
@message.content_attributes[:image_type] = 'ig_story_reply'
|
|
||||||
@message.save!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_conversation
|
def build_conversation
|
||||||
@@ -158,7 +139,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
account_id: conversation.account_id,
|
account_id: conversation.account_id,
|
||||||
inbox_id: conversation.inbox_id,
|
inbox_id: conversation.inbox_id,
|
||||||
message_type: message_type,
|
message_type: message_type,
|
||||||
status: @outgoing_echo ? :delivered : :sent,
|
|
||||||
source_id: message_identifier,
|
source_id: message_identifier,
|
||||||
content: message_content,
|
content: message_content,
|
||||||
sender: @outgoing_echo ? nil : contact,
|
sender: @outgoing_echo ? nil : contact,
|
||||||
@@ -167,7 +147,6 @@ 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[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||||
params
|
params
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
class Messages::MessageBuilder
|
class Messages::MessageBuilder
|
||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
include ::EmailHelper
|
|
||||||
include ::DataHelper
|
|
||||||
|
|
||||||
attr_reader :message
|
attr_reader :message
|
||||||
|
|
||||||
def initialize(user, conversation, params)
|
def initialize(user, conversation, params)
|
||||||
@@ -26,7 +23,7 @@ class Messages::MessageBuilder
|
|||||||
process_emails
|
process_emails
|
||||||
# When the message has no quoted content, it will just be rendered as a regular message
|
# When the message has no quoted content, it will just be rendered as a regular message
|
||||||
# The frontend is equipped to handle this case
|
# The frontend is equipped to handle this case
|
||||||
process_email_content
|
process_email_content if @account.feature_enabled?(:quoted_email_reply)
|
||||||
@message.save!
|
@message.save!
|
||||||
@message
|
@message
|
||||||
end
|
end
|
||||||
@@ -41,12 +38,30 @@ class Messages::MessageBuilder
|
|||||||
params = convert_to_hash(@params)
|
params = convert_to_hash(@params)
|
||||||
content_attributes = params.fetch(:content_attributes, {})
|
content_attributes = params.fetch(:content_attributes, {})
|
||||||
|
|
||||||
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
|
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||||
return content_attributes if content_attributes.is_a?(Hash)
|
return content_attributes if content_attributes.is_a?(Hash)
|
||||||
|
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Converts the given object to a hash.
|
||||||
|
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
|
||||||
|
# Otherwise, returns the object as-is.
|
||||||
|
def convert_to_hash(obj)
|
||||||
|
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||||
|
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
|
||||||
|
# Attempts to parse a string as JSON.
|
||||||
|
# If successful, returns the parsed hash with symbolized names.
|
||||||
|
# If unsuccessful, returns nil.
|
||||||
|
def parse_json(content)
|
||||||
|
JSON.parse(content, symbolize_names: true)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
def process_attachments
|
def process_attachments
|
||||||
return if @attachments.blank?
|
return if @attachments.blank?
|
||||||
|
|
||||||
@@ -95,6 +110,12 @@ class Messages::MessageBuilder
|
|||||||
email_string.gsub(/\s+/, '').split(',')
|
email_string.gsub(/\s+/, '').split(',')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_email_addresses(all_emails)
|
||||||
|
all_emails&.each do |email|
|
||||||
|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def message_type
|
def message_type
|
||||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||||
@@ -138,7 +159,6 @@ class Messages::MessageBuilder
|
|||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
content_attributes: content_attributes.presence,
|
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
@@ -158,17 +178,14 @@ class Messages::MessageBuilder
|
|||||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||||
normalized_content = normalize_email_body(@message.content)
|
normalized_content = normalize_email_body(@message.content)
|
||||||
|
|
||||||
# Process liquid templates in normalized content with code block protection
|
|
||||||
processed_content = process_liquid_in_email_body(normalized_content)
|
|
||||||
|
|
||||||
# Use custom HTML content if provided, otherwise generate from message content
|
# Use custom HTML content if provided, otherwise generate from message content
|
||||||
email_attributes[:html_content] = if custom_email_content_provided?
|
email_attributes[:html_content] = if custom_email_content_provided?
|
||||||
build_custom_html_content
|
build_custom_html_content
|
||||||
else
|
else
|
||||||
build_html_content(processed_content)
|
build_html_content(normalized_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
email_attributes[:text_content] = build_text_content(processed_content)
|
email_attributes[:text_content] = build_text_content(normalized_content)
|
||||||
email_attributes
|
email_attributes
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -187,6 +204,22 @@ class Messages::MessageBuilder
|
|||||||
text_content
|
text_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ensure_indifferent_access(hash)
|
||||||
|
return {} if hash.blank?
|
||||||
|
|
||||||
|
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_email_body(content)
|
||||||
|
content.to_s.gsub("\r\n", "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_email_html(content)
|
||||||
|
return '' if content.blank?
|
||||||
|
|
||||||
|
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||||
|
end
|
||||||
|
|
||||||
def custom_email_content_provided?
|
def custom_email_content_provided?
|
||||||
@params[:email_html_content].present?
|
@params[:email_html_content].present?
|
||||||
end
|
end
|
||||||
@@ -199,29 +232,4 @@ class Messages::MessageBuilder
|
|||||||
|
|
||||||
html_content
|
html_content
|
||||||
end
|
end
|
||||||
|
|
||||||
# Liquid processing methods for email content
|
|
||||||
def process_liquid_in_email_body(content)
|
|
||||||
return content if content.blank?
|
|
||||||
return content unless should_process_liquid?
|
|
||||||
|
|
||||||
# Protect code blocks from liquid processing
|
|
||||||
modified_content = modified_liquid_content(content)
|
|
||||||
template = Liquid::Template.parse(modified_content)
|
|
||||||
template.render(drops_with_sender)
|
|
||||||
rescue Liquid::Error
|
|
||||||
content
|
|
||||||
end
|
|
||||||
|
|
||||||
def should_process_liquid?
|
|
||||||
@message_type == 'outgoing' || @message_type == 'template'
|
|
||||||
end
|
|
||||||
|
|
||||||
def drops_with_sender
|
|
||||||
message_drops(@conversation).merge({
|
|
||||||
'agent' => UserDrop.new(sender)
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
|
||||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
|
||||||
update_attachment_file_type(attachment_obj)
|
update_attachment_file_type(attachment_obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -29,7 +27,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
file_type = attachment['type'].to_sym
|
file_type = attachment['type'].to_sym
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
|
||||||
params.merge!(file_type_params(attachment))
|
params.merge!(file_type_params(attachment))
|
||||||
elsif file_type == :location
|
elsif file_type == :location
|
||||||
params.merge!(location_params(attachment))
|
params.merge!(location_params(attachment))
|
||||||
@@ -41,17 +39,9 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def file_type_params(attachment)
|
def file_type_params(attachment)
|
||||||
# Handle different URL field names for different attachment types
|
|
||||||
url = case attachment['type'].to_sym
|
|
||||||
when :ig_story
|
|
||||||
attachment['payload']['story_media_url']
|
|
||||||
else
|
|
||||||
attachment['payload']['url']
|
|
||||||
end
|
|
||||||
|
|
||||||
{
|
{
|
||||||
external_url: url,
|
external_url: attachment['payload']['url'],
|
||||||
remote_file_url: url
|
remote_file_url: attachment['payload']['url']
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -78,21 +68,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
message.save!
|
message.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_ig_story_link(attachment)
|
|
||||||
message = attachment.message
|
|
||||||
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
|
|
||||||
message.content_attributes[:image_type] = 'ig_story'
|
|
||||||
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
|
|
||||||
message.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_ig_post_link(attachment)
|
|
||||||
message = attachment.message
|
|
||||||
message.content_attributes[:image_type] = 'ig_post'
|
|
||||||
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
|
|
||||||
message.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
# This is a placeholder method to be overridden by child classes
|
# This is a placeholder method to be overridden by child classes
|
||||||
def get_story_object_from_source_id(_source_id)
|
def get_story_object_from_source_id(_source_id)
|
||||||
{}
|
{}
|
||||||
@@ -101,6 +76,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def unsupported_file_type?(attachment_type)
|
def unsupported_file_type?(attachment_type)
|
||||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
[:template, :unsupported_type].include? attachment_type.to_sym
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
class V2::Reports::ChannelSummaryBuilder
|
|
||||||
include DateRangeHelper
|
|
||||||
|
|
||||||
pattr_initialize [:account!, :params!]
|
|
||||||
|
|
||||||
def build
|
|
||||||
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def conversations_by_channel_and_status
|
|
||||||
account.conversations
|
|
||||||
.joins(:inbox)
|
|
||||||
.where(created_at: range)
|
|
||||||
.group('inboxes.channel_type', 'conversations.status')
|
|
||||||
.count
|
|
||||||
.each_with_object({}) do |((channel_type, status), count), grouped|
|
|
||||||
grouped[channel_type] ||= {}
|
|
||||||
grouped[channel_type][status] = count
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_channel_stats(status_counts)
|
|
||||||
open_count = status_counts['open'] || 0
|
|
||||||
resolved_count = status_counts['resolved'] || 0
|
|
||||||
pending_count = status_counts['pending'] || 0
|
|
||||||
snoozed_count = status_counts['snoozed'] || 0
|
|
||||||
|
|
||||||
{
|
|
||||||
open: open_count,
|
|
||||||
resolved: resolved_count,
|
|
||||||
pending: pending_count,
|
|
||||||
snoozed: snoozed_count,
|
|
||||||
total: open_count + resolved_count + pending_count + snoozed_count
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
class V2::Reports::OutgoingMessagesCountBuilder
|
|
||||||
include DateRangeHelper
|
|
||||||
attr_reader :account, :params
|
|
||||||
|
|
||||||
def initialize(account, params)
|
|
||||||
@account = account
|
|
||||||
@params = params
|
|
||||||
end
|
|
||||||
|
|
||||||
def build
|
|
||||||
send("build_by_#{params[:group_by]}")
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def base_messages
|
|
||||||
account.messages.outgoing.unscope(:order).where(created_at: range)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_by_agent
|
|
||||||
counts = base_messages
|
|
||||||
.where(sender_type: 'User')
|
|
||||||
.where.not(sender_id: nil)
|
|
||||||
.group(:sender_id)
|
|
||||||
.count
|
|
||||||
|
|
||||||
user_names = account.users.where(id: counts.keys).index_by(&:id)
|
|
||||||
|
|
||||||
counts.map do |user_id, count|
|
|
||||||
user = user_names[user_id]
|
|
||||||
{ id: user_id, name: user&.name, outgoing_messages_count: count }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_by_team
|
|
||||||
counts = base_messages
|
|
||||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
|
||||||
.where.not(conversations: { team_id: nil })
|
|
||||||
.group('conversations.team_id')
|
|
||||||
.count
|
|
||||||
|
|
||||||
team_names = account.teams.where(id: counts.keys).index_by(&:id)
|
|
||||||
|
|
||||||
counts.map do |team_id, count|
|
|
||||||
team = team_names[team_id]
|
|
||||||
{ id: team_id, name: team&.name, outgoing_messages_count: count }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_by_inbox
|
|
||||||
counts = base_messages
|
|
||||||
.group(:inbox_id)
|
|
||||||
.count
|
|
||||||
|
|
||||||
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
|
|
||||||
|
|
||||||
counts.map do |inbox_id, count|
|
|
||||||
inbox = inbox_names[inbox_id]
|
|
||||||
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_by_label
|
|
||||||
counts = base_messages
|
|
||||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
|
||||||
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
|
|
||||||
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
|
|
||||||
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
|
|
||||||
.group('tags.name')
|
|
||||||
.count
|
|
||||||
|
|
||||||
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
|
|
||||||
|
|
||||||
counts.map do |label_name, count|
|
|
||||||
label = label_ids[label_name]
|
|
||||||
{ id: label&.id, name: label_name, outgoing_messages_count: count }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
class YearInReviewBuilder
|
|
||||||
attr_reader :account, :user_id, :year
|
|
||||||
|
|
||||||
def initialize(account:, user_id:, year:)
|
|
||||||
@account = account
|
|
||||||
@user_id = user_id
|
|
||||||
@year = year
|
|
||||||
end
|
|
||||||
|
|
||||||
def build
|
|
||||||
{
|
|
||||||
year: year,
|
|
||||||
total_conversations: total_conversations_count,
|
|
||||||
busiest_day: busiest_day_data,
|
|
||||||
support_personality: support_personality_data
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def year_range
|
|
||||||
@year_range ||= begin
|
|
||||||
start_time = Time.zone.local(year, 1, 1).beginning_of_day
|
|
||||||
end_time = Time.zone.local(year, 12, 31).end_of_day
|
|
||||||
start_time..end_time
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def total_conversations_count
|
|
||||||
account.conversations
|
|
||||||
.where(assignee_id: user_id, created_at: year_range)
|
|
||||||
.count
|
|
||||||
end
|
|
||||||
|
|
||||||
def busiest_day_data
|
|
||||||
daily_counts = account.conversations
|
|
||||||
.where(assignee_id: user_id, created_at: year_range)
|
|
||||||
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
|
|
||||||
.count
|
|
||||||
|
|
||||||
return nil if daily_counts.empty?
|
|
||||||
|
|
||||||
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
|
|
||||||
|
|
||||||
return nil if count.zero?
|
|
||||||
|
|
||||||
{
|
|
||||||
date: busiest_date.strftime('%b %d'),
|
|
||||||
count: count
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def support_personality_data
|
|
||||||
response_time = average_response_time
|
|
||||||
|
|
||||||
return { avg_response_time_seconds: 0 } if response_time.nil?
|
|
||||||
|
|
||||||
{
|
|
||||||
avg_response_time_seconds: response_time.to_i
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def average_response_time
|
|
||||||
avg_time = account.reporting_events
|
|
||||||
.where(
|
|
||||||
name: 'first_response',
|
|
||||||
user_id: user_id,
|
|
||||||
created_at: year_range
|
|
||||||
)
|
|
||||||
.average(:value)
|
|
||||||
|
|
||||||
avg_time&.to_f
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
before_action :agent_bot, except: [:index, :create]
|
before_action :agent_bot, except: [:index, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
|
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
|
||||||
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
def edit; end
|
def edit; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
params_with_defaults = article_params
|
@article = @portal.articles.create!(article_params)
|
||||||
params_with_defaults[:status] ||= :draft
|
|
||||||
@article = @portal.articles.create!(params_with_defaults)
|
|
||||||
@article.associate_root_article(article_params[:associated_article_id])
|
@article.associate_root_article(article_params[:associated_article_id])
|
||||||
|
@article.draft!
|
||||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
||||||
include AttachmentConcern
|
|
||||||
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||||
|
|
||||||
@@ -11,32 +9,25 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
|
||||||
return render_could_not_create_error(error) if error
|
|
||||||
|
|
||||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||||
@automation_rule.actions = actions
|
@automation_rule.actions = params[:actions]
|
||||||
@automation_rule.conditions = params[:conditions]
|
@automation_rule.conditions = params[:conditions]
|
||||||
|
|
||||||
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
|
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
||||||
|
|
||||||
@automation_rule.save!
|
@automation_rule.save!
|
||||||
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
process_attachments
|
||||||
|
@automation_rule
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
|
|
||||||
return render_could_not_create_error(error) if error
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@automation_rule.assign_attributes(automation_rules_permit)
|
automation_rule_update
|
||||||
@automation_rule.actions = actions if params[:actions]
|
process_attachments
|
||||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
|
||||||
@automation_rule.save!
|
|
||||||
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render_could_not_create_error(@automation_rule.errors.messages)
|
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,11 +43,29 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
@automation_rule = new_rule
|
@automation_rule = new_rule
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_attachments
|
||||||
|
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||||
|
return if actions.blank?
|
||||||
|
|
||||||
|
actions.each do |action|
|
||||||
|
blob_id = action['action_params']
|
||||||
|
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||||
|
@automation_rule.files.attach(blob)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def automation_rule_update
|
||||||
|
@automation_rule.update!(automation_rules_permit)
|
||||||
|
@automation_rule.actions = params[:actions] if params[:actions]
|
||||||
|
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||||
|
@automation_rule.save!
|
||||||
|
end
|
||||||
|
|
||||||
def automation_rules_permit
|
def automation_rules_permit
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :description, :event_name, :active,
|
:name, :description, :event_name, :account_id, :active,
|
||||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :type_matches?
|
||||||
|
|
||||||
def create
|
def create
|
||||||
case normalized_type
|
if type_matches?
|
||||||
when 'Conversation'
|
::BulkActionsJob.perform_later(
|
||||||
enqueue_conversation_job
|
account: @current_account,
|
||||||
head :ok
|
user: current_user,
|
||||||
when 'Contact'
|
params: permitted_params
|
||||||
check_authorization_for_contact_action
|
)
|
||||||
enqueue_contact_job
|
|
||||||
head :ok
|
head :ok
|
||||||
else
|
else
|
||||||
render json: { success: false }, status: :unprocessable_entity
|
render json: { success: false }, status: :unprocessable_entity
|
||||||
@@ -15,54 +16,11 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def normalized_type
|
def type_matches?
|
||||||
params[:type].to_s.camelize
|
['Conversation'].include?(params[:type])
|
||||||
end
|
end
|
||||||
|
|
||||||
def enqueue_conversation_job
|
def permitted_params
|
||||||
::BulkActionsJob.perform_later(
|
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
||||||
account: @current_account,
|
|
||||||
user: current_user,
|
|
||||||
params: conversation_params
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def enqueue_contact_job
|
|
||||||
Contacts::BulkActionJob.perform_later(
|
|
||||||
@current_account.id,
|
|
||||||
current_user.id,
|
|
||||||
contact_params
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_contact_action?
|
|
||||||
params[:action_name] == 'delete'
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_authorization_for_contact_action
|
|
||||||
authorize(Contact, :destroy?) if delete_contact_action?
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_params
|
|
||||||
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
|
|
||||||
# and then remove this method in favor of a common params method.
|
|
||||||
base = params.permit(
|
|
||||||
:snoozed_until,
|
|
||||||
fields: [:status, :assignee_id, :team_id]
|
|
||||||
)
|
|
||||||
append_common_bulk_attributes(base)
|
|
||||||
end
|
|
||||||
|
|
||||||
def contact_params
|
|
||||||
# TODO: remove this method in favor of a common params method.
|
|
||||||
# once legacy conversation payloads are migrated.
|
|
||||||
append_common_bulk_attributes({})
|
|
||||||
end
|
|
||||||
|
|
||||||
def append_common_bulk_attributes(base_params)
|
|
||||||
# NOTE: Conversation payloads historically diverged per action. Going forward we
|
|
||||||
# want all objects to share a common contract: `{ action_name, action_attributes }`
|
|
||||||
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
|
|
||||||
base_params.merge(common)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :current_account
|
|
||||||
before_action :authorize_account_update, only: [:update]
|
|
||||||
|
|
||||||
def show
|
|
||||||
render json: preferences_payload
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
params_to_update = captain_params
|
|
||||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
|
|
||||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
|
|
||||||
@current_account.save!
|
|
||||||
|
|
||||||
render json: preferences_payload
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def preferences_payload
|
|
||||||
{
|
|
||||||
providers: Llm::Models.providers,
|
|
||||||
models: Llm::Models.models,
|
|
||||||
features: features_with_account_preferences
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def authorize_account_update
|
|
||||||
authorize @current_account, :update?
|
|
||||||
end
|
|
||||||
|
|
||||||
def captain_params
|
|
||||||
permitted = {}
|
|
||||||
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
|
|
||||||
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
|
|
||||||
permitted
|
|
||||||
end
|
|
||||||
|
|
||||||
def merged_captain_models
|
|
||||||
existing_models = @current_account.captain_models || {}
|
|
||||||
existing_models.merge(permitted_captain_models)
|
|
||||||
end
|
|
||||||
|
|
||||||
def merged_captain_features
|
|
||||||
existing_features = @current_account.captain_features || {}
|
|
||||||
existing_features.merge(permitted_captain_features)
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_captain_models
|
|
||||||
params.require(:captain_models).permit(
|
|
||||||
:editor, :assistant, :copilot, :label_suggestion,
|
|
||||||
:audio_transcription, :help_center_search
|
|
||||||
).to_h.stringify_keys
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_captain_features
|
|
||||||
params.require(:captain_features).permit(
|
|
||||||
:editor, :assistant, :copilot, :label_suggestion,
|
|
||||||
:audio_transcription, :help_center_search
|
|
||||||
).to_h.stringify_keys
|
|
||||||
end
|
|
||||||
|
|
||||||
def features_with_account_preferences
|
|
||||||
preferences = Current.account.captain_preferences
|
|
||||||
account_features = preferences[:features] || {}
|
|
||||||
account_models = preferences[:models] || {}
|
|
||||||
|
|
||||||
Llm::Models.feature_keys.index_with do |feature_key|
|
|
||||||
config = Llm::Models.feature_config(feature_key)
|
|
||||||
config.merge(
|
|
||||||
enabled: account_features[feature_key] == true,
|
|
||||||
selected: account_models[feature_key] || config[:default]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:twilio_channel).permit(
|
params.require(:twilio_channel).permit(
|
||||||
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
def search
|
def search
|
||||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||||
|
|
||||||
contacts = Current.account.contacts.where(
|
contacts = resolved_contacts.where(
|
||||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
|
||||||
|
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||||
search: "%#{params[:q].strip}%"
|
search: "%#{params[:q].strip}%"
|
||||||
)
|
)
|
||||||
@contacts = fetch_contacts_with_has_more(contacts)
|
@contacts = fetch_contacts(contacts)
|
||||||
|
@contacts_count = @contacts.total_count
|
||||||
end
|
end
|
||||||
|
|
||||||
def import
|
def import
|
||||||
@@ -141,24 +143,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
.per(RESULTS_PER_PAGE)
|
.per(RESULTS_PER_PAGE)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_contacts_with_has_more(contacts)
|
|
||||||
includes_hash = { avatar_attachment: [:blob] }
|
|
||||||
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
|
||||||
|
|
||||||
# Calculate offset manually to fetch one extra record for has_more check
|
|
||||||
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
|
|
||||||
results = filtrate(contacts)
|
|
||||||
.includes(includes_hash)
|
|
||||||
.offset(offset)
|
|
||||||
.limit(RESULTS_PER_PAGE + 1)
|
|
||||||
.to_a
|
|
||||||
|
|
||||||
@has_more = results.size > RESULTS_PER_PAGE
|
|
||||||
results = results.first(RESULTS_PER_PAGE) if @has_more
|
|
||||||
@contacts_count = results.size
|
|
||||||
results
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_contact_inbox
|
def build_contact_inbox
|
||||||
return if params[:inbox_id].blank?
|
return if params[:inbox_id].blank?
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
||||||
# assigns agent/team to a conversation
|
# assigns agent/team to a conversation
|
||||||
def create
|
def create
|
||||||
if params.key?(:assignee_id) || agent_bot_assignment?
|
if params.key?(:assignee_id)
|
||||||
set_agent
|
set_agent
|
||||||
elsif params.key?(:team_id)
|
elsif params.key?(:team_id)
|
||||||
set_team
|
set_team
|
||||||
@@ -13,23 +13,17 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_agent
|
def set_agent
|
||||||
resource = Conversations::AssignmentService.new(
|
@agent = Current.account.users.find_by(id: params[:assignee_id])
|
||||||
conversation: @conversation,
|
@conversation.assignee = @agent
|
||||||
assignee_id: params[:assignee_id],
|
@conversation.save!
|
||||||
assignee_type: params[:assignee_type]
|
render_agent
|
||||||
).perform
|
|
||||||
|
|
||||||
render_agent(resource)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_agent(resource)
|
def render_agent
|
||||||
case resource
|
if @agent.nil?
|
||||||
when User
|
|
||||||
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
|
|
||||||
when AgentBot
|
|
||||||
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
|
|
||||||
else
|
|
||||||
render json: nil
|
render json: nil
|
||||||
|
else
|
||||||
|
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -38,8 +32,4 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
@conversation.update!(team: @team)
|
@conversation.update!(team: @team)
|
||||||
render json: @team
|
render json: @team
|
||||||
end
|
end
|
||||||
|
|
||||||
def agent_bot_assignment?
|
|
||||||
params[:assignee_type].to_s == 'AgentBot'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def meta
|
def meta
|
||||||
result = conversation_finder.perform_meta_only
|
result = conversation_finder.perform
|
||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -70,11 +70,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
||||||
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
|
|
||||||
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
|
||||||
|
|
||||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||||
@conversation.account.increment_email_sent_count
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -113,15 +110,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_last_seen
|
def update_last_seen
|
||||||
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
|
||||||
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
|
||||||
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
|
||||||
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
|
||||||
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
|
||||||
|
|
||||||
# No unread messages - apply throttling to limit DB writes
|
|
||||||
return unless should_update_last_seen?
|
|
||||||
|
|
||||||
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -154,25 +142,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||||
updates = { agent_last_seen_at: last_seen_at }
|
|
||||||
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
|
|
||||||
|
|
||||||
# rubocop:disable Rails/SkipsModelValidations
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
@conversation.update_columns(updates)
|
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||||
|
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_update_last_seen?
|
|
||||||
# Update if at least one relevant timestamp is older than 1 hour or not set
|
|
||||||
# This prevents redundant DB writes when agents repeatedly view the same conversation
|
|
||||||
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
|
|
||||||
return agent_needs_update unless assignee?
|
|
||||||
|
|
||||||
# For assignees, check both timestamps - update if either is old
|
|
||||||
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
|
|
||||||
agent_needs_update || assignee_needs_update
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_conversation_status
|
def set_conversation_status
|
||||||
@conversation.status = params[:status]
|
@conversation.status = params[:status]
|
||||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||||
|
|||||||
@@ -50,5 +50,3 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
|
|||||||
@current_page = params[:page] || 1
|
@current_page = params[:page] || 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
|
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_inbox
|
|
||||||
before_action :validate_whatsapp_channel
|
|
||||||
|
|
||||||
def show
|
|
||||||
service = CsatTemplateManagementService.new(@inbox)
|
|
||||||
result = service.template_status
|
|
||||||
|
|
||||||
if result[:service_error]
|
|
||||||
render json: { error: result[:service_error] }, status: :internal_server_error
|
|
||||||
else
|
|
||||||
render json: result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
template_params = extract_template_params
|
|
||||||
return render_missing_message_error if template_params[:message].blank?
|
|
||||||
|
|
||||||
service = CsatTemplateManagementService.new(@inbox)
|
|
||||||
result = service.create_template(template_params)
|
|
||||||
render_template_creation_result(result)
|
|
||||||
rescue ActionController::ParameterMissing
|
|
||||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def fetch_inbox
|
|
||||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
|
||||||
authorize @inbox, :show?
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_whatsapp_channel
|
|
||||||
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
|
|
||||||
|
|
||||||
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
|
|
||||||
status: :bad_request
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_template_params
|
|
||||||
params.require(:template).permit(:message, :button_text, :language)
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_missing_message_error
|
|
||||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_template_creation_result(result)
|
|
||||||
if result[:success]
|
|
||||||
render_successful_template_creation(result)
|
|
||||||
elsif result[:service_error]
|
|
||||||
render json: { error: result[:service_error] }, status: :internal_server_error
|
|
||||||
else
|
|
||||||
render_failed_template_creation(result)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_successful_template_creation(result)
|
|
||||||
if @inbox.twilio_whatsapp?
|
|
||||||
render json: {
|
|
||||||
template: {
|
|
||||||
friendly_name: result[:friendly_name],
|
|
||||||
content_sid: result[:content_sid],
|
|
||||||
status: result[:status] || 'pending',
|
|
||||||
language: result[:language] || 'en'
|
|
||||||
}
|
|
||||||
}, status: :created
|
|
||||||
else
|
|
||||||
render json: {
|
|
||||||
template: {
|
|
||||||
name: result[:template_name],
|
|
||||||
template_id: result[:template_id],
|
|
||||||
status: 'PENDING',
|
|
||||||
language: result[:language] || 'en'
|
|
||||||
}
|
|
||||||
}, status: :created
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_failed_template_creation(result)
|
|
||||||
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
|
||||||
error_message = whatsapp_error[:user_message] || result[:error]
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
error: error_message,
|
|
||||||
details: whatsapp_error[:technical_details]
|
|
||||||
}, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_whatsapp_error(response_body)
|
|
||||||
return { user_message: nil, technical_details: nil } if response_body.blank?
|
|
||||||
|
|
||||||
begin
|
|
||||||
error_data = JSON.parse(response_body)
|
|
||||||
whatsapp_error = error_data['error'] || {}
|
|
||||||
|
|
||||||
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
|
|
||||||
technical_details = {
|
|
||||||
code: whatsapp_error['code'],
|
|
||||||
subcode: whatsapp_error['error_subcode'],
|
|
||||||
type: whatsapp_error['type'],
|
|
||||||
title: whatsapp_error['error_user_title']
|
|
||||||
}.compact
|
|
||||||
|
|
||||||
{ user_message: user_message, technical_details: technical_details }
|
|
||||||
rescue JSON::ParserError
|
|
||||||
{ user_message: nil, technical_details: response_body }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -152,37 +152,31 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def format_csat_config(config)
|
def format_csat_config(config)
|
||||||
formatted = {
|
{
|
||||||
'display_type' => config['display_type'] || 'emoji',
|
display_type: config['display_type'] || 'emoji',
|
||||||
'message' => config['message'] || '',
|
message: config['message'] || '',
|
||||||
:survey_rules => {
|
survey_rules: {
|
||||||
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
operator: config.dig('survey_rules', 'operator') || 'contains',
|
||||||
'values' => config.dig('survey_rules', 'values') || []
|
values: config.dig('survey_rules', 'values') || []
|
||||||
},
|
}
|
||||||
'button_text' => config['button_text'] || 'Please rate us',
|
|
||||||
'language' => config['language'] || 'en'
|
|
||||||
}
|
}
|
||||||
format_template_config(config, formatted)
|
|
||||||
formatted
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_template_config(config, formatted)
|
|
||||||
formatted['template'] = config['template'] if config['template'].present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
||||||
{ survey_rules: [:operator, { values: [] }],
|
|
||||||
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||||
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
|
||||||
|
params.permit(
|
||||||
|
*inbox_attributes,
|
||||||
|
channel: [:type, *channel_attributes]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def channel_type_from_params
|
def channel_type_from_params
|
||||||
@@ -198,7 +192,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||||
|
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_channel?
|
def whatsapp_channel?
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :fetch_hook
|
||||||
|
before_action :ensure_hook_exists, except: [:destroy]
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@hook.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def repositories
|
||||||
|
repositories = github_service.repositories
|
||||||
|
filtered_repos = repositories.map do |repo|
|
||||||
|
{
|
||||||
|
full_name: repo[:full_name] || repo.full_name,
|
||||||
|
private: repo[:private] || repo.private
|
||||||
|
}
|
||||||
|
end
|
||||||
|
render json: filtered_repos
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_repositories
|
||||||
|
repositories = github_service.search_repositories(params[:q])
|
||||||
|
filtered_repos = repositories.map do |repo|
|
||||||
|
{
|
||||||
|
full_name: repo[:full_name] || repo.full_name,
|
||||||
|
private: repo[:private] || repo.private
|
||||||
|
}
|
||||||
|
end
|
||||||
|
render json: filtered_repos
|
||||||
|
end
|
||||||
|
|
||||||
|
def assignees
|
||||||
|
assignees = github_service.assignees(repo_full_name)
|
||||||
|
filtered_assignees = assignees.map do |assignee|
|
||||||
|
{
|
||||||
|
login: assignee.login,
|
||||||
|
avatar_url: assignee.avatar_url
|
||||||
|
}
|
||||||
|
end
|
||||||
|
render json: filtered_assignees
|
||||||
|
end
|
||||||
|
|
||||||
|
def labels
|
||||||
|
labels = github_service.labels(repo_full_name)
|
||||||
|
filtered_labels = labels.map do |label|
|
||||||
|
{
|
||||||
|
name: label.name,
|
||||||
|
color: label.color
|
||||||
|
}
|
||||||
|
end
|
||||||
|
render json: filtered_labels
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_issues
|
||||||
|
issues = github_service.search_issues(repo_full_name, params[:q])
|
||||||
|
filtered_issues = issues.map do |issue|
|
||||||
|
{
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
html_url: issue.html_url
|
||||||
|
}
|
||||||
|
end
|
||||||
|
render json: filtered_issues
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_issue
|
||||||
|
issue_data = github_service.create_issue(
|
||||||
|
repo_full_name,
|
||||||
|
params[:title],
|
||||||
|
params[:body],
|
||||||
|
issue_options
|
||||||
|
)
|
||||||
|
|
||||||
|
github_issue = create_linked_issue(issue_data)
|
||||||
|
render json: issue_response(github_issue), status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_issue
|
||||||
|
issue_data = github_service.issue(repo_full_name, params[:issue_number])
|
||||||
|
github_issue = create_linked_issue(issue_data)
|
||||||
|
render json: issue_response(github_issue), status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
def linked_issues
|
||||||
|
issues = GithubIssue.where(conversation_id: params[:conversation_id])
|
||||||
|
|
||||||
|
render json: issues.map do |issue|
|
||||||
|
{
|
||||||
|
id: issue.id,
|
||||||
|
issue_number: issue.issue_number,
|
||||||
|
title: issue.issue_title,
|
||||||
|
html_url: issue.html_url,
|
||||||
|
repo_full_name: issue.repo_full_name,
|
||||||
|
linked_by: {
|
||||||
|
id: issue.linked_by.id,
|
||||||
|
name: issue.linked_by.name
|
||||||
|
},
|
||||||
|
created_at: issue.created_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unlink_issue
|
||||||
|
github_issue = GithubIssue.find(params[:id])
|
||||||
|
github_issue.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_hook
|
||||||
|
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_hook_exists
|
||||||
|
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
|
||||||
|
end
|
||||||
|
|
||||||
|
def github_service
|
||||||
|
@github_service ||= Github::GithubService.new(hook: @hook)
|
||||||
|
end
|
||||||
|
|
||||||
|
def repo_full_name
|
||||||
|
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def issue_options
|
||||||
|
options = {}
|
||||||
|
options[:assignees] = params[:assignees] if params[:assignees].present?
|
||||||
|
options[:labels] = params[:labels] if params[:labels].present?
|
||||||
|
options
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_linked_issue(issue_data)
|
||||||
|
GithubIssue.create!(
|
||||||
|
conversation_id: params[:conversation_id],
|
||||||
|
account: Current.account,
|
||||||
|
repo_full_name: repo_full_name,
|
||||||
|
issue_number: issue_data.number,
|
||||||
|
issue_title: issue_data.title,
|
||||||
|
html_url: issue_data.html_url,
|
||||||
|
linked_by: Current.user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def issue_response(github_issue)
|
||||||
|
{
|
||||||
|
id: github_issue.id,
|
||||||
|
issue_number: github_issue.issue_number,
|
||||||
|
title: github_issue.issue_title,
|
||||||
|
html_url: github_issue.html_url,
|
||||||
|
repo_full_name: github_issue.repo_full_name
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||||
include AttachmentConcern
|
|
||||||
|
|
||||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||||
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
||||||
|
|
||||||
@@ -13,32 +11,26 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
|
||||||
return render_could_not_create_error(error) if error
|
|
||||||
|
|
||||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
@macro.actions = actions
|
@macro.actions = params[:actions]
|
||||||
|
|
||||||
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
|
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
||||||
|
|
||||||
@macro.save!
|
@macro.save!
|
||||||
blobs.each { |blob| @macro.files.attach(blob) }
|
process_attachments
|
||||||
|
@macro
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
|
|
||||||
return render_could_not_create_error(error) if error
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@macro.assign_attributes(macros_with_user)
|
@macro.update!(macros_with_user)
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
@macro.actions = actions if params[:actions]
|
process_attachments
|
||||||
@macro.save!
|
@macro.save!
|
||||||
blobs.each { |blob| @macro.files.attach(blob) }
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render_could_not_create_error(@macro.errors.messages)
|
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -55,9 +47,20 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def process_attachments
|
||||||
|
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||||
|
return if actions.blank?
|
||||||
|
|
||||||
|
actions.each do |action|
|
||||||
|
blob_id = action['action_params']
|
||||||
|
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||||
|
@macro.files.attach(blob)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :visibility,
|
:name, :account_id, :visibility,
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def process_attached_logo
|
def process_attached_logo
|
||||||
blob_id = params[:blob_id]
|
blob_id = params[:blob_id]
|
||||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||||
@portal.logo.attach(blob)
|
@portal.logo.attach(blob)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,7 +28,5 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
|||||||
search_type: search_type,
|
search_type: search_type,
|
||||||
params: params
|
params: params
|
||||||
).perform
|
).perform
|
||||||
rescue ArgumentError => e
|
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
|
||||||
include Tiktok::IntegrationHelper
|
|
||||||
|
|
||||||
def create
|
|
||||||
redirect_url = Tiktok::AuthClient.authorize_url(
|
|
||||||
state: generate_tiktok_token(Current.account.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if redirect_url
|
|
||||||
render json: { success: true, url: redirect_url }
|
|
||||||
else
|
|
||||||
render json: { success: false }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_success(file_blob)
|
def render_success(file_blob)
|
||||||
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
|
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_error(message, status)
|
def render_error(message, status)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def webhook_params
|
def webhook_params
|
||||||
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_webhook
|
def fetch_webhook
|
||||||
|
|||||||
@@ -92,11 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(*permitted_settings_attributes)
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_settings_attributes
|
|
||||||
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
@@ -115,5 +111,3 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
|
|
||||||
|
|||||||
@@ -9,13 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(
|
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
|
||||||
'LOGO_THUMBNAIL',
|
|
||||||
'BRAND_NAME',
|
|
||||||
'WIDGET_BRAND_URL',
|
|
||||||
'MAXIMUM_FILE_UPLOAD_SIZE',
|
|
||||||
'INSTALLATION_NAME'
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_contact
|
def set_contact
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
return head :too_many_requests if conversation.blank?
|
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
|
||||||
return head :payment_required unless conversation.account.email_transcript_enabled?
|
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
||||||
return head :too_many_requests unless conversation.account.within_email_rate_limit?
|
conversation,
|
||||||
|
conversation.contact.email
|
||||||
send_transcript_email
|
)&.deliver_later
|
||||||
|
end
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -76,16 +77,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
|
|
||||||
private
|
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)
|
def trigger_typing_event(event)
|
||||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
|
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversations_summary
|
|
||||||
@report_data = generate_conversations_report
|
|
||||||
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
|
|
||||||
end
|
|
||||||
|
|
||||||
def conversation_traffic
|
def conversation_traffic
|
||||||
@report_data = generate_conversations_heatmap_report
|
@report_data = generate_conversations_heatmap_report
|
||||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||||
@@ -62,31 +57,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
render json: bot_metrics
|
render json: bot_metrics
|
||||||
end
|
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
|
|
||||||
|
|
||||||
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
|
|
||||||
|
|
||||||
def outgoing_messages_count
|
|
||||||
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
|
|
||||||
|
|
||||||
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
|
|
||||||
render json: builder.build
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_csv(filename, template)
|
def generate_csv(filename, template)
|
||||||
@@ -164,28 +134,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
def conversation_metrics
|
def conversation_metrics
|
||||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||||
end
|
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
|
|
||||||
|
|
||||||
def outgoing_messages_count_params
|
|
||||||
{
|
|
||||||
group_by: params[:group_by],
|
|
||||||
since: params[:since],
|
|
||||||
until: params[:until]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
||||||
|
|
||||||
def agent
|
def agent
|
||||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||||
@@ -18,12 +18,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
|||||||
render_report_with(V2::Reports::LabelSummaryBuilder)
|
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||||
end
|
end
|
||||||
|
|
||||||
def channel
|
|
||||||
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
|
|
||||||
|
|
||||||
render_report_with(V2::Reports::ChannelSummaryBuilder)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_authorization
|
def check_authorization
|
||||||
@@ -46,12 +40,4 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
|||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:since, :until, :business_hours)
|
params.permit(:since, :until, :business_hours)
|
||||||
end
|
end
|
||||||
|
|
||||||
def date_range_too_long?
|
|
||||||
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
|
|
||||||
|
|
||||||
since_time = Time.zone.at(permitted_params[:since].to_i)
|
|
||||||
until_time = Time.zone.at(permitted_params[:until].to_i)
|
|
||||||
(until_time - since_time) > 6.months
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
|
|
||||||
def show
|
|
||||||
year = params[:year] || 2025
|
|
||||||
cache_key = "year_in_review_#{Current.account.id}_#{year}"
|
|
||||||
|
|
||||||
cached_data = Current.user.ui_settings&.dig(cache_key)
|
|
||||||
|
|
||||||
if cached_data.present?
|
|
||||||
render json: cached_data
|
|
||||||
else
|
|
||||||
builder = YearInReviewBuilder.new(
|
|
||||||
account: Current.account,
|
|
||||||
user_id: Current.user.id,
|
|
||||||
year: year
|
|
||||||
)
|
|
||||||
|
|
||||||
data = builder.build
|
|
||||||
|
|
||||||
ui_settings = Current.user.ui_settings || {}
|
|
||||||
ui_settings[cache_key] = data
|
|
||||||
Current.user.update(ui_settings: ui_settings)
|
|
||||||
|
|
||||||
render json: data
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
module AttachmentConcern
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
def validate_and_prepare_attachments(actions, record = nil)
|
|
||||||
blobs = []
|
|
||||||
return [blobs, actions, nil] if actions.blank?
|
|
||||||
|
|
||||||
sanitized = actions.map do |action|
|
|
||||||
next action unless action[:action_name] == 'send_attachment'
|
|
||||||
|
|
||||||
result = process_attachment_action(action, record, blobs)
|
|
||||||
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
|
||||||
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
[blobs, sanitized, nil]
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def process_attachment_action(action, record, blobs)
|
|
||||||
blob_id = action[:action_params].first
|
|
||||||
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
|
||||||
|
|
||||||
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
|
||||||
return action if blob_already_attached?(record, blob_id)
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def blob_already_attached?(record, blob_id)
|
|
||||||
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -25,9 +25,6 @@ module EnsureCurrentAccountHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_accessible_for_bot?(account)
|
def account_accessible_for_bot?(account)
|
||||||
return if @resource.account_id == account.id
|
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||||
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
|
||||||
|
|
||||||
render_unauthorized('Bot is not authorized to access this account')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,31 +1,6 @@
|
|||||||
class DashboardController < ActionController::Base
|
class DashboardController < ActionController::Base
|
||||||
include SwitchLocale
|
include SwitchLocale
|
||||||
|
|
||||||
GLOBAL_CONFIG_KEYS = %w[
|
|
||||||
LOGO
|
|
||||||
LOGO_DARK
|
|
||||||
LOGO_THUMBNAIL
|
|
||||||
INSTALLATION_NAME
|
|
||||||
WIDGET_BRAND_URL
|
|
||||||
TERMS_URL
|
|
||||||
BRAND_URL
|
|
||||||
BRAND_NAME
|
|
||||||
PRIVACY_URL
|
|
||||||
DISPLAY_MANIFEST
|
|
||||||
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
|
|
||||||
CHATWOOT_INBOX_TOKEN
|
|
||||||
API_CHANNEL_NAME
|
|
||||||
API_CHANNEL_THUMBNAIL
|
|
||||||
CLOUD_ANALYTICS_TOKEN
|
|
||||||
DIRECT_UPLOADS_ENABLED
|
|
||||||
MAXIMUM_FILE_UPLOAD_SIZE
|
|
||||||
HCAPTCHA_SITE_KEY
|
|
||||||
LOGOUT_REDIRECT_LINK
|
|
||||||
DISABLE_USER_PROFILE_UPDATE
|
|
||||||
DEPLOYMENT_ENV
|
|
||||||
INSTALLATION_PRICING_PLAN
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
before_action :set_application_pack
|
before_action :set_application_pack
|
||||||
before_action :set_global_config
|
before_action :set_global_config
|
||||||
before_action :set_dashboard_scripts
|
before_action :set_dashboard_scripts
|
||||||
@@ -44,7 +19,25 @@ class DashboardController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
|
@global_config = GlobalConfig.get(
|
||||||
|
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
||||||
|
'INSTALLATION_NAME',
|
||||||
|
'WIDGET_BRAND_URL', 'TERMS_URL',
|
||||||
|
'BRAND_URL', 'BRAND_NAME',
|
||||||
|
'PRIVACY_URL',
|
||||||
|
'DISPLAY_MANIFEST',
|
||||||
|
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
||||||
|
'CHATWOOT_INBOX_TOKEN',
|
||||||
|
'API_CHANNEL_NAME',
|
||||||
|
'API_CHANNEL_THUMBNAIL',
|
||||||
|
'ANALYTICS_TOKEN',
|
||||||
|
'DIRECT_UPLOADS_ENABLED',
|
||||||
|
'HCAPTCHA_SITE_KEY',
|
||||||
|
'LOGOUT_REDIRECT_LINK',
|
||||||
|
'DISABLE_USER_PROFILE_UPDATE',
|
||||||
|
'DEPLOYMENT_ENV',
|
||||||
|
'INSTALLATION_PRICING_PLAN'
|
||||||
|
).merge(app_config)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_dashboard_scripts
|
def set_dashboard_scripts
|
||||||
@@ -73,24 +66,15 @@ class DashboardController < ActionController::Base
|
|||||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||||
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
|
|
||||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
||||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||||
GIT_SHA: GIT_HASH,
|
GIT_SHA: GIT_HASH
|
||||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def allowed_login_methods
|
|
||||||
methods = ['email']
|
|
||||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
|
||||||
methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false'
|
|
||||||
methods
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_application_pack
|
def set_application_pack
|
||||||
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
||||||
'v3app'
|
'v3app'
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.from_email(params[:email])
|
@user = User.from_email(params[:email])
|
||||||
@user&.send_reset_password_instructions
|
if @user
|
||||||
build_response(I18n.t('messages.reset_password'), 200)
|
@user.send_reset_password_instructions
|
||||||
|
build_response(I18n.t('messages.reset_password_success'), 200)
|
||||||
|
else
|
||||||
|
build_response(I18n.t('messages.reset_password_failure'), 404)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
class Github::CallbacksController < ApplicationController
|
||||||
|
include Github::IntegrationHelper
|
||||||
|
|
||||||
|
def show
|
||||||
|
# Validate account context early for all flows that require it
|
||||||
|
account if params[:code].present?
|
||||||
|
|
||||||
|
if params[:installation_id].present? && params[:code].present?
|
||||||
|
# Both installation and OAuth code present - handle both
|
||||||
|
handle_installation_with_oauth
|
||||||
|
elsif params[:installation_id].present?
|
||||||
|
# Only installation_id present - redirect to OAuth
|
||||||
|
handle_installation
|
||||||
|
else
|
||||||
|
# Only OAuth code present - handle authorization
|
||||||
|
handle_authorization
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Github callback error: #{e.message}")
|
||||||
|
redirect_to fallback_redirect_uri
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def handle_installation_with_oauth
|
||||||
|
# Handle both installation and OAuth in one go
|
||||||
|
installation_id = params[:installation_id]
|
||||||
|
|
||||||
|
@response = oauth_client.auth_code.get_token(
|
||||||
|
params[:code],
|
||||||
|
redirect_uri: "#{base_url}/github/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
handle_response(installation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_installation
|
||||||
|
if params[:setup_action] == 'install'
|
||||||
|
installation_id = params[:installation_id]
|
||||||
|
|
||||||
|
redirect_to build_oauth_url(installation_id)
|
||||||
|
else
|
||||||
|
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
|
||||||
|
redirect_to github_integration_settings_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_authorization
|
||||||
|
@response = oauth_client.auth_code.get_token(
|
||||||
|
params[:code],
|
||||||
|
redirect_uri: "#{base_url}/github/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
handle_response
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_oauth_url(installation_id)
|
||||||
|
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||||
|
|
||||||
|
# Store installation_id in session for later use
|
||||||
|
session[:github_installation_id] = installation_id
|
||||||
|
|
||||||
|
# For now, redirect to a page that will initiate OAuth with proper account context
|
||||||
|
# This is a temporary solution until we have a proper account-agnostic setup
|
||||||
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def oauth_client
|
||||||
|
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||||
|
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||||
|
|
||||||
|
OAuth2::Client.new(
|
||||||
|
app_id,
|
||||||
|
app_secret,
|
||||||
|
{
|
||||||
|
site: 'https://github.com',
|
||||||
|
token_url: '/login/oauth/access_token',
|
||||||
|
authorize_url: '/login/oauth/authorize'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_response(installation_id = nil)
|
||||||
|
settings = build_hook_settings(installation_id)
|
||||||
|
hook = create_integration_hook(settings)
|
||||||
|
hook.save!
|
||||||
|
|
||||||
|
cleanup_session_data
|
||||||
|
redirect_to github_redirect_uri
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Github callback error: #{e.message}")
|
||||||
|
redirect_to fallback_redirect_uri
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_hook_settings(installation_id)
|
||||||
|
settings = {
|
||||||
|
token_type: parsed_body['token_type'],
|
||||||
|
scope: parsed_body['scope']
|
||||||
|
}
|
||||||
|
|
||||||
|
settings[:installation_id] = installation_id || session[:github_installation_id]
|
||||||
|
settings.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_integration_hook(settings)
|
||||||
|
account.hooks.new(
|
||||||
|
access_token: parsed_body['access_token'],
|
||||||
|
status: 'enabled',
|
||||||
|
app_id: 'github',
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cleanup_session_data
|
||||||
|
session.delete(:github_installation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account
|
||||||
|
@account ||= account_from_state
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_from_state
|
||||||
|
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||||
|
|
||||||
|
# Try signed GlobalID first (installation flow)
|
||||||
|
account = GlobalID::Locator.locate_signed(params[:state])
|
||||||
|
return account if account
|
||||||
|
|
||||||
|
# Fallback to JWT token (direct OAuth flow)
|
||||||
|
account_id = verify_github_token(params[:state])
|
||||||
|
return Account.find(account_id) if account_id
|
||||||
|
|
||||||
|
raise 'Invalid or expired state'
|
||||||
|
rescue StandardError
|
||||||
|
raise ActionController::BadRequest, 'Invalid account context'
|
||||||
|
end
|
||||||
|
|
||||||
|
def github_redirect_uri
|
||||||
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
|
||||||
|
end
|
||||||
|
|
||||||
|
def github_integration_settings_url
|
||||||
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
|
||||||
|
end
|
||||||
|
|
||||||
|
def fallback_redirect_uri
|
||||||
|
github_redirect_uri
|
||||||
|
rescue StandardError
|
||||||
|
# Fallback if no account context available
|
||||||
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
|
||||||
|
end
|
||||||
|
|
||||||
|
def parsed_body
|
||||||
|
@parsed_body ||= @response.response.parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_url
|
||||||
|
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Inherits from ActionController::Base to skip all middleware,
|
|
||||||
# authentication, and callbacks. Used for health checks
|
|
||||||
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
|
||||||
def show
|
|
||||||
render json: { status: 'woot' }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -15,20 +15,14 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
errors = []
|
|
||||||
params['app_config'].each do |key, value|
|
params['app_config'].each do |key, value|
|
||||||
next unless @allowed_configs.include?(key)
|
next unless @allowed_configs.include?(key)
|
||||||
|
|
||||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||||
i.value = value
|
i.value = value
|
||||||
errors.concat(i.errors.full_messages) unless i.save
|
i.save!
|
||||||
end
|
|
||||||
|
|
||||||
if errors.any?
|
|
||||||
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
|
||||||
else
|
|
||||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
|
||||||
end
|
end
|
||||||
|
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -42,21 +36,17 @@ 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],
|
'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],
|
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
||||||
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||||
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
'slack' => %w[SLACK_CLIENT_ID SLACK_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],
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
|
|
||||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
|
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
|
||||||
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
|
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
|
||||||
}
|
}
|
||||||
|
|
||||||
@allowed_configs = mapping.fetch(
|
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||||
@config,
|
|
||||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
class Tiktok::CallbacksController < ApplicationController
|
|
||||||
include Tiktok::IntegrationHelper
|
|
||||||
|
|
||||||
def show
|
|
||||||
return handle_authorization_error if params[:error].present?
|
|
||||||
return handle_ungranted_scopes_error unless all_scopes_granted?
|
|
||||||
|
|
||||||
process_successful_authorization
|
|
||||||
rescue StandardError => e
|
|
||||||
handle_error(e)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def all_scopes_granted?
|
|
||||||
granted_scopes = short_term_access_token[:scope].to_s.split(',')
|
|
||||||
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_successful_authorization
|
|
||||||
inbox, already_exists = find_or_create_inbox
|
|
||||||
|
|
||||||
if already_exists
|
|
||||||
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
|
||||||
else
|
|
||||||
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_error(error)
|
|
||||||
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
|
|
||||||
ChatwootExceptionTracker.new(error).capture_exception
|
|
||||||
|
|
||||||
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handles the case when a user denies permissions or cancels the authorization flow
|
|
||||||
def handle_authorization_error
|
|
||||||
redirect_to_error_page(
|
|
||||||
error_type: params[:error] || 'access_denied',
|
|
||||||
code: params[:error_code],
|
|
||||||
error_message: params[:error_description] || 'User cancelled the Authorization'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handles the case when a user partially accepted the required scopes
|
|
||||||
def handle_ungranted_scopes_error
|
|
||||||
redirect_to_error_page(
|
|
||||||
error_type: 'ungranted_scopes',
|
|
||||||
code: 400,
|
|
||||||
error_message: 'User did not grant all the required scopes'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Centralized method to redirect to error page with appropriate parameters
|
|
||||||
# This ensures consistent error handling across different error scenarios
|
|
||||||
# Frontend will handle the error page based on the error_type
|
|
||||||
def redirect_to_error_page(error_type:, code:, error_message:)
|
|
||||||
redirect_to app_new_tiktok_inbox_url(
|
|
||||||
account_id: account_id,
|
|
||||||
error_type: error_type,
|
|
||||||
code: code,
|
|
||||||
error_message: error_message
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_or_create_inbox
|
|
||||||
business_details = tiktok_client.business_account_details
|
|
||||||
channel_tiktok = find_channel
|
|
||||||
channel_exists = channel_tiktok.present?
|
|
||||||
|
|
||||||
if channel_tiktok
|
|
||||||
update_channel(channel_tiktok, business_details)
|
|
||||||
else
|
|
||||||
channel_tiktok = create_channel_with_inbox(business_details)
|
|
||||||
end
|
|
||||||
|
|
||||||
# reauthorized will also update cache keys for the associated inbox
|
|
||||||
channel_tiktok.reauthorized!
|
|
||||||
|
|
||||||
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
|
|
||||||
|
|
||||||
[channel_tiktok.inbox, channel_exists]
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_channel_with_inbox(business_details)
|
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
channel_tiktok = Channel::Tiktok.create!(
|
|
||||||
account: account,
|
|
||||||
business_id: short_term_access_token[:business_id],
|
|
||||||
access_token: short_term_access_token[:access_token],
|
|
||||||
refresh_token: short_term_access_token[:refresh_token],
|
|
||||||
expires_at: short_term_access_token[:expires_at],
|
|
||||||
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
|
||||||
)
|
|
||||||
|
|
||||||
account.inboxes.create!(
|
|
||||||
account: account,
|
|
||||||
channel: channel_tiktok,
|
|
||||||
name: business_details[:display_name].presence || business_details[:username]
|
|
||||||
)
|
|
||||||
|
|
||||||
channel_tiktok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_channel
|
|
||||||
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_channel(channel_tiktok, business_details)
|
|
||||||
channel_tiktok.update!(
|
|
||||||
access_token: short_term_access_token[:access_token],
|
|
||||||
refresh_token: short_term_access_token[:refresh_token],
|
|
||||||
expires_at: short_term_access_token[:expires_at],
|
|
||||||
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
|
||||||
)
|
|
||||||
|
|
||||||
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_avatar(inbox, avatar_url)
|
|
||||||
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
def account_id
|
|
||||||
@account_id ||= verify_tiktok_token(params[:state])
|
|
||||||
end
|
|
||||||
|
|
||||||
def account
|
|
||||||
@account ||= Account.find(account_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def short_term_access_token
|
|
||||||
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
|
|
||||||
end
|
|
||||||
|
|
||||||
def tiktok_client
|
|
||||||
@tiktok_client ||= Tiktok::Client.new(
|
|
||||||
business_id: short_term_access_token[:business_id],
|
|
||||||
access_token: short_term_access_token[:access_token]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
class Webhooks::ShopifyController < ActionController::API
|
|
||||||
before_action :verify_hmac!
|
|
||||||
|
|
||||||
def events
|
|
||||||
case request.headers['X-Shopify-Topic']
|
|
||||||
when 'shop/redact'
|
|
||||||
handle_shop_redact
|
|
||||||
end
|
|
||||||
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def verify_hmac!
|
|
||||||
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
|
|
||||||
return head :unauthorized if secret.blank?
|
|
||||||
|
|
||||||
data = request.body.read
|
|
||||||
request.body.rewind
|
|
||||||
|
|
||||||
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
|
|
||||||
return head :unauthorized if hmac_header.blank?
|
|
||||||
|
|
||||||
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
|
|
||||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_shop_redact
|
|
||||||
shop_domain = params[:shop_domain]
|
|
||||||
return if shop_domain.blank?
|
|
||||||
|
|
||||||
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
class Webhooks::TiktokController < ActionController::API
|
|
||||||
before_action :verify_signature!
|
|
||||||
|
|
||||||
def events
|
|
||||||
event = JSON.parse(request_payload)
|
|
||||||
if echo_event?
|
|
||||||
# Add delay to prevent race condition where echo arrives before send message API completes
|
|
||||||
# This avoids duplicate messages when echo comes early during API processing
|
|
||||||
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
|
|
||||||
else
|
|
||||||
::Webhooks::TiktokEventsJob.perform_later(event)
|
|
||||||
end
|
|
||||||
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def request_payload
|
|
||||||
@request_payload ||= request.body.read
|
|
||||||
end
|
|
||||||
|
|
||||||
def verify_signature!
|
|
||||||
signature_header = request.headers['Tiktok-Signature']
|
|
||||||
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
|
||||||
received_timestamp, received_signature = extract_signature_parts(signature_header)
|
|
||||||
|
|
||||||
return head :unauthorized unless client_secret && received_timestamp && received_signature
|
|
||||||
|
|
||||||
signature_payload = "#{received_timestamp}.#{request_payload}"
|
|
||||||
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
|
|
||||||
|
|
||||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
|
|
||||||
|
|
||||||
# Check timestamp delay (acceptable delay: 5 seconds)
|
|
||||||
current_timestamp = Time.current.to_i
|
|
||||||
delay = current_timestamp - received_timestamp
|
|
||||||
|
|
||||||
return head :unauthorized if delay > 5
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_signature_parts(signature_header)
|
|
||||||
return [nil, nil] if signature_header.blank?
|
|
||||||
|
|
||||||
keys = signature_header.split(',')
|
|
||||||
signature_parts = keys.map { |part| part.split('=') }.to_h
|
|
||||||
[signature_parts['t']&.to_i, signature_parts['s']]
|
|
||||||
end
|
|
||||||
|
|
||||||
def echo_event?
|
|
||||||
params[:event] == 'im_send_msg'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -14,14 +14,7 @@ class WidgetsController < ActionController::Base
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(
|
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
|
||||||
'LOGO_THUMBNAIL',
|
|
||||||
'BRAND_NAME',
|
|
||||||
'WIDGET_BRAND_URL',
|
|
||||||
'DIRECT_UPLOADS_ENABLED',
|
|
||||||
'MAXIMUM_FILE_UPLOAD_SIZE',
|
|
||||||
'INSTALLATION_NAME'
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_web_widget
|
def set_web_widget
|
||||||
|
|||||||
@@ -55,22 +55,6 @@ class ConversationFinder
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_meta_only
|
|
||||||
set_up
|
|
||||||
|
|
||||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
|
||||||
assigned_count = all_count - unassigned_count
|
|
||||||
|
|
||||||
{
|
|
||||||
count: {
|
|
||||||
mine_count: mine_count,
|
|
||||||
assigned_count: assigned_count,
|
|
||||||
unassigned_count: unassigned_count,
|
|
||||||
all_count: all_count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_up
|
def set_up
|
||||||
|
|||||||
@@ -57,35 +57,39 @@ module Api::V1::InboxesHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_smtp_connection(channel_data, smtp)
|
def check_smtp_connection(channel_data, smtp)
|
||||||
smtp.open_timeout = 10
|
|
||||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||||
smtp.finish
|
smtp.finish
|
||||||
rescue Net::SMTPAuthenticationError
|
|
||||||
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
|
|
||||||
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
|
|
||||||
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
|
|
||||||
rescue OpenSSL::SSL::SSLError
|
|
||||||
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
|
|
||||||
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
|
|
||||||
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
|
|
||||||
rescue StandardError => e
|
|
||||||
raise StandardError, e.message
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_smtp_encryption(channel_data, smtp)
|
def set_smtp_encryption(channel_data, smtp)
|
||||||
if channel_data[:smtp_enable_ssl_tls]
|
if channel_data[:smtp_enable_ssl_tls]
|
||||||
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
|
set_enable_tls(channel_data, smtp)
|
||||||
elsif channel_data[:smtp_enable_starttls_auto]
|
elsif channel_data[:smtp_enable_starttls_auto]
|
||||||
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
|
set_enable_starttls_auto(channel_data, smtp)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
|
def set_enable_starttls_auto(channel_data, smtp)
|
||||||
return unless smtp.respond_to?(method)
|
return unless smtp.respond_to?(:enable_starttls_auto)
|
||||||
|
|
||||||
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
|
if channel_data[:smtp_openssl_verify_mode]
|
||||||
context ? smtp.send(method, context) : smtp.send(method)
|
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||||
|
smtp.enable_starttls_auto(context)
|
||||||
|
else
|
||||||
|
smtp.enable_starttls_auto
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_enable_tls(channel_data, smtp)
|
||||||
|
return unless smtp.respond_to?(:enable_tls)
|
||||||
|
|
||||||
|
if channel_data[:smtp_openssl_verify_mode]
|
||||||
|
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||||
|
smtp.enable_tls(context)
|
||||||
|
else
|
||||||
|
smtp.enable_tls
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_openssl_mode(smtp_openssl_verify_mode)
|
def enable_openssl_mode(smtp_openssl_verify_mode)
|
||||||
|
|||||||
@@ -46,13 +46,6 @@ module Api::V2::Accounts::ReportsHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_conversations_report
|
|
||||||
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
|
|
||||||
summary = builder.summary
|
|
||||||
|
|
||||||
[generate_conversation_report_metrics(summary)]
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_params(base_params)
|
def build_params(base_params)
|
||||||
@@ -78,16 +71,4 @@ module Api::V2::Accounts::ReportsHelper
|
|||||||
report[:resolved_conversations_count]
|
report[:resolved_conversations_count]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_conversation_report_metrics(summary)
|
|
||||||
[
|
|
||||||
summary[:conversations_count],
|
|
||||||
summary[:incoming_messages_count],
|
|
||||||
summary[:outgoing_messages_count],
|
|
||||||
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
|
|
||||||
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
|
|
||||||
summary[:resolutions_count],
|
|
||||||
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
|
|
||||||
]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
|
|
||||||
# This module contains helper methods for converting between different data types,
|
|
||||||
# normalizing hashes, and safely handling JSON operations.
|
|
||||||
module DataHelper
|
|
||||||
# Ensures a hash supports indifferent access (string or symbol keys).
|
|
||||||
# Returns an empty hash if the input is blank.
|
|
||||||
def ensure_indifferent_access(hash)
|
|
||||||
return {} if hash.blank?
|
|
||||||
|
|
||||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_to_hash(obj)
|
|
||||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
|
||||||
|
|
||||||
obj
|
|
||||||
end
|
|
||||||
|
|
||||||
def safe_parse_json(content)
|
|
||||||
JSON.parse(content, symbolize_names: true)
|
|
||||||
rescue JSON::ParserError
|
|
||||||
{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -4,19 +4,6 @@ module EmailHelper
|
|||||||
domain.split('.').first
|
domain.split('.').first
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_email_html(content)
|
|
||||||
return '' if content.blank?
|
|
||||||
|
|
||||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
# Raise a standard error if any email address is invalid
|
|
||||||
def validate_email_addresses(emails_to_test)
|
|
||||||
emails_to_test&.each do |email|
|
|
||||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
||||||
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
||||||
# john+test@xyc.com is the same as john@xyc.com
|
# john+test@xyc.com is the same as john@xyc.com
|
||||||
@@ -34,10 +21,6 @@ module EmailHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_email_body(content)
|
|
||||||
content.to_s.gsub("\r\n", "\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def modified_liquid_content(email)
|
def modified_liquid_content(email)
|
||||||
# This regex is used to match the code blocks in the content
|
# This regex is used to match the code blocks in the content
|
||||||
# We don't want to process liquid in code blocks
|
# We don't want to process liquid in code blocks
|
||||||
@@ -46,10 +29,7 @@ module EmailHelper
|
|||||||
|
|
||||||
def message_drops(conversation)
|
def message_drops(conversation)
|
||||||
{
|
{
|
||||||
'contact' => ContactDrop.new(conversation.contact),
|
'contact' => ContactDrop.new(conversation.contact)
|
||||||
'conversation' => ConversationDrop.new(conversation),
|
|
||||||
'inbox' => InboxDrop.new(conversation.inbox),
|
|
||||||
'account' => AccountDrop.new(conversation.account)
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Github::IntegrationHelper
|
||||||
|
# Generates a signed JWT token for Github integration
|
||||||
|
#
|
||||||
|
# @param account_id [Integer] The account ID to encode in the token
|
||||||
|
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||||
|
def generate_github_token(account_id)
|
||||||
|
return if github_client_secret.blank?
|
||||||
|
|
||||||
|
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Failed to generate Github token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def github_token_payload(account_id)
|
||||||
|
{
|
||||||
|
sub: account_id,
|
||||||
|
iat: Time.current.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verifies and decodes a Github JWT token
|
||||||
|
#
|
||||||
|
# @param token [String] The JWT token to verify
|
||||||
|
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||||
|
def verify_github_token(token)
|
||||||
|
return if token.blank? || github_client_secret.blank?
|
||||||
|
|
||||||
|
github_decode_token(token, github_client_secret)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def github_client_secret
|
||||||
|
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def github_decode_token(token, secret)
|
||||||
|
JWT.decode(token, secret, true, {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
verify_expiration: true
|
||||||
|
}).first['sub']
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
# No need to replicate the same values in two places
|
# No need to replicate the same values in two places
|
||||||
|
|
||||||
# ------- Premium Features ------- #
|
# ------- Premium Features ------- #
|
||||||
saml:
|
captain:
|
||||||
name: 'SAML SSO'
|
name: 'Captain'
|
||||||
description: 'Configuration for controlling SAML Single Sign-On availability'
|
description: 'Enable AI-powered conversations with your customers.'
|
||||||
enabled: <%= ChatwootApp.enterprise? %>
|
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||||
icon: 'icon-lock-line'
|
icon: 'icon-captain'
|
||||||
config_key: 'saml'
|
config_key: 'captain'
|
||||||
enterprise: true
|
enterprise: true
|
||||||
custom_branding:
|
custom_branding:
|
||||||
name: 'Custom Branding'
|
name: 'Custom Branding'
|
||||||
@@ -41,12 +41,6 @@ help_center:
|
|||||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-book-2-line'
|
icon: 'icon-book-2-line'
|
||||||
captain:
|
|
||||||
name: 'Captain'
|
|
||||||
description: 'Enable AI-powered conversations with your customers.'
|
|
||||||
enabled: true
|
|
||||||
icon: 'icon-captain'
|
|
||||||
config_key: 'captain'
|
|
||||||
|
|
||||||
# ------- Communication Channels ------- #
|
# ------- Communication Channels ------- #
|
||||||
live_chat:
|
live_chat:
|
||||||
@@ -77,12 +71,6 @@ instagram:
|
|||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-instagram'
|
icon: 'icon-instagram'
|
||||||
config_key: 'instagram'
|
config_key: 'instagram'
|
||||||
tiktok:
|
|
||||||
name: 'TikTok'
|
|
||||||
description: 'Stay connected with your customers on TikTok'
|
|
||||||
enabled: true
|
|
||||||
icon: 'icon-tiktok'
|
|
||||||
config_key: 'tiktok'
|
|
||||||
whatsapp:
|
whatsapp:
|
||||||
name: 'WhatsApp'
|
name: 'WhatsApp'
|
||||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
module Tiktok::IntegrationHelper
|
|
||||||
# Generates a signed JWT token for Tiktok integration
|
|
||||||
#
|
|
||||||
# @param account_id [Integer] The account ID to encode in the token
|
|
||||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
|
||||||
def generate_tiktok_token(account_id)
|
|
||||||
return if client_secret.blank?
|
|
||||||
|
|
||||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verifies and decodes a Tiktok JWT token
|
|
||||||
#
|
|
||||||
# @param token [String] The JWT token to verify
|
|
||||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
|
||||||
def verify_tiktok_token(token)
|
|
||||||
return if token.blank? || client_secret.blank?
|
|
||||||
|
|
||||||
decode_token(token, client_secret)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def client_secret
|
|
||||||
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def token_payload(account_id)
|
|
||||||
{
|
|
||||||
sub: account_id,
|
|
||||||
iat: Time.current.to_i
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def decode_token(token, secret)
|
|
||||||
JWT.decode(token, secret, true, {
|
|
||||||
algorithm: 'HS256',
|
|
||||||
verify_expiration: true
|
|
||||||
}).first['sub']
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
import AddAccountModal from './components/app/AddAccountModal.vue';
|
||||||
import LoadingState from './components/widgets/LoadingState.vue';
|
import LoadingState from './components/widgets/LoadingState.vue';
|
||||||
import NetworkNotification from './components/NetworkNotification.vue';
|
import NetworkNotification from './components/NetworkNotification.vue';
|
||||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||||
@@ -24,6 +25,7 @@ export default {
|
|||||||
name: 'App',
|
name: 'App',
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
AddAccountModal,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
NetworkNotification,
|
NetworkNotification,
|
||||||
UpdateBanner,
|
UpdateBanner,
|
||||||
@@ -49,6 +51,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
showAddAccountModal: false,
|
||||||
latestChatwootVersion: null,
|
latestChatwootVersion: null,
|
||||||
reconnectService: null,
|
reconnectService: null,
|
||||||
};
|
};
|
||||||
@@ -61,12 +64,21 @@ export default {
|
|||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
accountUIFlags: 'accounts/getUIFlags',
|
||||||
}),
|
}),
|
||||||
|
hasAccounts() {
|
||||||
|
const { accounts = [] } = this.currentUser || {};
|
||||||
|
return accounts.length > 0;
|
||||||
|
},
|
||||||
hideOnOnboardingView() {
|
hideOnOnboardingView() {
|
||||||
return !isOnOnboardingView(this.$route);
|
return !isOnOnboardingView(this.$route);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
|
currentUser() {
|
||||||
|
if (!this.hasAccounts) {
|
||||||
|
this.showAddAccountModal = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
currentAccountId: {
|
currentAccountId: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler() {
|
handler() {
|
||||||
@@ -131,7 +143,7 @@ export default {
|
|||||||
<div
|
<div
|
||||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||||
id="app"
|
id="app"
|
||||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
class="flex flex-col w-full h-screen min-h-0"
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||||
>
|
>
|
||||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||||
@@ -144,6 +156,7 @@ export default {
|
|||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||||
<WootSnackbarBox />
|
<WootSnackbarBox />
|
||||||
<NetworkNotification />
|
<NetworkNotification />
|
||||||
</div>
|
</div>
|
||||||
@@ -164,4 +177,10 @@ export default {
|
|||||||
.v-popper--theme-tooltip .v-popper__arrow-container {
|
.v-popper--theme-tooltip .v-popper__arrow-container {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multiselect__input {
|
||||||
|
margin-bottom: 0px !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
class CaptainPreferences extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('captain/preferences', { accountScoped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
return axios.get(this.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreferences(data) {
|
|
||||||
return axios.put(this.url, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new CaptainPreferences();
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A client for the Captain Tasks API.
|
|
||||||
* @extends ApiClient
|
|
||||||
*/
|
|
||||||
class TasksAPI extends ApiClient {
|
|
||||||
/**
|
|
||||||
* Creates a new TasksAPI instance.
|
|
||||||
*/
|
|
||||||
constructor() {
|
|
||||||
super('captain/tasks', { accountScoped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrites content with a specific operation.
|
|
||||||
* @param {Object} options - The rewrite options.
|
|
||||||
* @param {string} options.content - The content to rewrite.
|
|
||||||
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
|
|
||||||
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
|
|
||||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
|
||||||
* @returns {Promise} A promise that resolves with the rewritten content.
|
|
||||||
*/
|
|
||||||
rewrite({ content, operation, conversationId }, signal) {
|
|
||||||
return axios.post(
|
|
||||||
`${this.url}/rewrite`,
|
|
||||||
{
|
|
||||||
content,
|
|
||||||
operation,
|
|
||||||
conversation_display_id: conversationId,
|
|
||||||
},
|
|
||||||
{ signal }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summarizes a conversation.
|
|
||||||
* @param {string} conversationId - The conversation ID to summarize.
|
|
||||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
|
||||||
* @returns {Promise} A promise that resolves with the summary.
|
|
||||||
*/
|
|
||||||
summarize(conversationId, signal) {
|
|
||||||
return axios.post(
|
|
||||||
`${this.url}/summarize`,
|
|
||||||
{
|
|
||||||
conversation_display_id: conversationId,
|
|
||||||
},
|
|
||||||
{ signal }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a reply suggestion for a conversation.
|
|
||||||
* @param {string} conversationId - The conversation ID.
|
|
||||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
|
||||||
* @returns {Promise} A promise that resolves with the reply suggestion.
|
|
||||||
*/
|
|
||||||
replySuggestion(conversationId, signal) {
|
|
||||||
return axios.post(
|
|
||||||
`${this.url}/reply_suggestion`,
|
|
||||||
{
|
|
||||||
conversation_display_id: conversationId,
|
|
||||||
},
|
|
||||||
{ signal }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets label suggestions for a conversation.
|
|
||||||
* @param {string} conversationId - The conversation ID.
|
|
||||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
|
||||||
* @returns {Promise} A promise that resolves with label suggestions.
|
|
||||||
*/
|
|
||||||
labelSuggestion(conversationId, signal) {
|
|
||||||
return axios.post(
|
|
||||||
`${this.url}/label_suggestion`,
|
|
||||||
{
|
|
||||||
conversation_display_id: conversationId,
|
|
||||||
},
|
|
||||||
{ signal }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a follow-up message to continue refining a previous task result.
|
|
||||||
* @param {Object} options - The follow-up options.
|
|
||||||
* @param {Object} options.followUpContext - The follow-up context from a previous task.
|
|
||||||
* @param {string} options.message - The follow-up message/request from the user.
|
|
||||||
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
|
|
||||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
|
||||||
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
|
|
||||||
*/
|
|
||||||
followUp({ followUpContext, message, conversationId }, signal) {
|
|
||||||
return axios.post(
|
|
||||||
`${this.url}/follow_up`,
|
|
||||||
{
|
|
||||||
follow_up_context: followUpContext,
|
|
||||||
message,
|
|
||||||
conversation_display_id: conversationId,
|
|
||||||
},
|
|
||||||
{ signal }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new TasksAPI();
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
class TiktokChannel extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('tiktok', { accountScoped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAuthorization(payload) {
|
|
||||||
return axios.post(`${this.url}/authorization`, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new TiktokChannel();
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Device } from '@twilio/voice-sdk';
|
|
||||||
import VoiceAPI from './voiceAPIClient';
|
|
||||||
|
|
||||||
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
|
|
||||||
|
|
||||||
class TwilioVoiceClient extends EventTarget {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.device = null;
|
|
||||||
this.activeConnection = null;
|
|
||||||
this.initialized = false;
|
|
||||||
this.inboxId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initializeDevice(inboxId) {
|
|
||||||
this.destroyDevice();
|
|
||||||
|
|
||||||
const response = await VoiceAPI.getToken(inboxId);
|
|
||||||
const { token, account_id } = response || {};
|
|
||||||
if (!token) throw new Error('Invalid token');
|
|
||||||
|
|
||||||
this.device = new Device(token, {
|
|
||||||
allowIncomingWhileBusy: true,
|
|
||||||
disableAudioContextSounds: true,
|
|
||||||
appParams: { account_id },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.device.removeAllListeners();
|
|
||||||
this.device.on('connect', conn => {
|
|
||||||
this.activeConnection = conn;
|
|
||||||
conn.on('disconnect', this.onDisconnect);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.device.on('disconnect', this.onDisconnect);
|
|
||||||
|
|
||||||
this.device.on('tokenWillExpire', async () => {
|
|
||||||
const r = await VoiceAPI.getToken(this.inboxId);
|
|
||||||
if (r?.token) this.device.updateToken(r.token);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.initialized = true;
|
|
||||||
this.inboxId = inboxId;
|
|
||||||
|
|
||||||
return this.device;
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasActiveConnection() {
|
|
||||||
return !!this.activeConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
endClientCall() {
|
|
||||||
if (this.activeConnection) {
|
|
||||||
this.activeConnection.disconnect();
|
|
||||||
}
|
|
||||||
this.activeConnection = null;
|
|
||||||
if (this.device) {
|
|
||||||
this.device.disconnectAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroyDevice() {
|
|
||||||
if (this.device) {
|
|
||||||
this.device.destroy();
|
|
||||||
}
|
|
||||||
this.activeConnection = null;
|
|
||||||
this.device = null;
|
|
||||||
this.initialized = false;
|
|
||||||
this.inboxId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async joinClientCall({ to, conversationId }) {
|
|
||||||
if (!this.device || !this.initialized || !to) return null;
|
|
||||||
if (this.activeConnection) return this.activeConnection;
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
To: to,
|
|
||||||
is_agent: 'true',
|
|
||||||
conversation_id: conversationId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const connection = await this.device.connect({ params });
|
|
||||||
this.activeConnection = connection;
|
|
||||||
|
|
||||||
connection.on('disconnect', this.onDisconnect);
|
|
||||||
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDisconnect = () => {
|
|
||||||
this.activeConnection = null;
|
|
||||||
this.dispatchEvent(createCallDisconnectedEvent());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new TwilioVoiceClient();
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from '../../ApiClient';
|
|
||||||
import ContactsAPI from '../../contacts';
|
|
||||||
|
|
||||||
class VoiceAPI extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('voice', { accountScoped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
|
||||||
initiateCall(contactId, inboxId) {
|
|
||||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
leaveConference(inboxId, conversationId) {
|
|
||||||
return axios
|
|
||||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
|
||||||
params: { conversation_id: conversationId },
|
|
||||||
})
|
|
||||||
.then(r => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
joinConference({ conversationId, inboxId, callSid }) {
|
|
||||||
return axios
|
|
||||||
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
|
||||||
conversation_id: conversationId,
|
|
||||||
call_sid: callSid,
|
|
||||||
})
|
|
||||||
.then(r => r.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getToken(inboxId) {
|
|
||||||
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
|
||||||
return axios
|
|
||||||
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
|
|
||||||
.then(r => r.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new VoiceAPI();
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from './ApiClient';
|
|
||||||
|
|
||||||
export const buildCompanyParams = (page, sort) => {
|
|
||||||
let params = `page=${page}`;
|
|
||||||
if (sort) {
|
|
||||||
params = `${params}&sort=${sort}`;
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildSearchParams = (query, page, sort) => {
|
|
||||||
let params = `q=${encodeURIComponent(query)}&page=${page}`;
|
|
||||||
if (sort) {
|
|
||||||
params = `${params}&sort=${sort}`;
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CompanyAPI extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('companies', { accountScoped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
get(params = {}) {
|
|
||||||
const { page = 1, sort = 'name' } = params;
|
|
||||||
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
|
|
||||||
return axios.get(requestURL);
|
|
||||||
}
|
|
||||||
|
|
||||||
search(query = '', page = 1, sort = 'name') {
|
|
||||||
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
|
|
||||||
return axios.get(requestURL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new CompanyAPI();
|
|
||||||
@@ -47,12 +47,6 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
initiateCall(contactId, inboxId) {
|
|
||||||
return axios.post(`${this.url}/${contactId}/call`, {
|
|
||||||
inbox_id: inboxId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateContactLabels(contactId, labels) {
|
updateContactLabels(contactId, labels) {
|
||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
action_type: action,
|
action_type: action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createTopupCheckout(credits) {
|
|
||||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
expect(accountAPI).toHaveProperty('delete');
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
expect(accountAPI).toHaveProperty('checkout');
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||||
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
|
||||||
expect(accountAPI).toHaveProperty('getLimits');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -61,29 +59,5 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
{ action_type: 'undelete' }
|
{ action_type: 'undelete' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('#createTopupCheckout with credits', () => {
|
|
||||||
accountAPI.createTopupCheckout(1000);
|
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
||||||
'/enterprise/api/v1/topup_checkout',
|
|
||||||
{ credits: 1000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#createTopupCheckout with different credit amounts', () => {
|
|
||||||
const creditAmounts = [1000, 2500, 6000, 12000];
|
|
||||||
creditAmounts.forEach(credits => {
|
|
||||||
accountAPI.createTopupCheckout(credits);
|
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
||||||
'/enterprise/api/v1/topup_checkout',
|
|
||||||
{ credits }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#getLimits', () => {
|
|
||||||
accountAPI.getLimits();
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ class ConversationApi extends ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assignAgent({ conversationId, agentId }) {
|
assignAgent({ conversationId, agentId }) {
|
||||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
return axios.post(
|
||||||
assignee_id: agentId,
|
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
||||||
});
|
{}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTeam({ conversationId, teamId }) {
|
assignTeam({ conversationId, teamId }) {
|
||||||
|
|||||||
@@ -32,16 +32,6 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
syncTemplates(inboxId) {
|
syncTemplates(inboxId) {
|
||||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||||
}
|
}
|
||||||
|
|
||||||
createCSATTemplate(inboxId, template) {
|
|
||||||
return axios.post(`${this.url}/${inboxId}/csat_template`, {
|
|
||||||
template,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getCSATTemplateStatus(inboxId) {
|
|
||||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Inboxes();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/* global axios */
|
||||||
|
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the data object for a OpenAI hook.
|
||||||
|
* @typedef {Object} ConversationMessageData
|
||||||
|
* @property {string} [tone] - The tone of the message.
|
||||||
|
* @property {string} [content] - The content of the message.
|
||||||
|
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client for the OpenAI API.
|
||||||
|
* @extends ApiClient
|
||||||
|
*/
|
||||||
|
class OpenAIAPI extends ApiClient {
|
||||||
|
/**
|
||||||
|
* Creates a new OpenAIAPI instance.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super('integrations', { accountScoped: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The conversation events supported by the API.
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
this.conversation_events = [
|
||||||
|
'summarize',
|
||||||
|
'reply_suggestion',
|
||||||
|
'label_suggestion',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message events supported by the API.
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
this.message_events = ['rephrase'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an event using the OpenAI API.
|
||||||
|
* @param {Object} options - The options for the event.
|
||||||
|
* @param {string} [options.type='rephrase'] - The type of event to process.
|
||||||
|
* @param {string} [options.content] - The content of the event.
|
||||||
|
* @param {string} [options.tone] - The tone of the event.
|
||||||
|
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
|
||||||
|
* @param {string} options.hookId - The ID of the hook to use for processing the event.
|
||||||
|
* @returns {Promise} A promise that resolves with the result of the event processing.
|
||||||
|
*/
|
||||||
|
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
|
||||||
|
/**
|
||||||
|
* @type {ConversationMessageData}
|
||||||
|
*/
|
||||||
|
let data = {
|
||||||
|
tone,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.conversation_events.includes(type)) {
|
||||||
|
data = {
|
||||||
|
conversation_display_id: conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
|
||||||
|
event: {
|
||||||
|
name: type,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new OpenAIAPI();
|
||||||
@@ -61,12 +61,6 @@ class ReportsAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
|
|
||||||
return axios.get(`${this.url}/conversations_summary`, {
|
|
||||||
params: { since, until, business_hours: businessHours },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||||
return axios.get(`${this.url}/conversation_traffic`, {
|
return axios.get(`${this.url}/conversation_traffic`, {
|
||||||
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||||
|
|||||||
@@ -14,48 +14,38 @@ class SearchAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
contacts({ q, page = 1, since, until }) {
|
contacts({ q, page = 1 }) {
|
||||||
return axios.get(`${this.url}/contacts`, {
|
return axios.get(`${this.url}/contacts`, {
|
||||||
params: {
|
params: {
|
||||||
q,
|
q,
|
||||||
page: page,
|
page: page,
|
||||||
since,
|
|
||||||
until,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
conversations({ q, page = 1, since, until }) {
|
conversations({ q, page = 1 }) {
|
||||||
return axios.get(`${this.url}/conversations`, {
|
return axios.get(`${this.url}/conversations`, {
|
||||||
params: {
|
params: {
|
||||||
q,
|
q,
|
||||||
page: page,
|
page: page,
|
||||||
since,
|
|
||||||
until,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
messages({ q, page = 1, since, until, from, inboxId }) {
|
messages({ q, page = 1 }) {
|
||||||
return axios.get(`${this.url}/messages`, {
|
return axios.get(`${this.url}/messages`, {
|
||||||
params: {
|
params: {
|
||||||
q,
|
q,
|
||||||
page: page,
|
page: page,
|
||||||
since,
|
|
||||||
until,
|
|
||||||
from,
|
|
||||||
inbox_id: inboxId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
articles({ q, page = 1, since, until }) {
|
articles({ q, page = 1 }) {
|
||||||
return axios.get(`${this.url}/articles`, {
|
return axios.get(`${this.url}/articles`, {
|
||||||
params: {
|
params: {
|
||||||
q,
|
q,
|
||||||
page: page,
|
page: page,
|
||||||
since,
|
|
||||||
until,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import companyAPI, {
|
|
||||||
buildCompanyParams,
|
|
||||||
buildSearchParams,
|
|
||||||
} from '../companies';
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
describe('#CompanyAPI', () => {
|
|
||||||
it('creates correct instance', () => {
|
|
||||||
expect(companyAPI).toBeInstanceOf(ApiClient);
|
|
||||||
expect(companyAPI).toHaveProperty('get');
|
|
||||||
expect(companyAPI).toHaveProperty('show');
|
|
||||||
expect(companyAPI).toHaveProperty('create');
|
|
||||||
expect(companyAPI).toHaveProperty('update');
|
|
||||||
expect(companyAPI).toHaveProperty('delete');
|
|
||||||
expect(companyAPI).toHaveProperty('search');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API calls', () => {
|
|
||||||
const originalAxios = window.axios;
|
|
||||||
const axiosMock = {
|
|
||||||
post: vi.fn(() => Promise.resolve()),
|
|
||||||
get: vi.fn(() => Promise.resolve()),
|
|
||||||
patch: vi.fn(() => Promise.resolve()),
|
|
||||||
delete: vi.fn(() => Promise.resolve()),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
window.axios = axiosMock;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.axios = originalAxios;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#get with default params', () => {
|
|
||||||
companyAPI.get({});
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies?page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#get with page and sort params', () => {
|
|
||||||
companyAPI.get({ page: 2, sort: 'domain' });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies?page=2&sort=domain'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#get with descending sort', () => {
|
|
||||||
companyAPI.get({ page: 1, sort: '-created_at' });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies?page=1&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with query', () => {
|
|
||||||
companyAPI.search('acme', 1, 'name');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=acme&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with special characters in query', () => {
|
|
||||||
companyAPI.search('acme & co', 2, 'domain');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with descending sort', () => {
|
|
||||||
companyAPI.search('test', 1, '-created_at');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#search with empty query', () => {
|
|
||||||
companyAPI.search('', 1, 'name');
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/companies/search?q=&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#buildCompanyParams', () => {
|
|
||||||
it('returns correct string with page only', () => {
|
|
||||||
expect(buildCompanyParams(1)).toBe('page=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with page and sort', () => {
|
|
||||||
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with different page', () => {
|
|
||||||
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with descending sort', () => {
|
|
||||||
expect(buildCompanyParams(1, '-created_at')).toBe(
|
|
||||||
'page=1&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string without sort parameter', () => {
|
|
||||||
expect(buildCompanyParams(2, '')).toBe('page=2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#buildSearchParams', () => {
|
|
||||||
it('returns correct string with all parameters', () => {
|
|
||||||
expect(buildSearchParams('acme', 1, 'name')).toBe(
|
|
||||||
'q=acme&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with special characters', () => {
|
|
||||||
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
|
|
||||||
'q=acme%20%26%20co&page=2&sort=domain'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with empty query', () => {
|
|
||||||
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string without sort parameter', () => {
|
|
||||||
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct string with descending sort', () => {
|
|
||||||
expect(buildSearchParams('company', 3, '-created_at')).toBe(
|
|
||||||
'q=company&page=3&sort=-created_at'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('encodes special characters correctly', () => {
|
|
||||||
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
|
|
||||||
'q=test%40example.com&page=1&sort=name'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -92,10 +92,8 @@ describe('#ConversationAPI', () => {
|
|||||||
it('#assignAgent', () => {
|
it('#assignAgent', () => {
|
||||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
`/api/v1/conversations/12/assignments`,
|
`/api/v1/conversations/12/assignments?assignee_id=34`,
|
||||||
{
|
{}
|
||||||
assignee_id: 34,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import searchAPI from '../search';
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
describe('#SearchAPI', () => {
|
|
||||||
it('creates correct instance', () => {
|
|
||||||
expect(searchAPI).toBeInstanceOf(ApiClient);
|
|
||||||
expect(searchAPI).toHaveProperty('get');
|
|
||||||
expect(searchAPI).toHaveProperty('contacts');
|
|
||||||
expect(searchAPI).toHaveProperty('conversations');
|
|
||||||
expect(searchAPI).toHaveProperty('messages');
|
|
||||||
expect(searchAPI).toHaveProperty('articles');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API calls', () => {
|
|
||||||
const originalAxios = window.axios;
|
|
||||||
const axiosMock = {
|
|
||||||
get: vi.fn(() => Promise.resolve()),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
window.axios = axiosMock;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.axios = originalAxios;
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#get', () => {
|
|
||||||
searchAPI.get({ q: 'test query' });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
|
|
||||||
params: { q: 'test query' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#contacts', () => {
|
|
||||||
searchAPI.contacts({ q: 'test', page: 1 });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
|
|
||||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#contacts with date filters', () => {
|
|
||||||
searchAPI.contacts({
|
|
||||||
q: 'test',
|
|
||||||
page: 2,
|
|
||||||
since: 1700000000,
|
|
||||||
until: 1732000000,
|
|
||||||
});
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
|
|
||||||
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#conversations', () => {
|
|
||||||
searchAPI.conversations({ q: 'test', page: 1 });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/search/conversations',
|
|
||||||
{
|
|
||||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#conversations with date filters', () => {
|
|
||||||
searchAPI.conversations({
|
|
||||||
q: 'test',
|
|
||||||
page: 1,
|
|
||||||
since: 1700000000,
|
|
||||||
until: 1732000000,
|
|
||||||
});
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/search/conversations',
|
|
||||||
{
|
|
||||||
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#messages', () => {
|
|
||||||
searchAPI.messages({ q: 'test', page: 1 });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
|
|
||||||
params: {
|
|
||||||
q: 'test',
|
|
||||||
page: 1,
|
|
||||||
since: undefined,
|
|
||||||
until: undefined,
|
|
||||||
from: undefined,
|
|
||||||
inbox_id: undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#messages with all filters', () => {
|
|
||||||
searchAPI.messages({
|
|
||||||
q: 'test',
|
|
||||||
page: 1,
|
|
||||||
since: 1700000000,
|
|
||||||
until: 1732000000,
|
|
||||||
from: 'contact:42',
|
|
||||||
inboxId: 10,
|
|
||||||
});
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
|
|
||||||
params: {
|
|
||||||
q: 'test',
|
|
||||||
page: 1,
|
|
||||||
since: 1700000000,
|
|
||||||
until: 1732000000,
|
|
||||||
from: 'contact:42',
|
|
||||||
inbox_id: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#articles', () => {
|
|
||||||
searchAPI.articles({ q: 'test', page: 1 });
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
|
|
||||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#articles with date filters', () => {
|
|
||||||
searchAPI.articles({
|
|
||||||
q: 'test',
|
|
||||||
page: 2,
|
|
||||||
since: 1700000000,
|
|
||||||
until: 1732000000,
|
|
||||||
});
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
|
|
||||||
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import ApiClient from '../ApiClient';
|
|
||||||
import tiktokClient from '../channel/tiktokClient';
|
|
||||||
|
|
||||||
describe('#TiktokClient', () => {
|
|
||||||
it('creates correct instance', () => {
|
|
||||||
expect(tiktokClient).toBeInstanceOf(ApiClient);
|
|
||||||
expect(tiktokClient).toHaveProperty('generateAuthorization');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#generateAuthorization', () => {
|
|
||||||
const originalAxios = window.axios;
|
|
||||||
const originalPathname = window.location.pathname;
|
|
||||||
const axiosMock = {
|
|
||||||
post: vi.fn(() => Promise.resolve()),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
window.axios = axiosMock;
|
|
||||||
window.history.pushState({}, '', '/app/accounts/1/settings');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.axios = originalAxios;
|
|
||||||
window.history.pushState({}, '', originalPathname);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('posts to the authorization endpoint', () => {
|
|
||||||
tiktokClient.generateAuthorization({ state: 'test-state' });
|
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
||||||
'/api/v1/accounts/1/tiktok/authorization',
|
|
||||||
{ state: 'test-state' }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
import ApiClient from './ApiClient';
|
|
||||||
|
|
||||||
class YearInReviewAPI extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
|
|
||||||
}
|
|
||||||
|
|
||||||
get(year) {
|
|
||||||
return axios.get(`${this.url}`, {
|
|
||||||
params: { year },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new YearInReviewAPI();
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB |
@@ -66,7 +66,7 @@ textarea {
|
|||||||
// Field base styles (Input, TextArea, Select)
|
// Field base styles (Input, TextArea, Select)
|
||||||
@layer components {
|
@layer components {
|
||||||
.field-base {
|
.field-base {
|
||||||
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
|
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-disabled {
|
.field-disabled {
|
||||||
@@ -78,7 +78,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
|
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
|
||||||
|
|
||||||
#{$form-input-selector} {
|
#{$form-input-selector} {
|
||||||
@apply field-base h-10;
|
@apply field-base h-10;
|
||||||
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='file'] {
|
input[type='file']:not(.multiselect__input) {
|
||||||
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +126,13 @@ label:has(.help-text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
.has-multi-select-error {
|
||||||
|
div.multiselect {
|
||||||
|
@apply mb-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FormKit support
|
// FormKit support
|
||||||
.formkit-outer[data-invalid='true'] {
|
.formkit-outer[data-invalid='true'] {
|
||||||
#{$form-input-selector},
|
#{$form-input-selector},
|
||||||
@@ -143,7 +150,9 @@ label:has(.help-text) {
|
|||||||
#{$form-input-selector},
|
#{$form-input-selector},
|
||||||
input:not([type]),
|
input:not([type]),
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select,
|
||||||
|
.multiselect > .multiselect__tags,
|
||||||
|
.multiselect:not(.no-margin) {
|
||||||
@apply field-error;
|
@apply field-error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,52 +94,21 @@
|
|||||||
--gray-11: 100 100 100;
|
--gray-11: 100 100 100;
|
||||||
--gray-12: 32 32 32;
|
--gray-12: 32 32 32;
|
||||||
|
|
||||||
--violet-1: 253 252 254;
|
--background-color: 253 253 253;
|
||||||
--violet-2: 250 248 255;
|
--text-blue: 8 109 224;
|
||||||
--violet-3: 244 240 254;
|
|
||||||
--violet-4: 235 228 255;
|
|
||||||
--violet-5: 225 217 255;
|
|
||||||
--violet-6: 212 202 254;
|
|
||||||
--violet-7: 194 178 248;
|
|
||||||
--violet-8: 169 153 236;
|
|
||||||
--violet-9: 110 86 207;
|
|
||||||
--violet-10: 100 84 196;
|
|
||||||
--violet-11: 101 85 183;
|
|
||||||
--violet-12: 47 38 95;
|
|
||||||
|
|
||||||
--background-color: 247 247 247;
|
|
||||||
--surface-1: 254 254 254;
|
|
||||||
--surface-2: 255 255 255;
|
|
||||||
--surface-active: 255 255 255;
|
|
||||||
--background-input-box: 0, 0, 0, 0.03;
|
|
||||||
--text-blue: 1 22 44;
|
|
||||||
--text-purple: 2 4 49;
|
|
||||||
--text-amber: 37 24 1;
|
|
||||||
--border-container: 236 236 236;
|
--border-container: 236 236 236;
|
||||||
--border-strong: 226 227 231;
|
--border-strong: 235 235 235;
|
||||||
--border-weak: 234 234 234;
|
--border-weak: 234 234 234;
|
||||||
--border-blue-strong: 18 61 117;
|
|
||||||
--solid-1: 255 255 255;
|
--solid-1: 255 255 255;
|
||||||
--solid-2: 255 255 255;
|
--solid-2: 255 255 255;
|
||||||
--solid-3: 255 255 255;
|
--solid-3: 255 255 255;
|
||||||
--solid-active: 255 255 255;
|
--solid-active: 255 255 255;
|
||||||
--solid-amber: 255 228 181;
|
--solid-amber: 252 232 193;
|
||||||
--solid-blue: 218 236 255;
|
--solid-blue: 218 236 255;
|
||||||
--solid-blue-2: 251 253 255;
|
|
||||||
--solid-iris: 230 231 255;
|
--solid-iris: 230 231 255;
|
||||||
--solid-purple: 230 231 255;
|
|
||||||
--solid-red: 254 200 201;
|
|
||||||
--solid-amber-button: 255 221 141;
|
|
||||||
--card-color: 255 255 255;
|
|
||||||
--overlay: 0, 0, 0, 0.12;
|
|
||||||
--overlay-avatar: 255, 255, 255, 0.67;
|
|
||||||
--button-color: 255 255 255;
|
|
||||||
--button-hover-color: 255, 255, 255, 0.2;
|
|
||||||
--label-background: 247 247 247;
|
|
||||||
--label-border: 0, 0, 0, 0.04;
|
|
||||||
|
|
||||||
--alpha-1: 215, 215, 215, 0.22;
|
--alpha-1: 67, 67, 67, 0.06;
|
||||||
--alpha-2: 196, 197, 198, 0.22;
|
--alpha-2: 201, 202, 207, 0.15;
|
||||||
--alpha-3: 255, 255, 255, 0.96;
|
--alpha-3: 255, 255, 255, 0.96;
|
||||||
--black-alpha-1: 0, 0, 0, 0.12;
|
--black-alpha-1: 0, 0, 0, 0.12;
|
||||||
--black-alpha-2: 0, 0, 0, 0.04;
|
--black-alpha-2: 0, 0, 0, 0.04;
|
||||||
@@ -240,56 +209,25 @@
|
|||||||
--gray-11: 180 180 180;
|
--gray-11: 180 180 180;
|
||||||
--gray-12: 238 238 238;
|
--gray-12: 238 238 238;
|
||||||
|
|
||||||
--violet-1: 20 17 31;
|
--background-color: 18 18 19;
|
||||||
--violet-2: 27 21 37;
|
--border-strong: 52 52 52;
|
||||||
--violet-3: 41 31 67;
|
--border-weak: 38 38 42;
|
||||||
--violet-4: 50 37 85;
|
|
||||||
--violet-5: 60 46 105;
|
|
||||||
--violet-6: 71 56 135;
|
|
||||||
--violet-7: 86 70 151;
|
|
||||||
--violet-8: 110 86 171;
|
|
||||||
--violet-9: 110 86 207;
|
|
||||||
--violet-10: 125 109 217;
|
|
||||||
--violet-11: 169 153 236;
|
|
||||||
--violet-12: 226 221 254;
|
|
||||||
|
|
||||||
--background-color: 28 29 32;
|
|
||||||
--surface-1: 20 21 23;
|
|
||||||
--surface-2: 22 23 26;
|
|
||||||
--surface-active: 53 57 66;
|
|
||||||
--background-input-box: 255, 255, 255, 0.02;
|
|
||||||
--text-blue: 213 234 255;
|
|
||||||
--text-purple: 232 233 254;
|
|
||||||
--text-amber: 255 247 234;
|
|
||||||
--border-strong: 46 45 50;
|
|
||||||
--border-weak: 31 31 37;
|
|
||||||
--border-blue-strong: 201 226 255;
|
|
||||||
--solid-1: 23 23 26;
|
--solid-1: 23 23 26;
|
||||||
--solid-2: 29 30 36;
|
--solid-2: 29 30 36;
|
||||||
--solid-3: 44 45 54;
|
--solid-3: 44 45 54;
|
||||||
--solid-active: 53 57 66;
|
--solid-active: 53 57 66;
|
||||||
--solid-amber: 56 50 41;
|
--solid-amber: 42 37 30;
|
||||||
--solid-blue: 15 57 102;
|
--solid-blue: 16 49 91;
|
||||||
--solid-blue-2: 26 29 35;
|
|
||||||
--solid-iris: 38 42 101;
|
--solid-iris: 38 42 101;
|
||||||
--solid-purple: 51 51 107;
|
--text-blue: 126 182 255;
|
||||||
--solid-red: 90 33 34;
|
|
||||||
--solid-amber-button: 255 221 141;
|
|
||||||
--card-color: 28 30 34;
|
|
||||||
--overlay: 0, 0, 0, 0.4;
|
|
||||||
--overlay-avatar: 0, 0, 0, 0.05;
|
|
||||||
--button-color: 42 43 51;
|
|
||||||
--button-hover-color: 0, 0, 0, 0.15;
|
|
||||||
--label-background: 36 38 45;
|
|
||||||
--label-border: 255, 255, 255, 0.03;
|
|
||||||
|
|
||||||
--alpha-1: 35, 36, 42, 0.8;
|
--alpha-1: 36, 36, 36, 0.8;
|
||||||
--alpha-2: 147, 153, 176, 0.12;
|
--alpha-2: 139, 147, 182, 0.15;
|
||||||
--alpha-3: 33, 34, 38, 0.95;
|
--alpha-3: 36, 38, 45, 0.9;
|
||||||
--black-alpha-1: 0, 0, 0, 0.3;
|
--black-alpha-1: 0, 0, 0, 0.3;
|
||||||
--black-alpha-2: 0, 0, 0, 0.2;
|
--black-alpha-2: 0, 0, 0, 0.2;
|
||||||
--border-blue: 39, 129, 246, 0.5;
|
--border-blue: 39, 129, 246, 0.5;
|
||||||
--border-container: 255, 255, 255, 0;
|
--border-container: 236, 236, 236, 0;
|
||||||
--white-alpha: 255, 255, 255, 0.1;
|
--white-alpha: 255, 255, 255, 0.1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
@import 'base';
|
@import 'base';
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
|
@import 'plugins/multiselect';
|
||||||
@import 'plugins/date-picker';
|
@import 'plugins/date-picker';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -65,84 +66,4 @@ body {
|
|||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* TYPOGRAPHY UTILITIES
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* | Class | Use Case |
|
|
||||||
* |--------------------|----------------------------------------------------|
|
|
||||||
* | .text-body-main | <p>, <span>, general body text |
|
|
||||||
* | .text-body-para | <p> for paragraphs, larger text blocks |
|
|
||||||
* | .text-heading-1 | <h1>, page titles, panel headers |
|
|
||||||
* | .text-heading-2 | <h2>, section headings, card titles |
|
|
||||||
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
|
|
||||||
* | .text-label | <label>, form labels, field names |
|
|
||||||
* | .text-label-small | <small>, footnotes, tags, badges, captions |
|
|
||||||
* | .text-button | <button>, standard button text |
|
|
||||||
* | .text-button-small | <button>, small/compact button text |
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* body-text-main: Main text style for general body text */
|
|
||||||
.text-body-main {
|
|
||||||
@apply font-inter text-sm font-420;
|
|
||||||
line-height: 21px; /* 150% */
|
|
||||||
letter-spacing: -0.28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* body-text-paragraph: For paragraphs or larger blocks of text */
|
|
||||||
.text-body-para {
|
|
||||||
@apply font-inter text-sm font-420;
|
|
||||||
line-height: 21px; /* 150% */
|
|
||||||
letter-spacing: -0.21px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* heading-1: Large heading for pages and panels */
|
|
||||||
.text-heading-1 {
|
|
||||||
@apply font-inter text-lg font-520;
|
|
||||||
line-height: 24px; /* 133.333% */
|
|
||||||
letter-spacing: -0.27px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* heading-2: Secondary heading for sections */
|
|
||||||
.text-heading-2 {
|
|
||||||
@apply font-inter text-base font-medium;
|
|
||||||
line-height: 24px; /* 133.333% */
|
|
||||||
letter-spacing: -0.27px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* heading-3: For card headings, breadcrumbs, subsections */
|
|
||||||
.text-heading-3 {
|
|
||||||
@apply font-inter text-sm font-medium;
|
|
||||||
line-height: 21px; /* 150% */
|
|
||||||
letter-spacing: -0.27px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* label: Standard label text for form fields */
|
|
||||||
.text-label {
|
|
||||||
@apply font-inter text-sm font-medium;
|
|
||||||
line-height: 21px; /* 150% */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* label-small: Smallest font for labels, footnotes, tags */
|
|
||||||
.text-label-small {
|
|
||||||
@apply font-inter text-xs font-440;
|
|
||||||
line-height: 16px; /* 133.333% */
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* button-text: Text for standard size buttons */
|
|
||||||
.text-button {
|
|
||||||
@apply font-inter text-sm font-460;
|
|
||||||
line-height: 21px; /* 150% */
|
|
||||||
letter-spacing: -0.28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* button-text-small: Text for smaller buttons */
|
|
||||||
.text-button-small {
|
|
||||||
@apply font-inter text-xs font-440;
|
|
||||||
line-height: 18px; /* 150% */
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
@mixin label-multiselect-hover {
|
||||||
|
&::after {
|
||||||
|
@apply text-n-brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-n-slate-3;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
@apply text-n-blue-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect {
|
||||||
|
&:not(.no-margin) {
|
||||||
|
@apply mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.invalid .multiselect__tags {
|
||||||
|
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect--disabled {
|
||||||
|
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
@apply cursor-not-allowed bg-transparent rounded-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect--active {
|
||||||
|
> .multiselect__tags {
|
||||||
|
@apply outline-n-blue-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
@apply min-h-[2.875rem] p-0 right-0 top-0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
@apply right-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__content-wrapper {
|
||||||
|
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__content {
|
||||||
|
@apply max-w-full;
|
||||||
|
|
||||||
|
.multiselect__option {
|
||||||
|
@apply text-sm font-normal flex justify-between items-center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect__option--highlight {
|
||||||
|
@apply bg-n-alpha-black2 text-n-slate-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect__option--highlight:hover {
|
||||||
|
@apply bg-n-brand/10 text-n-slate-12;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
@apply bg-transparent text-center text-n-slate-12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect__option--highlight::after {
|
||||||
|
@apply bg-transparent text-n-slate-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect__option--selected {
|
||||||
|
@apply bg-n-brand/20 text-n-slate-12;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect__option--highlight:hover {
|
||||||
|
@apply bg-transparent;
|
||||||
|
|
||||||
|
&::after:hover {
|
||||||
|
@apply text-n-slate-12 bg-transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tags {
|
||||||
|
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__spinner {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tags-wrap {
|
||||||
|
@apply inline-block leading-none mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__placeholder {
|
||||||
|
@apply text-n-slate-10 font-normal pt-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tag {
|
||||||
|
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tag-icon {
|
||||||
|
@include label-multiselect-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input {
|
||||||
|
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__single {
|
||||||
|
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-labels-wrap {
|
||||||
|
&.has-edited,
|
||||||
|
&:hover {
|
||||||
|
.multiselect {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect {
|
||||||
|
> .multiselect__select {
|
||||||
|
@apply invisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .multiselect__tags {
|
||||||
|
@apply outline-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect--active > .multiselect__tags {
|
||||||
|
@apply outline-n-blue-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-wrap--small {
|
||||||
|
// To be removed one SLA reports date picker is created
|
||||||
|
&.tiny {
|
||||||
|
.multiselect.no-margin {
|
||||||
|
@apply min-h-[32px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
@apply min-h-[32px] h-8;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
@apply top-[60%];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tags {
|
||||||
|
@apply min-h-[32px] max-h-[32px];
|
||||||
|
|
||||||
|
.multiselect__single {
|
||||||
|
@apply pt-1 pb-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tags,
|
||||||
|
.multiselect__input,
|
||||||
|
.multiselect {
|
||||||
|
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input {
|
||||||
|
@apply h-[2.375rem] min-h-[2.375rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__single {
|
||||||
|
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__placeholder {
|
||||||
|
@apply m-0 py-2 px-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tag {
|
||||||
|
@apply py-[6px] my-[1px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
@apply min-h-[2.5rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect--disabled .multiselect__current,
|
||||||
|
.multiselect--disabled .multiselect__select {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect--disabled {
|
||||||
|
background-color: rgba(var(--black-alpha-2)) !important;
|
||||||
|
|
||||||
|
.multiselect__tags {
|
||||||
|
@apply hover:outline-n-weak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect--active {
|
||||||
|
.multiselect__select::before {
|
||||||
|
@apply top-[62%];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select::before {
|
||||||
|
top: 60% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-wrap--medium {
|
||||||
|
.multiselect__tags,
|
||||||
|
.multiselect__input {
|
||||||
|
@apply items-center flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tags,
|
||||||
|
.multiselect__input,
|
||||||
|
.multiselect {
|
||||||
|
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input {
|
||||||
|
@apply h-[2.875rem] min-h-[2.875rem];
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__single {
|
||||||
|
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__placeholder {
|
||||||
|
@apply m-0 py-1 px-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
@apply min-h-[3rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect--disabled .multiselect__current,
|
||||||
|
.multiselect--disabled .multiselect__select {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tags-wrap {
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
|
|||||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||||
<div class="flex items-center gap-3 justify-between w-full">
|
<div class="flex items-center gap-3 justify-between w-full">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
|
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h3>
|
</h3>
|
||||||
<CardPopover
|
<CardPopover
|
||||||
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
|
|||||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
|
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@ const handleClick = () => {
|
|||||||
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
||||||
<div class="flex flex-col items-start gap-2">
|
<div class="flex flex-col items-start gap-2">
|
||||||
<div class="flex justify-between w-full items-center">
|
<div class="flex justify-between w-full items-center">
|
||||||
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
|
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
|
||||||
<Button
|
<Button
|
||||||
xs
|
xs
|
||||||
slate
|
slate
|
||||||
@@ -29,14 +29,14 @@ const handleClick = () => {
|
|||||||
@click.stop="handleClick"
|
@click.stop="handleClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
|
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="flex flex-col items-start gap-3 mt-3">
|
<ul class="flex flex-col items-start gap-3 mt-3">
|
||||||
<li
|
<li
|
||||||
v-for="feature in features"
|
v-for="feature in features"
|
||||||
:key="feature.id"
|
:key="feature.id"
|
||||||
class="flex items-center gap-3 text-body-para"
|
class="flex items-center gap-3 text-sm"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
:icon="feature.icon"
|
:icon="feature.icon"
|
||||||
|
|||||||
+3
@@ -39,6 +39,7 @@ const policyA = withCount({
|
|||||||
description: 'Distributes conversations evenly among available agents',
|
description: 'Distributes conversations evenly among available agents',
|
||||||
assignmentOrder: 'round_robin',
|
assignmentOrder: 'round_robin',
|
||||||
conversationPriority: 'high',
|
conversationPriority: 'high',
|
||||||
|
enabled: true,
|
||||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@@ -49,6 +50,7 @@ const policyB = withCount({
|
|||||||
description: 'Assigns based on capacity and workload',
|
description: 'Assigns based on capacity and workload',
|
||||||
assignmentOrder: 'capacity_based',
|
assignmentOrder: 'capacity_based',
|
||||||
conversationPriority: 'medium',
|
conversationPriority: 'medium',
|
||||||
|
enabled: true,
|
||||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@@ -59,6 +61,7 @@ const emptyPolicy = withCount({
|
|||||||
description: 'Policy with no assigned inboxes',
|
description: 'Policy with no assigned inboxes',
|
||||||
assignmentOrder: 'manual',
|
assignmentOrder: 'manual',
|
||||||
conversationPriority: 'low',
|
conversationPriority: 'low',
|
||||||
|
enabled: false,
|
||||||
inboxes: [],
|
inboxes: [],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user