Compare commits
219
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f4e7a243 | ||
|
|
08ab851a01 | ||
|
|
211d071832 | ||
|
|
3496764576 | ||
|
|
f590539b9b | ||
|
|
fc92e118ed | ||
|
|
7a453f50f4 | ||
|
|
458ed1e26d | ||
|
|
300d68f3f7 | ||
|
|
3ad2c33220 | ||
|
|
ca579bd62a | ||
|
|
699731d351 | ||
|
|
18a8e3db47 | ||
|
|
59ba91473a | ||
|
|
16b98b6017 | ||
|
|
de5fb7a405 | ||
|
|
55315089cf | ||
|
|
e5b8dc251f | ||
|
|
052b328a1f | ||
|
|
ff5d304541 | ||
|
|
06fa541f19 | ||
|
|
81b401c998 | ||
|
|
79b93bed77 | ||
|
|
257df30589 | ||
|
|
aba4e8bc53 | ||
|
|
7554156abe | ||
|
|
55633ab063 | ||
|
|
ffa124d0d5 | ||
|
|
b344bac1ba | ||
|
|
0e1c3c5596 | ||
|
|
b989ca6397 | ||
|
|
a4d2cb18f9 | ||
|
|
8d12cf0c6b | ||
|
|
6bdd4f0670 | ||
|
|
76c110e60e | ||
|
|
aa75347f96 | ||
|
|
0e481a690c | ||
|
|
ce1690eeb1 | ||
|
|
f2cabd6c82 | ||
|
|
33058b5f3f | ||
|
|
b46c07519a | ||
|
|
778c576ad7 | ||
|
|
4cba32b2d7 | ||
|
|
ef4e287f0d | ||
|
|
2320782f38 | ||
|
|
2f3b4ad215 | ||
|
|
b0112a2869 | ||
|
|
8606aa1310 | ||
|
|
d68ac25187 | ||
|
|
c53e750be0 | ||
|
|
f9c258f1a0 | ||
|
|
e863a52262 | ||
|
|
0a9edd4c3b | ||
|
|
f4643116df | ||
|
|
9145658597 | ||
|
|
99997a701a | ||
|
|
88cb5ba56f | ||
|
|
0c2ab7f5e7 | ||
|
|
583a533494 | ||
|
|
1ba00075ce | ||
|
|
3cefa9b767 | ||
|
|
b44d178acc | ||
|
|
472493b9af | ||
|
|
f801c1928d | ||
|
|
b18341ea6e | ||
|
|
068538e3d6 | ||
|
|
ad90deb709 | ||
|
|
39dfa35229 | ||
|
|
19faa7fdfa | ||
|
|
f6062f992e | ||
|
|
be721c2b50 | ||
|
|
7d6a43fc72 | ||
|
|
655db56be9 | ||
|
|
7f56cd92f8 | ||
|
|
c07529c2b0 | ||
|
|
d48503bdcf | ||
|
|
d35b0a9465 | ||
|
|
83873d49ae | ||
|
|
6ca38e10e9 | ||
|
|
1a1dfd09cb | ||
|
|
fee4975c59 | ||
|
|
35d0a7f1a7 | ||
|
|
85dd0baf0f | ||
|
|
47867c0b8a | ||
|
|
530125d4c5 | ||
|
|
7679004bb4 | ||
|
|
ae3ac33049 | ||
|
|
714f24de11 | ||
|
|
3038e672f8 | ||
|
|
7aad3c9c6b | ||
|
|
c6113852d7 | ||
|
|
94e930a141 | ||
|
|
7fe94dc1a2 | ||
|
|
cae097c5fa | ||
|
|
faf35738b3 | ||
|
|
341487b93e | ||
|
|
f569ed93ad | ||
|
|
eac292b11b | ||
|
|
75119d4a08 | ||
|
|
67d7ee0185 | ||
|
|
7e33e033cd | ||
|
|
02fa76f3df | ||
|
|
91d80004a6 | ||
|
|
b809cd2f15 | ||
|
|
ee773f31eb | ||
|
|
1f03fc4dc3 | ||
|
|
a42b99ada0 | ||
|
|
c6be04cdc1 | ||
|
|
14471cc20c | ||
|
|
6b42ff8d39 | ||
|
|
a88fef2e1d | ||
|
|
ee9f1d7adb | ||
|
|
b711bfd2ca | ||
|
|
42af4b1d01 | ||
|
|
9a7318a9db | ||
|
|
48fa7bf72b | ||
|
|
469e724e3a | ||
|
|
0c101b1f6b | ||
|
|
ad4ec9e93b | ||
|
|
5c560c7628 | ||
|
|
dbb164a37d | ||
|
|
fdcfed2cd7 | ||
|
|
b4f758f644 | ||
|
|
4df58501e3 | ||
|
|
693309202e | ||
|
|
ecd9cf0326 | ||
|
|
81d8d3862d | ||
|
|
c31325e982 | ||
|
|
28452b300d | ||
|
|
6784eb9b3d | ||
|
|
1baf5cbe19 | ||
|
|
c2e6ad6376 | ||
|
|
d908c880d2 | ||
|
|
fcc6e2b8b2 | ||
|
|
6cab741392 | ||
|
|
f3bc2476fc | ||
|
|
3214d06a83 | ||
|
|
8ea21eeefd | ||
|
|
fd28ed8d83 | ||
|
|
b5f5c5c1bc | ||
|
|
4ebfae8b44 | ||
|
|
d0824cc86a | ||
|
|
d2583d32e9 | ||
|
|
462ab5241c | ||
|
|
f984d745e7 | ||
|
|
ca13664ef9 | ||
|
|
304c938260 | ||
|
|
d5286c9535 | ||
|
|
855dd590ab | ||
|
|
7e70f7a68a | ||
|
|
84fd769570 | ||
|
|
661e905dbd | ||
|
|
270f26e471 | ||
|
|
53fce7be03 | ||
|
|
60a1e9b15d | ||
|
|
65312744c7 | ||
|
|
51b9fd8eca | ||
|
|
4dc7a653eb | ||
|
|
5ab913f7b5 | ||
|
|
c98c255ed0 | ||
|
|
446a219cd1 | ||
|
|
94829aea8d | ||
|
|
86cb4fd809 | ||
|
|
b434279422 | ||
|
|
d9900e50a0 | ||
|
|
97f1825a14 | ||
|
|
5ed84af2e3 | ||
|
|
df4de508e7 | ||
|
|
1230d1f251 | ||
|
|
62b36d4aec | ||
|
|
75c57ad039 | ||
|
|
6475a6a593 | ||
|
|
441cc065ae | ||
|
|
0b74be82a3 | ||
|
|
cbc2807296 | ||
|
|
c09e875c83 | ||
|
|
87313ecc35 | ||
|
|
8262123481 | ||
|
|
420be64c45 | ||
|
|
9acb0d86b5 | ||
|
|
2a5ecf84a1 | ||
|
|
286e3a449d | ||
|
|
e5ee6027b4 | ||
|
|
eb412b67bd | ||
|
|
6fb762f96c | ||
|
|
ab1ba1c4c7 | ||
|
|
d276025419 | ||
|
|
94b7154926 | ||
|
|
60951b45fd | ||
|
|
69ad953ae6 | ||
|
|
c4b076dbb2 | ||
|
|
dc49ae2515 | ||
|
|
b71a0da10d | ||
|
|
bb9e3a5495 | ||
|
|
647a65d481 | ||
|
|
99ec18c95b | ||
|
|
64ba23688b | ||
|
|
2fec2e5993 | ||
|
|
090316a078 | ||
|
|
fcd604dcde | ||
|
|
13b4fdb34c | ||
|
|
45d4d3660c | ||
|
|
0ea616a6ea | ||
|
|
6b8dd3c86a | ||
|
|
61d10044a0 | ||
|
|
4378506a35 | ||
|
|
1132556512 | ||
|
|
93f18315cc | ||
|
|
d89fa09359 | ||
|
|
07dd6448f4 | ||
|
|
661326ae51 | ||
|
|
18eb53acf4 | ||
|
|
1b02ebec68 | ||
|
|
5b9f997fa0 | ||
|
|
802f0694ed | ||
|
|
17500cc62d | ||
|
|
5140deb6f6 | ||
|
|
d1080c22cf | ||
|
|
819699307c |
+14
-22
@@ -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
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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'
|
||||
@@ -0,0 +1,28 @@
|
||||
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}`);
|
||||
@@ -94,3 +94,8 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
*
|
||||
!configs
|
||||
!configs/**
|
||||
!hooks
|
||||
!hooks/**
|
||||
!qlty.toml
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
ignored:
|
||||
- DL3008
|
||||
@@ -0,0 +1 @@
|
||||
source-path=SCRIPTDIR
|
||||
@@ -0,0 +1,8 @@
|
||||
rules:
|
||||
document-start: disable
|
||||
quoted-strings:
|
||||
required: only-when-needed
|
||||
extra-allowed: ["{|}"]
|
||||
key-duplicates: {}
|
||||
octal-values:
|
||||
forbid-implicit-octal: true
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
|
||||
Enabled: false
|
||||
|
||||
Layout/TrailingEmptyLines:
|
||||
Enabled: false
|
||||
Enabled: true
|
||||
|
||||
Style/SafeNavigationChainLength:
|
||||
Enabled: false
|
||||
|
||||
@@ -55,4 +55,21 @@
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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'
|
||||
@@ -108,7 +113,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
# ref application.rb
|
||||
gem 'ddtrace', require: false
|
||||
gem 'datadog', '~> 2.0', require: false
|
||||
gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
@@ -121,6 +126,8 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
gem 'sidekiq', '>= 7.3.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
# for sidekiq healthcheck
|
||||
gem 'sidekiq_alive'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
@@ -165,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'
|
||||
|
||||
@@ -177,6 +185,10 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
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'
|
||||
|
||||
@@ -206,6 +218,8 @@ group :development do
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
|
||||
gem 'tidewave'
|
||||
end
|
||||
|
||||
group :test do
|
||||
@@ -215,6 +229,7 @@ group :test do
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
gem 'test-prof'
|
||||
gem 'simplecov_json_formatter', require: false
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -239,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
|
||||
|
||||
+179
-83
@@ -25,35 +25,35 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actioncable (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actionmailbox (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actionmailer (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actionpack (7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
@@ -61,38 +61,38 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actiontext (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actionview (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
activejob (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
activerecord (7.1.5.1)
|
||||
activemodel (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
activemodel (7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
activerecord (7.1.5.2)
|
||||
activemodel (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.1.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
activestorage (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.1.5.1)
|
||||
activesupport (7.1.5.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -126,6 +126,8 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.4.3)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -153,10 +155,10 @@ GEM
|
||||
barnes (0.0.9)
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.2.0)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -192,10 +194,14 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
ddtrace (0.48.0)
|
||||
ffi (~> 1.0)
|
||||
datadog (2.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)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -213,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)
|
||||
@@ -224,6 +230,35 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.3)
|
||||
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)
|
||||
regexp_parser (~> 2.2)
|
||||
elastic-apm (4.6.2)
|
||||
@@ -264,6 +299,16 @@ 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
|
||||
dry-schema (~> 1.14)
|
||||
json (~> 2.0)
|
||||
mime-types (~> 3.4)
|
||||
rack (~> 3.1)
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
@@ -359,6 +404,7 @@ GEM
|
||||
grpc (1.72.0-x86_64-linux)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
gserver (0.0.1)
|
||||
haikunator (1.1.1)
|
||||
hairtrigger (1.0.0)
|
||||
activerecord (>= 6.0, < 8)
|
||||
@@ -401,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)
|
||||
@@ -443,6 +489,16 @@ GEM
|
||||
logger (~> 1.6)
|
||||
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)
|
||||
lint_roller (1.1.0)
|
||||
liquid (5.4.0)
|
||||
@@ -477,7 +533,7 @@ GEM
|
||||
mime-types-data (3.2023.0218.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
@@ -508,14 +564,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.18.8)
|
||||
nokogiri (1.18.9)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
nokogiri (1.18.9-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
nokogiri (1.18.9-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -533,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)
|
||||
@@ -548,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)
|
||||
@@ -576,7 +639,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.15)
|
||||
rack (3.2.0)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -585,33 +648,34 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (3.2.0)
|
||||
rack-protection (4.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (7.1.5.1)
|
||||
actioncable (= 7.1.5.1)
|
||||
actionmailbox (= 7.1.5.1)
|
||||
actionmailer (= 7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
actiontext (= 7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activemodel (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.1.5.2)
|
||||
actioncable (= 7.1.5.2)
|
||||
actionmailbox (= 7.1.5.2)
|
||||
actionmailer (= 7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
actiontext (= 7.1.5.2)
|
||||
actionview (= 7.1.5.2)
|
||||
activejob (= 7.1.5.2)
|
||||
activemodel (= 7.1.5.2)
|
||||
activerecord (= 7.1.5.2)
|
||||
activestorage (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.1.5.1)
|
||||
railties (= 7.1.5.2)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -619,9 +683,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.1)
|
||||
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)
|
||||
railties (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
railties (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -713,12 +777,25 @@ GEM
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.5.1)
|
||||
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)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -738,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)
|
||||
@@ -776,16 +856,20 @@ GEM
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6)
|
||||
sidekiq_alive (2.5.0)
|
||||
gserver (~> 0.0.1)
|
||||
sidekiq (>= 5, < 9)
|
||||
signet (0.17.0)
|
||||
addressable (~> 2.8)
|
||||
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
|
||||
@@ -814,7 +898,11 @@ GEM
|
||||
stripe (8.5.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.3.1)
|
||||
thor (1.4.0)
|
||||
tidewave (0.2.0)
|
||||
fast-mcp (~> 1.5.0)
|
||||
rack (>= 2.0)
|
||||
rails (>= 7.1.0)
|
||||
tilt (2.3.0)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
@@ -868,7 +956,6 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
@@ -897,6 +984,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.4.3)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -913,7 +1001,7 @@ DEPENDENCIES
|
||||
commonmarker
|
||||
csv-safe
|
||||
database_cleaner
|
||||
ddtrace
|
||||
datadog (~> 2.0)
|
||||
debug (~> 1.8)
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
@@ -925,6 +1013,7 @@ DEPENDENCIES
|
||||
facebook-messenger
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
@@ -965,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
|
||||
@@ -990,8 +1081,10 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
@@ -1000,7 +1093,9 @@ DEPENDENCIES
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
simplecov (= 0.17.1)
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
slack-ruby-client (~> 2.5.2)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
@@ -1009,6 +1104,7 @@ DEPENDENCIES
|
||||
stripe
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
time_diff
|
||||
twilio-ruby
|
||||
twitty (~> 0.1.5)
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
||||
|
||||
require_relative 'config/application'
|
||||
# Load Enterprise Edition rake tasks if they exist
|
||||
enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
|
||||
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
|
||||
|
||||
Rails.application.load_tasks
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.13.0
|
||||
4.4.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.2.0
|
||||
3.4.3
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# We don't want to update the name of the identified original contact.
|
||||
|
||||
class ContactIdentifyAction
|
||||
include UrlHelper
|
||||
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
|
||||
|
||||
def perform
|
||||
@@ -104,7 +105,14 @@ class ContactIdentifyAction
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached?
|
||||
enqueue_avatar_job
|
||||
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
|
||||
|
||||
def merge_contact(base_contact, merge_contact)
|
||||
|
||||
@@ -21,8 +21,6 @@ class ContactInboxBuilder
|
||||
email_source_id
|
||||
when 'Channel::Sms'
|
||||
phone_source_id
|
||||
when 'Channel::Voice'
|
||||
phone_source_id # Voice uses phone number as source ID
|
||||
when 'Channel::Api', 'Channel::WebWidget'
|
||||
SecureRandom.uuid
|
||||
else
|
||||
@@ -37,12 +35,7 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def phone_source_id
|
||||
unless @contact.phone_number.present?
|
||||
# For voice channels, we'll create a fallback source ID if phone number is missing
|
||||
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Voice'
|
||||
|
||||
raise ActionController::ParameterMissing, 'contact phone number'
|
||||
end
|
||||
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
|
||||
|
||||
@contact.phone_number
|
||||
end
|
||||
@@ -107,6 +100,6 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def allowed_channels?
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? || @inbox.channel_type == 'Channel::Voice'
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ class Messages::MessageBuilder
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@message_type = params[:message_type].to_s || 'outgoing'
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
@@ -33,6 +33,11 @@ class Messages::MessageBuilder
|
||||
def content_attributes
|
||||
params = convert_to_hash(@params)
|
||||
content_attributes = params.fetch(:content_attributes, {})
|
||||
|
||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return content_attributes if content_attributes.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
|
||||
# Converts the given object to a hash.
|
||||
@@ -100,9 +105,8 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def message_type
|
||||
# Allow incoming messages in both API and Voice channels
|
||||
if !['Channel::Api', 'Channel::Voice'].include?(@conversation.inbox.channel_type) && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api and Voice inboxes'
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
end
|
||||
|
||||
@message_type
|
||||
@@ -135,7 +139,7 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def message_params
|
||||
message_attrs = {
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
@@ -148,12 +152,5 @@ class Messages::MessageBuilder
|
||||
echo_id: @params[:echo_id],
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
|
||||
# Directly add content_attributes from params if present
|
||||
if @params[:content_attributes].present?
|
||||
message_attrs[:content_attributes] = content_attributes
|
||||
end
|
||||
|
||||
message_attrs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
|
||||
def index
|
||||
@inboxes = @assignment_policy.inboxes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(
|
||||
params[:assignment_policy_id]
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@assignment_policies = Current.account.assignment_policies
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@assignment_policy.update!(assignment_policy_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@assignment_policy.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
||||
end
|
||||
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -30,7 +30,14 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def facebook_pages
|
||||
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
|
||||
pages = []
|
||||
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
|
||||
|
||||
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
|
||||
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
|
||||
:scheduled_at, audience: [:type, :id], trigger_rules: {})
|
||||
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
|
||||
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
|
||||
protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
|
||||
before_action :validate_twilio_signature, only: [:incoming]
|
||||
before_action :handle_options_request, only: [:incoming, :conference_status]
|
||||
|
||||
# Handle CORS preflight OPTIONS requests
|
||||
def handle_options_request
|
||||
if request.method == "OPTIONS"
|
||||
set_cors_headers
|
||||
head :ok
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
|
||||
headers['Access-Control-Max-Age'] = '86400' # 24 hours
|
||||
end
|
||||
|
||||
# Handle incoming calls from Twilio
|
||||
def incoming
|
||||
# Set CORS headers first to ensure they're included
|
||||
set_cors_headers
|
||||
|
||||
# Log basic request info
|
||||
Rails.logger.info("🔔 INCOMING CALL WEBHOOK: CallSid=#{params['CallSid']} From=#{params['From']} To=#{params['To']}")
|
||||
|
||||
# Process incoming call using service
|
||||
begin
|
||||
# Ensure account is set properly
|
||||
if !Current.account && params[:account_id].present?
|
||||
Current.account = Account.find(params[:account_id])
|
||||
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
|
||||
end
|
||||
|
||||
# Validate required parameters
|
||||
validate_incoming_params
|
||||
|
||||
# Process the call
|
||||
service = Voice::IncomingCallService.new(
|
||||
account: Current.account,
|
||||
params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
|
||||
)
|
||||
twiml_response = service.process
|
||||
|
||||
# Return TwiML response
|
||||
Rails.logger.info("✅ INCOMING CALL: Successfully processed")
|
||||
render xml: twiml_response
|
||||
rescue StandardError => e
|
||||
# Log the error with detailed information
|
||||
Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
|
||||
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
|
||||
|
||||
# Return friendly error message to caller
|
||||
render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle conference status updates
|
||||
def conference_status
|
||||
# Set CORS headers first to ensure they're always included
|
||||
set_cors_headers
|
||||
|
||||
# Return immediately for OPTIONS requests
|
||||
if request.method == "OPTIONS"
|
||||
return head :ok
|
||||
end
|
||||
|
||||
# Log basic request info
|
||||
Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
|
||||
|
||||
# Process conference status updates using service
|
||||
begin
|
||||
# Set account for local development if needed
|
||||
if !Current.account && params[:account_id].present?
|
||||
Current.account = Account.find(params[:account_id])
|
||||
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
|
||||
end
|
||||
|
||||
# Validate required parameters
|
||||
if params['ConferenceSid'].blank? && params['CallSid'].blank?
|
||||
Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
|
||||
end
|
||||
|
||||
# Use service to process conference status
|
||||
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
|
||||
service.process
|
||||
|
||||
Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
|
||||
rescue StandardError => e
|
||||
# Log errors but don't affect the response
|
||||
Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
|
||||
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
|
||||
end
|
||||
|
||||
# Always return a successful response for Twilio
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_incoming_params
|
||||
if params['CallSid'].blank?
|
||||
raise "Missing required parameter: CallSid"
|
||||
end
|
||||
|
||||
if params['From'].blank?
|
||||
raise "Missing required parameter: From"
|
||||
end
|
||||
|
||||
if params['To'].blank?
|
||||
raise "Missing required parameter: To"
|
||||
end
|
||||
|
||||
if Current.account.nil?
|
||||
raise "Current account not set"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_twilio_signature
|
||||
begin
|
||||
validator = Voice::TwilioValidatorService.new(
|
||||
account: Current.account,
|
||||
params: params,
|
||||
request: request
|
||||
)
|
||||
|
||||
if !validator.valid?
|
||||
Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
|
||||
render_error('Invalid Twilio signature')
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
|
||||
render_error('Error validating Twilio request')
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
def render_error(message)
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: message)
|
||||
response.hangup
|
||||
render xml: response.to_s
|
||||
end
|
||||
end
|
||||
@@ -1,39 +0,0 @@
|
||||
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_contact
|
||||
|
||||
def create
|
||||
# Validate that contact has a phone number
|
||||
if @contact.phone_number.blank?
|
||||
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Use the outgoing call service to handle the entire process
|
||||
service = Voice::OutgoingCallService.new(
|
||||
account: Current.account,
|
||||
contact: @contact,
|
||||
user: Current.user
|
||||
)
|
||||
|
||||
# Process the call - this handles all the steps
|
||||
conversation = service.process
|
||||
|
||||
# Assign to @conversation so jbuilder template can access it
|
||||
@conversation = conversation
|
||||
|
||||
# Use the conversation jbuilder template to ensure consistent representation
|
||||
# This will ensure only display_id is used as the id, not the internal database id
|
||||
render 'api/v1/accounts/conversations/show'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error initiating call: #{e.message}")
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_contact
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
end
|
||||
@@ -122,7 +122,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def resolved_contacts
|
||||
return @resolved_contacts if @resolved_contacts
|
||||
|
||||
@resolved_contacts = Current.account.contacts.resolved_contacts
|
||||
@resolved_contacts = Current.account.contacts.resolved_contacts(use_crm_v2: Current.account.feature_enabled?('crm_v2'))
|
||||
|
||||
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
|
||||
@resolved_contacts
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :fetch_assignment_policy, only: [:create]
|
||||
before_action -> { check_authorization(AssignmentPolicy) }
|
||||
before_action :validate_assignment_policy, only: [:show, :destroy]
|
||||
|
||||
def show
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def create
|
||||
# There should be only one assignment policy for an inbox.
|
||||
# If there is a new request to add an assignment policy, we will
|
||||
# delete the old one and attach the new policy
|
||||
remove_inbox_assignment_policy
|
||||
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
|
||||
@assignment_policy = @inbox.assignment_policy
|
||||
end
|
||||
|
||||
def destroy
|
||||
remove_inbox_assignment_policy
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_inbox_assignment_policy
|
||||
@inbox.inbox_assignment_policy&.destroy
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
end
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:assignment_policy_id, :inbox_id)
|
||||
end
|
||||
|
||||
def validate_assignment_policy
|
||||
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
|
||||
end
|
||||
end
|
||||
@@ -69,6 +69,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -163,8 +172,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
'line' => Channel::Line,
|
||||
'telegram' => Channel::Telegram,
|
||||
'whatsapp' => Channel::Whatsapp,
|
||||
'sms' => Channel::Sms,
|
||||
'voice' => Channel::Voice
|
||||
'sms' => Channel::Sms
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
@@ -175,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
[]
|
||||
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
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
||||
# @portal.custom_domain = parsed_custom_domain
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_record_invalid(e)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,6 +46,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
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
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@@ -60,12 +73,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id)
|
||||
params.permit(:id, :email)
|
||||
end
|
||||
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
@@ -88,4 +101,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
domain = URI.parse(@portal.custom_domain)
|
||||
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
|
||||
end
|
||||
|
||||
def valid_email?(email)
|
||||
ValidEmail2::Address.new(email).valid?
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController')
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
class Api::V1::Accounts::Voice::TokensController < Api::V1::Accounts::BaseController
|
||||
before_action :set_voice_inbox
|
||||
|
||||
def create
|
||||
render json: build_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Voice::TokensController#create: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||
render json: { error: 'Failed to generate token', details: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_response
|
||||
{
|
||||
token: twilio_token.to_jwt,
|
||||
identity: client_identity,
|
||||
voice_enabled: true,
|
||||
account_sid: twilio_config[:account_sid],
|
||||
agent_id: Current.user.id,
|
||||
account_id: Current.account.id,
|
||||
inbox_id: @voice_inbox.id,
|
||||
phone_number: twilio_config[:phone_number],
|
||||
twiml_endpoint: twilio_config[:twiml_url],
|
||||
has_twiml_app: twilio_config[:outgoing_app_sid].present?
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_token
|
||||
Twilio::JWT::AccessToken.new(
|
||||
*twilio_credentials,
|
||||
identity: client_identity,
|
||||
ttl: 1.hour.to_i
|
||||
).tap { |t| t.add_grant(voice_grant) }
|
||||
end
|
||||
|
||||
def twilio_credentials
|
||||
twilio_config.values_at(:account_sid, :api_key_sid, :api_key_secret)
|
||||
end
|
||||
|
||||
def voice_grant
|
||||
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
|
||||
grant.incoming_allow = true
|
||||
grant.outgoing_application_sid = twilio_config[:outgoing_app_sid]
|
||||
grant.outgoing_application_params = outgoing_params
|
||||
end
|
||||
end
|
||||
|
||||
def outgoing_params
|
||||
{
|
||||
account_id: Current.account.id,
|
||||
agent_id: Current.user.id,
|
||||
identity: client_identity,
|
||||
client_name: client_identity,
|
||||
accountSid: twilio_config[:account_sid],
|
||||
is_agent: 'true'
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_config
|
||||
@twilio_config ||= begin
|
||||
cfg = @voice_inbox.channel.provider_config_hash || {}
|
||||
{
|
||||
account_sid: cfg['account_sid'],
|
||||
api_key_sid: cfg['api_key_sid'],
|
||||
api_key_secret: cfg['api_key_secret'],
|
||||
outgoing_app_sid: cfg['outgoing_application_sid'],
|
||||
phone_number: @voice_inbox.channel.phone_number,
|
||||
twiml_url: "#{ENV.fetch('FRONTEND_URL', '')}/api/v1/accounts/#{Current.account.id}/voice/twiml_for_client"
|
||||
}.with_indifferent_access.merge(client_identity:)
|
||||
end
|
||||
end
|
||||
|
||||
def client_identity
|
||||
@client_identity ||= "agent-#{Current.user.id}-account-#{Current.account.id}"
|
||||
end
|
||||
|
||||
def set_voice_inbox
|
||||
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
end
|
||||
@@ -1,230 +0,0 @@
|
||||
require 'twilio-ruby'
|
||||
|
||||
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: %i[end_call join_call reject_call]
|
||||
skip_before_action :authenticate_user!, only: :twiml_for_client
|
||||
protect_from_forgery with: :null_session, only: :twiml_for_client
|
||||
|
||||
before_action :render_options, if: -> { request.options? }
|
||||
after_action :set_cors_headers, if: -> { action_name == 'twiml_for_client' }
|
||||
|
||||
# ---------- PUBLIC ACTIONS --------------------------------------------------
|
||||
|
||||
def end_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
|
||||
|
||||
broadcast_status(call_sid, 'completed')
|
||||
render_success('Call successfully ended')
|
||||
rescue StandardError => e
|
||||
render_error("Failed to end call: #{e.message}")
|
||||
end
|
||||
|
||||
def join_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
outbound = convo_attr('requires_agent_join') == true
|
||||
|
||||
return render_not_found('active call') unless call_sid || outbound
|
||||
|
||||
conference_sid = convo_attr('conference_sid') || create_conference_sid!
|
||||
update_join_metadata!(call_sid)
|
||||
broadcast_status(call_sid, 'in-progress')
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
message: 'Agent joining call via WebRTC',
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true,
|
||||
conversation_id: @conversation.display_id,
|
||||
account_id: Current.account.id
|
||||
}
|
||||
rescue StandardError => e
|
||||
render_error("Failed to join call: #{e.message}")
|
||||
end
|
||||
|
||||
def reject_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||||
'agent_rejected' => true,
|
||||
'rejected_at' => Time.current.to_i,
|
||||
'rejected_by' => user_meta
|
||||
))
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.create_activity_message("#{current_user.name} declined to answer",
|
||||
rejected_by: current_user.name,
|
||||
rejected_at: Time.current.to_i)
|
||||
|
||||
render_success('Call rejected by agent')
|
||||
end
|
||||
|
||||
def call_status
|
||||
call_sid = params[:call_sid]
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
call = twilio_client.calls(call_sid).fetch
|
||||
render json: call.slice(:status, :duration, :direction, :from, :to, :start_time, :end_time)
|
||||
rescue StandardError => e
|
||||
render_error("Failed to fetch call status: #{e.message}")
|
||||
end
|
||||
|
||||
# TwiML for agent WebRTC dial‑in
|
||||
def twiml_for_client
|
||||
to = params[:To] || params[:to]
|
||||
return render_twiml_error('Missing conference ID parameter') if to.blank?
|
||||
|
||||
render xml: build_twiml(to), content_type: 'text/xml'
|
||||
rescue StandardError => e
|
||||
render_twiml_error(e.message)
|
||||
end
|
||||
|
||||
# ---------- PRIVATE ---------------------------------------------------------
|
||||
|
||||
private
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
def render_options
|
||||
head :ok
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Content-Type'] ||= 'text/xml; charset=utf-8'
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
|
||||
headers['Access-Control-Max-Age'] = '86400'
|
||||
end
|
||||
|
||||
def render_success(msg) = render json: { status: 'success', message: msg }
|
||||
def render_not_found(resource) = render json: { error: "No #{resource} found" }, status: :not_found
|
||||
def render_error(msg) = render json: { error: msg }, status: :internal_server_error
|
||||
|
||||
def fetch_conversation
|
||||
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= begin
|
||||
cfg = @conversation.inbox.channel.provider_config_hash
|
||||
Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
|
||||
end
|
||||
end
|
||||
|
||||
def in_progress?(call_sid)
|
||||
%w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status)
|
||||
end
|
||||
|
||||
def convo_attrs
|
||||
@conversation.additional_attributes || {}
|
||||
end
|
||||
|
||||
def convo_attr(key)
|
||||
convo_attrs[key]
|
||||
end
|
||||
|
||||
def user_meta
|
||||
{ id: current_user.id, name: current_user.name }
|
||||
end
|
||||
|
||||
def create_conference_sid!
|
||||
sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid))
|
||||
sid
|
||||
end
|
||||
|
||||
def update_join_metadata!(call_sid)
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => user_meta,
|
||||
'call_status' => 'in-progress'
|
||||
))
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.process_status_update('in-progress', nil, false, "#{current_user.name} joined the call")
|
||||
end
|
||||
|
||||
def broadcast_status(call_sid, status)
|
||||
ActionCable.server.broadcast "account_#{@conversation.account_id}", {
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: status,
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
timestamp: Time.current.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# ---- TwiML -----------------------------------------------------------------
|
||||
|
||||
def build_twiml(conference_name)
|
||||
# For agent legs, we need to add transcription too
|
||||
account_id = params[:account_id] || Current.account&.id
|
||||
agent_id = params[:agent_id] || current_user&.id
|
||||
transcription_url = "#{base_url}/twilio/transcription_callback?account_id=#{account_id}&conference_sid=#{conference_name}&speaker_type=agent&agent_id=#{agent_id}"
|
||||
|
||||
Twilio::TwiML::VoiceResponse.new do |r|
|
||||
# Add transcription for the agent leg too
|
||||
r.start do |start|
|
||||
start.transcription(
|
||||
status_callback_url: transcription_url,
|
||||
status_callback_method: 'POST',
|
||||
track: 'inbound_track', # Use inbound_track consistently for conference calls
|
||||
language_code: 'en-US'
|
||||
)
|
||||
end
|
||||
|
||||
r.dial do |dial|
|
||||
dial.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: true,
|
||||
endConferenceOnExit: true,
|
||||
muted: false,
|
||||
beep: false,
|
||||
waitUrl: '',
|
||||
earlyMedia: true,
|
||||
statusCallback: conference_callback_url,
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
statusCallbackMethod: 'POST',
|
||||
participantLabel: "agent-#{params[:agent_id] || current_user&.id}"
|
||||
)
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def conference_callback_url
|
||||
account_id = params[:account_id] || Current.account&.id
|
||||
"#{base_url.chomp('/')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status"
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', '')
|
||||
end
|
||||
|
||||
# ---- TwiML Error -----------------------------------------------------------
|
||||
|
||||
def render_twiml_error(message)
|
||||
response = Twilio::TwiML::VoiceResponse.new do |r|
|
||||
r.say(message: "Error: #{message}")
|
||||
r.hangup
|
||||
end
|
||||
set_cors_headers
|
||||
render xml: response.to_s, content_type: 'text/xml'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,7 +66,9 @@ 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?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
}, status: status
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
|
||||
|
||||
@@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController
|
||||
after_action :set_version_header
|
||||
|
||||
def identity_association
|
||||
microsoft_indentity
|
||||
microsoft_identity
|
||||
end
|
||||
|
||||
private
|
||||
@@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController
|
||||
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
|
||||
end
|
||||
|
||||
def microsoft_indentity
|
||||
def microsoft_identity
|
||||
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
|
||||
def notion_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
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 create
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,12 @@ class SlackUploadsController < ApplicationController
|
||||
end
|
||||
|
||||
def blob_url
|
||||
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
||||
# Only generate representations for images
|
||||
if @blob.content_type.start_with?('image/')
|
||||
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
||||
else
|
||||
url_for(@blob)
|
||||
end
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
|
||||
@@ -39,8 +39,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||
'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])
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
class SuperAdmin::ApplicationController < Administrate::ApplicationController
|
||||
include ActionView::Helpers::TagHelper
|
||||
include ActionView::Context
|
||||
include SuperAdmin::NavigationHelper
|
||||
|
||||
helper_method :render_vue_component
|
||||
helper_method :render_vue_component, :settings_open?, :settings_pages
|
||||
# authenticiation done via devise : SuperAdmin Model
|
||||
before_action :authenticate_super_admin!
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController
|
||||
:NumMedia,
|
||||
:Latitude,
|
||||
:Longitude,
|
||||
:MessageType
|
||||
:MessageType,
|
||||
:ProfileName
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Twilio::RecordingController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
# POST /twilio/recording_callback
|
||||
# This endpoint is called by Twilio when a call recording is available
|
||||
def recording_callback
|
||||
conference_sid = params['conference_sid']
|
||||
call_sid = params['CallSid']
|
||||
recording_url = params['RecordingUrl']
|
||||
recording_sid = params['RecordingSid']
|
||||
account_id = params['account_id']
|
||||
Rails.logger.info("[Twilio::RecordingController] Incoming recording_callback with params: #{params.inspect}")
|
||||
unless recording_url && account_id && (conference_sid || call_sid)
|
||||
Rails.logger.warn("[Twilio::RecordingController] Missing required params. recording_url: #{recording_url}, account_id: #{account_id}, conference_sid: #{conference_sid}, call_sid: #{call_sid}")
|
||||
return head :bad_request
|
||||
end
|
||||
|
||||
# Find the account
|
||||
account = Account.find_by(id: account_id)
|
||||
unless account
|
||||
Rails.logger.warn("[Twilio::RecordingController] Account not found for id: #{account_id}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Prefer lookup by conference_sid (most robust for conference recordings)
|
||||
conversation = if conference_sid
|
||||
account.conversations.find_by("additional_attributes ->> 'conference_sid' = ?", conference_sid)
|
||||
elsif call_sid
|
||||
account.conversations.find_by("additional_attributes ->> 'call_sid' = ?", call_sid)
|
||||
end
|
||||
unless conversation
|
||||
Rails.logger.warn("[Twilio::RecordingController] Conversation not found for conference_sid: #{conference_sid} or call_sid: #{call_sid}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Find the original voice call message (should be unique per conference)
|
||||
message = conversation.messages.voice_call.order(:created_at).first
|
||||
unless message
|
||||
Rails.logger.warn("[Twilio::RecordingController] No voice_call message found in conversation_id: #{conversation.id}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Download the recording from Twilio
|
||||
begin
|
||||
Rails.logger.info("[Twilio::RecordingController] Downloading recording from: #{recording_url}.mp3")
|
||||
file = URI.open(recording_url + '.mp3')
|
||||
rescue => e
|
||||
Rails.logger.error("[Twilio::RecordingController] Failed to download recording: #{e.message}")
|
||||
return head :internal_server_error
|
||||
end
|
||||
|
||||
# Attach the audio file to the message as an audio attachment
|
||||
begin
|
||||
att = message.attachments.create!(
|
||||
account_id: account.id,
|
||||
file: {
|
||||
io: file,
|
||||
filename: "twilio_recording_#{recording_sid}.mp3",
|
||||
content_type: 'audio/mpeg'
|
||||
},
|
||||
file_type: :audio,
|
||||
external_url: recording_url + '.mp3',
|
||||
meta: { recording_sid: recording_sid, conference_sid: conference_sid, call_sid: call_sid }
|
||||
)
|
||||
Rails.logger.info("[Twilio::RecordingController] Successfully attached recording to message_id: #{message.id}, attachment_id: #{att.id}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Twilio::RecordingController] Failed to attach recording: #{e.message}")
|
||||
return head :internal_server_error
|
||||
end
|
||||
|
||||
# Optionally, update message content_attributes to indicate recording is attached
|
||||
content_attributes = message.content_attributes || {}
|
||||
content_attributes['recording_attached'] = true
|
||||
content_attributes['conference_sid'] = conference_sid if conference_sid
|
||||
message.update!(content_attributes: content_attributes)
|
||||
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -1,62 +0,0 @@
|
||||
class Twilio::TranscriptionController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
# Receives real-time transcription updates from Twilio
|
||||
def transcription_callback
|
||||
# Set Current.account
|
||||
Current.account = Account.find_by(id: params[:account_id])
|
||||
|
||||
# Only process transcription content events
|
||||
if params['TranscriptionEvent'] == 'transcription-content'
|
||||
process_transcription_content
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_transcription_content
|
||||
# Extract transcript content from JSON
|
||||
data = JSON.parse(params['TranscriptionData'])
|
||||
transcript_content = data['transcript']
|
||||
confidence = data['confidence']
|
||||
|
||||
|
||||
# Find conversation by conference_sid from our standard format
|
||||
display_id = params[:conference_sid].match(/^conf_account_\d+_conv_(\d+)$/)[1]
|
||||
conversation = Current.account.conversations.find_by(display_id: display_id)
|
||||
|
||||
# Create message based on speaker_type
|
||||
create_message(conversation, transcript_content, confidence)
|
||||
end
|
||||
|
||||
def create_message(conversation, content, confidence)
|
||||
if params[:speaker_type] == 'contact'
|
||||
# Contact message (incoming)
|
||||
sender = conversation.contact
|
||||
message_type = :incoming
|
||||
else
|
||||
# Agent message (outgoing)
|
||||
sender = User.find_by(id: params[:agent_id])
|
||||
message_type = :outgoing
|
||||
end
|
||||
|
||||
# Create the message
|
||||
Messages::MessageBuilder.new(
|
||||
sender,
|
||||
conversation,
|
||||
content: content,
|
||||
message_type: message_type,
|
||||
private: false,
|
||||
additional_attributes: {
|
||||
transcription: true,
|
||||
call_sid: params['CallSid'],
|
||||
conference_sid: params[:conference_sid],
|
||||
speaker_type: params[:speaker_type],
|
||||
confidence: confidence,
|
||||
track: params['Track']
|
||||
}
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -1,189 +0,0 @@
|
||||
class Twilio::VoiceController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
before_action :set_call_details, only: %i[status_callback simple_twiml]
|
||||
before_action :set_inbox, only: %i[status_callback simple_twiml]
|
||||
|
||||
|
||||
def status_callback
|
||||
return head :ok unless @inbox
|
||||
|
||||
conversation = Voice::ConversationFinderService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: @call_sid,
|
||||
phone_number: incoming_number,
|
||||
is_outbound: outbound?,
|
||||
inbox: @inbox
|
||||
).perform
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: @call_sid,
|
||||
provider: :twilio
|
||||
).process_status_update(params[:CallStatus], params[:CallDuration]&.to_i, first_status_response?)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def simple_twiml
|
||||
return fallback_twiml unless @inbox
|
||||
|
||||
conversation = Voice::ConversationFinderService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: @call_sid,
|
||||
phone_number: incoming_number,
|
||||
is_outbound: outbound?,
|
||||
inbox: @inbox
|
||||
).perform
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: @call_sid,
|
||||
provider: :twilio
|
||||
).process_status_update('in-progress', nil, true)
|
||||
|
||||
conference_name = ensure_conference_name(conversation, params[:conference_name])
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: conversation.additional_attributes.merge(
|
||||
'conference_sid' => conference_name,
|
||||
'call_direction' => outbound? ? 'outbound' : 'inbound',
|
||||
'requires_agent_join' => true
|
||||
)
|
||||
)
|
||||
|
||||
render_twiml do |r|
|
||||
r.say(message: 'Please wait while we connect you to an agent')
|
||||
|
||||
# Enable real-time transcription for this call leg
|
||||
# For outbound calls, we're connecting to the contact, so this track is for the contact
|
||||
contact_id = conversation.contact_id
|
||||
callback_url = "#{base_url}/twilio/transcription_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}&speaker_type=contact&contact_id=#{contact_id}"
|
||||
Rails.logger.info("📞 VoiceController: Setting transcription callback to: #{callback_url}")
|
||||
|
||||
r.start do |start|
|
||||
start.transcription(
|
||||
status_callback_url: callback_url,
|
||||
status_callback_method: 'POST',
|
||||
track: 'inbound_track',
|
||||
language_code: 'en-US'
|
||||
)
|
||||
end
|
||||
|
||||
# Set up the conference
|
||||
conference_callback_url = "#{base_url}/api/v1/accounts/#{@inbox.account_id}/channels/voice/webhooks/conference_status"
|
||||
Rails.logger.info("📞 VoiceController: Setting conference callback to: #{conference_callback_url}")
|
||||
|
||||
r.dial do |d|
|
||||
d.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: false,
|
||||
endConferenceOnExit: true,
|
||||
beep: false,
|
||||
muted: false,
|
||||
waitUrl: '',
|
||||
earlyMedia: true,
|
||||
statusCallback: conference_callback_url,
|
||||
statusCallbackMethod: 'POST',
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
participantLabel: "caller-#{@call_sid.last(8)}",
|
||||
record: 'record-from-start',
|
||||
recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}",
|
||||
recording_status_callback_method: 'POST'
|
||||
)
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error creating voice conversation: #{e.message}")
|
||||
fallback_twiml
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_call_details
|
||||
@call_sid = params[:CallSid]
|
||||
@direction = params[:Direction]
|
||||
end
|
||||
|
||||
def set_inbox
|
||||
@inbox = find_inbox(outbound? ? params[:From] : params[:To])
|
||||
end
|
||||
|
||||
def outbound?
|
||||
@direction == 'outbound-api'
|
||||
end
|
||||
|
||||
def incoming_number
|
||||
outbound? ? params[:To] : params[:From]
|
||||
end
|
||||
|
||||
def first_status_response?
|
||||
params[:IsFirstResponseForStatus] == 'true'
|
||||
end
|
||||
|
||||
def render_twiml(status: :ok)
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
yield response
|
||||
render xml: response.to_s, status: status
|
||||
end
|
||||
|
||||
def build_message(conversation, content)
|
||||
Messages::MessageBuilder.new(
|
||||
nil,
|
||||
conversation,
|
||||
content: content,
|
||||
message_type: :activity,
|
||||
additional_attributes: { call_sid: @call_sid, call_status: 'in-progress', user_input: true }
|
||||
).perform
|
||||
end
|
||||
|
||||
def input_text
|
||||
return "Caller pressed #{params[:Digits]}" if params[:Digits].present?
|
||||
return "Caller said: \"#{params[:SpeechResult]}\"" if params[:SpeechResult].present?
|
||||
|
||||
'Caller responded'
|
||||
end
|
||||
|
||||
def ensure_conference_name(conversation, supplied)
|
||||
name = supplied.presence ||
|
||||
conversation.additional_attributes['conference_sid'] ||
|
||||
conversation.additional_attributes['conference_name']
|
||||
|
||||
return name if name&.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
|
||||
"conf_account_#{@inbox.account_id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
|
||||
def fallback_twiml
|
||||
render_twiml do |r|
|
||||
r.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup.')
|
||||
r.pause(length: 1)
|
||||
r.say(message: 'We will connect you with an agent shortly.')
|
||||
r.hangup
|
||||
end
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
def find_inbox(phone_number)
|
||||
return nil if phone_number.blank?
|
||||
|
||||
Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id')
|
||||
.find_by('channel_voice.phone_number = ?', phone_number)
|
||||
end
|
||||
|
||||
def find_or_create_conversation(inbox, phone_number, call_sid)
|
||||
Voice::ConversationFinderService.new(
|
||||
account: inbox.account,
|
||||
call_sid: call_sid,
|
||||
phone_number: phone_number,
|
||||
is_outbound: false,
|
||||
inbox: inbox
|
||||
).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("find_or_create_conversation error: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,16 @@ class Webhooks::InstagramController < ActionController::API
|
||||
def events
|
||||
Rails.logger.info('Instagram webhook received events')
|
||||
if params['object'].casecmp('instagram').zero?
|
||||
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
|
||||
entry_params = 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
|
||||
else
|
||||
Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
|
||||
@@ -14,6 +23,16 @@ class Webhooks::InstagramController < ActionController::API
|
||||
|
||||
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)
|
||||
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
|
||||
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
|
||||
|
||||
@@ -6,6 +6,7 @@ class WidgetsController < ActionController::Base
|
||||
before_action :set_web_widget
|
||||
before_action :ensure_account_is_active
|
||||
before_action :ensure_location_is_supported
|
||||
before_action :validate_origin_domain
|
||||
before_action :set_token
|
||||
before_action :set_contact
|
||||
before_action :build_contact
|
||||
@@ -14,7 +15,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
|
||||
@@ -24,6 +25,75 @@ class WidgetsController < ActionController::Base
|
||||
render json: { error: 'web widget does not exist' }, status: :not_found
|
||||
end
|
||||
|
||||
def validate_origin_domain
|
||||
# Only validate origin if website_url is configured
|
||||
return if @web_widget.website_url.blank?
|
||||
|
||||
origin_domain = extract_domain_from_origin
|
||||
return if domain_allowed?(origin_domain, @web_widget.website_url)
|
||||
|
||||
Rails.logger.warn('Widget access denied.')
|
||||
render json: { error: 'Widget access denied.' }, status: :forbidden
|
||||
end
|
||||
|
||||
def domain_allowed?(origin_domain, website_url)
|
||||
return false if origin_domain.blank?
|
||||
|
||||
# Split website_url by comma to handle multiple domains/wildcards
|
||||
allowed_domains = website_url.split(',').map(&:strip).reject(&:blank?)
|
||||
|
||||
allowed_domains.any? do |allowed_domain|
|
||||
# Extract domain and protocol from the allowed domain
|
||||
allowed_domain_without_protocol = extract_domain_from_url(allowed_domain)
|
||||
allowed_protocol = extract_protocol_from_url(allowed_domain)
|
||||
origin_protocol = extract_protocol_from_origin
|
||||
|
||||
# Check if the extracted domain starts with wildcard
|
||||
if allowed_domain_without_protocol&.start_with?('*.')
|
||||
# Handle wildcard domains (e.g., "*.example.com")
|
||||
wildcard_suffix = allowed_domain_without_protocol[2..-1] # Remove "*.", keep "example.com"
|
||||
origin_domain.end_with?(wildcard_suffix) && origin_protocol == allowed_protocol
|
||||
else
|
||||
# Handle exact domain and protocol match
|
||||
origin_domain == allowed_domain_without_protocol && origin_protocol == allowed_protocol
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def extract_domain_from_origin
|
||||
origin = request.headers['Origin']
|
||||
return nil if origin.blank?
|
||||
|
||||
extract_domain_from_url(origin)
|
||||
end
|
||||
|
||||
def extract_protocol_from_origin
|
||||
origin = request.headers['Origin']
|
||||
return nil if origin.blank?
|
||||
|
||||
extract_protocol_from_url(origin)
|
||||
end
|
||||
|
||||
def extract_domain_from_url(url)
|
||||
return nil if url.blank?
|
||||
|
||||
# Parse the URL and extract the domain
|
||||
uri = URI.parse(url)
|
||||
uri.host&.downcase
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_protocol_from_url(url)
|
||||
return nil if url.blank?
|
||||
|
||||
# Parse the URL and extract the protocol
|
||||
uri = URI.parse(url)
|
||||
uri.scheme&.downcase
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def set_token
|
||||
@token = permitted_params[:cw_conversation]
|
||||
@auth_token_params = if @token.present?
|
||||
|
||||
@@ -15,7 +15,6 @@ class AsyncDispatcher < BaseDispatcher
|
||||
CsatSurveyListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
MessageListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
|
||||
@@ -15,7 +15,13 @@ class NotificationFinder
|
||||
end
|
||||
|
||||
def unread_count
|
||||
@notifications.where(read_at: nil).count
|
||||
if type_included?('read')
|
||||
# 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
|
||||
|
||||
def count
|
||||
@@ -27,7 +33,7 @@ class NotificationFinder
|
||||
def set_up
|
||||
find_all_notifications
|
||||
filter_snoozed_notifications
|
||||
fitler_read_notifications
|
||||
filter_read_notifications
|
||||
end
|
||||
|
||||
def find_all_notifications
|
||||
@@ -38,7 +44,7 @@ class NotificationFinder
|
||||
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
||||
end
|
||||
|
||||
def fitler_read_notifications
|
||||
def filter_read_notifications
|
||||
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
||||
end
|
||||
|
||||
|
||||
@@ -107,8 +107,7 @@ module Api::V1::InboxesHelper
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels,
|
||||
'voice' => Current.account.voice_channels
|
||||
'sms' => Current.account.sms_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,12 +18,25 @@ module ReportingEventHelper
|
||||
end
|
||||
|
||||
def last_non_human_activity(conversation)
|
||||
# check if a handoff event already exists
|
||||
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
|
||||
# Try to get either a handoff or reopened event first
|
||||
# These will always take precedence over any other activity
|
||||
# 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
|
||||
|
||||
# if a handoff exists, last non human activity is when the handoff ended,
|
||||
# otherwise it's when the conversation was created
|
||||
handoff_event&.event_end_time || conversation.created_at
|
||||
return event.event_end_time if event&.event_end_time
|
||||
|
||||
# Fallback to bot resolved event
|
||||
# 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
|
||||
|
||||
private
|
||||
|
||||
+32
-9
@@ -1,5 +1,7 @@
|
||||
# TODO: Move this values to features.yml itself
|
||||
# No need to replicate the same values in two places
|
||||
|
||||
# ------- Premium Features ------- #
|
||||
captain:
|
||||
name: 'Captain'
|
||||
description: 'Enable AI-powered conversations with your customers.'
|
||||
@@ -32,6 +34,15 @@ disable_branding:
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
enterprise: true
|
||||
|
||||
# ------- Product Features ------- #
|
||||
help_center:
|
||||
name: 'Help Center'
|
||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||
enabled: true
|
||||
icon: 'icon-book-2-line'
|
||||
|
||||
# ------- Communication Channels ------- #
|
||||
live_chat:
|
||||
name: 'Live Chat'
|
||||
description: 'Improve your customer experience using a live chat on your website.'
|
||||
@@ -42,6 +53,12 @@ email:
|
||||
description: 'Manage your email customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-mail-send-fill'
|
||||
config_key: 'email'
|
||||
sms:
|
||||
name: 'SMS'
|
||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-message-line'
|
||||
messenger:
|
||||
name: 'Messenger'
|
||||
description: 'Stay connected with your customers on Facebook & Instagram.'
|
||||
@@ -69,22 +86,22 @@ line:
|
||||
description: 'Manage your Line customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-line-line'
|
||||
sms:
|
||||
name: 'SMS'
|
||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||
|
||||
# ------- OAuth & Authentication ------- #
|
||||
google:
|
||||
name: 'Google'
|
||||
description: 'Configuration for setting up Google OAuth Integration'
|
||||
enabled: true
|
||||
icon: 'icon-message-line'
|
||||
help_center:
|
||||
name: 'Help Center'
|
||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||
enabled: true
|
||||
icon: 'icon-book-2-line'
|
||||
icon: 'icon-google'
|
||||
config_key: 'google'
|
||||
microsoft:
|
||||
name: 'Microsoft'
|
||||
description: 'Configuration for setting up Microsoft Email'
|
||||
enabled: true
|
||||
icon: 'icon-microsoft'
|
||||
config_key: 'microsoft'
|
||||
|
||||
# ------- Third-party Integrations ------- #
|
||||
linear:
|
||||
name: 'Linear'
|
||||
description: 'Configuration for setting up Linear Integration'
|
||||
@@ -103,6 +120,12 @@ slack:
|
||||
enabled: true
|
||||
icon: 'icon-slack'
|
||||
config_key: 'slack'
|
||||
whatsapp_embedded:
|
||||
name: 'WhatsApp Embedded'
|
||||
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
config_key: 'whatsapp_embedded'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Configuration for setting up Shopify Integration'
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module SuperAdmin::FeaturesHelper
|
||||
def self.available_features
|
||||
YAML.load(ERB.new(Rails.root.join('enterprise/app/helpers/super_admin/features.yml').read).result).with_indifferent_access
|
||||
YAML.load(ERB.new(Rails.root.join('app/helpers/super_admin/features.yml').read).result).with_indifferent_access
|
||||
end
|
||||
|
||||
def self.plan_details
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
@@ -2,6 +2,7 @@ module WidgetHelper
|
||||
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
|
||||
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
|
||||
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
|
||||
|
||||
token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||
|
||||
[contact_inbox, token]
|
||||
|
||||
@@ -6,7 +6,6 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
@@ -15,20 +14,18 @@ import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
|
||||
components: {
|
||||
AddAccountModal,
|
||||
FloatingCallWidget,
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
@@ -42,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() {
|
||||
@@ -55,7 +54,6 @@ export default {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
showCallWidget: false, // Will be set to true when calls are active
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -65,10 +63,6 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
activeCall: 'calls/getActiveCall',
|
||||
hasActiveCall: 'calls/hasActiveCall',
|
||||
incomingCall: 'calls/getIncomingCall',
|
||||
hasIncomingCall: 'calls/hasIncomingCall',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -93,34 +87,14 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
hasIncomingCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.showCallWidget = true;
|
||||
} else {
|
||||
this.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
hasActiveCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.showCallWidget = true;
|
||||
} else {
|
||||
this.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeColorTheme();
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
|
||||
// Make app instance available globally for direct call widget updates
|
||||
window.app = this;
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(
|
||||
this.uiSettings?.locale || window.chatwootConfig.selectedLocale
|
||||
);
|
||||
},
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
@@ -138,77 +112,6 @@ export default {
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
},
|
||||
handleCallEnded() {
|
||||
this.showCallWidget = false;
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// Clear the activeCallConversation state in all ContactInfo components
|
||||
this.$nextTick(() => {
|
||||
const clearContactInfoCallState = (components) => {
|
||||
if (!components) return;
|
||||
|
||||
components.forEach(component => {
|
||||
if (component.$options && component.$options.name === 'ContactInfo') {
|
||||
if (component.activeCallConversation) {
|
||||
component.activeCallConversation = null;
|
||||
component.$forceUpdate();
|
||||
}
|
||||
}
|
||||
if (component.$children && component.$children.length) {
|
||||
clearContactInfoCallState(component.$children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
clearContactInfoCallState(this.$children);
|
||||
});
|
||||
},
|
||||
handleCallJoined() {
|
||||
this.showCallWidget = true;
|
||||
},
|
||||
handleCallRejected() {
|
||||
this.showCallWidget = false;
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
},
|
||||
forceEndCall() {
|
||||
this.showCallWidget = false;
|
||||
if (window.forceEndCallHandlers) {
|
||||
window.forceEndCallHandlers.forEach(handler => {
|
||||
try {
|
||||
handler();
|
||||
} catch (e) {
|
||||
// Optionally log error in production
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.activeCall && this.activeCall.callSid) {
|
||||
const { callSid, conversationId } = this.activeCall;
|
||||
const savedCallSid = callSid;
|
||||
const savedConversationId = conversationId;
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
if (savedConversationId) {
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(() => {
|
||||
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||
})
|
||||
.catch(() => {
|
||||
setTimeout(() => {
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
}, 1000);
|
||||
useAlert({ message: 'Call UI has been reset', type: 'info' });
|
||||
});
|
||||
} else {
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
}
|
||||
} else {
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
@@ -217,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);
|
||||
@@ -239,8 +143,7 @@ export default {
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
||||
:class="{ 'app-rtl--wrapper': isRTL }"
|
||||
class="flex flex-col w-full h-screen min-h-0"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
@@ -256,25 +159,6 @@ export default {
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
<!-- Floating call widget that appears during active calls -->
|
||||
<FloatingCallWidget
|
||||
v-if="showCallWidget || hasActiveCall || hasIncomingCall"
|
||||
:key="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : 'no-call')"
|
||||
:call-sid="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : '')"
|
||||
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : (incomingCall ? incomingCall.inboxName : 'Primary')"
|
||||
:conversation-id="activeCall ? activeCall.conversationId : (incomingCall ? incomingCall.conversationId : null)"
|
||||
:contact-name="activeCall ? activeCall.contactName : (incomingCall ? incomingCall.contactName : '')"
|
||||
:contact-id="activeCall ? activeCall.contactId : (incomingCall ? incomingCall.contactId : null)"
|
||||
:inbox-id="activeCall ? activeCall.inboxId : (incomingCall ? incomingCall.inboxId : null)"
|
||||
:inbox-avatar-url="activeCall ? activeCall.inboxAvatarUrl : (incomingCall ? incomingCall.inboxAvatarUrl : '')"
|
||||
:inbox-phone-number="activeCall ? activeCall.inboxPhoneNumber : (incomingCall ? incomingCall.inboxPhoneNumber : '')"
|
||||
:avatar-url="activeCall ? activeCall.avatarUrl : (incomingCall ? incomingCall.avatarUrl : '')"
|
||||
:phone-number="activeCall ? activeCall.phoneNumber : (incomingCall ? incomingCall.phoneNumber : '')"
|
||||
use-web-rtc
|
||||
@callEnded="handleCallEnded"
|
||||
@callJoined="handleCallJoined"
|
||||
@callRejected="handleCallRejected"
|
||||
/>
|
||||
</div>
|
||||
<LoadingState v-else />
|
||||
</template>
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 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();
|
||||
@@ -0,0 +1,16 @@
|
||||
/* 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();
|
||||
@@ -0,0 +1,21 @@
|
||||
/* 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();
|
||||
@@ -1,856 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class VoiceAPI extends ApiClient {
|
||||
constructor() {
|
||||
// Use 'voice' as the resource with accountScoped: true
|
||||
super('voice', { accountScoped: true });
|
||||
|
||||
// Client-side Twilio device
|
||||
this.device = null;
|
||||
this.activeConnection = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
// Initiate a call to a contact
|
||||
initiateCall(contactId) {
|
||||
if (!contactId) {
|
||||
throw new Error('Contact ID is required to initiate a call');
|
||||
}
|
||||
|
||||
// Based on the route definition, the correct URL path is /api/v1/accounts/{accountId}/contacts/{contactId}/call
|
||||
// The endpoint is defined in the contacts namespace, not voice namespace
|
||||
return axios.post(`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`);
|
||||
}
|
||||
|
||||
// End an active call
|
||||
endCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to end a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to end a call');
|
||||
}
|
||||
|
||||
// Validate call SID format - Twilio call SID starts with 'CA' or 'TJ'
|
||||
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
|
||||
throw new Error(
|
||||
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
|
||||
);
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/end_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Get call status
|
||||
getCallStatus(callSid) {
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to get call status');
|
||||
}
|
||||
|
||||
return axios.get(`${this.url}/call_status`, {
|
||||
params: { call_sid: callSid },
|
||||
});
|
||||
}
|
||||
|
||||
// Join an incoming call as an agent (join the conference)
|
||||
// This is used for the WebRTC client-side setup, not for phone calls anymore
|
||||
joinCall(params) {
|
||||
// Check if we have individual parameters or a params object
|
||||
const conversationId = params.conversation_id || params.conversationId;
|
||||
const callSid = params.call_sid || params.callSid;
|
||||
const accountId = params.account_id;
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to join a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to join a call');
|
||||
}
|
||||
|
||||
// Build request payload with proper naming convention
|
||||
const payload = {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
// Add account_id if provided
|
||||
if (accountId) {
|
||||
payload.account_id = accountId;
|
||||
}
|
||||
|
||||
console.log('Calling join_call API endpoint with payload:', payload);
|
||||
|
||||
return axios.post(`${this.url}/join_call`, payload);
|
||||
}
|
||||
|
||||
// Reject an incoming call as an agent (don't join the conference)
|
||||
rejectCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to reject a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to reject a call');
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/reject_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Client SDK methods
|
||||
|
||||
// Get a capability token for the Twilio Client
|
||||
getToken(inboxId) {
|
||||
console.log(`Requesting token for inbox ID: ${inboxId} at URL: ${this.url}/tokens`);
|
||||
|
||||
// Log the base URL for debugging
|
||||
console.log(`Base URL: ${this.baseUrl()}`);
|
||||
|
||||
// Check if inboxId is valid
|
||||
if (!inboxId) {
|
||||
console.error('No inbox ID provided for token request');
|
||||
return Promise.reject(new Error('Inbox ID is required'));
|
||||
}
|
||||
|
||||
// Add more request details to help debugging
|
||||
return axios.post(`${this.url}/tokens`, { inbox_id: inboxId }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).catch(error => {
|
||||
// Extract useful error details for debugging
|
||||
const errorInfo = {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
url: `${this.url}/tokens`,
|
||||
inboxId,
|
||||
};
|
||||
|
||||
console.error('Token request error details:', errorInfo);
|
||||
|
||||
// Try to extract a more useful error message from the HTML response if it's a 500 error
|
||||
if (error.response?.status === 500 && typeof error.response.data === 'string') {
|
||||
// Look for specific error patterns in the HTML
|
||||
const htmlData = error.response.data;
|
||||
|
||||
// Check for common Ruby/Rails error patterns
|
||||
const nameMatchResult = htmlData.match(/<h2>(.*?)<\/h2>/);
|
||||
const detailsMatchResult = htmlData.match(/<pre>([\s\S]*?)<\/pre>/);
|
||||
|
||||
const errorName = nameMatchResult ? nameMatchResult[1] : null;
|
||||
const errorDetails = detailsMatchResult ? detailsMatchResult[1] : null;
|
||||
|
||||
if (errorName || errorDetails) {
|
||||
const enhancedError = new Error(`Server error: ${errorName || 'Internal Server Error'}`);
|
||||
enhancedError.details = errorDetails;
|
||||
enhancedError.originalError = error;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the Twilio Device
|
||||
async initializeDevice(inboxId) {
|
||||
// If already initialized, return the existing device after checking its health
|
||||
if (this.initialized && this.device) {
|
||||
const deviceState = this.device.state;
|
||||
console.log('Device already initialized, current state:', deviceState);
|
||||
|
||||
// If the device is in a bad state, destroy and reinitialize
|
||||
if (deviceState === 'error' || deviceState === 'unregistered') {
|
||||
console.log('Device is in a bad state, destroying and reinitializing...');
|
||||
try {
|
||||
this.device.destroy();
|
||||
} catch (e) {
|
||||
console.log('Error destroying device:', e);
|
||||
}
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
} else {
|
||||
// Device is in a good state, return it
|
||||
return this.device;
|
||||
}
|
||||
}
|
||||
|
||||
// Device needs to be initialized or reinitialized
|
||||
try {
|
||||
console.log(`Starting Twilio Device initialization for inbox: ${inboxId}`);
|
||||
|
||||
// Import the Twilio Voice SDK
|
||||
let Device;
|
||||
try {
|
||||
// We know the package is installed via package.json
|
||||
const { Device: TwilioDevice } = await import('@twilio/voice-sdk');
|
||||
Device = TwilioDevice;
|
||||
console.log('✓ Twilio Voice SDK imported successfully');
|
||||
} catch (importError) {
|
||||
console.error('✗ Failed to import Twilio Voice SDK:', importError);
|
||||
throw new Error(`Failed to load Twilio Voice SDK: ${importError.message}`);
|
||||
}
|
||||
|
||||
// Validate inbox ID
|
||||
if (!inboxId) {
|
||||
throw new Error('Inbox ID is required to initialize the Twilio Device');
|
||||
}
|
||||
|
||||
// Step 1: Get a token from the server
|
||||
console.log(`Requesting Twilio token for inbox: ${inboxId}`);
|
||||
let response;
|
||||
try {
|
||||
response = await this.getToken(inboxId);
|
||||
console.log(`✓ Token response received with status: ${response.status}`);
|
||||
} catch (tokenError) {
|
||||
console.error('✗ Token request failed:', tokenError);
|
||||
|
||||
// Enhanced error handling for token requests
|
||||
if (tokenError.details) {
|
||||
// If we already have extracted details from the error, include those
|
||||
console.error('Token error details:', tokenError.details);
|
||||
throw new Error(`Failed to get token: ${tokenError.message}`);
|
||||
}
|
||||
|
||||
// Check for specific HTTP error status codes
|
||||
if (tokenError.response) {
|
||||
const status = tokenError.response.status;
|
||||
const data = tokenError.response.data;
|
||||
|
||||
if (status === 401) {
|
||||
throw new Error('Authentication error: Please check your Twilio credentials');
|
||||
} else if (status === 403) {
|
||||
throw new Error('Permission denied: You don\'t have access to this inbox');
|
||||
} else if (status === 404) {
|
||||
throw new Error('Inbox not found or does not have voice capability');
|
||||
} else if (status === 500) {
|
||||
throw new Error('Server error: The server encountered an error processing your request. Check your Twilio configuration.');
|
||||
} else if (data && data.error) {
|
||||
throw new Error(`Server error: ${data.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to get token: ${tokenError.message}`);
|
||||
}
|
||||
|
||||
// Validate token response
|
||||
if (!response.data || !response.data.token) {
|
||||
console.error('✗ Invalid token response data:', response.data);
|
||||
|
||||
// Check if we have an error message in the response
|
||||
if (response.data && response.data.error) {
|
||||
throw new Error(`Server did not return a valid token: ${response.data.error}`);
|
||||
} else {
|
||||
throw new Error('Server did not return a valid token');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for warnings about missing TwiML App SID
|
||||
if (response.data.warning) {
|
||||
console.warn('⚠️ Twilio Voice Warning:', response.data.warning);
|
||||
|
||||
if (!response.data.has_twiml_app) {
|
||||
console.error(
|
||||
'🚨 IMPORTANT: Missing TwiML App SID. Browser-based calling requires a ' +
|
||||
'TwiML App configured in Twilio Console. Set the Voice Request URL to: ' +
|
||||
response.data.twiml_endpoint
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token data
|
||||
const { token, identity, voice_enabled, account_sid } = response.data;
|
||||
|
||||
// Log diagnostic information
|
||||
console.log(`✓ Token data received for identity: ${identity}`);
|
||||
console.log(`✓ Voice enabled: ${voice_enabled}`);
|
||||
console.log(`✓ Twilio Account SID available: ${!!account_sid}`);
|
||||
|
||||
// Log the TwiML endpoint that will be used
|
||||
if (response.data.twiml_endpoint) {
|
||||
console.log(`✓ TwiML endpoint: ${response.data.twiml_endpoint}`);
|
||||
} else {
|
||||
console.warn('⚠️ No TwiML endpoint found in token response');
|
||||
}
|
||||
|
||||
// Check if voice is enabled
|
||||
if (!voice_enabled) {
|
||||
throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
|
||||
}
|
||||
|
||||
// Store the TwiML endpoint URL for later use
|
||||
this.twimlEndpoint = response.data.twiml_endpoint;
|
||||
|
||||
// Step 2: Create Twilio Device with better options
|
||||
const deviceOptions = {
|
||||
// Use absolute minimal options - less is more for audio compatibility
|
||||
allowIncomingWhileBusy: true, // Allow incoming calls while already on a call
|
||||
debug: true, // Enable debug logging
|
||||
warnings: true, // Show warnings in console
|
||||
disableAudioContextSounds: true, // Disable browser audio context for sounds
|
||||
// Add explicit edge parameter - this helps avoid connectivity issues
|
||||
edge: ['ashburn', 'sydney', 'roaming'],
|
||||
// Explicitly set codec preferences
|
||||
codecPreferences: ['opus', 'pcmu'],
|
||||
// Add the account ID to any calls made by this device
|
||||
appParams: {
|
||||
account_id: response.data.account_id,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Creating Twilio Device with options:', deviceOptions);
|
||||
|
||||
try {
|
||||
this.device = new Device(token, deviceOptions);
|
||||
console.log('✓ Twilio Device created successfully');
|
||||
} catch (deviceError) {
|
||||
console.error('✗ Failed to create Twilio Device:', deviceError);
|
||||
throw new Error(`Failed to create Twilio Device: ${deviceError.message}`);
|
||||
}
|
||||
|
||||
// Step 3: Set up event listeners with enhanced error handling
|
||||
this._setupDeviceEventListeners(inboxId);
|
||||
|
||||
// Step 4: Register the device with Twilio
|
||||
console.log('Registering Twilio Device...');
|
||||
try {
|
||||
await this.device.register();
|
||||
console.log('✓ Twilio Device registered successfully');
|
||||
this.initialized = true;
|
||||
return this.device;
|
||||
} catch (registerError) {
|
||||
console.error('✗ Failed to register Twilio Device:', registerError);
|
||||
|
||||
// Handle specific registration errors
|
||||
if (registerError.message && registerError.message.includes('token')) {
|
||||
throw new Error('Invalid Twilio token. Check your account credentials.');
|
||||
} else if (registerError.message && registerError.message.includes('permission')) {
|
||||
throw new Error('Missing microphone permission. Please allow microphone access.');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to register device: ${registerError.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Clear device and initialized flag in case of error
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
|
||||
console.error('Failed to initialize Twilio Device:', error);
|
||||
|
||||
// Create a detailed error with context for debugging
|
||||
const enhancedError = new Error(`Twilio Device initialization failed: ${error.message}`);
|
||||
enhancedError.originalError = error;
|
||||
enhancedError.inboxId = inboxId;
|
||||
enhancedError.timestamp = new Date().toISOString();
|
||||
enhancedError.browserInfo = {
|
||||
userAgent: navigator.userAgent,
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
};
|
||||
|
||||
// Add specific advice for known error cases
|
||||
if (error.message.includes('permission')) {
|
||||
enhancedError.advice = 'Please ensure your browser allows microphone access.';
|
||||
} else if (error.message.includes('token')) {
|
||||
enhancedError.advice = 'Check your Twilio credentials in the Voice channel settings.';
|
||||
} else if (error.message.includes('TwiML')) {
|
||||
enhancedError.advice = 'Set up a valid TwiML app in your Twilio console and configure it in the inbox settings.';
|
||||
} else if (error.message.includes('configuration')) {
|
||||
enhancedError.advice = 'Review your Voice inbox configuration to ensure all required fields are completed.';
|
||||
}
|
||||
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to set up device event listeners
|
||||
_setupDeviceEventListeners(inboxId) {
|
||||
if (!this.device) return;
|
||||
|
||||
// Remove any existing listeners to prevent duplicates
|
||||
this.device.removeAllListeners();
|
||||
|
||||
// Add standard event listeners
|
||||
this.device.on('registered', () => {
|
||||
console.log('✓ Twilio Device registered with Twilio servers');
|
||||
});
|
||||
|
||||
this.device.on('unregistered', () => {
|
||||
console.log('⚠️ Twilio Device unregistered from Twilio servers');
|
||||
});
|
||||
|
||||
this.device.on('tokenWillExpire', () => {
|
||||
console.log('⚠️ Twilio token is about to expire, refreshing...');
|
||||
this.getToken(inboxId)
|
||||
.then(newTokenResponse => {
|
||||
if (newTokenResponse.data && newTokenResponse.data.token) {
|
||||
console.log('✓ Successfully obtained new token');
|
||||
this.device.updateToken(newTokenResponse.data.token);
|
||||
} else {
|
||||
console.error('✗ Failed to get a valid token for renewal');
|
||||
}
|
||||
})
|
||||
.catch(tokenError => {
|
||||
console.error('✗ Error refreshing token:', tokenError);
|
||||
});
|
||||
});
|
||||
|
||||
this.device.on('incoming', connection => {
|
||||
console.log('📞 Incoming call received via Twilio Device');
|
||||
this.activeConnection = connection;
|
||||
|
||||
// Set up connection-specific events
|
||||
this._setupConnectionEventListeners(connection);
|
||||
});
|
||||
|
||||
this.device.on('error', error => {
|
||||
// Enhanced error logging with full details
|
||||
const errorDetails = {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
description: error.description || 'No description',
|
||||
twilioErrorObject: error,
|
||||
connectionInfo: this.activeConnection ? {
|
||||
parameters: this.activeConnection.parameters,
|
||||
status: this.activeConnection.status && this.activeConnection.status(),
|
||||
direction: this.activeConnection.direction,
|
||||
} : 'No active connection',
|
||||
deviceState: this.device.state,
|
||||
browserInfo: {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.error('❌ DETAILED Twilio Device Error:', errorDetails);
|
||||
|
||||
// Provide helpful troubleshooting tips based on error code
|
||||
switch (error.code) {
|
||||
case 31000:
|
||||
console.error('⚠️ Error 31000: General Error. This could be an authentication, configuration, or network issue.');
|
||||
console.error('31000 Error Details:', {
|
||||
sdp: error.sdp || 'No SDP data',
|
||||
callState: error.call ? error.call.state : 'No call state',
|
||||
connectionState: error.connection ? error.connection.state : 'No connection state',
|
||||
peerConnectionState: error.peerConnection ? error.peerConnection.iceConnectionState : 'No ICE state',
|
||||
message: error.message,
|
||||
twilioError: error,
|
||||
info: error.info || 'No additional info',
|
||||
solution: 'Check Twilio account status, SDP negotiations, and network connectivity'
|
||||
});
|
||||
|
||||
// Create a network diagnostic to check connectivity
|
||||
fetch('https://status.twilio.com/api/v2/status.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Twilio service status check:', data);
|
||||
})
|
||||
.catch(statusError => {
|
||||
console.error('Failed to check Twilio status:', statusError);
|
||||
});
|
||||
break;
|
||||
case 31002:
|
||||
console.error('⚠️ Error 31002: Permission Denied. Your browser microphone is blocked or unavailable.');
|
||||
break;
|
||||
case 31003:
|
||||
console.error('⚠️ Error 31003: TwiML App Error. Your TwiML application does not exist or is misconfigured.');
|
||||
break;
|
||||
case 31005:
|
||||
console.error('⚠️ Error 31005: Error sent from gateway in HANGUP. This usually means the TwiML endpoint is not reachable or returning invalid TwiML.');
|
||||
console.error('Additional details for 31005:', {
|
||||
activeConnection: this.activeConnection ? 'Yes' : 'No',
|
||||
deviceState: this.device ? this.device.state : 'No device',
|
||||
params: this.activeConnection ? this.activeConnection.parameters : 'No params',
|
||||
twimlEndpoint: this.activeConnection && this.activeConnection.parameters ?
|
||||
this.activeConnection.parameters.To : 'Unknown endpoint',
|
||||
hangupReason: error.hangupReason || 'Unknown', // Capture hangup reason
|
||||
message: error.message,
|
||||
description: error.description,
|
||||
customMessage: error.customMessage,
|
||||
originalError: error.originalError ? JSON.stringify(error.originalError) : 'None'
|
||||
});
|
||||
break;
|
||||
case 31008:
|
||||
console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
|
||||
break;
|
||||
case 31204:
|
||||
console.error('⚠️ Error 31204: ICE Connection Failed. WebRTC connection failure, check firewall settings.');
|
||||
break;
|
||||
default:
|
||||
console.error(`⚠️ Unspecified error with code ${error.code}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.device.on('connect', connection => {
|
||||
console.log('📞 Call connected');
|
||||
this.activeConnection = connection;
|
||||
this._setupConnectionEventListeners(connection);
|
||||
});
|
||||
|
||||
this.device.on('disconnect', () => {
|
||||
console.log('📞 Call disconnected');
|
||||
this.activeConnection = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for the active connection with enhanced audio diagnostic logging
|
||||
_setupConnectionEventListeners(connection) {
|
||||
if (!connection) return;
|
||||
|
||||
// Add advanced audio debug data
|
||||
const getAudioDiagnostics = () => {
|
||||
const audioContext = window.AudioContext || window.webkitAudioContext;
|
||||
let audioInfo = { supported: !!audioContext };
|
||||
|
||||
try {
|
||||
if (audioContext) {
|
||||
const context = new audioContext();
|
||||
audioInfo = {
|
||||
...audioInfo,
|
||||
sampleRate: context.sampleRate,
|
||||
state: context.state,
|
||||
baseLatency: context.baseLatency,
|
||||
outputLatency: context.outputLatency,
|
||||
destination: {
|
||||
maxChannelCount: context.destination.maxChannelCount,
|
||||
numberOfInputs: context.destination.numberOfInputs,
|
||||
numberOfOutputs: context.destination.numberOfOutputs
|
||||
}
|
||||
};
|
||||
context.close();
|
||||
}
|
||||
} catch (e) {
|
||||
audioInfo.error = e.message;
|
||||
}
|
||||
|
||||
// Check if microphone is accessible
|
||||
let microphoneInfo = { detected: false, active: false, tracks: [] };
|
||||
if (window.activeAudioStream) {
|
||||
const tracks = window.activeAudioStream.getAudioTracks();
|
||||
microphoneInfo = {
|
||||
detected: true,
|
||||
active: tracks.some(track => track.enabled && track.readyState === 'live'),
|
||||
tracks: tracks.map(track => ({
|
||||
id: track.id,
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
muted: track.muted,
|
||||
readyState: track.readyState,
|
||||
constraints: track.getConstraints()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
audioContext: audioInfo,
|
||||
microphone: microphoneInfo,
|
||||
speakersMuted: typeof window.speechSynthesis !== 'undefined' ?
|
||||
window.speechSynthesis.speaking === false : 'unknown'
|
||||
};
|
||||
};
|
||||
|
||||
connection.on('error', error => {
|
||||
// Significantly enhanced connection error logging with audio diagnostics
|
||||
const diagnostics = getAudioDiagnostics();
|
||||
|
||||
const connectionErrorDetails = {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
description: error.description || 'No description',
|
||||
twilioErrorObject: error,
|
||||
connectionInfo: {
|
||||
parameters: connection.parameters,
|
||||
status: connection.status && connection.status(),
|
||||
direction: connection.direction,
|
||||
},
|
||||
deviceState: this.device ? this.device.state : 'No device',
|
||||
timestamp: new Date().toISOString(),
|
||||
// Audio diagnostics for troubleshooting
|
||||
audioDiagnostics: diagnostics,
|
||||
// Browser media permissions
|
||||
mediaPermissions: {
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
activeAudioStream: !!window.activeAudioStream,
|
||||
activeAudioTracks: window.activeAudioStream ?
|
||||
window.activeAudioStream.getAudioTracks().length : 0
|
||||
}
|
||||
};
|
||||
|
||||
console.error('❌ DETAILED Connection Error with Audio Diagnostics:', connectionErrorDetails);
|
||||
});
|
||||
|
||||
connection.on('mute', isMuted => {
|
||||
console.log(`📞 Call ${isMuted ? 'muted' : 'unmuted'}`);
|
||||
});
|
||||
|
||||
connection.on('accept', () => {
|
||||
// Enhanced logging for accept event with audio diagnostics
|
||||
const diagnostics = getAudioDiagnostics();
|
||||
|
||||
console.log('📞 Call accepted with audio diagnostics:', {
|
||||
connectionParameters: connection.parameters,
|
||||
status: connection.status && connection.status(),
|
||||
audioDiagnostics: diagnostics,
|
||||
activeAudioStream: window.activeAudioStream ? {
|
||||
active: window.activeAudioStream.active,
|
||||
id: window.activeAudioStream.id,
|
||||
trackCount: window.activeAudioStream.getTracks().length
|
||||
} : 'No active stream'
|
||||
});
|
||||
|
||||
// AUDIO HEALTH CHECK AFTER CONNECTION
|
||||
setTimeout(() => {
|
||||
console.log('🔊 AUDIO HEALTH CHECK:', {
|
||||
connectionActive: this.activeConnection === connection,
|
||||
connectionState: connection.status && connection.status(),
|
||||
audioTracks: window.activeAudioStream ?
|
||||
window.activeAudioStream.getAudioTracks().map(track => ({
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
muted: track.muted
|
||||
})) : 'No active stream',
|
||||
// Device state after 5 seconds
|
||||
deviceState: this.device ? this.device.state : 'No device'
|
||||
});
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
connection.on('disconnect', () => {
|
||||
console.log('📞 Call disconnected', {
|
||||
disconnectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
|
||||
finalStatus: connection.status && connection.status(),
|
||||
audioDiagnostics: getAudioDiagnostics()
|
||||
});
|
||||
this.activeConnection = null;
|
||||
});
|
||||
|
||||
connection.on('reject', () => {
|
||||
console.log('📞 Call rejected', {
|
||||
rejectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
|
||||
audioDiagnostics: getAudioDiagnostics()
|
||||
});
|
||||
this.activeConnection = null;
|
||||
});
|
||||
|
||||
// Additional event for warning messages
|
||||
connection.on('warning', warning => {
|
||||
console.warn('⚠️ Connection Warning:', warning);
|
||||
});
|
||||
|
||||
// Listen for TwiML processing events
|
||||
connection.on('twiml-processing', twiml => {
|
||||
console.log('📄 Processing TwiML:', twiml);
|
||||
});
|
||||
|
||||
// Enhanced audio events for debugging
|
||||
if (typeof connection.on === 'function') {
|
||||
try {
|
||||
// Check for volume events
|
||||
connection.on('volume', (inputVolume, outputVolume) => {
|
||||
// Log only significant volume changes to avoid console spam
|
||||
if (Math.abs(inputVolume) > 50 || Math.abs(outputVolume) > 50) {
|
||||
console.log(`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for media stream events if supported
|
||||
if (typeof connection.getRemoteStream === 'function') {
|
||||
const remoteStream = connection.getRemoteStream();
|
||||
if (remoteStream) {
|
||||
console.log('✅ Remote audio stream available:', {
|
||||
active: remoteStream.active,
|
||||
id: remoteStream.id,
|
||||
tracks: remoteStream.getTracks().map(t => ({
|
||||
kind: t.kind,
|
||||
enabled: t.enabled,
|
||||
readyState: t.readyState
|
||||
}))
|
||||
});
|
||||
} else {
|
||||
console.warn('⚠️ No remote audio stream available');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error setting up enhanced audio events:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make a call using the Twilio Client
|
||||
makeClientCall(params) {
|
||||
if (!this.device || !this.initialized) {
|
||||
throw new Error('Twilio Device not initialized');
|
||||
}
|
||||
|
||||
this.activeConnection = this.device.connect(params);
|
||||
return this.activeConnection;
|
||||
}
|
||||
|
||||
// Join a conference call using the Twilio Client
|
||||
joinClientCall(conferenceParams) {
|
||||
if (!this.device || !this.initialized) {
|
||||
throw new Error('Twilio Device not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// IMPORTANT: Do NOT try to register if already registered
|
||||
// Only check state is ready
|
||||
if (this.device.state !== 'ready' && this.device.state !== 'registered') {
|
||||
// Don't try to register again if already registered
|
||||
}
|
||||
|
||||
// This is CRITICAL for Twilio - params must be formatted exactly right
|
||||
// and passed directly in the format Twilio expects
|
||||
const params = {
|
||||
// REQUIRED: Twilio Voice JS SDK expects 'To' parameter to be a properly formatted string
|
||||
To: `${conferenceParams.To}`,
|
||||
|
||||
// Additional params for our server
|
||||
account_id: conferenceParams.account_id,
|
||||
is_agent: 'true'
|
||||
};
|
||||
|
||||
// Check To parameter exists - fail if missing
|
||||
if (!params.To) {
|
||||
throw new Error('Missing To parameter for conference');
|
||||
}
|
||||
|
||||
// Make sure 'To' is explicitly a string
|
||||
const stringifiedTo = String(params.To);
|
||||
console.log('🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=', stringifiedTo);
|
||||
|
||||
// Follow Twilio documentation format - params should be nested under 'params' property
|
||||
console.log('🎯 TRYING CONNECTION: Using documented format with params property');
|
||||
|
||||
// Just use the minimal required parameters
|
||||
const connection = this.device.connect({
|
||||
params: {
|
||||
To: stringifiedTo, // Conference ID
|
||||
is_agent: 'true' // Flag to indicate agent is joining
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🎯 CONFERENCE CONNECTION RESULT:', connection ? 'Success' : 'Failed');
|
||||
this.activeConnection = connection;
|
||||
|
||||
if (connection && typeof connection.then === 'function') {
|
||||
// It's a Promise - newer Twilio SDK version
|
||||
connection.then(resolvedConnection => {
|
||||
this.activeConnection = resolvedConnection;
|
||||
try {
|
||||
if (typeof resolvedConnection.on === 'function') {
|
||||
resolvedConnection.on('accept', () => {
|
||||
// Connection accepted
|
||||
});
|
||||
}
|
||||
} catch (listenerError) {
|
||||
// Could not add listeners to Promise connection
|
||||
}
|
||||
}).catch(connError => {
|
||||
// WebRTC Promise connection error
|
||||
});
|
||||
} else {
|
||||
// It's a synchronous connection - older Twilio SDK
|
||||
}
|
||||
return connection;
|
||||
} catch (error) {
|
||||
// Error joining conference
|
||||
}
|
||||
}
|
||||
|
||||
// Get the status of the device with additional diagnostic info
|
||||
getDeviceStatus() {
|
||||
if (!this.device) {
|
||||
return 'not_initialized';
|
||||
}
|
||||
|
||||
const deviceState = this.device.state;
|
||||
|
||||
// Append a recommended action based on the state
|
||||
switch (deviceState) {
|
||||
case 'registered':
|
||||
return 'ready';
|
||||
case 'unregistered':
|
||||
return 'disconnected';
|
||||
case 'destroyed':
|
||||
return 'terminated';
|
||||
case 'busy':
|
||||
return 'busy';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return deviceState;
|
||||
}
|
||||
}
|
||||
|
||||
// Get comprehensive diagnostic information about the device and connection
|
||||
getDiagnosticInfo() {
|
||||
const browserInfo = {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
vendor: navigator.vendor,
|
||||
hasMediaDevices: !!navigator.mediaDevices,
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
};
|
||||
|
||||
const deviceInfo = this.device ? {
|
||||
state: this.device.state,
|
||||
isInitialized: this.initialized,
|
||||
capabilities: this.device.capabilities || {},
|
||||
isBusy: this.device.isBusy || false,
|
||||
audio: {
|
||||
isAudioSelectionSupported: this.device.isAudioSelectionSupported || false
|
||||
}
|
||||
} : { state: 'not_initialized' };
|
||||
|
||||
const connectionInfo = this.activeConnection ? {
|
||||
status: this.activeConnection.status(),
|
||||
isMuted: this.activeConnection.isMuted(),
|
||||
direction: this.activeConnection.direction,
|
||||
parameters: this.activeConnection.parameters,
|
||||
} : { status: 'no_connection' };
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
browser: browserInfo,
|
||||
device: deviceInfo,
|
||||
connection: connectionInfo
|
||||
};
|
||||
}
|
||||
|
||||
// Get the status of the active connection
|
||||
getConnectionStatus() {
|
||||
if (!this.activeConnection) {
|
||||
return 'no_connection';
|
||||
}
|
||||
|
||||
const status = this.activeConnection.status();
|
||||
|
||||
// Translate connection statuses to more user-friendly terms
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'connecting';
|
||||
case 'open':
|
||||
return 'connected';
|
||||
case 'connecting':
|
||||
return 'connecting';
|
||||
case 'ringing':
|
||||
return 'ringing';
|
||||
case 'closed':
|
||||
return 'ended';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceAPI();
|
||||
@@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient {
|
||||
deleteLogo(portalSlug) {
|
||||
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;
|
||||
|
||||
@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
agent_bot: botId,
|
||||
});
|
||||
}
|
||||
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SamlSettingsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('saml_settings', { accountScoped: true });
|
||||
}
|
||||
|
||||
get() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.put(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
delete() {
|
||||
return axios.delete(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SamlSettingsAPI();
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('syncTemplates');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
|
||||
inboxesAPI.deleteInboxAvatar(2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||
});
|
||||
|
||||
it('#syncTemplates', () => {
|
||||
inboxesAPI.syncTemplates(2);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/sync_templates'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,30 +37,6 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-wrapper {
|
||||
@apply h-screen flex-grow-0 min-h-0 w-full;
|
||||
|
||||
.button--fixed-top {
|
||||
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
|
||||
}
|
||||
}
|
||||
|
||||
.banner + .app-wrapper {
|
||||
// Reduce the height of the dashboard to make room for the banner.
|
||||
// And causing the top right green-action button to be pushed down when scrolling.
|
||||
@apply h-[calc(100%-48px)];
|
||||
|
||||
.button--fixed-top {
|
||||
@apply top-14;
|
||||
}
|
||||
|
||||
.off-canvas-content {
|
||||
.button--fixed-top {
|
||||
@apply top-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import AgentCapacityPolicyCard from './AgentCapacityPolicyCard.vue';
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Smith',
|
||||
email: 'john.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah.johnson@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Mike Chen',
|
||||
email: 'mike.chen@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Emily Davis',
|
||||
email: 'emily.davis@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Alex Rodriguez',
|
||||
email: 'alex.rodriguez@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=5',
|
||||
},
|
||||
];
|
||||
|
||||
const withCount = policy => ({
|
||||
...policy,
|
||||
assignedAgentCount: policy.users.length,
|
||||
});
|
||||
|
||||
const policyA = withCount({
|
||||
id: 1,
|
||||
name: 'High Volume Support',
|
||||
description:
|
||||
'Capacity-based policy for handling high conversation volumes with experienced agents',
|
||||
users: [mockUsers[0], mockUsers[1], mockUsers[2]],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const policyB = withCount({
|
||||
id: 2,
|
||||
name: 'Specialized Team',
|
||||
description: 'Custom capacity limits for specialized support team members',
|
||||
users: [mockUsers[3], mockUsers[4]],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const emptyPolicy = withCount({
|
||||
id: 3,
|
||||
name: 'New Policy',
|
||||
description: 'Recently created policy with no assigned agents yet',
|
||||
users: [],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const loadingPolicy = withCount({
|
||||
id: 4,
|
||||
name: 'Loading Policy',
|
||||
description: 'Policy currently loading agent information',
|
||||
users: [],
|
||||
isFetchingUsers: true,
|
||||
});
|
||||
|
||||
const onEdit = id => console.log('Edit policy:', id);
|
||||
const onDelete = id => console.log('Delete policy:', id);
|
||||
const onFetchUsers = id => console.log('Fetch users for policy:', id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AgentCapacityPolicyCard"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Multiple Cards (Various States)">
|
||||
<div class="p-4 bg-n-background">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="policyA"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="policyB"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="emptyPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="loadingPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import CardPopover from '../components/CardPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
assignedAgentCount: { type: Number, default: 0 },
|
||||
users: { type: Array, default: () => [] },
|
||||
isFetchingUsers: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'fetchUsers']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const users = computed(() => {
|
||||
return props.users.map(user => {
|
||||
return {
|
||||
name: user.name,
|
||||
key: user.id,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit', props.id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.id);
|
||||
};
|
||||
|
||||
const handleFetchUsers = () => {
|
||||
if (props.users?.length > 0) return;
|
||||
emit('fetchUsers', props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5">
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<CardPopover
|
||||
:title="
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.POPOVER')
|
||||
"
|
||||
icon="i-lucide-users-round"
|
||||
:count="assignedAgentCount"
|
||||
:items="users"
|
||||
:is-fetching="isFetchingUsers"
|
||||
@fetch="handleFetchUsers"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.EDIT')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="px-2"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import AssignmentCard from './AssignmentCard.vue';
|
||||
|
||||
const agentAssignments = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Assignment policy',
|
||||
description: 'Manage how conversations get assigned in inboxes.',
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: 'Assign by conversations evenly or by available capacity',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: 'Add fair distribution rules to avoid overloading any agent',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: 'Add inboxes to a policy - one policy per inbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Agent capacity policy',
|
||||
description: 'Manage workload for agents.',
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: 'Define maximum conversations per inbox',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: 'Create exceptions based on labels and time',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: 'Add agents to a policy - one policy per agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AssignmentCard"
|
||||
:layout="{ type: 'grid', width: '1000px' }"
|
||||
>
|
||||
<Variant title="Assignment Card">
|
||||
<div class="px-4 py-4 bg-n-background flex gap-6 justify-between">
|
||||
<AssignmentCard
|
||||
v-for="(item, index) in agentAssignments"
|
||||
:key="index"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:features="item.features"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
features: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex justify-between w-full items-center">
|
||||
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
|
||||
<Button
|
||||
xs
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-chevron-right"
|
||||
@click.stop="handleClick"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col items-start gap-3 mt-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.id"
|
||||
class="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<Icon
|
||||
:icon="feature.icon"
|
||||
class="text-n-slate-11 size-4 flex-shrink-0"
|
||||
/>
|
||||
{{ feature.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import AssignmentPolicyCard from './AssignmentPolicyCard.vue';
|
||||
|
||||
const mockInboxes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
inbox_type: 'Website',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
channel_type: 'Channel::Email',
|
||||
inbox_type: 'Email',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
inbox_type: 'WhatsApp',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
channel_type: 'Channel::FacebookPage',
|
||||
inbox_type: 'Messenger',
|
||||
},
|
||||
];
|
||||
|
||||
const withCount = policy => ({
|
||||
...policy,
|
||||
assignedInboxCount: policy.inboxes.length,
|
||||
});
|
||||
|
||||
const policyA = withCount({
|
||||
id: 1,
|
||||
name: 'Website & Email',
|
||||
description: 'Distributes conversations evenly among available agents',
|
||||
assignmentOrder: 'round_robin',
|
||||
conversationPriority: 'high',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const policyB = withCount({
|
||||
id: 2,
|
||||
name: 'WhatsApp & Messenger',
|
||||
description: 'Assigns based on capacity and workload',
|
||||
assignmentOrder: 'capacity_based',
|
||||
conversationPriority: 'medium',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const emptyPolicy = withCount({
|
||||
id: 3,
|
||||
name: 'No Inboxes Yet',
|
||||
description: 'Policy with no assigned inboxes',
|
||||
assignmentOrder: 'manual',
|
||||
conversationPriority: 'low',
|
||||
enabled: false,
|
||||
inboxes: [],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const onEdit = id => console.log('Edit policy:', id);
|
||||
const onDelete = id => console.log('Delete policy:', id);
|
||||
const onFetch = () => console.log('Fetch inboxes');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AssignmentPolicyCard"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Three Cards (Two with inboxes, One empty)">
|
||||
<div class="p-4 bg-n-background">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<AssignmentPolicyCard
|
||||
v-bind="policyA"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
<AssignmentPolicyCard
|
||||
v-bind="policyB"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
<AssignmentPolicyCard
|
||||
v-bind="emptyPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { formatToTitleCase } from 'dashboard/helper/commons';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import CardPopover from '../components/CardPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
assignmentOrder: { type: String, default: '' },
|
||||
conversationPriority: { type: String, default: '' },
|
||||
assignedInboxCount: { type: Number, default: 0 },
|
||||
enabled: { type: Boolean, default: false },
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isFetchingInboxes: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'fetchInboxes']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxes = computed(() => {
|
||||
return props.inboxes.map(inbox => {
|
||||
return {
|
||||
name: inbox.name,
|
||||
id: inbox.id,
|
||||
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const order = computed(() => {
|
||||
return formatToTitleCase(props.assignmentOrder);
|
||||
});
|
||||
|
||||
const priority = computed(() => {
|
||||
return formatToTitleCase(props.conversationPriority);
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit', props.id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.id);
|
||||
};
|
||||
|
||||
const handleFetchInboxes = () => {
|
||||
if (props.inboxes?.length > 0) return;
|
||||
emit('fetchInboxes', props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5">
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{
|
||||
enabled
|
||||
? t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
||||
)
|
||||
: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
|
||||
)
|
||||
"
|
||||
icon="i-lucide-inbox"
|
||||
:count="assignedInboxCount"
|
||||
:items="inboxes"
|
||||
:is-fetching="isFetchingInboxes"
|
||||
@fetch="handleFetchInboxes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="
|
||||
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.EDIT')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="px-2"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<div v-if="order" class="w-px h-2.5 bg-n-slate-5" />
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 py-1.5">
|
||||
<span v-if="order" class="text-n-slate-11 text-sm">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
|
||||
}}
|
||||
<span class="text-n-slate-12">{{ order }}</span>
|
||||
</span>
|
||||
<div v-if="order" class="w-px h-3 bg-n-strong" />
|
||||
<span v-if="priority" class="text-n-slate-11 text-sm">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
||||
}}
|
||||
<span class="text-n-slate-12">{{ priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!searchValue.value) return props.items;
|
||||
const query = searchValue.value.toLowerCase();
|
||||
|
||||
return picoSearch(props.items, query, ['name']);
|
||||
});
|
||||
|
||||
const handleAdd = item => {
|
||||
emit('add', item);
|
||||
togglePopover(false);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (showPopover.value) {
|
||||
togglePopover(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="handleClickOutside"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
slate
|
||||
type="button"
|
||||
icon="i-lucide-plus"
|
||||
sm
|
||||
:label="label"
|
||||
@click="togglePopover(!showPopover)"
|
||||
/>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
class="top-full mt-2 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
|
||||
>
|
||||
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
|
||||
<Input
|
||||
v-model="searchValue"
|
||||
:placeholder="searchPlaceholder"
|
||||
custom-input-class="bg-transparent !outline-none w-full ltr:!pl-10 rtl:!pr-10 h-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="flex gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
|
||||
:class="{ 'items-center': item.color, 'items-start': !item.color }"
|
||||
@click="handleAdd(item)"
|
||||
>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-2 text-n-slate-12 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<span
|
||||
v-else-if="item.color"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
class="size-3 rounded-sm"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col items-start gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
:title="item.name || item.title"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0"
|
||||
>
|
||||
{{ item.name || item.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.id"
|
||||
class="text-xs text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ `#${item.id}` }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.email || item.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate min-w-0"
|
||||
>
|
||||
{{ item.email || item.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
defineProps({
|
||||
nameLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
namePlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
descriptionLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
descriptionPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
statusLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
statusPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['validationChange']);
|
||||
|
||||
const policyName = defineModel('policyName', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const description = defineModel('description', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const enabled = defineModel('enabled', {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
});
|
||||
|
||||
const validationRules = {
|
||||
policyName: { required, minLength: minLength(1) },
|
||||
description: { required, minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, { policyName, description });
|
||||
|
||||
const isValid = computed(() => !v$.value.$invalid);
|
||||
|
||||
watch(
|
||||
isValid,
|
||||
() => {
|
||||
emit('validationChange', {
|
||||
isValid: isValid.value,
|
||||
section: 'baseInfo',
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
<!-- Policy Name Field -->
|
||||
<div class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="nameLabel"
|
||||
name="policyName"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="policyName"
|
||||
type="text"
|
||||
:placeholder="namePlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="descriptionLabel"
|
||||
name="description"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="description"
|
||||
type="text"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
|
||||
<!-- Status Field -->
|
||||
<div v-if="statusLabel" class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="statusLabel"
|
||||
name="enabled"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model="enabled" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ statusPlaceholder }}
|
||||
</span>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['fetch']);
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
|
||||
const handleButtonClick = () => {
|
||||
emit('fetch');
|
||||
togglePopover(!showPopover.value);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (showPopover.value) {
|
||||
togglePopover(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="handleClickOutside"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<button
|
||||
v-if="count"
|
||||
class="h-6 px-2 rounded-md bg-n-alpha-2 gap-1.5 flex items-center"
|
||||
@click="handleButtonClick()"
|
||||
>
|
||||
<Icon :icon="icon" class="size-3.5 text-n-slate-12" />
|
||||
<span class="text-n-slate-12 text-sm">
|
||||
{{ count }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
class="top-full mt-1 ltr:left-0 rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak p-3 rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 pb-2">
|
||||
<Icon :icon="icon" class="size-3.5" />
|
||||
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4 w-full">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between gap-2 min-w-0 w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<span
|
||||
:title="item.name"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.id"
|
||||
class="text-sm text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ `#${item.id}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="item.email" class="text-sm text-n-slate-11 flex-shrink-0">
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const handleDelete = itemId => {
|
||||
emit('delete', itemId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0 && emptyStateMessage"
|
||||
class="custom-dashed-border flex items-center justify-center py-6 w-full"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-n-weak">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 col-span-2">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2 col-span-1">
|
||||
<span
|
||||
:title="item.email || item.phoneNumber"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0"
|
||||
>
|
||||
{{ item.email || item.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 justify-end flex items-center">
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
slate
|
||||
ghost
|
||||
sm
|
||||
type="button"
|
||||
@click="handleDelete(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
const props = defineProps({
|
||||
tagsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const excludedLabels = defineModel('excludedLabels', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
||||
type: Number,
|
||||
default: 10,
|
||||
});
|
||||
|
||||
// Duration limits: 10 minutes to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 10;
|
||||
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hoveredLabel = ref(null);
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
const addedTags = computed(() =>
|
||||
props.tagsList
|
||||
.filter(label => excludedLabels.value.includes(label.name))
|
||||
.map(label => ({ id: label.id, title: label.name, ...label }))
|
||||
);
|
||||
|
||||
const filteredTags = computed(() =>
|
||||
props.tagsList.filter(
|
||||
label => !addedTags.value.some(tag => tag.id === label.id)
|
||||
)
|
||||
);
|
||||
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % 60 === 0) return DURATION_UNITS.HOURS;
|
||||
return DURATION_UNITS.MINUTES;
|
||||
};
|
||||
|
||||
const onClickAddTag = tag => {
|
||||
excludedLabels.value = [...excludedLabels.value, tag.name];
|
||||
};
|
||||
|
||||
const onClickRemoveTag = tag => {
|
||||
excludedLabels.value = excludedLabels.value.filter(
|
||||
name => name !== tag.title
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(excludeOlderThanMinutes.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 flex-col flex gap-6">
|
||||
<div class="flex flex-col items-start gap-1 py-1">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div
|
||||
class="flex items-start gap-2 flex-wrap"
|
||||
@mouseleave="hoveredLabel = null"
|
||||
>
|
||||
<LabelItem
|
||||
v-for="tag in addedTags"
|
||||
:key="tag.id"
|
||||
:label="tag"
|
||||
:is-hovered="hoveredLabel === tag.id"
|
||||
class="h-8"
|
||||
@remove="onClickRemoveTag"
|
||||
@hover="hoveredLabel = tag.id"
|
||||
/>
|
||||
<AddDataDropdown
|
||||
:label="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.ADD_TAG'
|
||||
)
|
||||
"
|
||||
:search-placeholder="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.DROPDOWN.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:items="filteredTags"
|
||||
class="[&>button]:!text-n-blue-text"
|
||||
@add="onClickAddTag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DURATION.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model:unit="windowUnit"
|
||||
v-model:model-value="excludeOlderThanMinutes"
|
||||
:min="MIN_DURATION_MINUTES"
|
||||
:max="MAX_DURATION_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fairDistributionLimit = defineModel('fairDistributionLimit', {
|
||||
type: Number,
|
||||
default: 100,
|
||||
set(value) {
|
||||
return Number(value) || 0;
|
||||
},
|
||||
});
|
||||
|
||||
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
type: Number,
|
||||
default: 3600,
|
||||
set(value) {
|
||||
return Number(value) || 0;
|
||||
},
|
||||
});
|
||||
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % 60 === 0) return DURATION_UNITS.HOURS;
|
||||
return DURATION_UNITS.MINUTES;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-start xl:items-center flex-col md:flex-row gap-4 lg:gap-3 bg-n-solid-1 p-4 outline outline-1 outline-n-weak rounded-xl"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.INPUT_MAX'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="fairDistributionLimit"
|
||||
type="number"
|
||||
placeholder="100"
|
||||
max="100000"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex sm:flex-row flex-col items-start sm:items-center gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.DURATION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model:model-value="fairDistributionWindow"
|
||||
v-model:unit="windowUnit"
|
||||
:min="10"
|
||||
:max="1438560"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete', 'add', 'update']);
|
||||
|
||||
const inboxCapacityLimits = defineModel('inboxCapacityLimits', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
const DEFAULT_CONVERSATION_LIMIT = 10;
|
||||
const MIN_CONVERSATION_LIMIT = 1;
|
||||
const MAX_CONVERSATION_LIMIT = 100000;
|
||||
|
||||
const selectedInboxIds = computed(
|
||||
() => new Set(inboxCapacityLimits.value.map(limit => limit.inboxId))
|
||||
);
|
||||
|
||||
const availableInboxes = computed(() =>
|
||||
props.inboxList.filter(
|
||||
inbox => inbox && !selectedInboxIds.value.has(inbox.id)
|
||||
)
|
||||
);
|
||||
|
||||
const isLimitValid = limit => {
|
||||
return (
|
||||
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
||||
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
||||
);
|
||||
};
|
||||
|
||||
const inboxMap = computed(
|
||||
() => new Map(props.inboxList.map(inbox => [inbox.id, inbox]))
|
||||
);
|
||||
|
||||
const handleAddInbox = inbox => {
|
||||
emit('add', {
|
||||
inboxId: inbox.id,
|
||||
conversationLimit: DEFAULT_CONVERSATION_LIMIT,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLimit = limitId => {
|
||||
emit('delete', limitId);
|
||||
};
|
||||
|
||||
const handleLimitChange = limit => {
|
||||
if (isLimitValid(limit)) {
|
||||
emit('update', limit);
|
||||
}
|
||||
};
|
||||
|
||||
const getInboxName = inboxId => {
|
||||
return inboxMap.value.get(inboxId)?.name || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 flex-col flex gap-3">
|
||||
<div class="flex items-center w-full gap-8 justify-between pt-1 pb-3">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.LABEL`) }}
|
||||
</label>
|
||||
|
||||
<AddDataDropdown
|
||||
:label="t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.ADD_BUTTON`)"
|
||||
:search-placeholder="
|
||||
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SELECT_INBOX`)
|
||||
"
|
||||
:items="availableInboxes"
|
||||
@add="handleAddInbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!inboxCapacityLimits.length"
|
||||
class="custom-dashed-border flex items-center justify-center py-6 w-full"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.EMPTY_STATE`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-col flex gap-3">
|
||||
<div
|
||||
v-for="(limit, index) in inboxCapacityLimits"
|
||||
:key="limit.id || `temp-${index}`"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-start rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full"
|
||||
>
|
||||
{{ getInboxName(limit.inboxId) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-shrink-0 flex items-center"
|
||||
:class="[
|
||||
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
|
||||
]"
|
||||
>
|
||||
<label class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2">
|
||||
{{
|
||||
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`)
|
||||
}}
|
||||
</label>
|
||||
|
||||
<div class="h-5 w-px bg-n-weak" />
|
||||
|
||||
<input
|
||||
v-model.number="limit.conversationLimit"
|
||||
type="number"
|
||||
:min="MIN_CONVERSATION_LIMIT"
|
||||
:max="MAX_CONVERSATION_LIMIT"
|
||||
class="reset-base bg-transparent focus:outline-none max-w-20 text-sm"
|
||||
:class="[
|
||||
!isLimitValid(limit)
|
||||
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
|
||||
: 'placeholder:text-n-slate-10 text-n-slate-12',
|
||||
]"
|
||||
:placeholder="
|
||||
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
|
||||
"
|
||||
@blur="handleLimitChange(limit)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRemoveLimit(limit.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="absolute top-4 right-4">
|
||||
<input
|
||||
:id="`${id}`"
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import AddDataDropdown from '../AddDataDropdown.vue';
|
||||
|
||||
const mockInboxes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
email: 'support@company.com',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
email: 'help@company.com',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
phoneNumber: '+1 555-0123',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
email: 'messenger@company.com',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Twitter DM',
|
||||
email: 'twitter@company.com',
|
||||
icon: 'i-lucide-twitter',
|
||||
},
|
||||
];
|
||||
|
||||
const mockTags = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'urgent',
|
||||
color: '#ff4757',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bug',
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'feature-request',
|
||||
color: '#4834d4',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'documentation',
|
||||
color: '#26de81',
|
||||
},
|
||||
];
|
||||
|
||||
const handleAdd = item => {
|
||||
console.log('Add item:', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AddDataDropdown"
|
||||
:layout="{ type: 'grid', width: '500px' }"
|
||||
>
|
||||
<Variant title="Basic Usage - Inboxes">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
|
||||
<AddDataDropdown
|
||||
label="Add Inbox"
|
||||
search-placeholder="Search inboxes..."
|
||||
:items="mockInboxes"
|
||||
@add="handleAdd"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Basic Usage - Tags">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
|
||||
<AddDataDropdown
|
||||
label="Add Tag"
|
||||
search-placeholder="Search tags..."
|
||||
:items="mockTags"
|
||||
@add="handleAdd"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import BaseInfo from '../BaseInfo.vue';
|
||||
|
||||
const policyName = ref('Round Robin Policy');
|
||||
const description = ref(
|
||||
'Distributes conversations evenly among available agents'
|
||||
);
|
||||
const enabled = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/BaseInfo"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background">
|
||||
<BaseInfo
|
||||
v-model:policy-name="policyName"
|
||||
v-model:description="description"
|
||||
v-model:enabled="enabled"
|
||||
name-label="Policy Name"
|
||||
name-placeholder="Enter policy name"
|
||||
description-label="Description"
|
||||
description-placeholder="Enter policy description"
|
||||
status-label="Status"
|
||||
status-placeholder="Active"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import CardPopover from '../CardPopover.vue';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
];
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Smith',
|
||||
email: 'john.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah.johnson@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Mike Chen',
|
||||
email: 'mike.chen@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Emily Davis',
|
||||
email: 'emily.davis@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Alex Rodriguez',
|
||||
email: 'alex.rodriguez@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=5',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/CardPopover"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
|
||||
<CardPopover
|
||||
:count="3"
|
||||
title="Added Inboxes"
|
||||
icon="i-lucide-inbox"
|
||||
:items="mockItems.slice(0, 3)"
|
||||
@fetch="() => console.log('Fetch triggered')"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
|
||||
<CardPopover
|
||||
:count="3"
|
||||
title="Added Agents"
|
||||
icon="i-lucide-users-round"
|
||||
:items="mockUsers.slice(0, 3)"
|
||||
@fetch="() => console.log('Fetch triggered')"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import DataTable from '../DataTable.vue';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
email: 'support@company.com',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
email: 'help@company.com',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
phoneNumber: '+1 555-0123',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
];
|
||||
|
||||
const mockAgentList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Smith',
|
||||
email: 'jane.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
];
|
||||
|
||||
const handleDelete = itemId => {
|
||||
console.log('Delete item:', itemId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/DataTable"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="With Data">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="mockItems"
|
||||
:is-fetching="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Agents">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="mockAgentList"
|
||||
:is-fetching="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable :items="[]" is-fetching @delete="handleDelete" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="[]"
|
||||
:is-fetching="false"
|
||||
empty-state-message="No items found"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import ExclusionRules from '../ExclusionRules.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const mockTagsList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'urgent',
|
||||
color: '#ff4757',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bug',
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'feature-request',
|
||||
color: '#4834d4',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'documentation',
|
||||
color: '#26de81',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'enhancement',
|
||||
color: '#2ed573',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'question',
|
||||
color: '#ffa502',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'duplicate',
|
||||
color: '#747d8c',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'wontfix',
|
||||
color: '#57606f',
|
||||
},
|
||||
];
|
||||
|
||||
const excludedLabelsBasic = ref([]);
|
||||
const excludeOlderThanHoursBasic = ref(10);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/ExclusionRules"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background h-[600px]">
|
||||
<ExclusionRules
|
||||
v-model:excluded-labels="excludedLabelsBasic"
|
||||
v-model:exclude-older-than-minutes="excludeOlderThanHoursBasic"
|
||||
:tags-list="mockTagsList"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import FairDistribution from '../FairDistribution.vue';
|
||||
|
||||
const fairDistributionLimit = ref(100);
|
||||
const fairDistributionWindow = ref(3600);
|
||||
const windowUnit = ref('minutes');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/FairDistribution"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background">
|
||||
<FairDistribution
|
||||
v-model:fair-distribution-limit="fairDistributionLimit"
|
||||
v-model:fair-distribution-window="fairDistributionWindow"
|
||||
v-model:window-unit="windowUnit"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import InboxCapacityLimits from '../InboxCapacityLimits.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const mockInboxList = [
|
||||
{
|
||||
value: 1,
|
||||
label: 'Website Support',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: 'Email Support',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: 'WhatsApp Business',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: 'Facebook Messenger',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: 'Twitter DM',
|
||||
icon: 'i-lucide-twitter',
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: 'Telegram',
|
||||
icon: 'i-lucide-send',
|
||||
},
|
||||
];
|
||||
|
||||
const inboxCapacityLimitsEmpty = ref([]);
|
||||
const inboxCapacityLimitsNew = ref([
|
||||
{ id: 1, inboxId: 1, conversationLimit: 5 },
|
||||
{ inboxId: null, conversationLimit: null },
|
||||
]);
|
||||
|
||||
const handleDelete = id => {
|
||||
console.log('Delete capacity limit:', id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/InboxCapacityLimits"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<Variant title="Empty State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
is-fetching
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With New Row and existing data">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsNew"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Interactive Demo">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<div class="mt-4 p-4 bg-n-alpha-2 rounded-lg">
|
||||
<h4 class="text-sm font-medium mb-2">Current Limits:</h4>
|
||||
<pre class="text-xs">{{
|
||||
JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user