Compare commits

..
Author SHA1 Message Date
google-labs-jules[bot] 99ef76562b Allow agents to export contacts
This change modifies the ContactPolicy to allow users with the agent role to export contacts. Previously, only administrators could perform this action.

Includes tests to verify that both agents and administrators can export contacts.
2025-06-09 16:47:58 +00:00
google-labs-jules[bot] 3b29214dfc Allow agents to export contacts
This change modifies the ContactPolicy to allow users with the agent role to export contacts. Previously, only administrators could perform this action.

Includes tests to verify that both agents and administrators can export contacts.
2025-06-09 16:40:30 +00:00
2870 changed files with 22552 additions and 145073 deletions
+22 -14
View File
@@ -1,7 +1,6 @@
version: 2.1 version: 2.1
orbs: orbs:
node: circleci/node@6.1.0 node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
defaults: &defaults defaults: &defaults
working_directory: ~/build working_directory: ~/build
@@ -90,6 +89,14 @@ jobs:
command: | command: |
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
bundle install bundle install
# pnpm install
- run:
name: Download cc-test-reporter
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
# Swagger verification # Swagger verification
- run: - run:
@@ -101,11 +108,10 @@ jobs:
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." 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 exit 1
fi 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 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 java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# Configure environment and database # we remove the FRONTED_URL from the .env before running the tests
- run: - run:
name: Database Setup and Configure Environment Variables name: Database Setup and Configure Environment Variables
command: | command: |
@@ -143,11 +149,17 @@ jobs:
command: pnpm run eslint command: pnpm run eslint
- run: - run:
name: Run frontend tests (with coverage) name: Run frontend tests
command: | command: |
mkdir -p ~/build/coverage/frontend mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
pnpm run test:coverage 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
- run: - run:
name: Run backend tests name: Run backend tests
@@ -155,18 +167,18 @@ jobs:
mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend mkdir -p ~/build/coverage/backend
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ bundle exec rspec --format progress \
--format RspecJunitFormatter \ --format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \ --out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES} -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
# Qlty coverage publish - run:
- qlty-orb/coverage_publish: name: Code Climate Test Coverage (Backend)
files: | command: |
coverage/coverage.json ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
coverage/lcov.info
- run: - run:
name: List coverage directory contents name: List coverage directory contents
@@ -177,7 +189,3 @@ jobs:
root: ~/build root: ~/build
paths: paths:
- coverage - coverage
- store_artifacts:
path: coverage
destination: coverage
+62
View File
@@ -0,0 +1,62 @@
version: '2'
plugins:
rubocop:
enabled: false
channel: rubocop-0-73
eslint:
enabled: false
csslint:
enabled: true
scss-lint:
enabled: true
brakeman:
enabled: false
checks:
similar-code:
enabled: false
method-count:
enabled: true
config:
threshold: 32
file-lines:
enabled: true
config:
threshold: 300
method-lines:
config:
threshold: 50
exclude_patterns:
- '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'
-7
View File
@@ -6,13 +6,6 @@
# Use `rake secret` to generate this variable # Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex 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 # Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000 FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages # To use a dedicated URL for help center pages
-1
View File
@@ -103,7 +103,6 @@ module.exports = {
'⌘', '⌘',
'📄', '📄',
'🎉', '🎉',
'🚀',
'💬', '💬',
'👥', '👥',
'📥', '📥',
-28
View File
@@ -1,28 +0,0 @@
name: Auto-assign PR to Author
on:
pull_request:
types: [opened]
jobs:
auto-assign:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Auto-assign PR to author
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
await github.rest.issues.addAssignees({
owner,
repo,
issue_number: pull_number,
assignees: [author]
});
console.log(`Assigned PR #${pull_number} to ${author}`);
-5
View File
@@ -6,11 +6,6 @@ name: Deploy Check
on: on:
pull_request: 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: jobs:
deployment_check: deployment_check:
name: Check Deployment name: Check Deployment
@@ -5,11 +5,6 @@ on:
branches: branches:
- develop - develop
# 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: jobs:
log_lines_check: log_lines_check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-99
View File
@@ -1,99 +0,0 @@
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 \
--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/
-5
View File
@@ -5,11 +5,6 @@ on:
branches: branches:
- develop - develop
# 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: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
-5
View File
@@ -94,8 +94,3 @@ yarn-debug.log*
.vscode .vscode
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
-7
View File
@@ -1,7 +0,0 @@
*
!configs
!configs/**
!hooks
!hooks/**
!qlty.toml
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
ignored:
- DL3008
-1
View File
@@ -1 +0,0 @@
source-path=SCRIPTDIR
-8
View File
@@ -1,8 +0,0 @@
rules:
document-start: disable
quoted-strings:
required: only-when-needed
extra-allowed: ["{|}"]
key-duplicates: {}
octal-values:
forbid-implicit-octal: true
-84
View File
@@ -1,84 +0,0 @@
# This file was automatically generated by `qlty init`.
# You can modify it to suit your needs.
# We recommend you to commit this file to your repository.
#
# This configuration is used by both Qlty CLI and Qlty Cloud.
#
# Qlty CLI -- Code quality toolkit for developers
# Qlty Cloud -- Fully automated Code Health Platform
#
# Try Qlty Cloud: https://qlty.sh
#
# For a guide to configuration, visit https://qlty.sh/d/config
# Or for a full reference, visit https://qlty.sh/d/qlty-toml
config_version = "0"
exclude_patterns = [
"*_min.*",
"*-min.*",
"*.min.*",
"**/.yarn/**",
"**/*.d.ts",
"**/assets/**",
"**/bower_components/**",
"**/build/**",
"**/cache/**",
"**/config/**",
"**/db/**",
"**/deps/**",
"**/dist/**",
"**/extern/**",
"**/external/**",
"**/generated/**",
"**/Godeps/**",
"**/gradlew/**",
"**/mvnw/**",
"**/node_modules/**",
"**/protos/**",
"**/seed/**",
"**/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",
]
test_patterns = [
"**/test/**",
"**/spec/**",
"**/*.test.*",
"**/*.spec.*",
"**/*_test.*",
"**/*_spec.*",
"**/test_*.*",
"**/spec_*.*",
]
[smells]
mode = "comment"
[smells.boolean_logic]
threshold = 4
[smells.file_complexity]
threshold = 66
enabled = true
[smells.return_statements]
threshold = 4
[smells.nested_control_flow]
threshold = 4
[smells.function_parameters]
threshold = 4
[smells.function_complexity]
threshold = 5
[smells.duplication]
enabled = true
threshold = 20
[[source]]
name = "default"
default = true
+1 -1
View File
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
Enabled: false Enabled: false
Layout/TrailingEmptyLines: Layout/TrailingEmptyLines:
Enabled: true Enabled: false
Style/SafeNavigationChainLength: Style/SafeNavigationChainLength:
Enabled: false Enabled: false
-17
View File
@@ -56,20 +56,3 @@
## Ruby Best Practices ## Ruby Best Practices
- Use compact `module/class` definitions; avoid nested styles - 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.
+4 -21
View File
@@ -62,10 +62,6 @@ gem 'redis-namespace'
# super fast record imports in bulk # super fast record imports in bulk
gem 'activerecord-import' gem 'activerecord-import'
gem 'searchkick'
gem 'opensearch-ruby'
gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
@@ -78,12 +74,9 @@ gem 'barnes'
gem 'devise', '>= 4.9.4' gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3' gem 'devise_token_auth', '>= 1.2.3'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization # authorization
gem 'jwt' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.20.1' gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3' gem 'administrate-field-active_storage', '>= 1.0.3'
@@ -96,14 +89,14 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---## ##--- gems for channels ---##
gem 'facebook-messenger' gem 'facebook-messenger'
gem 'line-bot-api' gem 'line-bot-api'
gem 'twilio-ruby' gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty' # gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5' gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.7.0' gem 'slack-ruby-client', '~> 2.5.2'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc' gem 'grpc'
@@ -115,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---# ##-- apm and error monitoring ---#
# loaded only when environment variables are set. # loaded only when environment variables are set.
# ref application.rb # ref application.rb
gem 'datadog', '~> 2.0', require: false gem 'ddtrace', require: false
gem 'elastic-apm', require: false gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
@@ -128,8 +121,6 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false
gem 'sidekiq', '>= 7.3.1' gem 'sidekiq', '>= 7.3.1'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0' gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
@@ -174,7 +165,6 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth # need for google auth
gem 'omniauth', '>= 2.1.2' gem 'omniauth', '>= 2.1.2'
gem 'omniauth-saml'
gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
@@ -187,10 +177,6 @@ gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm-schema'
gem 'shopify_api' gem 'shopify_api'
@@ -220,8 +206,6 @@ group :development do
gem 'stackprof' gem 'stackprof'
# Should install the associated chrome extension to view query logs # Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3' gem 'meta_request', '>= 0.8.3'
gem 'tidewave'
end end
group :test do group :test do
@@ -231,7 +215,6 @@ group :test do
gem 'webmock' gem 'webmock'
# test profiling # test profiling
gem 'test-prof' gem 'test-prof'
gem 'simplecov_json_formatter', require: false
end end
group :development, :test do group :development, :test do
@@ -256,7 +239,7 @@ group :development, :test do
gem 'rubocop-factory_bot', require: false gem 'rubocop-factory_bot', require: false
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false gem 'simplecov', '0.17.1', require: false
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end
+100 -209
View File
@@ -25,35 +25,35 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.1.5.2) actioncable (7.1.5.1)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.1.5.2) actionmailbox (7.1.5.1)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.1)
activejob (= 7.1.5.2) activejob (= 7.1.5.1)
activerecord (= 7.1.5.2) activerecord (= 7.1.5.1)
activestorage (= 7.1.5.2) activestorage (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.1.5.2) actionmailer (7.1.5.1)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.1)
actionview (= 7.1.5.2) actionview (= 7.1.5.1)
activejob (= 7.1.5.2) activejob (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.1.5.2) actionpack (7.1.5.1)
actionview (= 7.1.5.2) actionview (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4)
@@ -61,38 +61,38 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actiontext (7.1.5.2) actiontext (7.1.5.1)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.1)
activerecord (= 7.1.5.2) activerecord (= 7.1.5.1)
activestorage (= 7.1.5.2) activestorage (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.5.2) actionview (7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (7.1.5.2) activejob (7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.5.2) activemodel (7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
activerecord (7.1.5.2) activerecord (7.1.5.1)
activemodel (= 7.1.5.2) activemodel (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activerecord-import (2.1.0) activerecord-import (2.1.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (7.1.5.2) activestorage (7.1.5.1)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.1)
activejob (= 7.1.5.2) activejob (= 7.1.5.1)
activerecord (= 7.1.5.2) activerecord (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.5.2) activesupport (7.1.5.1)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@@ -126,8 +126,6 @@ GEM
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.2) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
@@ -155,10 +153,10 @@ GEM
barnes (0.0.9) barnes (0.0.9)
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.3.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.1) benchmark (0.4.0)
bigdecimal (3.2.2) bigdecimal (3.1.9)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -174,8 +172,6 @@ GEM
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
@@ -194,14 +190,10 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.4.1) date (3.4.1)
ddtrace (0.48.0)
ffi (~> 1.0)
msgpack
debug (1.8.0) debug (1.8.0)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
@@ -212,11 +204,6 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
rotp (~> 6.0)
devise_token_auth (1.2.5) devise_token_auth (1.2.5)
bcrypt (~> 3.0) bcrypt (~> 3.0)
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
@@ -224,7 +211,7 @@ GEM
diff-lcs (1.5.1) diff-lcs (1.5.1)
digest-crc (0.6.5) digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
docile (1.4.1) docile (1.4.0)
domain_name (0.5.20190701) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
dotenv (3.1.2) dotenv (3.1.2)
@@ -235,35 +222,6 @@ GEM
addressable (~> 2.8) addressable (~> 2.8)
drb (2.2.3) drb (2.2.3)
dry-cli (1.1.0) dry-cli (1.1.0)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-core (1.1.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.2.0)
dry-initializer (3.2.0)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-schema (1.14.1)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.1)
dry-initializer (~> 3.2)
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.8.3)
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) ecma-re-validator (0.4.0)
regexp_parser (~> 2.2) regexp_parser (~> 2.2)
elastic-apm (4.6.2) elastic-apm (4.6.2)
@@ -286,34 +244,22 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.13.1) faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.2)
json
logger
faraday-follow_redirects (0.3.0) faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-mashify (1.0.0) faraday-mashify (0.1.1)
faraday (~> 2.0) faraday (~> 2.0)
hashie hashie
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
faraday-net_http (3.4.0) faraday-net_http (3.1.0)
net-http (>= 0.5.0) net-http
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
faraday-retry (2.2.1) faraday-retry (2.2.1)
faraday (~> 2.0) faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
faraday (>= 2.0, < 3)
fast-mcp (1.5.0)
addressable (~> 2.8)
base64
dry-schema (~> 1.14)
json (~> 2.0)
mime-types (~> 3.4)
rack (~> 3.1)
fcm (1.0.8) fcm (1.0.8)
faraday (>= 1.0.0, < 3.0) faraday (>= 1.0.0, < 3.0)
googleauth (~> 1) googleauth (~> 1)
@@ -409,7 +355,6 @@ GEM
grpc (1.72.0-x86_64-linux) grpc (1.72.0-x86_64-linux)
google-protobuf (>= 3.25, < 5.0) google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
gserver (0.0.1)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (1.0.0) hairtrigger (1.0.0)
activerecord (>= 6.0, < 8) activerecord (>= 6.0, < 8)
@@ -452,7 +397,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.13.2) json (2.12.0)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -467,7 +412,7 @@ GEM
judoscale-sidekiq (1.8.2) judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0) sidekiq (>= 5.0)
jwt (2.10.1) jwt (2.8.1)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -488,22 +433,10 @@ GEM
json (>= 1.8) json (>= 1.8)
rexml rexml
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
launchy (3.1.1) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) letter_opener (1.8.1)
logger (~> 1.6) launchy (>= 2.2, < 3)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0) line-bot-api (1.28.0)
lint_roller (1.1.0) lint_roller (1.1.0)
liquid (5.4.0) liquid (5.4.0)
@@ -538,7 +471,7 @@ GEM
mime-types-data (3.2023.0218.1) mime-types-data (3.2023.0218.1)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9) mini_portile2 (2.8.8)
minitest (5.25.5) minitest (5.25.5)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
@@ -549,7 +482,7 @@ GEM
mutex_m (0.3.0) mutex_m (0.3.0)
neighbor (0.2.3) neighbor (0.2.3)
activerecord (>= 5.2) activerecord (>= 5.2)
net-http (0.6.0) net-http (0.4.1)
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
@@ -569,14 +502,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.18.9) nokogiri (1.18.8)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin) nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin) nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu) nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -594,9 +527,8 @@ GEM
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.3) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-google-oauth2 (1.1.3) omniauth-google-oauth2 (1.1.3)
@@ -610,12 +542,6 @@ GEM
omniauth-rails_csrf_protection (1.0.2) omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) 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) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
@@ -637,14 +563,14 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (6.0.2) public_suffix (6.0.0)
puma (6.4.3) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.0) rack (2.2.15)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -653,34 +579,33 @@ GEM
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.2.0) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (4.1.1) rack-protection (3.2.0)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) rack (~> 2.2, >= 2.2.4)
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.1) rack-session (1.0.2)
base64 (>= 0.1.0) rack (< 3)
rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rackup (2.2.1) rackup (1.0.1)
rack (>= 3) rack (< 3)
rails (7.1.5.2) webrick
actioncable (= 7.1.5.2) rails (7.1.5.1)
actionmailbox (= 7.1.5.2) actioncable (= 7.1.5.1)
actionmailer (= 7.1.5.2) actionmailbox (= 7.1.5.1)
actionpack (= 7.1.5.2) actionmailer (= 7.1.5.1)
actiontext (= 7.1.5.2) actionpack (= 7.1.5.1)
actionview (= 7.1.5.2) actiontext (= 7.1.5.1)
activejob (= 7.1.5.2) actionview (= 7.1.5.1)
activemodel (= 7.1.5.2) activejob (= 7.1.5.1)
activerecord (= 7.1.5.2) activemodel (= 7.1.5.1)
activestorage (= 7.1.5.2) activerecord (= 7.1.5.1)
activesupport (= 7.1.5.2) activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.5.2) railties (= 7.1.5.1)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -688,9 +613,9 @@ GEM
rails-html-sanitizer (1.6.1) rails-html-sanitizer (1.6.1)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.1.5.2) railties (7.1.5.1)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.1)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.1)
irb irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -727,8 +652,7 @@ GEM
retriable (3.1.2) retriable (3.1.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.4.4) rexml (3.4.1)
rotp (6.3.0)
rspec-core (3.13.0) rspec-core (3.13.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.2) rspec-expectations (3.13.2)
@@ -783,25 +707,12 @@ GEM
faraday (>= 1) faraday (>= 1)
faraday-multipart (>= 1) faraday-multipart (>= 1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-saml (1.18.1)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
ruby2ruby (2.5.0) ruby2ruby (2.5.0)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_llm (1.5.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
ruby_llm-schema (0.1.0)
ruby_parser (3.20.0) ruby_parser (3.20.0)
sexp_processor (~> 4.16) sexp_processor (~> 4.16)
sass (3.7.4) sass (3.7.4)
@@ -821,9 +732,6 @@ GEM
parser parser
scss_lint (0.60.0) scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5) sass (~> 3.5, >= 3.5.5)
searchkick (5.5.2)
activemodel (>= 7.1)
hashie
securerandom (0.4.1) securerandom (0.4.1)
seed_dump (3.3.1) seed_dump (3.3.1)
activerecord (>= 4) activerecord (>= 4)
@@ -862,22 +770,18 @@ GEM
fugit (~> 1.8) fugit (~> 1.8)
globalid (>= 1.0.1) globalid (>= 1.0.1)
sidekiq (>= 6) sidekiq (>= 6)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
signet (0.17.0) signet (0.17.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
multi_json (~> 1.10) multi_json (~> 1.10)
simplecov (0.22.0) simplecov (0.17.1)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) json (>= 1.8, < 3)
simplecov_json_formatter (~> 0.1) simplecov-html (~> 0.10.0)
simplecov-html (0.13.2) simplecov-html (0.10.2)
simplecov_json_formatter (0.1.4) slack-ruby-client (2.5.2)
slack-ruby-client (2.7.0) faraday (>= 2.0)
faraday (>= 2.0.1)
faraday-mashify faraday-mashify
faraday-multipart faraday-multipart
gli gli
@@ -904,18 +808,14 @@ GEM
stripe (8.5.0) stripe (8.5.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.4.0) thor (1.3.1)
tidewave (0.2.0)
fast-mcp (~> 1.5.0)
rack (>= 2.0)
rails (>= 7.1.0)
tilt (2.3.0) tilt (2.3.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.4.3) timeout (0.4.3)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (7.6.0) twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
nokogiri (>= 1.6, < 2.0) nokogiri (>= 1.6, < 2.0)
@@ -962,6 +862,7 @@ GEM
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
websocket-driver (0.7.7) websocket-driver (0.7.7)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
@@ -990,7 +891,6 @@ DEPENDENCIES
administrate (>= 0.20.1) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.4.3)
annotate annotate
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@@ -1007,11 +907,10 @@ DEPENDENCIES
commonmarker commonmarker
csv-safe csv-safe
database_cleaner database_cleaner
datadog (~> 2.0) ddtrace
debug (~> 1.8) debug (~> 1.8)
devise (>= 4.9.4) devise (>= 4.9.4)
devise-secure_password! devise-secure_password!
devise-two-factor (>= 5.0.0)
devise_token_auth (>= 1.2.3) devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0) dotenv-rails (>= 3.0.0)
down down
@@ -1020,7 +919,6 @@ DEPENDENCIES
facebook-messenger facebook-messenger
factory_bot_rails (>= 6.4.3) factory_bot_rails (>= 6.4.3)
faker faker
faraday_middleware-aws-sigv4
fcm fcm
flag_shih_tzu flag_shih_tzu
foreman foreman
@@ -1061,8 +959,6 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3) omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2 omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-saml
opensearch-ruby
pg pg
pg_search pg_search
pgvector pgvector
@@ -1088,10 +984,8 @@ DEPENDENCIES
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-openai ruby-openai
ruby_llm-schema
scout_apm scout_apm
scss_lint scss_lint
searchkick
seed_dump seed_dump
sentry-rails (>= 5.19.0) sentry-rails (>= 5.19.0)
sentry-ruby sentry-ruby
@@ -1100,10 +994,8 @@ DEPENDENCIES
shoulda-matchers shoulda-matchers
sidekiq (>= 7.3.1) sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0) sidekiq-cron (>= 1.12.0)
sidekiq_alive simplecov (= 0.17.1)
simplecov (>= 0.21) slack-ruby-client (~> 2.5.2)
simplecov_json_formatter
slack-ruby-client (~> 2.7.0)
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher
@@ -1111,9 +1003,8 @@ DEPENDENCIES
stripe stripe
telephone_number telephone_number
test-prof test-prof
tidewave
time_diff time_diff
twilio-ruby twilio-ruby (~> 5.66)
twitty (~> 0.1.5) twitty (~> 0.1.5)
tzinfo-data tzinfo-data
uglifier uglifier
-3
View File
@@ -2,8 +2,5 @@
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative 'config/application' 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 Rails.application.load_tasks
+1 -1
View File
@@ -1 +1 @@
4.4.0 3.13.0
+1 -1
View File
@@ -1 +1 @@
3.4.3 3.2.0
+1 -9
View File
@@ -6,7 +6,6 @@
# We don't want to update the name of the identified original contact. # We don't want to update the name of the identified original contact.
class ContactIdentifyAction class ContactIdentifyAction
include UrlHelper
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }] pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
def perform def perform
@@ -105,14 +104,7 @@ class ContactIdentifyAction
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! @contact.save!
enqueue_avatar_job Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached?
end
def enqueue_avatar_job
return unless params[:avatar_url].present? && !@contact.avatar.attached?
return unless url_valid?(params[:avatar_url])
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url])
end end
def merge_contact(base_contact, merge_contact) def merge_contact(base_contact, merge_contact)
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return; if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') { if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10; textElement.textContent = '•'.repeat(10);
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true'; textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText); navigator.clipboard.writeText(secretField.dataset.secretText);
} }
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field { .cell-data__secret-field {
align-items: center; align-items: center;
color: $hint-grey;
display: flex; display: flex;
span { span {
flex: 0 0 auto; flex: 1;
} }
[data-secret-toggler], button {
[data-secret-copier] { margin-left: 5px;
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg { svg {
fill: currentColor; fill: currentColor;
height: 1.25rem;
width: 1.25rem;
} }
} }
} }
-2
View File
@@ -52,5 +52,3 @@ class AgentBuilder
}.compact)) }.compact))
end end
end end
AgentBuilder.prepend_mod_with('AgentBuilder')
-54
View File
@@ -1,54 +0,0 @@
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: <agent_name> from <business_name>
# Professional: <business_name>
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 <email@domain.com>"
parse_email(account.support_email)
end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end
-51
View File
@@ -1,51 +0,0 @@
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
-21
View File
@@ -1,21 +0,0 @@
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
-60
View File
@@ -7,7 +7,6 @@ class Messages::MessageBuilder
@private = params[:private] || false @private = params[:private] || false
@conversation = conversation @conversation = conversation
@user = user @user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = content_attributes&.dig(:automation_rule_id)
@@ -21,9 +20,6 @@ class Messages::MessageBuilder
@message = @conversation.messages.build(message_params) @message = @conversation.messages.build(message_params)
process_attachments process_attachments
process_emails 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 if @account.feature_enabled?(:quoted_email_reply)
@message.save! @message.save!
@message @message
end end
@@ -96,14 +92,6 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails @message.content_attributes[:to_emails] = to_emails
end 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) def process_email_string(email_string)
return [] if email_string.blank? return [] if email_string.blank?
@@ -165,52 +153,4 @@ class Messages::MessageBuilder
source_id: @params[:source_id] source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end 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)
email_attributes[:html_content] = build_html_content(normalized_content)
email_attributes[:text_content] = build_text_content(normalized_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 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
end end
@@ -1,112 +0,0 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_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)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
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)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins(conversation: { taggings: :tag })
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end
@@ -38,34 +38,27 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
end end
def scope_for_resolutions_count def scope_for_resolutions_count
scope.reporting_events.where( scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved, name: :conversation_resolved,
account_id: account.id, conversations: { status: :resolved }, created_at: range
created_at: range ).distinct
)
end end
def scope_for_bot_resolutions_count def scope_for_bot_resolutions_count
scope.reporting_events.where( scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved, name: :conversation_bot_resolved,
account_id: account.id, conversations: { status: :resolved }, created_at: range
created_at: range ).distinct
)
end end
def scope_for_bot_handoffs_count def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where( scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff, name: :conversation_bot_handoff,
account_id: account.id,
created_at: range created_at: range
).distinct ).distinct
end end
def grouped_count 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( @grouped_values = object_scope.group_by_period(
group_by, group_by,
:created_at, :created_at,
@@ -1,20 +0,0 @@
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
@@ -1,36 +0,0 @@
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
@@ -30,14 +30,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end end
def facebook_pages def facebook_pages
pages = [] @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
fb_pages = fb_object.get_connections('me', 'accounts')
pages.concat(fb_pages)
while fb_pages.respond_to?(:next_page) && (next_page = fb_pages.next_page)
fb_pages = next_page
pages.concat(fb_pages)
end
@page_details = mark_already_existing_facebook_pages(pages)
end end
def set_instagram_id(page_access_token, facebook_channel) def set_instagram_id(page_access_token, facebook_channel)
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
def campaign_params def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id, params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {}) :scheduled_at, audience: [:type, :id], trigger_rules: {})
end end
end end
@@ -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] before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts) @contacts = fetch_contacts(resolved_contacts)
@contacts_count = @contacts.total_count
end end
def search def search
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
OR contacts.additional_attributes->>\'company_name\' ILIKE :search', OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%" search: "%#{params[:q].strip}%"
) )
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end end
def import def import
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def active def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end end
def show; end def show; end
@@ -122,7 +122,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def resolved_contacts def resolved_contacts
return @resolved_contacts if @resolved_contacts return @resolved_contacts if @resolved_contacts
@resolved_contacts = Current.account.contacts.resolved_contacts(use_crm_v2: Current.account.feature_enabled?('crm_v2')) @resolved_contacts = Current.account.contacts.resolved_contacts
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present? @resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts @resolved_contacts
@@ -133,14 +133,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contacts(contacts) def fetch_contacts(contacts)
# Build includes hash to avoid separate query when contact_inboxes are needed contacts_with_avatar = filtrate(contacts)
includes_hash = { avatar_attachment: [:blob] } .includes([{ avatar_attachment: [:blob] }])
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes .page(@current_page).per(RESULTS_PER_PAGE)
filtrate(contacts) return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
.includes(includes_hash)
.page(@current_page) contacts_with_avatar
.per(RESULTS_PER_PAGE)
end end
def build_contact_inbox def build_contact_inbox
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: scope, scope: 'email profile https://mail.google.com/',
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,46 +0,0 @@
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
@@ -4,8 +4,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show, :health] before_action :check_authorization, except: [:show]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -70,23 +69,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_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 private
def fetch_inbox def fetch_inbox
@@ -98,22 +80,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end 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 def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type]) return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end end
@@ -198,18 +170,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
[] []
end end
end 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 end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,9 +1,8 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
def destroy def destroy
revoke_linear_token
@hook.destroy! @hook.destroy!
head :ok head :ok
end end
@@ -28,16 +27,10 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end end
def create_issue def create_issue
issue = linear_processor_service.create_issue(permitted_params, Current.user) issue = linear_processor_service.create_issue(permitted_params)
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -45,34 +38,21 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def link_issue def link_issue
issue_id = permitted_params[:issue_id] issue_id = permitted_params[:issue_id]
title = permitted_params[:title] title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title, Current.user) issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
def unlink_issue def unlink_issue
link_id = permitted_params[:link_id] link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id) issue = linear_processor_service.unlink_issue(link_id)
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -121,15 +101,4 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def fetch_hook def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end end
def revoke_linear_token
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
end
end
end end
@@ -1,14 +0,0 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,21 +0,0 @@
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include NotionConcern
def create
redirect_url = notion_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/notion/callback",
response_type: 'code',
owner: 'user',
state: state,
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -1,23 +0,0 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -26,8 +26,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain # @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present? process_attached_logo if params[:blob_id].present?
rescue ActiveRecord::RecordInvalid => e rescue StandardError => e
render_record_invalid(e) Rails.logger.error e
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
end end
end end
@@ -46,20 +47,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def send_instructions
email = permitted_params[:email]
return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank?
return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email)
return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank?
PortalInstructionsMailer.send_cname_instructions(
portal: @portal,
recipient_email: email
).deliver_later
render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok
end
def process_attached_logo def process_attached_logo
blob_id = params[:blob_id] blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id) blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -73,20 +60,19 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:id, :email) params.permit(:id)
end end
def portal_params def portal_params
params.require(:portal).permit( params.require(:portal).permit(
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link, :account_id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
) )
end end
def live_chat_widget_params def live_chat_widget_params
permitted_params = params.permit(:inbox_id) permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id) return {} if permitted_params[:inbox_id].blank?
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id]) inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget? return {} unless inbox.web_widget?
@@ -102,10 +88,4 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
domain = URI.parse(@portal.custom_domain) domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end end
def valid_email?(email)
ValidEmail2::Address.new(email).valid?
end
end end
Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController')
@@ -1,77 +0,0 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
# Handles both initial authorization and reauthorization
# If inbox_id is present in params, it performs reauthorization
def create
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
render_error_response(e)
end
private
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
inbox_id: params[:inbox_id]
)
service.perform
end
def fetch_and_validate_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
validate_reauthorization_required
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
render json: {
success: false,
message: I18n.t('inbox.reauthorization.not_required')
}, status: :unprocessable_entity
end
def can_upgrade_to_embedded_signup?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
true
end
def render_success_response(inbox)
response = {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present?
render json: response
end
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
success: false,
error: error.message
}, status: :unprocessable_entity
end
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
missing_params << 'business_id' if params[:business_id].blank?
missing_params << 'waba_id' if params[:waba_id].blank?
return if missing_params.empty?
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end
@@ -1,68 +0,0 @@
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
@@ -9,7 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
private private
def set_global_config def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
end end
def set_contact def set_contact
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,10 +14,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder) render_report_with(V2::Reports::InboxSummaryBuilder)
end end
def label
render_report_with(V2::Reports::LabelSummaryBuilder)
end
private private
def check_authorization def check_authorization
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def scope def base_url
'email profile https://mail.google.com/' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def scope def base_url
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -1,21 +0,0 @@
module NotionConcern
extend ActiveSupport::Concern
def notion_client
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
})
end
private
def scope
''
end
end
+2 -19
View File
@@ -4,28 +4,17 @@ module SwitchLocale
private private
def switch_locale(&) 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] 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 locale ||= locale_from_custom_domain
# if locale is not set in account, let's use DEFAULT_LOCALE env variable # if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= ENV.fetch('DEFAULT_LOCALE', nil) locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
set_locale(locale, &) set_locale(locale, &)
end end
def switch_locale_using_account_locale(&) def switch_locale_using_account_locale(&)
# Get the locale from the user first locale = locale_from_account(@current_account)
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, &) set_locale(locale, &)
end end
@@ -43,12 +32,6 @@ module SwitchLocale
@portal.default_locale @portal.default_locale
end end
def locale_from_user
return unless @user
@user.ui_settings&.dig('locale')
end
def set_locale(locale, &) def set_locale(locale, &)
safe_locale = validate_and_get_locale(locale) safe_locale = validate_and_get_locale(locale)
# Ensure locale won't bleed into other requests # Ensure locale won't bleed into other requests
+2 -4
View File
@@ -15,7 +15,7 @@ class DashboardController < ActionController::Base
private private
def ensure_html_format def ensure_html_format
render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json? head :not_acceptable unless request.format.html?
end end
def set_global_config def set_global_config
@@ -66,9 +66,7 @@ class DashboardController < ActionController::Base
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH GIT_SHA: GIT_HASH
@@ -47,8 +47,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end end
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
email = auth_hash.dig('info', 'email') # find the user with their email instead of UID and token
@resource = resource_class.from_email(email) @resource = resource_class.where(
email: auth_hash['info']['email']
).first
end end
def validate_signup_email_is_business_domain? def validate_signup_email_is_business_domain?
@@ -73,5 +75,3 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
'user' 'user'
end end
end end
DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
@@ -44,5 +44,3 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
}, status: status }, status: status
end end
end end
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
@@ -9,14 +9,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end end
def create def create
return handle_mfa_verification if mfa_verification_request? # Authenticate user via the temporary sso auth token
return handle_sso_authentication if sso_authentication_request? if params[:sso_auth_token].present? && @resource.present?
authenticate_resource_with_sso_token
user = find_user_for_authentication yield @resource if block_given?
return handle_mfa_required(user) if user&.mfa_enabled? render_create_success
else
# Only proceed with standard authentication if no MFA is required super
super end
end end
def render_create_success def render_create_success
@@ -25,31 +25,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private 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) def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil) frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -71,41 +46,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = User.from_email(params[:email]) user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end 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 end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
+2 -2
View File
@@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController
after_action :set_version_header after_action :set_version_header
def identity_association def identity_association
microsoft_identity microsoft_indentity
end end
private private
@@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
end end
def microsoft_identity def microsoft_indentity
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil) @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
end end
end end
@@ -1,36 +0,0 @@
class Notion::CallbacksController < OauthCallbackController
include NotionConcern
private
def provider_name
'notion'
end
def oauth_client
notion_client
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'notion',
settings: {
token_type: parsed_body['token_type'],
workspace_name: parsed_body['workspace_name'],
workspace_id: parsed_body['workspace_id'],
workspace_icon: parsed_body['workspace_icon'],
bot_id: parsed_body['bot_id'],
owner: parsed_body['owner']
}
)
hook.save!
redirect_to notion_redirect_uri
end
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
+8 -8
View File
@@ -6,6 +6,7 @@ class OauthCallbackController < ApplicationController
) )
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/' redirect_to '/'
@@ -63,6 +64,10 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
@@ -81,17 +86,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_from_signed_id def account_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? ::Redis::Alfred.get(cache_key)
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
end end
def account def account
@account ||= account_from_signed_id @account ||= Account.find(account_id)
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -1,11 +1,4 @@
class Platform::Api::V1::AccountsController < PlatformController class Platform::Api::V1::AccountsController < PlatformController
def index
@resources = @platform_app.platform_app_permissibles
.where(permissible_type: 'Account')
.includes(:permissible)
.map(&:permissible)
end
def show; end def show; end
def create def create
@@ -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] before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
def index def index
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations @conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
end end
def show; end def show; end
@@ -7,19 +7,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index def index
@articles = @portal.articles.published.includes(:category, :author) @articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count @articles_count = @articles.count
search_articles search_articles
order_by_sort_param order_by_sort_param
limit_results limit_results
end end
def show def show; end
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
end
def tracking_pixel def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@@ -58,6 +58,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
end end
def set_global_config def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME') @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
end end
end end
@@ -8,9 +8,7 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
@categories = @portal.categories.order(position: :asc) @categories = @portal.categories.order(position: :asc)
end end
def show def show; end
@og_image_url = helpers.set_og_image_url(@portal.name, @category.name)
end
private private
@@ -4,9 +4,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :redirect_to_portal_with_locale, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal' layout 'portal'
def show def show; end
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def sitemap def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root @help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
+1 -6
View File
@@ -17,12 +17,7 @@ class SlackUploadsController < ApplicationController
end end
def blob_url def blob_url
# Only generate representations for images url_for(@blob.representation(resize_to_fill: [250, nil]))
if @blob.content_type.start_with?('image/')
url_for(@blob.representation(resize_to_fill: [250, nil]))
else
url_for(@blob)
end
end end
def avatar_url def avatar_url
@@ -39,10 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'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]
} }
@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])
@@ -7,9 +7,8 @@
class SuperAdmin::ApplicationController < Administrate::ApplicationController class SuperAdmin::ApplicationController < Administrate::ApplicationController
include ActionView::Helpers::TagHelper include ActionView::Helpers::TagHelper
include ActionView::Context include ActionView::Context
include SuperAdmin::NavigationHelper
helper_method :render_vue_component, :settings_open?, :settings_pages helper_method :render_vue_component
# authenticiation done via devise : SuperAdmin Model # authenticiation done via devise : SuperAdmin Model
before_action :authenticate_super_admin! before_action :authenticate_super_admin!
@@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
redirect_to new_super_admin_user_path, notice: notice redirect_to new_super_admin_user_path, notice: notice
end end
end end
#
def update # def update
requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present? # super
super # send_foo_updated_email(requested_resource)
end # end
# Override this method to specify custom lookup behavior. # Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update` # This will be used to set the resource for the `show`, `edit`, and `update`
@@ -5,6 +5,6 @@ class Survey::ResponsesController < ActionController::Base
private private
def set_global_config 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')
end end
end end
@@ -27,11 +27,7 @@ class Twilio::CallbackController < ApplicationController
*Array.new(10) { |i| :"MediaUrl#{i}" }, *Array.new(10) { |i| :"MediaUrl#{i}" },
*Array.new(10) { |i| :"MediaContentType#{i}" }, *Array.new(10) { |i| :"MediaContentType#{i}" },
:MessagingServiceSid, :MessagingServiceSid,
:NumMedia, :NumMedia
:Latitude,
:Longitude,
:MessageType,
:ProfileName
) )
end end
end end
@@ -4,16 +4,7 @@ class Webhooks::InstagramController < ActionController::API
def events def events
Rails.logger.info('Instagram webhook received events') Rails.logger.info('Instagram webhook received events')
if params['object'].casecmp('instagram').zero? if params['object'].casecmp('instagram').zero?
entry_params = params.to_unsafe_hash[:entry] ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
if contains_echo_event?(entry_params)
# 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::InstagramEventsJob.set(wait: 2.seconds).perform_later(entry_params)
else
::Webhooks::InstagramEventsJob.perform_later(entry_params)
end
render json: :ok render json: :ok
else else
Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}") Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
@@ -23,16 +14,6 @@ class Webhooks::InstagramController < ActionController::API
private private
def contains_echo_event?(entry_params)
return false unless entry_params.is_a?(Array)
entry_params.any? do |entry|
# Check messaging array for echo events
messaging_events = entry[:messaging] || []
messaging_events.any? { |messaging| messaging.dig(:message, :is_echo).present? }
end
end
def valid_token?(token) def valid_token?(token)
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
+2 -7
View File
@@ -14,7 +14,7 @@ class WidgetsController < ActionController::Base
private private
def set_global_config 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')
end end
def set_web_widget def set_web_widget
@@ -70,12 +70,7 @@ class WidgetsController < ActionController::Base
end end
def allow_iframe_requests def allow_iframe_requests
if @web_widget.allowed_domains.blank? response.headers.delete('X-Frame-Options')
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
end end
+1 -1
View File
@@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard
SHOW_PAGE_ATTRIBUTES = %i[ SHOW_PAGE_ATTRIBUTES = %i[
id id
avatar_url avatar_url
unconfirmed_email
name name
type type
display_name display_name
email email
unconfirmed_email
created_at created_at
updated_at updated_at
confirmed_at confirmed_at
+1 -2
View File
@@ -156,8 +156,7 @@ class ConversationFinder
def filter_by_labels def filter_by_labels
return unless params[:labels] return unless params[:labels]
pattern = params[:labels].map { |label| Regexp.escape(label) }.join('|') @conversations = @conversations.tagged_with(params[:labels], any: true)
@conversations = @conversations.where('cached_label_list ~ ?', "\\y(#{pattern})\\y")
end end
def filter_by_source_id def filter_by_source_id
+9 -44
View File
@@ -6,54 +6,19 @@ class EmailChannelFinder
end end
def perform def perform
channel_from_primary_recipients || channel_from_bcc_recipients channel = nil
end
private 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)
def channel_from_primary_recipients break if channel.present?
primary_recipient_emails.each do |email|
channel = channel_from_email(email)
return channel if channel.present?
end end
channel
nil
end end
def channel_from_bcc_recipients def recipient_mails
bcc_recipient_emails.each do |email| recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
channel = channel_from_email(email) recipient_addresses.flatten.compact
# 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
end end
+3 -9
View File
@@ -15,13 +15,7 @@ class NotificationFinder
end end
def unread_count def unread_count
if type_included?('read') @notifications.where(read_at: nil).count
# If we're including read notifications, filter to unread
@notifications.where(read_at: nil).count
else
# Already filtered to unread notifications, just count
@notifications.count
end
end end
def count def count
@@ -33,7 +27,7 @@ class NotificationFinder
def set_up def set_up
find_all_notifications find_all_notifications
filter_snoozed_notifications filter_snoozed_notifications
filter_read_notifications fitler_read_notifications
end end
def find_all_notifications def find_all_notifications
@@ -44,7 +38,7 @@ class NotificationFinder
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed') @notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
end end
def filter_read_notifications def fitler_read_notifications
@notifications = @notifications.where(read_at: nil) unless type_included?('read') @notifications = @notifications.where(read_at: nil) unless type_included?('read')
end end
@@ -36,13 +36,9 @@ module Api::V2::Accounts::ReportsHelper
end end
def generate_labels_report def generate_labels_report
reports = V2::Reports::LabelSummaryBuilder.new( Current.account.labels.map do |label|
account: Current.account, label_report = report_builder({ type: :label, id: label.id }).short_summary
params: build_params({}) [label.title] + generate_readable_report_metrics(label_report)
).build
reports.map do |report|
[report[:name]] + generate_readable_report_metrics(report)
end end
end end
+3 -7
View File
@@ -1,13 +1,9 @@
module MessageFormatHelper module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content) def transform_user_mention_content(message_content)
# attachment message without content, message_content is nil # attachment message without content, message_content is nil
return '' unless message_content.presence message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
# Use CommonMarker to convert markdown to plain text for notifications
# This handles all markdown formatting (links, bold, italic, etc.) not just mentions
# Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support)
# To: @👍 customer support
CommonMarker.render_doc(message_content).to_plaintext.strip
end end
def render_message_content(message_content) def render_message_content(message_content)
-29
View File
@@ -1,22 +1,4 @@
module PortalHelper 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?
client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF']
uri = URI.parse(cdn_url)
uri.path = '/og'
uri.query = URI.encode_www_form(
clientRef: client_ref,
title: title,
portalName: portal_name
)
uri.to_s
end
def generate_portal_bg_color(portal_color, theme) def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white' base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})" "color-mix(in srgb, #{portal_color} 20%, #{base_color})"
@@ -75,17 +57,6 @@ module PortalHelper
end end
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) def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end end
+4 -4
View File
@@ -53,13 +53,13 @@ module ReportHelper
end end
def resolutions def resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
created_at: range) conversations: { status: :resolved }, created_at: range).distinct
end end
def bot_resolutions def bot_resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range) conversations: { status: :resolved }, created_at: range).distinct
end end
def bot_handoffs def bot_handoffs
+5 -18
View File
@@ -18,25 +18,12 @@ module ReportingEventHelper
end end
def last_non_human_activity(conversation) def last_non_human_activity(conversation)
# Try to get either a handoff or reopened event first # check if a handoff event already exists
# These will always take precedence over any other activity handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
# Also, any of these events can happen at any time in the course of a conversation lifecycle.
# So we pick the latest event
event = ReportingEvent.where(
conversation_id: conversation.id,
name: %w[conversation_bot_handoff conversation_opened]
).order(event_end_time: :desc).first
return event.event_end_time if event&.event_end_time # if a handoff exists, last non human activity is when the handoff ended,
# otherwise it's when the conversation was created
# Fallback to bot resolved event handoff_event&.event_end_time || conversation.created_at
# Because this will be closest to the most accurate activity instead of conversation.created_at
bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
return bot_event.event_end_time if bot_event&.event_end_time
# If no events found, return conversation creation time
conversation.created_at
end end
private private
@@ -1,16 +0,0 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
end
def settings_pages
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
general_feature + features.to_a
end
end
+5 -11
View File
@@ -1,6 +1,6 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddAccountModal from './components/app/AddAccountModal.vue'; import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
@@ -19,7 +19,6 @@ import {
verifyServiceWorkerExistence, verifyServiceWorkerExistence,
} from './helper/pushHelper'; } from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService'; import ReconnectService from 'dashboard/helper/ReconnectService';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default { export default {
name: 'App', name: 'App',
@@ -39,14 +38,12 @@ export default {
const { accountId } = useAccount(); const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher) // Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize(); const { currentFontSize } = useFontSize();
const { uiSettings } = useUISettings();
return { return {
router, router,
store, store,
currentAccountId: accountId, currentAccountId: accountId,
currentFontSize, currentFontSize,
uiSettings,
}; };
}, },
data() { data() {
@@ -91,10 +88,7 @@ export default {
mounted() { mounted() {
this.initializeColorTheme(); this.initializeColorTheme();
this.listenToThemeChanges(); this.listenToThemeChanges();
// If user locale is set, use it; otherwise use account locale this.setLocale(window.chatwootConfig.selectedLocale);
this.setLocale(
this.uiSettings?.locale || window.chatwootConfig.selectedLocale
);
}, },
unmounted() { unmounted() {
if (this.reconnectService) { if (this.reconnectService) {
@@ -120,8 +114,7 @@ export default {
const { locale, latest_chatwoot_version: latestChatwootVersion } = const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId); this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {}; const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale this.setLocale(locale);
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion; this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken); vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router); this.reconnectService = new ReconnectService(this.store, this.router);
@@ -143,7 +136,8 @@ export default {
<div <div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem" v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app" id="app"
class="flex flex-col w-full h-screen min-h-0" class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'" :dir="isRTL ? 'rtl' : 'ltr'"
> >
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" /> <UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
@@ -1,43 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class AgentCapacityPolicies extends ApiClient {
constructor() {
super('agent_capacity_policies', { accountScoped: true });
}
getUsers(policyId) {
return axios.get(`${this.url}/${policyId}/users`);
}
addUser(policyId, userData) {
return axios.post(`${this.url}/${policyId}/users`, {
user_id: userData.id,
capacity: userData.capacity,
});
}
removeUser(policyId, userId) {
return axios.delete(`${this.url}/${policyId}/users/${userId}`);
}
createInboxLimit(policyId, limitData) {
return axios.post(`${this.url}/${policyId}/inbox_limits`, {
inbox_id: limitData.inboxId,
conversation_limit: limitData.conversationLimit,
});
}
updateInboxLimit(policyId, limitId, limitData) {
return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, {
conversation_limit: limitData.conversationLimit,
});
}
deleteInboxLimit(policyId, limitId) {
return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`);
}
}
export default new AgentCapacityPolicies();
@@ -1,36 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class AssignmentPolicies extends ApiClient {
constructor() {
super('assignment_policies', { accountScoped: true });
}
getInboxes(policyId) {
return axios.get(`${this.url}/${policyId}/inboxes`);
}
setInboxPolicy(inboxId, policyId) {
return axios.post(
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`,
{
assignment_policy_id: policyId,
}
);
}
getInboxPolicy(inboxId) {
return axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
);
}
removeInboxPolicy(inboxId) {
return axios.delete(
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
);
}
}
export default new AssignmentPolicies();
+11 -11
View File
@@ -38,7 +38,13 @@ export default {
} }
return false; return false;
}, },
profileUpdate({ displayName, avatar, ...profileAttributes }) { profileUpdate({
password,
password_confirmation,
displayName,
avatar,
...profileAttributes
}) {
const formData = new FormData(); const formData = new FormData();
Object.keys(profileAttributes).forEach(key => { Object.keys(profileAttributes).forEach(key => {
const hasValue = profileAttributes[key] === undefined; const hasValue = profileAttributes[key] === undefined;
@@ -47,22 +53,16 @@ export default {
} }
}); });
formData.append('profile[display_name]', displayName || ''); formData.append('profile[display_name]', displayName || '');
if (password && password_confirmation) {
formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation);
}
if (avatar) { if (avatar) {
formData.append('profile[avatar]', avatar); formData.append('profile[avatar]', avatar);
} }
return axios.put(endPoints('profileUpdate').url, formData); return axios.put(endPoints('profileUpdate').url, formData);
}, },
profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) {
return axios.put(endPoints('profileUpdate').url, {
profile: {
current_password: currentPassword,
password,
password_confirmation: passwordConfirmation,
},
});
},
updateUISettings({ uiSettings }) { updateUISettings({ uiSettings }) {
return axios.put(endPoints('profileUpdate').url, { return axios.put(endPoints('profileUpdate').url, {
profile: { ui_settings: uiSettings }, profile: { ui_settings: uiSettings },
@@ -1,36 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainCustomTools extends ApiClient {
constructor() {
super('captain/custom_tools', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: { page, searchKey },
});
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
create(data = {}) {
return axios.post(this.url, {
custom_tool: data,
});
}
update(id, data = {}) {
return axios.put(`${this.url}/${id}`, {
custom_tool: data,
});
}
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
export default new CaptainCustomTools();
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
super('captain/assistant_responses', { accountScoped: true }); super('captain/assistant_responses', { accountScoped: true });
} }
get({ page = 1, search, assistantId, documentId, status } = {}) { get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
return axios.get(this.url, { return axios.get(this.url, {
params: { params: {
page, page,
search, searchKey,
assistant_id: assistantId, assistant_id: assistantId,
document_id: documentId, document_id: documentId,
status, status,
@@ -1,36 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainScenarios extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId, page = 1, searchKey } = {}) {
return axios.get(`${this.url}/${assistantId}/scenarios`, {
params: { page, searchKey },
});
}
show({ assistantId, id }) {
return axios.get(`${this.url}/${assistantId}/scenarios/${id}`);
}
create({ assistantId, ...data } = {}) {
return axios.post(`${this.url}/${assistantId}/scenarios`, {
scenario: data,
});
}
update({ assistantId, id }, data = {}) {
return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, {
scenario: data,
});
}
delete({ assistantId, id }) {
return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`);
}
}
export default new CaptainScenarios();
@@ -1,16 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainTools extends ApiClient {
constructor() {
super('captain/assistants/tools', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, {
params,
});
}
}
export default new CaptainTools();
@@ -1,21 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class WhatsappChannel extends ApiClient {
constructor() {
super('whatsapp', { accountScoped: true });
}
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
reauthorizeWhatsApp({ inboxId, ...params }) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
...params,
inbox_id: inboxId,
});
}
}
export default new WhatsappChannel();
@@ -51,7 +51,6 @@ const endPoints = {
resendConfirmation: { resendConfirmation: {
url: '/api/v1/profile/resend_confirmation', url: '/api/v1/profile/resend_confirmation',
}, },
resetAccessToken: { resetAccessToken: {
url: '/api/v1/profile/reset_access_token', url: '/api/v1/profile/reset_access_token',
}, },
@@ -21,14 +21,6 @@ class PortalsAPI extends ApiClient {
deleteLogo(portalSlug) { deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`); return axios.delete(`${this.url}/${portalSlug}/logo`);
} }
sendCnameInstructions(portalSlug, email) {
return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email });
}
sslStatus(portalSlug) {
return axios.get(`${this.url}/${portalSlug}/ssl_status`);
}
} }
export default PortalsAPI; export default PortalsAPI;
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class InboxHealthAPI extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
}
export default new InboxHealthAPI();
-4
View File
@@ -28,10 +28,6 @@ class Inboxes extends CacheEnabledApiClient {
agent_bot: botId, agent_bot: botId,
}); });
} }
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
} }
export default new Inboxes(); export default new Inboxes();
@@ -33,11 +33,9 @@ class LinearAPI extends ApiClient {
); );
} }
unlinkIssue(linkId, issueIdentifier, conversationId) { unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, { return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId, link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
}); });
} }

Some files were not shown because too many files have changed in this diff Show More