diff --git a/.circleci/config.yml b/.circleci/config.yml
index 65ceda04c..804c63857 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -3,6 +3,7 @@ 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:
@@ -12,10 +13,106 @@ 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/install-pnpm
+ - node/install-packages:
+ pkg-manager: pnpm
+ override-ci-command: pnpm i
+
+ # Swagger verification
+ - run:
+ name: Verify swagger API specification
+ command: |
+ bundle exec rake swagger:build
+ if [[ `git status swagger/swagger.json --porcelain` ]]
+ then
+ echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
+ exit 1
+ fi
+ mkdir -p ~/tmp
+ curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
+ java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
+
+ # Bundle audit
+ - run:
+ name: Bundle audit
+ command: bundle exec bundle audit update && bundle exec bundle audit check -v
+
+ # Rubocop linting
+ - run:
+ name: Rubocop
+ command: bundle exec rubocop --parallel
+
+ # ESLint linting
+ - run:
+ name: eslint
+ command: pnpm run eslint
+
+ # Separate job for frontend tests
+ frontend-tests:
<<: *defaults
steps:
- checkout
@@ -25,8 +122,38 @@ jobs:
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- - run: node --version
- - run: pnpm --version
+
+ - 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: '23.7'
+ - node/install-pnpm
+ - node/install-packages:
+ pkg-manager: pnpm
+ override-ci-command: pnpm i
+
- run:
name: Add PostgreSQL repository and update
command: |
@@ -91,20 +218,6 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
- # 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
-
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -127,57 +240,91 @@ jobs:
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 (with coverage)
- command: |
- mkdir -p ~/build/coverage/frontend
- pnpm run test:coverage
-
- # 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
- TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
+
+ # Use round-robin distribution (same as GitHub Actions) for better test isolation
+ # This prevents tests with similar timing from being grouped on the same runner
+ SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
+ TESTS=""
+
+ for i in "${!SPEC_FILES[@]}"; do
+ if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
+ TESTS="$TESTS ${SPEC_FILES[$i]}"
+ fi
+ done
+
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
- -- ${TESTFILES}
+ -- $TESTS
no_output_timeout: 30m
- # Qlty coverage publish
- - qlty-orb/coverage_publish:
- files: |
- coverage/coverage.json
- coverage/lcov.info
+ # 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/.env.example b/.env.example
index 3ff349ffc..d5c7a76f9 100644
--- a/.env.example
+++ b/.env.example
@@ -105,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
@@ -114,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
@@ -215,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=
diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml
index 385feddfc..011f862b0 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: 23
+ 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: 23
+ 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,7 +90,7 @@ 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:
@@ -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/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 7ca033f87..bcc83c1ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,3 +99,5 @@ CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
+.pnpm-store/*
+local/
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 ea688792b..f5b8a2c1c 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -7,6 +7,7 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
+ - ./rubocop/one_class_per_file.rb
Layout/LineLength:
Max: 150
@@ -87,7 +88,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
-
+ - 'enterprise/app/helpers/captain/tool_execution_helper.rb'
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -205,6 +206,9 @@ UseFromEmail:
CustomCopLocation:
Enabled: true
+Style/OneClassPerFile:
+ Enabled: true
+
AllCops:
NewCops: enable
Exclude:
diff --git a/AGENTS.md b/AGENTS.md
index e3b022a2e..ef1d3b26d 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,6 +40,8 @@
- 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
diff --git a/Gemfile b/Gemfile
index 8413739f2..cf3460c38 100644
--- a/Gemfile
+++ b/Gemfile
@@ -55,6 +55,9 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
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'
@@ -193,6 +196,10 @@ gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents
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 ###
diff --git a/Gemfile.lock b/Gemfile.lock
index 9d0c6286b..cd8428806 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -136,9 +136,13 @@ GEM
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)
@@ -146,10 +150,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)
barnes (0.0.9)
@@ -618,6 +625,25 @@ GEM
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)
@@ -995,6 +1021,7 @@ DEPENDENCIES
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
+ aws-actionmailbox-ses (~> 0)
aws-sdk-s3
azure-storage-blob!
barnes
@@ -1065,6 +1092,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-saml
opensearch-ruby
+ opentelemetry-exporter-otlp
+ opentelemetry-sdk
pg
pg_search
pgvector
diff --git a/VERSION_CW b/VERSION_CW
index fdc669880..88f181192 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.4.0
+4.8.0
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/messages/message_builder.rb b/app/builders/messages/message_builder.rb
index 857d901e5..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)
@@ -38,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?
@@ -110,12 +95,6 @@ class Messages::MessageBuilder
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'
@@ -159,6 +138,7 @@ 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],
@@ -178,14 +158,17 @@ class Messages::MessageBuilder
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(normalized_content)
+ build_html_content(processed_content)
end
- email_attributes[:text_content] = build_text_content(normalized_content)
+ email_attributes[:text_content] = build_text_content(processed_content)
email_attributes
end
@@ -204,22 +187,6 @@ class Messages::MessageBuilder
text_content
end
- def ensure_indifferent_access(hash)
- return {} if hash.blank?
-
- hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
- end
-
- def normalize_email_body(content)
- content.to_s.gsub("\r\n", "\n")
- end
-
- def render_email_html(content)
- return '' if content.blank?
-
- ChatwootMarkdownRenderer.new(content).render_message.to_s
- end
-
def custom_email_content_provided?
@params[:email_html_content].present?
end
@@ -232,4 +199,29 @@ class Messages::MessageBuilder
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/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/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/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/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb
index ecbddd905..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', 'INSTALLATION_NAME')
+ @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/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/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index d81b4c9da..5003c4c70 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
+ 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
@@ -71,10 +78,18 @@ class DashboardController < ActionController::Base
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/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index 5cf158b98..1a9539bb6 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
@@ -42,10 +48,13 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'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/widgets_controller.rb b/app/controllers/widgets_controller.rb
index 9a6a376f7..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', 'INSTALLATION_NAME')
+ @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
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/super_admin/features.yml b/app/helpers/super_admin/features.yml
index f49004e79..b05c603cd 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.'
diff --git a/app/javascript/dashboard/api/companies.js b/app/javascript/dashboard/api/companies.js
new file mode 100644
index 000000000..090b530c4
--- /dev/null
+++ b/app/javascript/dashboard/api/companies.js
@@ -0,0 +1,37 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+export const buildCompanyParams = (page, sort) => {
+ let params = `page=${page}`;
+ if (sort) {
+ params = `${params}&sort=${sort}`;
+ }
+ return params;
+};
+
+export const buildSearchParams = (query, page, sort) => {
+ let params = `q=${encodeURIComponent(query)}&page=${page}`;
+ if (sort) {
+ params = `${params}&sort=${sort}`;
+ }
+ return params;
+};
+
+class CompanyAPI extends ApiClient {
+ constructor() {
+ super('companies', { accountScoped: true });
+ }
+
+ get(params = {}) {
+ const { page = 1, sort = 'name' } = params;
+ const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
+ return axios.get(requestURL);
+ }
+
+ search(query = '', page = 1, sort = 'name') {
+ const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
+ return axios.get(requestURL);
+ }
+}
+
+export default new CompanyAPI();
diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js
index 025df2122..1e76ac987 100644
--- a/app/javascript/dashboard/api/contacts.js
+++ b/app/javascript/dashboard/api/contacts.js
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
+ initiateCall(contactId, inboxId) {
+ return axios.post(`${this.url}/${contactId}/call`, {
+ inbox_id: inboxId,
+ });
+ }
+
updateContactLabels(contactId, labels) {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js
index 3f12dc007..de80a3c0d 100644
--- a/app/javascript/dashboard/api/enterprise/account.js
+++ b/app/javascript/dashboard/api/enterprise/account.js
@@ -6,6 +6,7 @@ class EnterpriseAccountAPI extends ApiClient {
super('', { accountScoped: true, enterprise: true });
}
+ // V1 endpoints
checkout() {
return axios.post(`${this.url}checkout`);
}
@@ -23,6 +24,49 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
+
+ // V2 Billing endpoints
+ get v2BillingUrl() {
+ const accountId = this.accountIdFromRoute;
+ return `/enterprise/api/v2/accounts/${accountId}/billing`;
+ }
+
+ getCreditGrants() {
+ return axios.get(`${this.v2BillingUrl}/credit_grants`);
+ }
+
+ getPricingPlans() {
+ return axios.get(`${this.v2BillingUrl}/pricing_plans`);
+ }
+
+ getTopupOptions() {
+ return axios.get(`${this.v2BillingUrl}/topup_options`);
+ }
+
+ topupCredits(credits) {
+ return axios.post(`${this.v2BillingUrl}/topup`, { credits });
+ }
+
+ subscribeToPlan(pricingPlanId, quantity) {
+ return axios.post(`${this.v2BillingUrl}/subscribe`, {
+ pricing_plan_id: pricingPlanId,
+ quantity,
+ });
+ }
+
+ cancelSubscription(reason = null, feedback = null) {
+ return axios.post(`${this.v2BillingUrl}/cancel_subscription`, {
+ reason,
+ feedback,
+ });
+ }
+
+ changePricingPlan(pricingPlanId, quantity) {
+ return axios.post(`${this.v2BillingUrl}/change_pricing_plan`, {
+ pricing_plan_id: pricingPlanId,
+ quantity,
+ });
+ }
}
export default new EnterpriseAccountAPI();
diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js
index 0f539bfa9..f94fca452 100644
--- a/app/javascript/dashboard/api/inbox/conversation.js
+++ b/app/javascript/dashboard/api/inbox/conversation.js
@@ -63,10 +63,9 @@ class ConversationApi extends ApiClient {
}
assignAgent({ conversationId, agentId }) {
- return axios.post(
- `${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
- {}
- );
+ return axios.post(`${this.url}/${conversationId}/assignments`, {
+ assignee_id: agentId,
+ });
}
assignTeam({ conversationId, teamId }) {
diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js
index ad203a14c..3fcf241ee 100644
--- a/app/javascript/dashboard/api/integrations/openapi.js
+++ b/app/javascript/dashboard/api/integrations/openapi.js
@@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient {
content,
};
+ // Always include conversation_display_id when available for session tracking
+ if (conversationId) {
+ data.conversation_display_id = conversationId;
+ }
+
+ // For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
diff --git a/app/javascript/dashboard/api/specs/companies.spec.js b/app/javascript/dashboard/api/specs/companies.spec.js
new file mode 100644
index 000000000..82fdc1c97
--- /dev/null
+++ b/app/javascript/dashboard/api/specs/companies.spec.js
@@ -0,0 +1,142 @@
+import companyAPI, {
+ buildCompanyParams,
+ buildSearchParams,
+} from '../companies';
+import ApiClient from '../ApiClient';
+
+describe('#CompanyAPI', () => {
+ it('creates correct instance', () => {
+ expect(companyAPI).toBeInstanceOf(ApiClient);
+ expect(companyAPI).toHaveProperty('get');
+ expect(companyAPI).toHaveProperty('show');
+ expect(companyAPI).toHaveProperty('create');
+ expect(companyAPI).toHaveProperty('update');
+ expect(companyAPI).toHaveProperty('delete');
+ expect(companyAPI).toHaveProperty('search');
+ });
+
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const axiosMock = {
+ post: vi.fn(() => Promise.resolve()),
+ get: vi.fn(() => Promise.resolve()),
+ patch: vi.fn(() => Promise.resolve()),
+ delete: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ });
+
+ it('#get with default params', () => {
+ companyAPI.get({});
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/companies?page=1&sort=name'
+ );
+ });
+
+ it('#get with page and sort params', () => {
+ companyAPI.get({ page: 2, sort: 'domain' });
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/companies?page=2&sort=domain'
+ );
+ });
+
+ it('#get with descending sort', () => {
+ companyAPI.get({ page: 1, sort: '-created_at' });
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/companies?page=1&sort=-created_at'
+ );
+ });
+
+ it('#search with query', () => {
+ companyAPI.search('acme', 1, 'name');
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/companies/search?q=acme&page=1&sort=name'
+ );
+ });
+
+ it('#search with special characters in query', () => {
+ companyAPI.search('acme & co', 2, 'domain');
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
+ );
+ });
+
+ it('#search with descending sort', () => {
+ companyAPI.search('test', 1, '-created_at');
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/companies/search?q=test&page=1&sort=-created_at'
+ );
+ });
+
+ it('#search with empty query', () => {
+ companyAPI.search('', 1, 'name');
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/companies/search?q=&page=1&sort=name'
+ );
+ });
+ });
+});
+
+describe('#buildCompanyParams', () => {
+ it('returns correct string with page only', () => {
+ expect(buildCompanyParams(1)).toBe('page=1');
+ });
+
+ it('returns correct string with page and sort', () => {
+ expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
+ });
+
+ it('returns correct string with different page', () => {
+ expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
+ });
+
+ it('returns correct string with descending sort', () => {
+ expect(buildCompanyParams(1, '-created_at')).toBe(
+ 'page=1&sort=-created_at'
+ );
+ });
+
+ it('returns correct string without sort parameter', () => {
+ expect(buildCompanyParams(2, '')).toBe('page=2');
+ });
+});
+
+describe('#buildSearchParams', () => {
+ it('returns correct string with all parameters', () => {
+ expect(buildSearchParams('acme', 1, 'name')).toBe(
+ 'q=acme&page=1&sort=name'
+ );
+ });
+
+ it('returns correct string with special characters', () => {
+ expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
+ 'q=acme%20%26%20co&page=2&sort=domain'
+ );
+ });
+
+ it('returns correct string with empty query', () => {
+ expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
+ });
+
+ it('returns correct string without sort parameter', () => {
+ expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
+ });
+
+ it('returns correct string with descending sort', () => {
+ expect(buildSearchParams('company', 3, '-created_at')).toBe(
+ 'q=company&page=3&sort=-created_at'
+ );
+ });
+
+ it('encodes special characters correctly', () => {
+ expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
+ 'q=test%40example.com&page=1&sort=name'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js
index dd1615802..de0d7a7d0 100644
--- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js
+++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js
@@ -92,8 +92,10 @@ describe('#ConversationAPI', () => {
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
- `/api/v1/conversations/12/assignments?assignee_id=34`,
- {}
+ `/api/v1/conversations/12/assignments`,
+ {
+ assignee_id: 34,
+ }
);
});
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
new file mode 100644
index 000000000..4603521eb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
@@ -0,0 +1,95 @@
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANT_SWITCHER.EMPTY_LIST') }} +
++ {{ section.title }} +
+ + +