diff --git a/.circleci/config.yml b/.circleci/config.yml
index 99795db91..65ceda04c 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,6 +1,7 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
+ qlty-orb: qltysh/qlty-orb@0.0
defaults: &defaults
working_directory: ~/build
@@ -89,14 +90,6 @@ jobs:
command: |
source ~/.rvm/scripts/rvm
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
- run:
@@ -108,10 +101,11 @@ jobs:
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
+ mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
- # we remove the FRONTED_URL from the .env before running the tests
+ # Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
command: |
@@ -149,17 +143,11 @@ jobs:
command: pnpm run eslint
- run:
- name: Run frontend tests
+ name: Run frontend tests (with coverage)
command: |
mkdir -p ~/build/coverage/frontend
- ~/tmp/cc-test-reporter before-build
pnpm run test:coverage
- - run:
- name: Code Climate Test Coverage (Frontend)
- command: |
- ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
-
# Run backend tests
- run:
name: Run backend tests
@@ -167,18 +155,18 @@ jobs:
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
- ~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
- bundle exec rspec --format progress \
+ bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- - run:
- name: Code Climate Test Coverage (Backend)
- command: |
- ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
+ # Qlty coverage publish
+ - qlty-orb/coverage_publish:
+ files: |
+ coverage/coverage.json
+ coverage/lcov.info
- run:
name: List coverage directory contents
@@ -189,3 +177,7 @@ jobs:
root: ~/build
paths:
- coverage
+
+ - store_artifacts:
+ path: coverage
+ destination: coverage
diff --git a/.gitignore b/.gitignore
index bb0df62a8..7ca033f87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,3 +95,7 @@ yarn-debug.log*
.claude/settings.local.json
.cursor
CLAUDE.local.md
+
+# Histoire deployment
+.netlify
+.histoire
diff --git a/Gemfile b/Gemfile
index 615267a77..927a853a0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -62,6 +62,10 @@ gem 'redis-namespace'
# super fast record imports in bulk
gem 'activerecord-import'
+gem 'searchkick'
+gem 'opensearch-ruby'
+gem 'faraday_middleware-aws-sigv4'
+
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
@@ -77,6 +81,7 @@ gem 'devise_token_auth', '>= 1.2.3'
# authorization
gem 'jwt'
gem 'pundit'
+
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
@@ -167,6 +172,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
+gem 'omniauth-saml'
gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
@@ -223,6 +229,7 @@ group :test do
gem 'webmock'
# test profiling
gem 'test-prof'
+ gem 'simplecov_json_formatter', require: false
end
group :development, :test do
@@ -247,7 +254,7 @@ group :development, :test do
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
- gem 'simplecov', '0.17.1', require: false
+ gem 'simplecov', '>= 0.21', require: false
gem 'spring'
gem 'spring-watcher-listen'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 8fa38a31e..2cce9f322 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -219,7 +219,7 @@ GEM
diff-lcs (1.5.1)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
- docile (1.4.0)
+ docile (1.4.1)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (3.1.2)
@@ -299,6 +299,9 @@ GEM
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
+ faraday_middleware-aws-sigv4 (1.0.1)
+ aws-sigv4 (~> 1.0)
+ faraday (>= 2.0, < 3)
fast-mcp (1.5.0)
addressable (~> 2.8)
base64
@@ -444,7 +447,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
- json (2.12.0)
+ json (2.13.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -586,8 +589,9 @@ GEM
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
- omniauth (2.1.2)
+ omniauth (2.1.3)
hashie (>= 3.4.6)
+ logger
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.3)
@@ -601,6 +605,12 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
+ omniauth-saml (2.2.4)
+ omniauth (~> 2.1)
+ ruby-saml (~> 1.18)
+ opensearch-ruby (3.4.0)
+ faraday (>= 1.0, < 3)
+ multi_json (>= 1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -767,6 +777,9 @@ GEM
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
+ ruby-saml (1.18.1)
+ nokogiri (>= 1.13.10)
+ rexml
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
@@ -802,6 +815,9 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
+ searchkick (5.5.2)
+ activemodel (>= 7.1)
+ hashie
securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
@@ -848,11 +864,12 @@ GEM
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
- simplecov (0.17.1)
+ simplecov (0.22.0)
docile (~> 1.1)
- json (>= 1.8, < 3)
- simplecov-html (~> 0.10.0)
- simplecov-html (0.10.2)
+ simplecov-html (~> 0.11)
+ simplecov_json_formatter (~> 0.1)
+ simplecov-html (0.13.2)
+ simplecov_json_formatter (0.1.4)
slack-ruby-client (2.5.2)
faraday (>= 2.0)
faraday-mashify
@@ -996,6 +1013,7 @@ DEPENDENCIES
facebook-messenger
factory_bot_rails (>= 6.4.3)
faker
+ faraday_middleware-aws-sigv4
fcm
flag_shih_tzu
foreman
@@ -1036,6 +1054,8 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
+ omniauth-saml
+ opensearch-ruby
pg
pg_search
pgvector
@@ -1064,6 +1084,7 @@ DEPENDENCIES
ruby_llm-schema
scout_apm
scss_lint
+ searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
@@ -1073,7 +1094,8 @@ DEPENDENCIES
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
- simplecov (= 0.17.1)
+ simplecov (>= 0.21)
+ simplecov_json_formatter
slack-ruby-client (~> 2.5.2)
spring
spring-watcher-listen
diff --git a/Rakefile b/Rakefile
index e85f91391..591d2c4c6 100644
--- a/Rakefile
+++ b/Rakefile
@@ -4,3 +4,7 @@
require_relative 'config/application'
Rails.application.load_tasks
+
+# Load Enterprise Edition rake tasks if they exist
+enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s
+require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb
index caa5a04d8..8b7c21e8e 100644
--- a/app/builders/v2/reports/label_summary_builder.rb
+++ b/app/builders/v2/reports/label_summary_builder.rb
@@ -28,7 +28,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
{
conversation_counts: fetch_conversation_counts(conversation_filter),
- resolved_counts: fetch_resolved_counts(conversation_filter),
+ resolved_counts: fetch_resolved_counts,
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
@@ -62,10 +62,21 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
fetch_counts(conversation_filter)
end
- def fetch_resolved_counts(conversation_filter)
- # since the base query is ActsAsTaggableOn,
- # the status :resolved won't automatically be converted to integer status
- fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
+ def fetch_resolved_counts
+ # Count resolution events, not conversations currently in resolved status
+ # Filter by reporting_event.created_at, not conversation.created_at
+ reporting_event_filter = { name: 'conversation_resolved', account_id: account.id }
+ reporting_event_filter[:created_at] = range if range.present?
+
+ ReportingEvent
+ .joins(conversation: { taggings: :tag })
+ .where(
+ reporting_event_filter.merge(
+ taggings: { taggable_type: 'Conversation', context: 'labels' }
+ )
+ )
+ .group('tags.name')
+ .count
end
def fetch_counts(conversation_filter)
@@ -84,9 +95,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
- .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
- .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
- .joins('INNER JOIN tags ON taggings.tag_id = tags.id')
+ .joins(conversation: { taggings: :tag })
.where(
conversations: conversation_filter,
name: event_name,
diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb
index 03a87a6fa..bb3b1250c 100644
--- a/app/builders/v2/reports/timeseries/count_report_builder.rb
+++ b/app/builders/v2/reports/timeseries/count_report_builder.rb
@@ -38,27 +38,34 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
end
def scope_for_resolutions_count
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(
+ scope.reporting_events.where(
name: :conversation_resolved,
- conversations: { status: :resolved }, created_at: range
- ).distinct
+ account_id: account.id,
+ created_at: range
+ )
end
def scope_for_bot_resolutions_count
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(
+ scope.reporting_events.where(
name: :conversation_bot_resolved,
- conversations: { status: :resolved }, created_at: range
- ).distinct
+ account_id: account.id,
+ created_at: range
+ )
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
+ account_id: account.id,
created_at: range
).distinct
end
def grouped_count
+ # IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
+ # It converts timestamps to the target timezone before grouping, which means
+ # the same event can fall into different day buckets depending on timezone
+ # Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb
index ac88c595a..ecbddd905 100644
--- a/app/controllers/api/v1/widget/configs_controller.rb
+++ b/app/controllers/api/v1/widget/configs_controller.rb
@@ -9,7 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
end
def set_contact
diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb
index a8ea8ae05..1221d7155 100644
--- a/app/controllers/concerns/switch_locale.rb
+++ b/app/controllers/concerns/switch_locale.rb
@@ -4,17 +4,28 @@ module SwitchLocale
private
def switch_locale(&)
- # priority is for locale set in query string (mostly for widget/from js sdk)
+ # Priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= params[:locale]
+ # Use the user's locale if available
+ locale ||= locale_from_user
+
+ # Use the locale from a custom domain if applicable
locale ||= locale_from_custom_domain
+
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
+
set_locale(locale, &)
end
def switch_locale_using_account_locale(&)
- locale = locale_from_account(@current_account)
+ # Get the locale from the user first
+ locale = locale_from_user
+
+ # Fallback to the account's locale if the user's locale is not set
+ locale ||= locale_from_account(@current_account)
+
set_locale(locale, &)
end
@@ -32,6 +43,12 @@ module SwitchLocale
@portal.default_locale
end
+ def locale_from_user
+ return unless @user
+
+ @user.ui_settings&.dig('locale')
+ end
+
def set_locale(locale, &)
safe_locale = validate_and_get_locale(locale)
# Ensure locale won't bleed into other requests
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 4a2df5ee5..d81b4c9da 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -66,7 +66,7 @@ class DashboardController < ActionController::Base
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
- FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
+ FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
index db312e94f..fd3dba87c 100644
--- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
+++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
@@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
- # find the user with their email instead of UID and token
- @resource = resource_class.where(
- email: auth_hash['info']['email']
- ).first
+ email = auth_hash.dig('info', 'email')
+ @resource = resource_class.from_email(email)
end
def validate_signup_email_is_business_domain?
@@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
'user'
end
end
+
+DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb
index 17dd32086..00976c3cd 100644
--- a/app/controllers/devise_overrides/passwords_controller.rb
+++ b/app/controllers/devise_overrides/passwords_controller.rb
@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
}, status: status
end
end
+
+DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 66b052b1e..46158bce9 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -58,6 +58,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME')
end
end
diff --git a/app/controllers/survey/responses_controller.rb b/app/controllers/survey/responses_controller.rb
index 8bbd0fe88..afcb3f4c0 100644
--- a/app/controllers/survey/responses_controller.rb
+++ b/app/controllers/survey/responses_controller.rb
@@ -5,6 +5,6 @@ class Survey::ResponsesController < ActionController::Base
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
end
end
diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb
index 70e4c967b..4be690ffe 100644
--- a/app/controllers/widgets_controller.rb
+++ b/app/controllers/widgets_controller.rb
@@ -14,7 +14,7 @@ class WidgetsController < ActionController::Base
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
end
def set_web_widget
diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb
index 3ed303556..15de0fbd7 100644
--- a/app/helpers/portal_helper.rb
+++ b/app/helpers/portal_helper.rb
@@ -1,4 +1,5 @@
module PortalHelper
+ include UrlHelper
def set_og_image_url(portal_name, title)
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
return if cdn_url.blank?
@@ -74,6 +75,17 @@ module PortalHelper
end
end
+ def generate_portal_brand_url(brand_url, referer)
+ url = URI.parse(brand_url.to_s)
+ query_params = Rack::Utils.parse_query(url.query)
+ query_params['utm_medium'] = 'helpcenter'
+ query_params['utm_campaign'] = 'branding'
+ query_params['utm_source'] = URI.parse(referer).host if url_valid?(referer)
+
+ url.query = query_params.to_query
+ url.to_s
+ end
+
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb
index 99f3fd36b..09a84b110 100644
--- a/app/helpers/report_helper.rb
+++ b/app/helpers/report_helper.rb
@@ -53,13 +53,13 @@ module ReportHelper
end
def resolutions
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
- conversations: { status: :resolved }, created_at: range).distinct
+ scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
+ created_at: range)
end
def bot_resolutions
- scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
- conversations: { status: :resolved }, created_at: range).distinct
+ scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
+ created_at: range)
end
def bot_handoffs
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index 8da7e7476..0fcb8c9fe 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -19,6 +19,7 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
+import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
name: 'App',
@@ -38,12 +39,14 @@ export default {
const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
+ const { uiSettings } = useUISettings();
return {
router,
store,
currentAccountId: accountId,
currentFontSize,
+ uiSettings,
};
},
data() {
@@ -88,7 +91,10 @@ export default {
mounted() {
this.initializeColorTheme();
this.listenToThemeChanges();
- this.setLocale(window.chatwootConfig.selectedLocale);
+ // If user locale is set, use it; otherwise use account locale
+ this.setLocale(
+ this.uiSettings?.locale || window.chatwootConfig.selectedLocale
+ );
},
unmounted() {
if (this.reconnectService) {
@@ -114,7 +120,8 @@ export default {
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
- this.setLocale(locale);
+ // If user locale is set, use it; otherwise use account locale
+ this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
diff --git a/app/javascript/dashboard/api/agentCapacityPolicies.js b/app/javascript/dashboard/api/agentCapacityPolicies.js
new file mode 100644
index 000000000..7792ce469
--- /dev/null
+++ b/app/javascript/dashboard/api/agentCapacityPolicies.js
@@ -0,0 +1,43 @@
+/* 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();
diff --git a/app/javascript/dashboard/api/assignmentPolicies.js b/app/javascript/dashboard/api/assignmentPolicies.js
new file mode 100644
index 000000000..e6baca97a
--- /dev/null
+++ b/app/javascript/dashboard/api/assignmentPolicies.js
@@ -0,0 +1,36 @@
+/* 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();
diff --git a/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js
new file mode 100644
index 000000000..43932aa71
--- /dev/null
+++ b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js
@@ -0,0 +1,98 @@
+import agentCapacityPolicies from '../agentCapacityPolicies';
+import ApiClient from '../ApiClient';
+
+describe('#AgentCapacityPoliciesAPI', () => {
+ it('creates correct instance', () => {
+ expect(agentCapacityPolicies).toBeInstanceOf(ApiClient);
+ expect(agentCapacityPolicies).toHaveProperty('get');
+ expect(agentCapacityPolicies).toHaveProperty('show');
+ expect(agentCapacityPolicies).toHaveProperty('create');
+ expect(agentCapacityPolicies).toHaveProperty('update');
+ expect(agentCapacityPolicies).toHaveProperty('delete');
+ expect(agentCapacityPolicies).toHaveProperty('getUsers');
+ expect(agentCapacityPolicies).toHaveProperty('addUser');
+ expect(agentCapacityPolicies).toHaveProperty('removeUser');
+ expect(agentCapacityPolicies).toHaveProperty('createInboxLimit');
+ expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit');
+ expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit');
+ });
+
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const axiosMock = {
+ get: vi.fn(() => Promise.resolve()),
+ post: vi.fn(() => Promise.resolve()),
+ put: vi.fn(() => Promise.resolve()),
+ delete: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ // Mock accountIdFromRoute
+ Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', {
+ get: () => '1',
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ });
+
+ it('#getUsers', () => {
+ agentCapacityPolicies.getUsers(123);
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/agent_capacity_policies/123/users'
+ );
+ });
+
+ it('#addUser', () => {
+ const userData = { id: 456, capacity: 20 };
+ agentCapacityPolicies.addUser(123, userData);
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/agent_capacity_policies/123/users',
+ {
+ user_id: 456,
+ capacity: 20,
+ }
+ );
+ });
+
+ it('#removeUser', () => {
+ agentCapacityPolicies.removeUser(123, 456);
+ expect(axiosMock.delete).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/agent_capacity_policies/123/users/456'
+ );
+ });
+
+ it('#createInboxLimit', () => {
+ const limitData = { inboxId: 1, conversationLimit: 10 };
+ agentCapacityPolicies.createInboxLimit(123, limitData);
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits',
+ {
+ inbox_id: 1,
+ conversation_limit: 10,
+ }
+ );
+ });
+
+ it('#updateInboxLimit', () => {
+ const limitData = { conversationLimit: 15 };
+ agentCapacityPolicies.updateInboxLimit(123, 789, limitData);
+ expect(axiosMock.put).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789',
+ {
+ conversation_limit: 15,
+ }
+ );
+ });
+
+ it('#deleteInboxLimit', () => {
+ agentCapacityPolicies.deleteInboxLimit(123, 789);
+ expect(axiosMock.delete).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789'
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js
new file mode 100644
index 000000000..8d0aea7d0
--- /dev/null
+++ b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js
@@ -0,0 +1,70 @@
+import assignmentPolicies from '../assignmentPolicies';
+import ApiClient from '../ApiClient';
+
+describe('#AssignmentPoliciesAPI', () => {
+ it('creates correct instance', () => {
+ expect(assignmentPolicies).toBeInstanceOf(ApiClient);
+ expect(assignmentPolicies).toHaveProperty('get');
+ expect(assignmentPolicies).toHaveProperty('show');
+ expect(assignmentPolicies).toHaveProperty('create');
+ expect(assignmentPolicies).toHaveProperty('update');
+ expect(assignmentPolicies).toHaveProperty('delete');
+ expect(assignmentPolicies).toHaveProperty('getInboxes');
+ expect(assignmentPolicies).toHaveProperty('setInboxPolicy');
+ expect(assignmentPolicies).toHaveProperty('getInboxPolicy');
+ expect(assignmentPolicies).toHaveProperty('removeInboxPolicy');
+ });
+
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const axiosMock = {
+ get: vi.fn(() => Promise.resolve()),
+ post: vi.fn(() => Promise.resolve()),
+ delete: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ // Mock accountIdFromRoute
+ Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', {
+ get: () => '1',
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ });
+
+ it('#getInboxes', () => {
+ assignmentPolicies.getInboxes(123);
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/assignment_policies/123/inboxes'
+ );
+ });
+
+ it('#setInboxPolicy', () => {
+ assignmentPolicies.setInboxPolicy(456, 123);
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/inboxes/456/assignment_policy',
+ {
+ assignment_policy_id: 123,
+ }
+ );
+ });
+
+ it('#getInboxPolicy', () => {
+ assignmentPolicies.getInboxPolicy(456);
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/inboxes/456/assignment_policy'
+ );
+ });
+
+ it('#removeInboxPolicy', () => {
+ assignmentPolicies.removeInboxPolicy(456);
+ expect(axiosMock.delete).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/inboxes/456/assignment_policy'
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue
new file mode 100644
index 000000000..10f301230
--- /dev/null
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue
@@ -0,0 +1,116 @@
+
+
+
+
+ {{ description }}
+ {{ description }}
+ {{ description }}
+
+ {{ description }}
+
+ {{ name }}
+
+ {{ title }}
+
+
+
+
+ {{ name }}
+
+
+ {{ label }}
+
+