diff --git a/.circleci/config.yml b/.circleci/config.yml
index 99795db91..09bd5191d 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,7 +1,9 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
+ qlty-orb: qltysh/qlty-orb@0.0
+# Shared defaults for setup steps
defaults: &defaults
working_directory: ~/build
machine:
@@ -11,21 +13,147 @@ defaults: &defaults
RAILS_LOG_TO_STDOUT: false
COVERAGE: true
LOG_LEVEL: warn
- parallelism: 4
jobs:
- build:
+ # Separate job for linting (no parallelism needed)
+ 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: '23.7'
+ node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- - run: node --version
- - run: pnpm --version
+
+ # 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
+ steps:
+ - checkout
+ - node/install:
+ node-version: '24.13'
+ - node/install-pnpm
+ - node/install-packages:
+ pkg-manager: pnpm
+ override-ci-command: pnpm i
+
+ - run:
+ 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: 16
+ steps:
+ - checkout
+ - node/install:
+ node-version: '24.13'
+ - node/install-pnpm
+ - node/install-packages:
+ pkg-manager: pnpm
+ override-ci-command: pnpm i
+
- run:
name: Add PostgreSQL repository and update
command: |
@@ -89,29 +217,51 @@ jobs:
command: |
source ~/.rvm/scripts/rvm
bundle install
- # pnpm install
+
+ # Install and configure OpenSearch
+ - run:
+ name: Install OpenSearch
+ command: |
+ # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
+ wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
+ tar -xzf opensearch-2.11.0-linux-x64.tar.gz
+ sudo mv opensearch-2.11.0 /opt/opensearch
- run:
- name: Download cc-test-reporter
+ name: Configure and Start OpenSearch
command: |
- mkdir -p ~/tmp
- curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
- chmod +x ~/tmp/cc-test-reporter
+ # Configure OpenSearch for single-node testing
+ 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
- # Swagger verification
- run:
- name: Verify swagger API specification
+ name: Wait for OpenSearch to be ready
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
- 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
+ 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
- # we remove the FRONTED_URL from the .env before running the tests
+ # Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
command: |
@@ -127,65 +277,98 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
+ echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare
- # 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
- command: |
- mkdir -p ~/build/coverage/frontend
- ~/tmp/cc-test-reporter before-build
- pnpm run test:coverage
-
- - run:
- name: Code Climate Test Coverage (Frontend)
- command: |
- ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
-
- # Run backend tests
+ # Run backend tests (parallelized)
- run:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
- ~/tmp/cc-test-reporter before-build
- TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
- bundle exec rspec --format progress \
+
+ # 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 \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
- -- ${TESTFILES}
+ -- $TESTS
no_output_timeout: 30m
- - run:
- name: Code Climate Test Coverage (Backend)
- command: |
- ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
+ # Store test results for better splitting in future runs
+ - store_test_results:
+ path: ~/tmp/test-results
- run:
- name: List coverage directory contents
+ name: Move coverage files if they exist
command: |
- ls -R ~/build/coverage
+ if [ -d "coverage" ]; then
+ mkdir -p ~/build/coverage
+ cp -r coverage ~/build/coverage/backend || true
+ fi
+ when: always
- persist_to_workspace:
root: ~/build
paths:
- 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:
+ path: 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
diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml
index 6932b5f10..375742ff7 100644
--- a/.devcontainer/docker-compose.base.yml
+++ b/.devcontainer/docker-compose.base.yml
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
- NODE_VERSION: '23.7.0'
+ NODE_VERSION: '24.13.0'
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.
USER_UID: '1000'
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index a9185ea09..d696f99cc 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
- NODE_VERSION: '23.7.0'
+ NODE_VERSION: '24.13.0'
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.
USER_UID: '1000'
diff --git a/.env.example b/.env.example
index 2ab2933dc..55750b2f2 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,13 @@
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
+# Active Record Encryption keys (required for MFA/2FA functionality)
+# Generate these keys by running: rails db:encryption:init
+# IMPORTANT: Use different keys for each environment (development, staging, production)
+# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
+# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
+# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
+
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
@@ -98,6 +105,7 @@ MAILER_INBOUND_EMAIL_DOMAIN=
# mandrill for Mandrill
# postmark for Postmark
# sendgrid for Sendgrid
+# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
@@ -107,6 +115,10 @@ RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_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:
# Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
@@ -208,6 +220,7 @@ 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
# DD_TRACE_AGENT_URL=
+
# MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY=
@@ -249,6 +262,8 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance
# control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10
+# Enable verbose logging each time a job is dequeued in Sidekiq
+# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features
@@ -259,3 +274,5 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
+
+# REDIS_ALFRED_SIZE=10
diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml
index 45ff25203..1d1116d0c 100644
--- a/.github/workflows/frontend-fe.yml
+++ b/.github/workflows/frontend-fe.yml
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml
index 385feddfc..c2a626388 100644
--- a/.github/workflows/run_foss_spec.yml
+++ b/.github/workflows/run_foss_spec.yml
@@ -1,4 +1,6 @@
name: Run Chatwoot CE spec
+permissions:
+ contents: read
on:
push:
branches:
@@ -8,11 +10,58 @@ on:
workflow_dispatch:
jobs:
- test:
- runs-on: ubuntu-22.04
+ # Separate linting jobs for faster feedback
+ lint-backend:
+ 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:
postgres:
- image: pgvector/pgvector:pg15
+ image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
@@ -20,8 +69,6 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
- # needed because the postgres container does not provide a healthcheck
- # tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
@@ -29,7 +76,7 @@ jobs:
--health-timeout 5s
--health-retries 5
redis:
- image: redis
+ image: redis:alpine
ports:
- 6379:6379
options: --entrypoint redis-server
@@ -43,11 +90,11 @@ jobs:
- uses: ruby/setup-ruby@v1
with:
- bundler-cache: true # runs 'bundle install' and caches installed gems automatically
+ bundler-cache: true
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
@@ -64,19 +111,36 @@ jobs:
- name: Seed database
run: bundle exec rake db:schema:load
- - name: Run frontend tests
- run: pnpm run test:coverage
-
- # Run rails tests
- - name: Run backend tests
+ - name: Run backend tests (parallelized)
run: |
- bundle exec rspec --profile=10 --format documentation
+ # Get all spec files and split them using round-robin distribution
+ # 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:
NODE_OPTIONS: --openssl-legacy-provider
- - name: Upload rails log folder
+ - name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
- name: rails-log-folder
+ name: rspec-results-${{ matrix.ci_node_index }}
+ 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
diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml
new file mode 100644
index 000000000..69d019cc9
--- /dev/null
+++ b/.github/workflows/run_mfa_spec.yml
@@ -0,0 +1,100 @@
+name: Run MFA Tests
+permissions:
+ contents: read
+
+on:
+ pull_request:
+
+# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
+concurrency:
+ group: pr-${{ github.workflow }}-${{ github.head_ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-22.04
+ # Only run if MFA test keys are available
+ if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
+
+ services:
+ postgres:
+ image: pgvector/pgvector:pg15
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ''
+ POSTGRES_DB: postgres
+ POSTGRES_HOST_AUTH_METHOD: trust
+ ports:
+ - 5432:5432
+ options: >-
+ --mount type=tmpfs,destination=/var/lib/postgresql/data
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ redis:
+ image: redis
+ ports:
+ - 6379:6379
+ options: --entrypoint redis-server
+
+ env:
+ RAILS_ENV: test
+ POSTGRES_HOST: localhost
+ # Active Record encryption keys required for MFA - test keys only, not for production use
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Create database
+ run: bundle exec rake db:create
+
+ - name: Install pgvector extension
+ run: |
+ PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
+
+ - name: Seed database
+ run: bundle exec rake db:schema:load
+
+ - name: Run MFA-related backend tests
+ run: |
+ bundle exec rspec \
+ spec/services/mfa/token_service_spec.rb \
+ spec/services/mfa/authentication_service_spec.rb \
+ spec/requests/api/v1/profile/mfa_controller_spec.rb \
+ spec/controllers/devise_overrides/sessions_controller_spec.rb \
+ spec/models/application_record_external_credentials_encryption_spec.rb \
+ --profile=10 \
+ --format documentation
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
+
+ - name: Run MFA-related tests in user_spec
+ run: |
+ # Run specific MFA-related tests from user_spec
+ bundle exec rspec spec/models/user_spec.rb \
+ -e "two factor" \
+ -e "2FA" \
+ -e "MFA" \
+ -e "otp" \
+ -e "backup code" \
+ --profile=10 \
+ --format documentation
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
+
+ - name: Upload test logs
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: mfa-test-logs
+ path: |
+ log/test.log
+ tmp/screenshots/
diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml
index c2a4bd174..7869bf89c 100644
--- a/.github/workflows/size-limit.yml
+++ b/.github/workflows/size-limit.yml
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4
with:
- node-version: 23
+ node-version: 24
cache: 'pnpm'
- name: pnpm
diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml
index 460c4ba1f..b27d90408 100644
--- a/.github/workflows/test_docker_build.yml
+++ b/.github/workflows/test_docker_build.yml
@@ -36,5 +36,5 @@ jobs:
platforms: ${{ matrix.platform }}
push: false
load: false
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ cache-from: type=gha,scope=${{ matrix.platform }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
diff --git a/.gitignore b/.gitignore
index bb0df62a8..bcc83c1ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,3 +95,9 @@ yarn-debug.log*
.claude/settings.local.json
.cursor
CLAUDE.local.md
+
+# Histoire deployment
+.netlify
+.histoire
+.pnpm-store/*
+local/
diff --git a/.nvmrc b/.nvmrc
index 6f7af3750..cf2efde81 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20.5.1
\ No newline at end of file
+24.13.0
\ No newline at end of file
diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml
index 57981a5f7..780b38374 100644
--- a/.qlty/qlty.toml
+++ b/.qlty/qlty.toml
@@ -39,7 +39,7 @@ exclude_patterns = [
"**/target/**",
"**/templates/**",
"**/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/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",
+ "**/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",
]
test_patterns = [
diff --git a/.rubocop.yml b/.rubocop.yml
index d359a087a..1957d083f 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -8,6 +8,8 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
+ - ./rubocop/attachment_download.rb
+ - ./rubocop/one_class_per_file.rb
Layout/LineLength:
Max: 150
@@ -24,7 +26,7 @@ Metrics/MethodLength:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength:
- Max: 25
+ Max: 50
Style/Documentation:
Enabled: false
@@ -41,6 +43,12 @@ Style/SymbolArray:
Style/OpenStructUse:
Enabled: false
+Chatwoot/AttachmentDownload:
+ Enabled: true
+ Exclude:
+ - 'spec/**/*'
+ - 'test/**/*'
+
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
@@ -88,7 +96,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
-
+ - enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -224,6 +232,9 @@ UseFromEmail:
CustomCopLocation:
Enabled: true
+Style/OneClassPerFile:
+ Enabled: true
+
AllCops:
NewCops: enable
Exclude:
diff --git a/AGENTS.md b/AGENTS.md
index ad374799c..474fe6e7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,9 @@
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **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
@@ -37,11 +40,20 @@
- MVP focus: Least code change, happy-path only
- 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
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- Remove dead/unreachable/unused code
- 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
+
+## Commit Messages
+
+- Prefer Conventional Commits: `type(scope): subject` (scope optional)
+- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
## Project-Specific
@@ -55,4 +67,22 @@
## Ruby Best Practices
-- Use compact `module/class` definitions; avoid nested styles
\ No newline at end of file
+- Use compact `module/class` definitions; avoid nested styles
+
+## Enterprise Edition Notes
+
+- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
+- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
+- Follow the Enterprise development practices documented here:
+ - https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
+
+Practical checklist for any change impacting core logic or public APIs
+- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
+- If adding new endpoints, services, or models, consider whether Enterprise needs:
+ - An override (e.g., `enterprise/app/...`), or
+ - An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
+- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
+- 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.
+- 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/`.
diff --git a/Gemfile b/Gemfile
index d804909e2..f0fb62413 100644
--- a/Gemfile
+++ b/Gemfile
@@ -23,6 +23,7 @@ gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
+gem 'email-provider-info'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -56,6 +57,9 @@ gem 'azure-blob', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
+##-- for actionmailbox --##
+gem 'aws-actionmailbox-ses', '~> 0'
+
##-- gems for database --#
gem 'groupdate'
gem 'pg'
@@ -64,6 +68,10 @@ gem 'redis-namespace'
# super fast record imports in bulk
gem 'activerecord-import'
+gem 'searchkick'
+gem 'opensearch-ruby'
+gem 'faraday_middleware-aws-sigv4'
+
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
@@ -73,12 +81,15 @@ gem 'vite_rails'
gem 'barnes'
##--- gems for authentication & authorization ---##
-gem 'devise'
-gem 'devise-secure_password'
-gem 'devise_token_auth'
+gem 'devise', '>= 4.9.4'
+gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
+gem 'devise_token_auth', '>= 1.2.3'
+# two-factor authentication
+gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt'
gem 'pundit'
+
# super admin
gem 'administrate'
gem 'administrate-field-active_storage'
@@ -91,14 +102,14 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---##
gem 'facebook-messenger'
gem 'line-bot-api'
-gem 'twilio-ruby', '~> 5.66'
+gem 'twilio-ruby'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
-gem 'slack-ruby-client', '~> 2.5.2'
+gem 'slack-ruby-client', '~> 2.7.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc'
@@ -110,7 +121,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
# ref application.rb
-gem 'ddtrace', require: false
+gem 'datadog', '~> 2.0', require: false
gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
@@ -153,7 +164,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
-gem 'stripe'
+gem 'stripe', '~> 18.0'
## - helper gems --##
## to populate db with sample data
@@ -169,6 +180,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
+gem 'omniauth-saml'
gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
@@ -181,11 +193,16 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents', '>= 0.4.3'
+gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
+gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
+# OpenTelemetry for LLM observability
+gem 'opentelemetry-sdk'
+gem 'opentelemetry-exporter-otlp'
+
gem 'shopify_api'
### Gems required only in specific deployment environments ###
@@ -200,8 +217,8 @@ group :production do
end
group :development do
- gem 'annotate'
- gem 'bullet', '~> 7.2.0'
+ gem 'annotaterb'
+ gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
gem 'web-console', '>= 4.2.1'
@@ -214,6 +231,8 @@ group :development do
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3'
+
+ gem 'tidewave'
end
group :test do
@@ -223,6 +242,7 @@ group :test do
gem 'webmock'
# test profiling
gem 'test-prof'
+ gem 'simplecov_json_formatter', require: false
end
group :development, :test do
@@ -248,7 +268,7 @@ group :development, :test do
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
- gem 'simplecov', '0.17.1', require: false
+ gem 'simplecov', '>= 0.21', require: false
gem 'spring'
gem 'spring-watcher-listen'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 34b69e92d..916d4a179 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,12 @@
+GIT
+ remote: https://github.com/chatwoot/devise-secure_password
+ revision: 479987594b576dbf23aed8dbd962e78342bc683c
+ branch: chatwoot
+ specs:
+ devise-secure_password (2.1.0)
+ devise (>= 4.0.0, < 5.0.0)
+ railties (>= 5.0.0, < 8.0.0)
+
GEM
remote: https://rubygems.org/
specs:
@@ -96,19 +105,23 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 8.0)
selectize-rails (~> 0.6)
- ai-agents (0.4.3)
- ruby_llm (~> 1.3)
- annotate (3.2.0)
- activerecord (>= 3.2, < 8.0)
- rake (>= 10.4, < 14.0)
+ ai-agents (0.7.0)
+ ruby_llm (~> 1.8.2)
+ annotaterb (4.20.0)
+ activerecord (>= 6.0.0)
+ activesupport (>= 6.0.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
+ aws-actionmailbox-ses (0.1.0)
+ actionmailbox (>= 7.1.0)
+ aws-sdk-s3 (~> 1, >= 1.123.0)
+ aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
- aws-sdk-core (3.171.1)
+ aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
@@ -116,10 +129,13 @@ GEM
aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.122.0)
- aws-sdk-core (~> 3, >= 3.165.0)
+ aws-sdk-s3 (1.126.0)
+ aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
+ aws-sdk-sns (1.70.0)
+ aws-sdk-core (~> 3, >= 3.188.0)
+ aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.9.1)
@@ -146,6 +162,7 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
+ cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
@@ -169,10 +186,15 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
- date (3.4.1)
- ddtrace (0.48.0)
- ffi (~> 1.0)
+ datadog (2.25.0)
+ cgi
+ datadog-ruby_core_source (~> 3.5, >= 3.5.0)
+ libdatadog (~> 24.0.1.1.0)
+ libddwaf (~> 1.30.0.0.0)
+ logger
msgpack
+ datadog-ruby_core_source (3.5.1)
+ date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -183,9 +205,11 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
- devise-secure_password (2.1.0)
- devise (>= 4.0.0, < 5.0.0)
- railties (>= 5.0.0, < 8.0.0)
+ devise-two-factor (6.3.0)
+ activesupport (>= 7.0, < 8.2)
+ devise (>= 4.0, < 5.0)
+ railties (>= 7.0, < 8.2)
+ rotp (~> 6.0)
devise_token_auth (1.2.5)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
@@ -193,7 +217,7 @@ GEM
diff-lcs (1.5.1)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
- docile (1.4.0)
+ docile (1.4.1)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (3.1.2)
@@ -204,12 +228,42 @@ GEM
addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.3.0)
+ dry-configurable (1.3.0)
+ dry-core (~> 1.1)
+ zeitwerk (~> 2.6)
+ dry-core (1.2.0)
+ concurrent-ruby (~> 1.0)
+ logger
+ zeitwerk (~> 2.6)
+ dry-inflector (1.3.1)
+ dry-initializer (3.2.0)
+ dry-logic (1.6.0)
+ bigdecimal
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.1)
+ zeitwerk (~> 2.6)
+ dry-schema (1.15.0)
+ concurrent-ruby (~> 1.0)
+ dry-configurable (~> 1.0, >= 1.0.1)
+ dry-core (~> 1.1)
+ dry-initializer (~> 3.2)
+ dry-logic (~> 1.6)
+ dry-types (~> 1.8)
+ zeitwerk (~> 2.6)
+ dry-types (1.9.0)
+ bigdecimal (>= 3.0)
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.0)
+ dry-inflector (~> 1.0)
+ dry-logic (~> 1.4)
+ zeitwerk (~> 2.6)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
elastic-apm (4.6.2)
concurrent-ruby (~> 1.0)
http (>= 3.0)
ruby2_keywords
+ email-provider-info (0.0.1)
email_reply_trimmer (0.1.13)
erb (5.0.2)
erubi (1.13.1)
@@ -229,7 +283,7 @@ GEM
i18n (>= 1.8.11, < 2)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
- faraday-mashify (0.1.1)
+ faraday-mashify (1.0.2)
faraday (~> 2.0)
hashie
faraday-multipart (1.0.4)
@@ -238,6 +292,16 @@ GEM
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
+ faraday_middleware-aws-sigv4 (1.0.1)
+ aws-sigv4 (~> 1.0)
+ faraday (>= 2.0, < 3)
+ fast-mcp (1.6.0)
+ addressable (~> 2.8)
+ base64
+ dry-schema (~> 1.14)
+ json (~> 2.0)
+ mime-types (~> 3.4)
+ rack (>= 2.0, < 4.0)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
@@ -420,6 +484,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
+ libdatadog (24.0.1.1.0)
+ libdatadog (24.0.1.1.0-x86_64-linux)
+ libddwaf (1.30.0.0.0)
+ ffi (~> 1.0)
+ libddwaf (1.30.0.0.0-arm64-darwin)
+ ffi (~> 1.0)
+ libddwaf (1.30.0.0.0-x86_64-darwin)
+ ffi (~> 1.0)
+ libddwaf (1.30.0.0.0-x86_64-linux)
+ ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -523,7 +597,32 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
+ omniauth-saml (2.2.4)
+ omniauth (~> 2.1)
+ ruby-saml (~> 1.18)
+ opensearch-ruby (3.4.0)
+ faraday (>= 1.0, < 3)
+ multi_json (>= 1.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)
os (1.1.4)
ostruct (0.6.1)
@@ -643,6 +742,7 @@ GEM
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.1)
+ rotp (6.3.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -701,13 +801,16 @@ GEM
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
+ ruby-saml (1.18.1)
+ nokogiri (>= 1.13.10)
+ rexml
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
- ruby_llm (1.5.1)
+ ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -736,6 +839,8 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
+ searchkick (6.0.3)
+ activemodel (>= 7.2)
securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
@@ -783,13 +888,14 @@ GEM
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
- simplecov (0.17.1)
+ simplecov (0.22.0)
docile (~> 1.1)
- json (>= 1.8, < 3)
- simplecov-html (~> 0.10.0)
- simplecov-html (0.10.2)
- slack-ruby-client (2.5.2)
- faraday (>= 2.0)
+ simplecov-html (~> 0.11)
+ simplecov_json_formatter (~> 0.1)
+ simplecov-html (0.13.2)
+ simplecov_json_formatter (0.1.4)
+ slack-ruby-client (2.7.0)
+ faraday (>= 2.0.1)
faraday-mashify
faraday-multipart
gli
@@ -814,10 +920,14 @@ GEM
stackprof (0.2.25)
statsd-ruby (1.5.0)
stringio (3.1.7)
- stripe (8.5.0)
+ stripe (18.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
+ tidewave (0.4.1)
+ fast-mcp (~> 1.6.0)
+ rack (>= 2.0)
+ rails (>= 7.1.0)
tilt (2.3.0)
time_diff (0.3.0)
activesupport
@@ -904,35 +1014,39 @@ DEPENDENCIES
administrate
administrate-field-active_storage
administrate-field-belongs_to_search
- ai-agents (>= 0.4.3)
- annotate
+ ai-agents (>= 0.7.0)
+ annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
+ aws-actionmailbox-ses (~> 0)
aws-sdk-s3
azure-blob
barnes
bootsnap
brakeman
browser
- bullet (~> 7.2.0)
+ bullet
bundle-audit
byebug
climate_control
commonmarker (~> 0.23.11)
csv-safe
database_cleaner
- ddtrace
+ datadog (~> 2.0)
debug (~> 1.8)
- devise
- devise-secure_password
- devise_token_auth
+ devise (>= 4.9.4)
+ devise-secure_password!
+ devise-two-factor (>= 5.0.0)
+ devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
down
elastic-apm
+ email-provider-info
email_reply_trimmer
facebook-messenger
factory_bot_rails (>= 6.4.3)
faker
+ faraday_middleware-aws-sigv4
fcm
flag_shih_tzu
foreman
@@ -973,6 +1087,10 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
+ omniauth-saml
+ opensearch-ruby
+ opentelemetry-exporter-otlp
+ opentelemetry-sdk
pg
pg_search
pgvector
@@ -999,9 +1117,11 @@ DEPENDENCIES
rubocop-rspec
rubocop-rspec_rails
ruby-openai
+ ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
+ searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
@@ -1011,17 +1131,19 @@ DEPENDENCIES
sidekiq (>= 7.3.1)
sidekiq-cron (>= 2.3.1)
sidekiq_alive
- simplecov (= 0.17.1)
- slack-ruby-client (~> 2.5.2)
+ simplecov (>= 0.21)
+ simplecov_json_formatter
+ slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen
squasher
stackprof
- stripe
+ stripe (~> 18.0)
telephone_number
test-prof
+ tidewave
time_diff
- twilio-ruby (~> 5.66)
+ twilio-ruby
twitty (~> 0.1.5)
tzinfo-data
uglifier
diff --git a/README.md b/README.md
index 21316b422..d8b8ae7a2 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,6 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
-
@@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
-*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
+*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
diff --git a/Rakefile b/Rakefile
index e85f91391..2e996417e 100644
--- a/Rakefile
+++ b/Rakefile
@@ -2,5 +2,8 @@
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative 'config/application'
+# Load Enterprise Edition rake tasks if they exist
+enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
+require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
Rails.application.load_tasks
diff --git a/VERSION_CW b/VERSION_CW
index fdc669880..2da431623 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.4.0
+4.10.0
diff --git a/VERSION_CWCTL b/VERSION_CWCTL
index 4d9d11cf5..1545d9665 100644
--- a/VERSION_CWCTL
+++ b/VERSION_CWCTL
@@ -1 +1 @@
-3.4.2
+3.5.0
diff --git a/app.json b/app.json
index 08e725c8e..91fb0fbd5 100644
--- a/app.json
+++ b/app.json
@@ -36,6 +36,10 @@
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
+ },
+ "NODE_OPTIONS": {
+ "description": "Increase V8 heap for Vite build to avoid OOM",
+ "value": "--max-old-space-size=4096"
}
},
"formation": {
diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb
index 54f478920..2fe11cae0 100644
--- a/app/builders/agent_builder.rb
+++ b/app/builders/agent_builder.rb
@@ -52,3 +52,5 @@ class AgentBuilder
}.compact))
end
end
+
+AgentBuilder.prepend_mod_with('AgentBuilder')
diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb
index 788ae39d1..40e571f43 100644
--- a/app/builders/contact_inbox_builder.rb
+++ b/app/builders/contact_inbox_builder.rb
@@ -103,3 +103,5 @@ class ContactInboxBuilder
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
+
+ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb
new file mode 100644
index 000000000..731b1b0f5
--- /dev/null
+++ b/app/builders/email/base_builder.rb
@@ -0,0 +1,54 @@
+class Email::BaseBuilder
+ pattr_initialize [:inbox!]
+
+ private
+
+ def channel
+ @channel ||= inbox.channel
+ end
+
+ def account
+ @account ||= inbox.account
+ end
+
+ def conversation
+ @conversation ||= message.conversation
+ end
+
+ def custom_sender_name
+ message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications')
+ end
+
+ def sender_name(sender_email)
+ # Friendly: from
+ # Professional:
+ if inbox.friendly?
+ I18n.t(
+ 'conversations.reply.email.header.friendly_name',
+ sender_name: custom_sender_name,
+ business_name: business_name,
+ from_email: sender_email
+ )
+ else
+ I18n.t(
+ 'conversations.reply.email.header.professional_name',
+ business_name: business_name,
+ from_email: sender_email
+ )
+ end
+ end
+
+ def business_name
+ inbox.business_name || inbox.sanitized_name
+ end
+
+ def account_support_email
+ # Parse the email to ensure it's in the correct format, the user
+ # can save it in the format "Name "
+ parse_email(account.support_email)
+ end
+
+ def parse_email(email_string)
+ Mail::Address.new(email_string).address
+ end
+end
diff --git a/app/builders/email/from_builder.rb b/app/builders/email/from_builder.rb
new file mode 100644
index 000000000..fff33dc0a
--- /dev/null
+++ b/app/builders/email/from_builder.rb
@@ -0,0 +1,51 @@
+class Email::FromBuilder < Email::BaseBuilder
+ pattr_initialize [:inbox!, :message!]
+
+ def build
+ return sender_name(account_support_email) unless inbox.email?
+
+ from_email = case email_channel_type
+ when :standard_imap_smtp,
+ :google_oauth,
+ :microsoft_oauth,
+ :forwarding_own_smtp
+ channel.email
+ when :imap_chatwoot_smtp,
+ :forwarding_chatwoot_smtp
+ channel.verified_for_sending ? channel.email : account_support_email
+ else
+ account_support_email
+ end
+
+ sender_name(from_email)
+ end
+
+ private
+
+ def email_channel_type
+ return :google_oauth if channel.google?
+ return :microsoft_oauth if channel.microsoft?
+ return :standard_imap_smtp if imap_and_smtp_enabled?
+ return :imap_chatwoot_smtp if imap_enabled_without_smtp?
+ return :forwarding_own_smtp if forwarding_with_own_smtp?
+ return :forwarding_chatwoot_smtp if forwarding_without_smtp?
+
+ :unknown
+ end
+
+ def imap_and_smtp_enabled?
+ channel.imap_enabled && channel.smtp_enabled
+ end
+
+ def imap_enabled_without_smtp?
+ channel.imap_enabled && !channel.smtp_enabled
+ end
+
+ def forwarding_with_own_smtp?
+ !channel.imap_enabled && channel.smtp_enabled
+ end
+
+ def forwarding_without_smtp?
+ !channel.imap_enabled && !channel.smtp_enabled
+ end
+end
diff --git a/app/builders/email/reply_to_builder.rb b/app/builders/email/reply_to_builder.rb
new file mode 100644
index 000000000..d330c922a
--- /dev/null
+++ b/app/builders/email/reply_to_builder.rb
@@ -0,0 +1,21 @@
+class Email::ReplyToBuilder < Email::BaseBuilder
+ pattr_initialize [:inbox!, :message!]
+
+ def build
+ reply_to = if inbox.email?
+ channel.email
+ elsif inbound_email_enabled?
+ "reply+#{conversation.uuid}@#{account.inbound_email_domain}"
+ else
+ account_support_email
+ end
+
+ sender_name(reply_to)
+ end
+
+ private
+
+ def inbound_email_enabled?
+ account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present?
+ end
+end
diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb
index e1087b19f..7df72e14a 100644
--- a/app/builders/messages/message_builder.rb
+++ b/app/builders/messages/message_builder.rb
@@ -1,5 +1,8 @@
class Messages::MessageBuilder
include ::FileTypeHelper
+ include ::EmailHelper
+ include ::DataHelper
+
attr_reader :message
def initialize(user, conversation, params)
@@ -7,6 +10,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
+ @account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
@@ -20,6 +24,9 @@ class Messages::MessageBuilder
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
+ # When the message has no quoted content, it will just be rendered as a regular message
+ # The frontend is equipped to handle this case
+ process_email_content
@message.save!
@message
end
@@ -34,30 +41,12 @@ class Messages::MessageBuilder
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
- return parse_json(content_attributes) if content_attributes.is_a?(String)
+ return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
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
return if @attachments.blank?
@@ -92,18 +81,20 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails
end
+ def process_email_content
+ return unless should_process_email_content?
+
+ @message.content_attributes ||= {}
+ email_attributes = build_email_attributes
+ @message.content_attributes[:email] = email_attributes
+ end
+
def process_email_string(email_string)
return [] if email_string.blank?
email_string.gsub(/\s+/, '').split(',')
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
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -147,10 +138,90 @@ class Messages::MessageBuilder
private: @private,
sender: sender,
content_type: @params[:content_type],
+ content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
+
+ def email_inbox?
+ @conversation.inbox&.inbox_type == 'Email'
+ end
+
+ def should_process_email_content?
+ email_inbox? && !@private && @message.content.present?
+ end
+
+ def build_email_attributes
+ email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
+ 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
+ email_attributes[:html_content] = if custom_email_content_provided?
+ build_custom_html_content
+ else
+ build_html_content(processed_content)
+ end
+
+ email_attributes[:text_content] = build_text_content(processed_content)
+ email_attributes
+ end
+
+ def build_html_content(normalized_content)
+ html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
+ rendered_html = render_email_html(normalized_content)
+ html_content[:full] = rendered_html
+ html_content[:reply] = rendered_html
+ html_content
+ end
+
+ def build_text_content(normalized_content)
+ text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
+ text_content[:full] = normalized_content
+ text_content[:reply] = normalized_content
+ text_content
+ end
+
+ def custom_email_content_provided?
+ @params[:email_html_content].present?
+ end
+
+ def build_custom_html_content
+ html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
+
+ html_content[:full] = @params[:email_html_content]
+ html_content[:reply] = @params[:email_html_content]
+
+ html_content
+ 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
+
+Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb
index 4e7f2849d..8821da9d5 100644
--- a/app/builders/messages/messenger/message_builder.rb
+++ b/app/builders/messages/messenger/message_builder.rb
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
attachment_obj.save!
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_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)
end
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
- if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
+ if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
end
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: attachment['payload']['url'],
- remote_file_url: attachment['payload']['url']
+ external_url: url,
+ remote_file_url: url
}
end
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
message.save!
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
def get_story_object_from_source_id(_source_id)
{}
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
private
def unsupported_file_type?(attachment_type)
- [:template, :unsupported_type].include? attachment_type.to_sym
+ [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
end
diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb
index 4de65926d..d4a9e7c0b 100644
--- a/app/builders/v2/reports/base_summary_builder.rb
+++ b/app/builders/v2/reports/base_summary_builder.rb
@@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
- @resolved_count = fetch_resolved_count
- @avg_resolution_time = fetch_average_time('conversation_resolved')
- @avg_first_response_time = fetch_average_time('first_response')
- @avg_reply_time = fetch_average_time('reply_time')
+ load_reporting_events_data
+ end
+
+ def load_reporting_events_data
+ # Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
+ index_key = group_by_key.to_s.split('.').last
+
+ results = reporting_events
+ .select(
+ "#{group_by_key} as #{index_key}",
+ "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
+ "AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
+ "AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
+ "AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
+ )
+ .group(group_by_key)
+ .index_by { |record| record.public_send(index_key) }
+
+ @resolved_count = results.transform_values(&:resolved_count)
+ @avg_resolution_time = results.transform_values(&:avg_resolution_time)
+ @avg_first_response_time = results.transform_values(&:avg_first_response_time)
+ @avg_reply_time = results.transform_values(&:avg_reply_time)
end
def reporting_events
@@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
- def fetch_average_time(event_name)
- get_grouped_average(reporting_events.where(name: event_name))
- end
-
- def fetch_resolved_count
- reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
- end
-
def group_by_key
# Override this method
end
@@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
- def get_grouped_average(events)
- events.group(group_by_key).average(average_value_key)
- end
-
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
diff --git a/app/builders/v2/reports/channel_summary_builder.rb b/app/builders/v2/reports/channel_summary_builder.rb
new file mode 100644
index 000000000..2df8fc081
--- /dev/null
+++ b/app/builders/v2/reports/channel_summary_builder.rb
@@ -0,0 +1,38 @@
+class V2::Reports::ChannelSummaryBuilder
+ include DateRangeHelper
+
+ pattr_initialize [:account!, :params!]
+
+ def build
+ conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
+ end
+
+ private
+
+ def conversations_by_channel_and_status
+ account.conversations
+ .joins(:inbox)
+ .where(created_at: range)
+ .group('inboxes.channel_type', 'conversations.status')
+ .count
+ .each_with_object({}) do |((channel_type, status), count), grouped|
+ grouped[channel_type] ||= {}
+ grouped[channel_type][status] = count
+ end
+ end
+
+ def build_channel_stats(status_counts)
+ open_count = status_counts['open'] || 0
+ resolved_count = status_counts['resolved'] || 0
+ pending_count = status_counts['pending'] || 0
+ snoozed_count = status_counts['snoozed'] || 0
+
+ {
+ open: open_count,
+ resolved: resolved_count,
+ pending: pending_count,
+ snoozed: snoozed_count,
+ total: open_count + resolved_count + pending_count + snoozed_count
+ }
+ end
+end
diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb
index e27385856..935afeb82 100644
--- a/app/builders/v2/reports/inbox_summary_builder.rb
+++ b/app/builders/v2/reports/inbox_summary_builder.rb
@@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
- @resolved_count = fetch_resolved_count
- @avg_resolution_time = fetch_average_time('conversation_resolved')
- @avg_first_response_time = fetch_average_time('first_response')
- @avg_reply_time = fetch_average_time('reply_time')
+ load_reporting_events_data
end
def fetch_conversations_count
diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb
index caa5a04d8..8b7c21e8e 100644
--- a/app/builders/v2/reports/label_summary_builder.rb
+++ b/app/builders/v2/reports/label_summary_builder.rb
@@ -28,7 +28,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
{
conversation_counts: fetch_conversation_counts(conversation_filter),
- resolved_counts: fetch_resolved_counts(conversation_filter),
+ resolved_counts: fetch_resolved_counts,
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
@@ -62,10 +62,21 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
fetch_counts(conversation_filter)
end
- def fetch_resolved_counts(conversation_filter)
- # since the base query is ActsAsTaggableOn,
- # the status :resolved won't automatically be converted to integer status
- fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
+ def fetch_resolved_counts
+ # Count resolution events, not conversations currently in resolved status
+ # Filter by reporting_event.created_at, not conversation.created_at
+ reporting_event_filter = { name: 'conversation_resolved', account_id: account.id }
+ reporting_event_filter[:created_at] = range if range.present?
+
+ ReportingEvent
+ .joins(conversation: { taggings: :tag })
+ .where(
+ reporting_event_filter.merge(
+ taggings: { taggable_type: 'Conversation', context: 'labels' }
+ )
+ )
+ .group('tags.name')
+ .count
end
def fetch_counts(conversation_filter)
@@ -84,9 +95,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
- .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
- .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
- .joins('INNER JOIN tags ON taggings.tag_id = tags.id')
+ .joins(conversation: { taggings: :tag })
.where(
conversations: conversation_filter,
name: event_name,
diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb
index 03a87a6fa..bb3b1250c 100644
--- a/app/builders/v2/reports/timeseries/count_report_builder.rb
+++ b/app/builders/v2/reports/timeseries/count_report_builder.rb
@@ -38,27 +38,34 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
end
def scope_for_resolutions_count
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(
+ scope.reporting_events.where(
name: :conversation_resolved,
- conversations: { status: :resolved }, created_at: range
- ).distinct
+ account_id: account.id,
+ created_at: range
+ )
end
def scope_for_bot_resolutions_count
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(
+ scope.reporting_events.where(
name: :conversation_bot_resolved,
- conversations: { status: :resolved }, created_at: range
- ).distinct
+ account_id: account.id,
+ created_at: range
+ )
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
+ account_id: account.id,
created_at: range
).distinct
end
def grouped_count
+ # IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
+ # It converts timestamps to the target timezone before grouping, which means
+ # the same event can fall into different day buckets depending on timezone
+ # Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
diff --git a/app/builders/year_in_review_builder.rb b/app/builders/year_in_review_builder.rb
new file mode 100644
index 000000000..545fe8029
--- /dev/null
+++ b/app/builders/year_in_review_builder.rb
@@ -0,0 +1,74 @@
+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
diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb
index 64c35d33d..c2f919659 100644
--- a/app/controllers/api/v1/accounts/agent_bots_controller.rb
+++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :agent_bot, except: [:index, :create]
def index
- @agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
+ @agent_bots = AgentBot.accessible_to(Current.account)
end
def show; end
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
private
def agent_bot
- @agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
+ @agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id])
end
diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb
index da2be2312..8a6fd61f8 100644
--- a/app/controllers/api/v1/accounts/articles_controller.rb
+++ b/app/controllers/api/v1/accounts/articles_controller.rb
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end
def create
- @article = @portal.articles.create!(article_params)
+ params_with_defaults = article_params
+ params_with_defaults[:status] ||= :draft
+ @article = @portal.articles.create!(params_with_defaults)
@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?
end
diff --git a/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb
new file mode 100644
index 000000000..ac1d0a712
--- /dev/null
+++ b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb
@@ -0,0 +1,20 @@
+class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
+ before_action :fetch_assignment_policy
+ before_action -> { check_authorization(AssignmentPolicy) }
+
+ def index
+ @inboxes = @assignment_policy.inboxes
+ end
+
+ private
+
+ def fetch_assignment_policy
+ @assignment_policy = Current.account.assignment_policies.find(
+ params[:assignment_policy_id]
+ )
+ end
+
+ def permitted_params
+ params.permit(:assignment_policy_id)
+ end
+end
diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb
new file mode 100644
index 000000000..1807d6afb
--- /dev/null
+++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb
@@ -0,0 +1,36 @@
+class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
+ before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
+ before_action :check_authorization
+
+ def index
+ @assignment_policies = Current.account.assignment_policies
+ end
+
+ def show; end
+
+ def create
+ @assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
+ end
+
+ def update
+ @assignment_policy.update!(assignment_policy_params)
+ end
+
+ def destroy
+ @assignment_policy.destroy!
+ head :ok
+ end
+
+ private
+
+ def fetch_assignment_policy
+ @assignment_policy = Current.account.assignment_policies.find(params[:id])
+ end
+
+ def assignment_policy_params
+ params.require(:assignment_policy).permit(
+ :name, :description, :assignment_order, :conversation_priority,
+ :fair_distribution_limit, :fair_distribution_window, :enabled
+ )
+ end
+end
diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb
index 3d894808d..0840d0eea 100644
--- a/app/controllers/api/v1/accounts/automation_rules_controller.rb
+++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb
@@ -1,4 +1,6 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
+ include AttachmentConcern
+
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end
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.actions = params[:actions]
+ @automation_rule.actions = actions
@automation_rule.conditions = params[:conditions]
- render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
+ return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
@automation_rule.save!
- process_attachments
- @automation_rule
+ blobs.each { |blob| @automation_rule.files.attach(blob) }
end
def update
- ActiveRecord::Base.transaction do
- automation_rule_update
- process_attachments
+ blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
+ return render_could_not_create_error(error) if error
+ ActiveRecord::Base.transaction do
+ @automation_rule.assign_attributes(automation_rules_permit)
+ @automation_rule.actions = actions if params[:actions]
+ @automation_rule.conditions = params[:conditions] if params[:conditions]
+ @automation_rule.save!
+ blobs.each { |blob| @automation_rule.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
- render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
+ render_could_not_create_error(@automation_rule.errors.messages)
end
end
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
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
- 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
params.permit(
- :name, :description, :event_name, :account_id, :active,
+ :name, :description, :event_name, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb
index 34db47861..222c66714 100644
--- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb
+++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb
@@ -1,13 +1,12 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
- before_action :type_matches?
-
def create
- if type_matches?
- ::BulkActionsJob.perform_later(
- account: @current_account,
- user: current_user,
- params: permitted_params
- )
+ case normalized_type
+ when 'Conversation'
+ enqueue_conversation_job
+ head :ok
+ when 'Contact'
+ check_authorization_for_contact_action
+ enqueue_contact_job
head :ok
else
render json: { success: false }, status: :unprocessable_entity
@@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private
- def type_matches?
- ['Conversation'].include?(params[:type])
+ def normalized_type
+ params[:type].to_s.camelize
end
- def permitted_params
- params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
+ def enqueue_conversation_job
+ ::BulkActionsJob.perform_later(
+ 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
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
new file mode 100644
index 000000000..156c031fa
--- /dev/null
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -0,0 +1,76 @@
+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
diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
index 58ec3bfca..f3b14d49f 100644
--- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
+++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
- :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
+ :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
)
end
end
diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb
index 039786905..e6270c807 100644
--- a/app/controllers/api/v1/accounts/contacts_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts_controller.rb
@@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
- @contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts)
+ @contacts_count = @contacts.total_count
end
def search
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%"
)
- @contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
+ @contacts_count = @contacts.total_count
end
def import
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
- @contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
+ @contacts_count = @contacts.total_count
end
def show; end
@@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contacts(contacts)
- contacts_with_avatar = filtrate(contacts)
- .includes([{ avatar_attachment: [:blob] }])
- .page(@current_page).per(RESULTS_PER_PAGE)
+ # Build includes hash to avoid separate query when contact_inboxes are needed
+ includes_hash = { avatar_attachment: [:blob] }
+ includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
- return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
-
- contacts_with_avatar
+ filtrate(contacts)
+ .includes(includes_hash)
+ .page(@current_page)
+ .per(RESULTS_PER_PAGE)
end
def build_contact_inbox
diff --git a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb
index 1fb2095e3..49806e97c 100644
--- a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation
def create
- if params.key?(:assignee_id)
+ if params.key?(:assignee_id) || agent_bot_assignment?
set_agent
elsif params.key?(:team_id)
set_team
@@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private
def set_agent
- @agent = Current.account.users.find_by(id: params[:assignee_id])
- @conversation.assignee = @agent
- @conversation.save!
- render_agent
+ resource = Conversations::AssignmentService.new(
+ conversation: @conversation,
+ assignee_id: params[:assignee_id],
+ assignee_type: params[:assignee_type]
+ ).perform
+
+ render_agent(resource)
end
- def render_agent
- if @agent.nil?
- render json: nil
+ def render_agent(resource)
+ case resource
+ 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 partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
+ render json: nil
end
end
@@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
@conversation.update!(team: @team)
render json: @team
end
+
+ def agent_bot_assignment?
+ params[:assignee_type].to_s == 'AgentBot'
+ end
end
diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb
index 500c7772f..223530e27 100644
--- a/app/controllers/api/v1/accounts/conversations/base_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
- authorize @conversation.inbox, :show?
+ authorize @conversation, :show?
end
end
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index e27869d82..4301eaa4a 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
- authorize @conversation.inbox, :show?
+ authorize @conversation, :show?
end
def inbox
diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
index f5bed6c34..0cde5f5c1 100644
--- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
+++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
+
+Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
new file mode 100644
index 000000000..bb5dab680
--- /dev/null
+++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
@@ -0,0 +1,111 @@
+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
diff --git a/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb
new file mode 100644
index 000000000..cf52951a5
--- /dev/null
+++ b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb
@@ -0,0 +1,46 @@
+class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
+ before_action :fetch_inbox
+ before_action :fetch_assignment_policy, only: [:create]
+ before_action -> { check_authorization(AssignmentPolicy) }
+ before_action :validate_assignment_policy, only: [:show, :destroy]
+
+ def show
+ @assignment_policy = @inbox.assignment_policy
+ end
+
+ def create
+ # There should be only one assignment policy for an inbox.
+ # If there is a new request to add an assignment policy, we will
+ # delete the old one and attach the new policy
+ remove_inbox_assignment_policy
+ @inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
+ @assignment_policy = @inbox.assignment_policy
+ end
+
+ def destroy
+ remove_inbox_assignment_policy
+ head :ok
+ end
+
+ private
+
+ def remove_inbox_assignment_policy
+ @inbox.inbox_assignment_policy&.destroy
+ end
+
+ def fetch_inbox
+ @inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
+ end
+
+ def fetch_assignment_policy
+ @assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
+ end
+
+ def permitted_params
+ params.permit(:assignment_policy_id, :inbox_id)
+ end
+
+ def validate_assignment_policy
+ return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
+ end
+end
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 78b4b9e2f..322c7c7fe 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
- before_action :check_authorization, except: [:show]
+ before_action :check_authorization, except: [:show, :health]
+ before_action :validate_whatsapp_cloud_channel, only: [:health]
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -70,16 +71,22 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def sync_templates
- unless @inbox.channel.is_a?(Channel::Whatsapp)
- return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
- end
+ return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
- Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
+ trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
+ def health
+ health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
+ render json: health_data
+ rescue StandardError => e
+ Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
private
def fetch_inbox
@@ -91,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
+ def validate_whatsapp_cloud_channel
+ return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
+
+ render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
+ end
+
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -139,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def format_csat_config(config)
- {
- display_type: config['display_type'] || 'emoji',
- message: config['message'] || '',
- survey_rules: {
- operator: config.dig('survey_rules', 'operator') || 'contains',
- values: config.dig('survey_rules', 'values') || []
- }
+ formatted = {
+ 'display_type' => config['display_type'] || 'emoji',
+ 'message' => config['message'] || '',
+ :survey_rules => {
+ 'operator' => config.dig('survey_rules', 'operator') || 'contains',
+ '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
def inbox_attributes
[: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,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
- { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
+ { csat_config: [:display_type, :message, :button_text, :language,
+ { survey_rules: [:operator, { values: [] }],
+ template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
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
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
def channel_type_from_params
@@ -179,10 +198,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def get_channel_attributes(channel_type)
- if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
- channel_type.constantize::EDITABLE_ATTRS.presence
- else
- []
+ channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
+ end
+
+ def whatsapp_channel?
+ @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
+ end
+
+ def trigger_template_sync
+ if @inbox.whatsapp?
+ Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
+ elsif @inbox.twilio? && @inbox.channel.whatsapp?
+ Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb
index c5f795d34..845caab5e 100644
--- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb
@@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
private
def authorize_request
- authorize @conversation.inbox, :show?
+ authorize @conversation, :show?
end
def render_response(response)
diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb
index 5dcdd2023..c4e0cd6dd 100644
--- a/app/controllers/api/v1/accounts/macros_controller.rb
+++ b/app/controllers/api/v1/accounts/macros_controller.rb
@@ -1,4 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
+ include AttachmentConcern
+
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
end
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.set_visibility(current_user, permitted_params)
- @macro.actions = params[:actions]
+ @macro.actions = actions
- render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
+ return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
@macro.save!
- process_attachments
- @macro
+ blobs.each { |blob| @macro.files.attach(blob) }
end
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
- @macro.update!(macros_with_user)
+ @macro.assign_attributes(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
- process_attachments
+ @macro.actions = actions if params[:actions]
@macro.save!
+ blobs.each { |blob| @macro.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
- render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
+ render_could_not_create_error(@macro.errors.messages)
end
end
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
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
params.permit(
- :name, :account_id, :visibility,
+ :name, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index af96441f8..8eb24b757 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def process_attached_logo
blob_id = params[:blob_id]
- blob = ActiveStorage::Blob.find_by(id: blob_id)
+ blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end
@@ -78,14 +78,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
- :id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
+ :id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
- return {} if permitted_params[:inbox_id].blank?
+ return {} unless permitted_params.key?(:inbox_id)
+ return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb
index 13e3a6a6c..7ee25e02e 100644
--- a/app/controllers/api/v1/accounts/search_controller.rb
+++ b/app/controllers/api/v1/accounts/search_controller.rb
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
+ rescue ArgumentError => e
+ render json: { error: e.message }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb
new file mode 100644
index 000000000..7c7320393
--- /dev/null
+++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb
@@ -0,0 +1,15 @@
+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
diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb
index 6530279da..479d8ae1b 100644
--- a/app/controllers/api/v1/accounts/upload_controller.rb
+++ b/app/controllers/api/v1/accounts/upload_controller.rb
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def render_success(file_blob)
- render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
+ render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
end
def render_error(message, status)
diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb
index 7ea257ed2..9f8e94821 100644
--- a/app/controllers/api/v1/accounts/webhooks_controller.rb
+++ b/app/controllers/api/v1/accounts/webhooks_controller.rb
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def webhook_params
- params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
+ params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
end
def fetch_webhook
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index 3e7d876c3..d52f396fc 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
- before_action :validate_feature_enabled!
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -65,15 +64,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
- def validate_feature_enabled!
- return if Current.account.feature_whatsapp_embedded_signup?
-
- render json: {
- success: false,
- error: 'WhatsApp embedded signup is not enabled for this account'
- }, status: :forbidden
- end
-
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 773126755..57062a5b2 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
- params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
+ params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
+ conversation_required_attributes: [])
end
def check_signup_enabled
diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb
new file mode 100644
index 000000000..dd874f222
--- /dev/null
+++ b/app/controllers/api/v1/profile/mfa_controller.rb
@@ -0,0 +1,68 @@
+class Api::V1::Profile::MfaController < Api::BaseController
+ before_action :check_mfa_feature_available
+ before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
+ before_action :check_mfa_disabled, only: [:create, :verify]
+ before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
+ before_action :validate_password, only: [:destroy]
+
+ def show; end
+
+ def create
+ mfa_service.enable_two_factor!
+ end
+
+ def verify
+ @backup_codes = mfa_service.verify_and_activate!
+ end
+
+ def destroy
+ mfa_service.disable_two_factor!
+ end
+
+ def backup_codes
+ @backup_codes = mfa_service.generate_backup_codes!
+ end
+
+ private
+
+ def mfa_service
+ @mfa_service ||= Mfa::ManagementService.new(user: current_user)
+ end
+
+ def check_mfa_enabled
+ render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
+ end
+
+ def check_mfa_feature_available
+ return if Chatwoot.mfa_enabled?
+
+ render json: {
+ error: I18n.t('errors.mfa.feature_unavailable')
+ }, status: :forbidden
+ end
+
+ def check_mfa_disabled
+ render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
+ end
+
+ def validate_otp
+ authenticated = Mfa::AuthenticationService.new(
+ user: current_user,
+ otp_code: mfa_params[:otp_code]
+ ).authenticate
+
+ return if authenticated
+
+ render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
+ end
+
+ def validate_password
+ return if current_user.valid_password?(mfa_params[:password])
+
+ render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
+ end
+
+ def mfa_params
+ params.permit(:otp_code, :password)
+ end
+end
diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb
index ac88c595a..458d0486c 100644
--- a/app/controllers/api/v1/widget/configs_controller.rb
+++ b/app/controllers/api/v1/widget/configs_controller.rb
@@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
+ @global_config = GlobalConfig.get(
+ 'LOGO_THUMBNAIL',
+ 'BRAND_NAME',
+ 'WIDGET_BRAND_URL',
+ 'MAXIMUM_FILE_UPLOAD_SIZE',
+ 'INSTALLATION_NAME'
+ )
end
def set_contact
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index 6e2d0ff4c..714aeb0c9 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
+ def conversations_summary
+ @report_data = generate_conversations_report
+ generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
+ end
+
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb
index f31a53c7e..98b3f05d7 100644
--- a/app/controllers/api/v2/accounts/summary_reports_controller.rb
+++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
- before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
+ before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
+ def channel
+ return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
+
+ render_report_with(V2::Reports::ChannelSummaryBuilder)
+ end
+
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
+
+ def date_range_too_long?
+ return false if permitted_params[:since].blank? || permitted_params[:until].blank?
+
+ since_time = Time.zone.at(permitted_params[:since].to_i)
+ until_time = Time.zone.at(permitted_params[:until].to_i)
+ (until_time - since_time) > 6.months
+ end
end
diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb
new file mode 100644
index 000000000..7946614bb
--- /dev/null
+++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb
@@ -0,0 +1,26 @@
+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
diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb
index 9b0f9021f..338b290da 100644
--- a/app/controllers/concerns/access_token_auth_helper.rb
+++ b/app/controllers/concerns/access_token_auth_helper.rb
@@ -14,6 +14,7 @@ module AccessTokenAuthHelper
ensure_access_token
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
+ # NOTE: This ensures that current_user is set and available for the rest of the controller actions
@resource = @access_token.owner
Current.user = @resource if allowed_current_user_type?(@resource)
end
diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb
new file mode 100644
index 000000000..2652f04be
--- /dev/null
+++ b/app/controllers/concerns/attachment_concern.rb
@@ -0,0 +1,35 @@
+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
diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb
index 3baf9ee1e..ea36a48f2 100644
--- a/app/controllers/concerns/ensure_current_account_helper.rb
+++ b/app/controllers/concerns/ensure_current_account_helper.rb
@@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper
end
def account_accessible_for_bot?(account)
- render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
+ return if @resource.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
diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb
index a8ea8ae05..1221d7155 100644
--- a/app/controllers/concerns/switch_locale.rb
+++ b/app/controllers/concerns/switch_locale.rb
@@ -4,17 +4,28 @@ module SwitchLocale
private
def switch_locale(&)
- # priority is for locale set in query string (mostly for widget/from js sdk)
+ # Priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= params[:locale]
+ # Use the user's locale if available
+ locale ||= locale_from_user
+
+ # Use the locale from a custom domain if applicable
locale ||= locale_from_custom_domain
+
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
+
set_locale(locale, &)
end
def switch_locale_using_account_locale(&)
- locale = locale_from_account(@current_account)
+ # Get the locale from the user first
+ locale = locale_from_user
+
+ # Fallback to the account's locale if the user's locale is not set
+ locale ||= locale_from_account(@current_account)
+
set_locale(locale, &)
end
@@ -32,6 +43,12 @@ module SwitchLocale
@portal.default_locale
end
+ def locale_from_user
+ return unless @user
+
+ @user.ui_settings&.dig('locale')
+ end
+
def set_locale(locale, &)
safe_locale = validate_and_get_locale(locale)
# Ensure locale won't bleed into other requests
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 4a2df5ee5..d57ad0e53 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -1,6 +1,31 @@
class DashboardController < ActionController::Base
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_global_config
before_action :set_dashboard_scripts
@@ -19,25 +44,7 @@ class DashboardController < ActionController::Base
end
def set_global_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)
+ @global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
end
def set_dashboard_scripts
@@ -66,15 +73,24 @@ class DashboardController < ActionController::Base
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
- FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
+ TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
+ FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
- GIT_SHA: GIT_HASH
+ GIT_SHA: GIT_HASH,
+ ALLOWED_LOGIN_METHODS: allowed_login_methods
}
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
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app'
diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
index db312e94f..900125670 100644
--- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
+++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
@@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end
+ def sign_in_user_on_mobile
+ @resource.skip_confirmation! if confirmable_enabled?
+
+ # once the resource is found and verified
+ # we can just send them to the login page again with the SSO params
+ # that will log them in
+ encoded_email = ERB::Util.url_encode(@resource.email)
+ params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query
+
+ mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
+ redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true
+ end
+
def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
@@ -47,10 +60,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
- # find the user with their email instead of UID and token
- @resource = resource_class.where(
- email: auth_hash['info']['email']
- ).first
+ email = auth_hash.dig('info', 'email')
+ @resource = resource_class.from_email(email)
end
def validate_signup_email_is_business_domain?
@@ -75,3 +86,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
'user'
end
end
+
+DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb
index 17dd32086..00976c3cd 100644
--- a/app/controllers/devise_overrides/passwords_controller.rb
+++ b/app/controllers/devise_overrides/passwords_controller.rb
@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
}, status: status
end
end
+
+DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb
index fc7b12767..974fb05e4 100644
--- a/app/controllers/devise_overrides/sessions_controller.rb
+++ b/app/controllers/devise_overrides/sessions_controller.rb
@@ -9,14 +9,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def create
- # Authenticate user via the temporary sso auth token
- if params[:sso_auth_token].present? && @resource.present?
- authenticate_resource_with_sso_token
- yield @resource if block_given?
- render_create_success
- else
- super
- end
+ return handle_mfa_verification if mfa_verification_request?
+ return handle_sso_authentication if sso_authentication_request?
+
+ user = find_user_for_authentication
+ return handle_mfa_required(user) if user&.mfa_enabled?
+
+ # Only proceed with standard authentication if no MFA is required
+ super
end
def render_create_success
@@ -25,6 +25,31 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
+ def find_user_for_authentication
+ return nil unless params[:email].present? && params[:password].present?
+
+ normalized_email = params[:email].strip.downcase
+ user = User.from_email(normalized_email)
+ return nil unless user&.valid_password?(params[:password])
+ return nil unless user.active_for_authentication?
+
+ user
+ end
+
+ def mfa_verification_request?
+ params[:mfa_token].present?
+ end
+
+ def sso_authentication_request?
+ params[:sso_auth_token].present? && @resource.present?
+ end
+
+ def handle_sso_authentication
+ authenticate_resource_with_sso_token
+ yield @resource if block_given?
+ render_create_success
+ end
+
def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -46,6 +71,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
+
+ def handle_mfa_required(user)
+ render json: {
+ mfa_required: true,
+ mfa_token: Mfa::TokenService.new(user: user).generate_token
+ }, status: :partial_content
+ end
+
+ def handle_mfa_verification
+ user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
+ return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
+
+ authenticated = Mfa::AuthenticationService.new(
+ user: user,
+ otp_code: params[:otp_code],
+ backup_code: params[:backup_code]
+ ).authenticate
+
+ return render_mfa_error('errors.mfa.invalid_code') unless authenticated
+
+ sign_in_mfa_user(user)
+ end
+
+ def sign_in_mfa_user(user)
+ @resource = user
+ @token = @resource.create_token
+ @resource.save!
+
+ sign_in(:user, @resource, store: false, bypass: false)
+ render_create_success
+ end
+
+ def render_mfa_error(message_key, status = :bad_request)
+ render json: { error: I18n.t(message_key) }, status: status
+ end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb
index 4e3b5dca9..242dcde77 100644
--- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb
+++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb
@@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
def index
- @conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
+ @conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
end
def show; end
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 66b052b1e..46158bce9 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -58,6 +58,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME')
end
end
diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index 5cf158b98..ec51305b5 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -15,14 +15,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def create
+ errors = []
params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value
- i.save!
+ errors.concat(i.errors.full_messages) unless 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
- redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
private
@@ -40,12 +46,16 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
+ 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
- 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
+ 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
}
- @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
+ @allowed_configs = mapping.fetch(
+ @config,
+ %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
+ )
end
end
diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb
index ff242030a..e98e61c95 100644
--- a/app/controllers/super_admin/users_controller.rb
+++ b/app/controllers/super_admin/users_controller.rb
@@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
redirect_to new_super_admin_user_path, notice: notice
end
end
- #
- # def update
- # super
- # send_foo_updated_email(requested_resource)
- # end
+
+ def update
+ requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present?
+ super
+ end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
diff --git a/app/controllers/survey/responses_controller.rb b/app/controllers/survey/responses_controller.rb
index 8bbd0fe88..afcb3f4c0 100644
--- a/app/controllers/survey/responses_controller.rb
+++ b/app/controllers/survey/responses_controller.rb
@@ -5,6 +5,6 @@ class Survey::ResponsesController < ActionController::Base
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
end
end
diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb
new file mode 100644
index 000000000..e484905c3
--- /dev/null
+++ b/app/controllers/tiktok/callbacks_controller.rb
@@ -0,0 +1,144 @@
+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
diff --git a/app/controllers/webhooks/tiktok_controller.rb b/app/controllers/webhooks/tiktok_controller.rb
new file mode 100644
index 000000000..efaa1830c
--- /dev/null
+++ b/app/controllers/webhooks/tiktok_controller.rb
@@ -0,0 +1,53 @@
+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
diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb
index 70e4c967b..7f45ce636 100644
--- a/app/controllers/widgets_controller.rb
+++ b/app/controllers/widgets_controller.rb
@@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED')
+ @global_config = GlobalConfig.get(
+ 'LOGO_THUMBNAIL',
+ 'BRAND_NAME',
+ 'WIDGET_BRAND_URL',
+ 'DIRECT_UPLOADS_ENABLED',
+ 'MAXIMUM_FILE_UPLOAD_SIZE',
+ 'INSTALLATION_NAME'
+ )
end
def set_web_widget
@@ -70,7 +77,12 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
- response.headers.delete('X-Frame-Options')
+ if @web_widget.allowed_domains.blank?
+ response.headers.delete('X-Frame-Options')
+ else
+ domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
+ response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
+ end
end
end
diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb
index 8abdefd1a..753b617ef 100644
--- a/app/dashboards/user_dashboard.rb
+++ b/app/dashboards/user_dashboard.rb
@@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard
SHOW_PAGE_ATTRIBUTES = %i[
id
avatar_url
- unconfirmed_email
name
type
display_name
email
+ unconfirmed_email
created_at
updated_at
confirmed_at
diff --git a/app/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb
index 41cd8e910..1b6d6f844 100644
--- a/app/finders/email_channel_finder.rb
+++ b/app/finders/email_channel_finder.rb
@@ -6,19 +6,54 @@ class EmailChannelFinder
end
def perform
- channel = nil
-
- recipient_mails.each do |email|
- normalized_email = normalize_email_with_plus_addressing(email)
- channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
-
- break if channel.present?
- end
- channel
+ channel_from_primary_recipients || channel_from_bcc_recipients
end
- def recipient_mails
- recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
- recipient_addresses.flatten.compact
+ private
+
+ def channel_from_primary_recipients
+ primary_recipient_emails.each do |email|
+ channel = channel_from_email(email)
+ return channel if channel.present?
+ end
+
+ nil
+ end
+
+ def channel_from_bcc_recipients
+ bcc_recipient_emails.each do |email|
+ channel = channel_from_email(email)
+
+ # Skip if BCC processing is disabled for this account
+ next if channel && !allow_bcc_processing?(channel.account_id)
+
+ return channel if channel.present?
+ end
+
+ nil
+ end
+
+ def primary_recipient_emails
+ (@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact
+ end
+
+ def bcc_recipient_emails
+ @email_object.bcc.to_a.flatten.compact
+ end
+
+ def channel_from_email(email)
+ normalized_email = normalize_email_with_plus_addressing(email)
+ Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
+ end
+
+ def bcc_processing_skipped_accounts
+ config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '')
+ return [] if config_value.blank?
+
+ config_value.split(',').map(&:to_i)
+ end
+
+ def allow_bcc_processing?(account_id)
+ bcc_processing_skipped_accounts.exclude?(account_id)
end
end
diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb
index 23694d08d..1f34d7e97 100644
--- a/app/helpers/api/v2/accounts/reports_helper.rb
+++ b/app/helpers/api/v2/accounts/reports_helper.rb
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
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
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
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
diff --git a/app/helpers/data_helper.rb b/app/helpers/data_helper.rb
new file mode 100644
index 000000000..66f5a9508
--- /dev/null
+++ b/app/helpers/data_helper.rb
@@ -0,0 +1,24 @@
+# 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
diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb
index 05b6a53e3..fcc8b463d 100644
--- a/app/helpers/email_helper.rb
+++ b/app/helpers/email_helper.rb
@@ -4,6 +4,19 @@ module EmailHelper
domain.split('.').first
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
# 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
@@ -21,6 +34,10 @@ module EmailHelper
end
end
+ def normalize_email_body(content)
+ content.to_s.gsub("\r\n", "\n")
+ end
+
def modified_liquid_content(email)
# This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks
@@ -29,7 +46,10 @@ module EmailHelper
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
diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb
index 3ed303556..15de0fbd7 100644
--- a/app/helpers/portal_helper.rb
+++ b/app/helpers/portal_helper.rb
@@ -1,4 +1,5 @@
module PortalHelper
+ include UrlHelper
def set_og_image_url(portal_name, title)
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
return if cdn_url.blank?
@@ -74,6 +75,17 @@ module PortalHelper
end
end
+ def generate_portal_brand_url(brand_url, referer)
+ url = URI.parse(brand_url.to_s)
+ query_params = Rack::Utils.parse_query(url.query)
+ query_params['utm_medium'] = 'helpcenter'
+ query_params['utm_campaign'] = 'branding'
+ query_params['utm_source'] = URI.parse(referer).host if url_valid?(referer)
+
+ url.query = query_params.to_query
+ url.to_s
+ end
+
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb
index 99f3fd36b..09a84b110 100644
--- a/app/helpers/report_helper.rb
+++ b/app/helpers/report_helper.rb
@@ -53,13 +53,13 @@ module ReportHelper
end
def resolutions
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
- conversations: { status: :resolved }, created_at: range).distinct
+ scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
+ created_at: range)
end
def bot_resolutions
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
- conversations: { status: :resolved }, created_at: range).distinct
+ scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
+ created_at: range)
end
def bot_handoffs
diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml
index f49004e79..34c7a8138 100644
--- a/app/helpers/super_admin/features.yml
+++ b/app/helpers/super_admin/features.yml
@@ -9,6 +9,13 @@ captain:
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
+saml:
+ name: 'SAML SSO'
+ description: 'Configuration for controlling SAML Single Sign-On availability'
+ enabled: <%= ChatwootApp.enterprise? %>
+ icon: 'icon-lock-line'
+ config_key: 'saml'
+ enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
@@ -71,6 +78,12 @@ instagram:
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
+tiktok:
+ name: 'TikTok'
+ description: 'Stay connected with your customers on TikTok'
+ enabled: true
+ icon: 'icon-tiktok'
+ config_key: 'tiktok'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb
new file mode 100644
index 000000000..b2de4a092
--- /dev/null
+++ b/app/helpers/tiktok/integration_helper.rb
@@ -0,0 +1,47 @@
+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
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index 8da7e7476..a63cb1a90 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -1,6 +1,5 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue
new file mode 100644
index 000000000..3c749e751
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+ {{ name }}
+
+
+
+
+
+
+ {{ description }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.story.vue
new file mode 100644
index 000000000..e35be8155
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.story.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue
new file mode 100644
index 000000000..1e477eafe
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
{{ title }}
+
+
+
{{ description }}
+
+
+
+ -
+
+ {{ feature.label }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue
new file mode 100644
index 000000000..cd6f1d49b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue
new file mode 100644
index 000000000..fe9965777
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+ {{ name }}
+
+
+
+
+ {{
+ enabled
+ ? t(
+ 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
+ )
+ : t(
+ 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
+ )
+ }}
+
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
+ {{
+ `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
+ }}
+ {{ order }}
+
+
+
+ {{
+ `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
+ }}
+ {{ priority }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue
new file mode 100644
index 000000000..a078b9cc7
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.name || item.title }}
+
+
+
+ {{ item.email || item.phoneNumber }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue
new file mode 100644
index 000000000..b430b3f97
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusPlaceholder }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue
new file mode 100644
index 000000000..50d7794c9
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+ {{ `#${item.id}` }}
+
+
+
+
+ {{ item.email }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue
new file mode 100644
index 000000000..aeea0cbdd
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+ {{ emptyStateMessage }}
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+
+ {{ item.email || item.phoneNumber }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
new file mode 100644
index 000000000..351e3240f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+ {{
+ t(
+ 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION'
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue
new file mode 100644
index 000000000..be5b26a7e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue
new file mode 100644
index 000000000..b31248653
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.EMPTY_STATE`) }}
+
+
+
+
+
+
+
+ {{ getInboxName(limit.inboxId) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue
new file mode 100644
index 000000000..3d0c8a8b3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ label }}
+
+
+ {{ description }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue
new file mode 100644
index 000000000..e69aa798f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue
new file mode 100644
index 000000000..a3bfe9bee
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue
new file mode 100644
index 000000000..9694a26c7
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+ console.log('Fetch triggered')"
+ />
+
+
+
+
+ console.log('Fetch triggered')"
+ />
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue
new file mode 100644
index 000000000..a81a29976
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/ExclusionRules.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/ExclusionRules.story.vue
new file mode 100644
index 000000000..7e0dbd595
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/ExclusionRules.story.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue
new file mode 100644
index 000000000..edec5fc92
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/InboxCapacityLimits.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/InboxCapacityLimits.story.vue
new file mode 100644
index 000000000..9d90112a1
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/InboxCapacityLimits.story.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Current Limits:
+
{{
+ JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
+ }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue
new file mode 100644
index 000000000..df1f8655c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CardLayout.vue b/app/javascript/dashboard/components-next/CardLayout.vue
index 462402167..166f5ea4c 100644
--- a/app/javascript/dashboard/components-next/CardLayout.vue
+++ b/app/javascript/dashboard/components-next/CardLayout.vue
@@ -19,7 +19,7 @@ const handleClick = () => {
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { formatDistanceToNow } from 'date-fns';
+
+import CardLayout from 'dashboard/components-next/CardLayout.vue';
+import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
+import Icon from 'dashboard/components-next/icon/Icon.vue';
+
+const props = defineProps({
+ id: { type: Number, required: true },
+ name: { type: String, default: '' },
+ domain: { type: String, default: '' },
+ contactsCount: { type: Number, default: 0 },
+ description: { type: String, default: '' },
+ avatarUrl: { type: String, default: '' },
+ updatedAt: { type: [String, Number], default: null },
+});
+
+const emit = defineEmits(['showCompany']);
+
+const { t } = useI18n();
+
+const onClickViewDetails = () => emit('showCompany', props.id);
+
+const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
+
+const avatarSource = computed(() => props.avatarUrl || null);
+
+const formattedUpdatedAt = computed(() => {
+ if (!props.updatedAt) return '';
+ return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
+});
+
+
+
+
+
+
+
+
+
+ {{ displayName }}
+
+
+
+ {{ domain }}
+
+
+
+
+
+
+ {{ domain }}
+
+
+ {{ description }}
+
+
+
+
+ {{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
+
+
+
+ {{ formattedUpdatedAt }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue
new file mode 100644
index 000000000..f0dc4255f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ {{ headerTitle }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue
new file mode 100644
index 000000000..8cf75d555
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.SORT_BY.LABEL') }}
+
+
+
+
+
+ {{ t('COMPANIES.ORDER.LABEL') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue
new file mode 100644
index 000000000..b69de343e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue b/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue
index af04de9a7..bc1370a0a 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue
@@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => {
}
};
-const handleRemoveLabel = labelId => {
- return handleLabelAction({ value: labelId });
+const handleRemoveLabel = label => {
+ return handleLabelAction({ value: label.id });
};
watch(
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue
index 47b779b61..041f06410 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
+import Policy from 'dashboard/components/policy.vue';
defineProps({
selectedContact: {
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
-
-
+
+
+
-
-
-
- {{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
-
-
+
+
+
+ {{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
+
+
+
-
-
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
index 0e893b767..12bed151d 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
+import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -20,9 +21,17 @@ const props = defineProps({
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
+ selectable: { type: Boolean, default: false },
+ isSelected: { type: Boolean, default: false },
});
-const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
+const emit = defineEmits([
+ 'toggle',
+ 'updateContact',
+ 'showContact',
+ 'select',
+ 'avatarHover',
+]);
const { t } = useI18n();
@@ -88,111 +97,148 @@ const onClickExpand = () => {
};
const onClickViewDetails = () => emit('showContact', props.id);
+
+const toggleSelect = checked => {
+ emit('select', checked);
+};
+
+const handleAvatarHover = isHovered => {
+ emit('avatarHover', isHovered);
+};
-
-
-
-
-
-
- {{ name }}
-
-
-
-
- {{ additionalAttributes.companyName }}
-
-
-
-
-
-
- {{ email }}
-
-
-
-
- {{ phoneNumber }}
-
-
-
+
+
+
+
-
- {{ formattedLocation }}
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
index bb4327816..fd755022d 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
@@ -1,11 +1,13 @@
@@ -67,7 +80,9 @@ const toggleBlock = () => {
>
-
+
{
:disabled="isUpdating"
@click="toggleBlock"
/>
+
{
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
index e77a0c10d..3dc29738e 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
+ TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
@@ -65,6 +66,7 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
+ tiktok: '',
linkedin: '',
twitter: '',
},
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
index 75400692e..5ac469088 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
@@ -40,7 +40,12 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
-