Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73140998ff | ||
|
|
6365a7ea53 | ||
|
|
2adc040a8f | ||
|
|
86da3f7c06 | ||
|
|
c22a31c198 | ||
|
|
8019e7c636 | ||
|
|
29f670189a | ||
|
|
2b5638287c | ||
|
|
c117257b42 | ||
|
|
07b561952b | ||
|
|
d747b95f6e | ||
|
|
7314c279ee | ||
|
|
ca5e112a8c | ||
|
|
116ed54c7e | ||
|
|
cfa0bb953b | ||
|
|
b6a14bda48 | ||
|
|
fd8919b901 | ||
|
|
0d490640f2 | ||
|
|
02216471c3 | ||
|
|
68d8e62a5c | ||
|
|
bb8bafe3dc | ||
|
|
d2ba9a2ad3 | ||
|
|
3fce56c98f | ||
|
|
26b4a24f11 | ||
|
|
a8ed074bf0 | ||
|
|
96fe3e146d | ||
|
|
696564863c | ||
|
|
df4c8cf58b | ||
|
|
2bd8e76886 | ||
|
|
1de8d3e56d | ||
|
|
f2054e703a | ||
|
|
89d02e2c92 | ||
|
|
0d8e249fe4 | ||
|
|
20fa5eeaa5 | ||
|
|
f2eaa845dc | ||
|
|
863c033699 | ||
|
|
141dfc3321 | ||
|
|
aa21c15d0e | ||
|
|
3051da1e44 | ||
|
|
399c91adaa | ||
|
|
eb759255d8 | ||
|
|
cc86b8c7f1 | ||
|
|
a971ff00f8 | ||
|
|
67dc21ea5f | ||
|
|
efc3b5e7d4 | ||
|
|
eed2eaceb0 | ||
|
|
0a17976913 | ||
|
|
57904a56a0 | ||
|
|
728a5a6710 | ||
|
|
87fe1e9ad7 | ||
|
|
5c3b85334b | ||
|
|
e6a7e836a0 | ||
|
|
b269cca0bf | ||
|
|
1df5fd513a | ||
|
|
4627aad56d | ||
|
|
f23d95e004 | ||
|
|
1ef945de7b | ||
|
|
92fc286ecb | ||
|
|
dedb0426fb | ||
|
|
eb0410af53 | ||
|
|
3ded13c8d5 | ||
|
|
f1079574e3 | ||
|
|
73ad27e94c | ||
|
|
6a83cad69d | ||
|
|
7e0507e3b5 | ||
|
|
94a9e4e067 | ||
|
|
7b20ecd27b | ||
|
|
e635122ff6 | ||
|
|
fa07b9158a | ||
|
|
46eb6f39f3 | ||
|
|
f0538b25ed | ||
|
|
389b864020 | ||
|
|
26778156dc | ||
|
|
48627da0f9 | ||
|
|
6a712b7592 | ||
|
|
e9c60aec04 | ||
|
|
a8e9acfae9 | ||
|
|
f5908b0f8a | ||
|
|
45fa697885 | ||
|
|
51fbf583b6 | ||
|
|
38d6ee6dd2 | ||
|
|
a547c28c8d | ||
|
|
5608f5a1a2 | ||
|
|
da4110a495 | ||
|
|
9a2136caf1 | ||
|
|
a44192bbe7 | ||
|
|
5a5b30fe1e | ||
|
|
08b9134486 | ||
|
|
7874a6a3dd | ||
|
|
4e9f644646 | ||
|
|
e9edfcafbb | ||
|
|
5f2b2f4221 | ||
|
|
70c183ea6e | ||
|
|
6c07f62cfc | ||
|
|
e33f28dc33 | ||
|
|
58ca82c720 | ||
|
|
7acf3c8817 | ||
|
|
77f492590e | ||
|
|
5b56f64838 | ||
|
|
bf806f0c28 | ||
|
|
93374f4327 | ||
|
|
c9823d9409 | ||
|
|
2c4e65d68e | ||
|
|
6ae5e67b52 | ||
|
|
32da4eec27 | ||
|
|
945a08eadb | ||
|
|
559d1b6576 | ||
|
|
eed1825d5a | ||
|
|
651645fbd2 | ||
|
|
b4a252a459 | ||
|
|
bdcb1934c0 | ||
|
|
4f09c2203c | ||
|
|
f455e7994e | ||
|
|
28e16f7ee0 | ||
|
|
fb0be60ae2 | ||
|
|
3c9c0298ba | ||
|
|
d6af182f82 | ||
|
|
c07d03e4e5 | ||
|
|
e81152608d | ||
|
|
615e81731c | ||
|
|
fb1aa085cf | ||
|
|
e8a01ace41 | ||
|
|
01363042ce | ||
|
|
5bf39d20e5 | ||
|
|
90352b3a20 | ||
|
|
ba8df900e3 | ||
|
|
9b75d9bd1b | ||
|
|
ec6c3b3571 | ||
|
|
48ba273730 | ||
|
|
5491ca2470 | ||
|
|
72391f9c36 | ||
|
|
f89d9a4401 | ||
|
|
e8ae73230d | ||
|
|
40c75941f5 | ||
|
|
d9b840f161 | ||
|
|
1fbdd68222 | ||
|
|
ef54f07d5b | ||
|
|
e771d99552 | ||
|
|
358a3924b8 | ||
|
|
6b87d6784e | ||
|
|
faaf67129e | ||
|
|
159c810117 | ||
|
|
ce400a36d7 | ||
|
|
d20de25da1 | ||
|
|
c31d693add | ||
|
|
31497d9c63 | ||
|
|
a35c3e4c06 | ||
|
|
344e8d5016 | ||
|
|
3e27e28848 | ||
|
|
38af08534c | ||
|
|
bfa8a8ed60 | ||
|
|
7ab60d9f9c | ||
|
|
faac1486e9 | ||
|
|
8340bd615c | ||
|
|
75d3569f22 | ||
|
|
545453537f | ||
|
|
d75702c6b2 | ||
|
|
b76ec878f1 | ||
|
|
a954e1eaca | ||
|
|
bc5f1722e1 | ||
|
|
8f39e62570 | ||
|
|
7520ca7a99 | ||
|
|
35f4e63605 | ||
|
|
5773089865 | ||
|
|
d01c7d3fa7 | ||
|
|
959d2c0d8c | ||
|
|
622f29a0b7 |
+196
-49
@@ -3,6 +3,7 @@ orbs:
|
|||||||
node: circleci/node@6.1.0
|
node: circleci/node@6.1.0
|
||||||
qlty-orb: qltysh/qlty-orb@0.0
|
qlty-orb: qltysh/qlty-orb@0.0
|
||||||
|
|
||||||
|
# Shared defaults for setup steps
|
||||||
defaults: &defaults
|
defaults: &defaults
|
||||||
working_directory: ~/build
|
working_directory: ~/build
|
||||||
machine:
|
machine:
|
||||||
@@ -12,10 +13,106 @@ defaults: &defaults
|
|||||||
RAILS_LOG_TO_STDOUT: false
|
RAILS_LOG_TO_STDOUT: false
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
LOG_LEVEL: warn
|
LOG_LEVEL: warn
|
||||||
parallelism: 4
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Separate job for linting (no parallelism needed)
|
||||||
|
lint:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
|
||||||
|
# Install minimal system dependencies for linting
|
||||||
|
- run:
|
||||||
|
name: Install System Dependencies
|
||||||
|
command: |
|
||||||
|
sudo apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
||||||
|
libpq-dev \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libreadline-dev \
|
||||||
|
libyaml-dev \
|
||||||
|
openjdk-11-jdk \
|
||||||
|
jq \
|
||||||
|
software-properties-common \
|
||||||
|
ca-certificates \
|
||||||
|
imagemagick \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt1-dev \
|
||||||
|
file \
|
||||||
|
g++ \
|
||||||
|
gcc \
|
||||||
|
autoconf \
|
||||||
|
gnupg2 \
|
||||||
|
patch \
|
||||||
|
ruby-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libgmp-dev \
|
||||||
|
libncurses5-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libgdbm6 \
|
||||||
|
libgdbm-dev \
|
||||||
|
libvips
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Install RVM and Ruby 3.4.4
|
||||||
|
command: |
|
||||||
|
sudo apt-get install -y gpg
|
||||||
|
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
||||||
|
\curl -sSL https://get.rvm.io | bash -s stable
|
||||||
|
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
||||||
|
source ~/.rvm/scripts/rvm
|
||||||
|
rvm install "3.4.4"
|
||||||
|
rvm use 3.4.4 --default
|
||||||
|
gem install bundler -v 2.5.16
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Install Application Dependencies
|
||||||
|
command: |
|
||||||
|
source ~/.rvm/scripts/rvm
|
||||||
|
bundle install
|
||||||
|
|
||||||
|
- node/install:
|
||||||
|
node-version: '23.7'
|
||||||
|
- node/install-pnpm
|
||||||
|
- node/install-packages:
|
||||||
|
pkg-manager: pnpm
|
||||||
|
override-ci-command: pnpm i
|
||||||
|
|
||||||
|
# Swagger verification
|
||||||
|
- run:
|
||||||
|
name: Verify swagger API specification
|
||||||
|
command: |
|
||||||
|
bundle exec rake swagger:build
|
||||||
|
if [[ `git status swagger/swagger.json --porcelain` ]]
|
||||||
|
then
|
||||||
|
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p ~/tmp
|
||||||
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||||
|
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||||
|
|
||||||
|
# Bundle audit
|
||||||
|
- run:
|
||||||
|
name: Bundle audit
|
||||||
|
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
||||||
|
|
||||||
|
# Rubocop linting
|
||||||
|
- run:
|
||||||
|
name: Rubocop
|
||||||
|
command: bundle exec rubocop --parallel
|
||||||
|
|
||||||
|
# ESLint linting
|
||||||
|
- run:
|
||||||
|
name: eslint
|
||||||
|
command: pnpm run eslint
|
||||||
|
|
||||||
|
# Separate job for frontend tests
|
||||||
|
frontend-tests:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@@ -25,8 +122,38 @@ jobs:
|
|||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
- run: node --version
|
|
||||||
- run: pnpm --version
|
- run:
|
||||||
|
name: Run frontend tests (with coverage)
|
||||||
|
command: pnpm run test:coverage
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Move coverage files if they exist
|
||||||
|
command: |
|
||||||
|
if [ -d "coverage" ]; then
|
||||||
|
mkdir -p ~/build/coverage
|
||||||
|
cp -r coverage ~/build/coverage/frontend || true
|
||||||
|
fi
|
||||||
|
when: always
|
||||||
|
|
||||||
|
- persist_to_workspace:
|
||||||
|
root: ~/build
|
||||||
|
paths:
|
||||||
|
- coverage
|
||||||
|
|
||||||
|
# Backend tests with parallelization
|
||||||
|
backend-tests:
|
||||||
|
<<: *defaults
|
||||||
|
parallelism: 16
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- node/install:
|
||||||
|
node-version: '23.7'
|
||||||
|
- node/install-pnpm
|
||||||
|
- node/install-packages:
|
||||||
|
pkg-manager: pnpm
|
||||||
|
override-ci-command: pnpm i
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Add PostgreSQL repository and update
|
name: Add PostgreSQL repository and update
|
||||||
command: |
|
command: |
|
||||||
@@ -91,20 +218,6 @@ jobs:
|
|||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
# Swagger verification
|
|
||||||
- run:
|
|
||||||
name: Verify swagger API specification
|
|
||||||
command: |
|
|
||||||
bundle exec rake swagger:build
|
|
||||||
if [[ `git status swagger/swagger.json --porcelain` ]]
|
|
||||||
then
|
|
||||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p ~/tmp
|
|
||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
|
||||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
|
||||||
|
|
||||||
# Configure environment and database
|
# Configure environment and database
|
||||||
- run:
|
- run:
|
||||||
name: Database Setup and Configure Environment Variables
|
name: Database Setup and Configure Environment Variables
|
||||||
@@ -127,57 +240,91 @@ jobs:
|
|||||||
name: Run DB migrations
|
name: Run DB migrations
|
||||||
command: bundle exec rails db:chatwoot_prepare
|
command: bundle exec rails db:chatwoot_prepare
|
||||||
|
|
||||||
# Bundle audit
|
# Run backend tests (parallelized)
|
||||||
- run:
|
|
||||||
name: Bundle audit
|
|
||||||
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
|
||||||
|
|
||||||
# Rubocop linting
|
|
||||||
- run:
|
|
||||||
name: Rubocop
|
|
||||||
command: bundle exec rubocop
|
|
||||||
|
|
||||||
# ESLint linting
|
|
||||||
- run:
|
|
||||||
name: eslint
|
|
||||||
command: pnpm run eslint
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Run frontend tests (with coverage)
|
|
||||||
command: |
|
|
||||||
mkdir -p ~/build/coverage/frontend
|
|
||||||
pnpm run test:coverage
|
|
||||||
|
|
||||||
# Run backend tests
|
|
||||||
- run:
|
- run:
|
||||||
name: Run backend tests
|
name: Run backend tests
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/tmp/test-results/rspec
|
mkdir -p ~/tmp/test-results/rspec
|
||||||
mkdir -p ~/tmp/test-artifacts
|
mkdir -p ~/tmp/test-artifacts
|
||||||
mkdir -p ~/build/coverage/backend
|
mkdir -p ~/build/coverage/backend
|
||||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
|
||||||
|
# Use round-robin distribution (same as GitHub Actions) for better test isolation
|
||||||
|
# This prevents tests with similar timing from being grouped on the same runner
|
||||||
|
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||||
|
TESTS=""
|
||||||
|
|
||||||
|
for i in "${!SPEC_FILES[@]}"; do
|
||||||
|
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
|
||||||
|
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||||
--format RspecJunitFormatter \
|
--format RspecJunitFormatter \
|
||||||
--out ~/tmp/test-results/rspec.xml \
|
--out ~/tmp/test-results/rspec.xml \
|
||||||
-- ${TESTFILES}
|
-- $TESTS
|
||||||
no_output_timeout: 30m
|
no_output_timeout: 30m
|
||||||
|
|
||||||
# Qlty coverage publish
|
# Store test results for better splitting in future runs
|
||||||
- qlty-orb/coverage_publish:
|
- store_test_results:
|
||||||
files: |
|
path: ~/tmp/test-results
|
||||||
coverage/coverage.json
|
|
||||||
coverage/lcov.info
|
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: List coverage directory contents
|
name: Move coverage files if they exist
|
||||||
command: |
|
command: |
|
||||||
ls -R ~/build/coverage
|
if [ -d "coverage" ]; then
|
||||||
|
mkdir -p ~/build/coverage
|
||||||
|
cp -r coverage ~/build/coverage/backend || true
|
||||||
|
fi
|
||||||
|
when: always
|
||||||
|
|
||||||
- persist_to_workspace:
|
- persist_to_workspace:
|
||||||
root: ~/build
|
root: ~/build
|
||||||
paths:
|
paths:
|
||||||
- coverage
|
- coverage
|
||||||
|
|
||||||
|
# Collect coverage from all jobs
|
||||||
|
coverage:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- attach_workspace:
|
||||||
|
at: ~/build
|
||||||
|
|
||||||
|
# Qlty coverage publish
|
||||||
|
- qlty-orb/coverage_publish:
|
||||||
|
files: |
|
||||||
|
coverage/frontend/lcov.info
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: List coverage directory contents
|
||||||
|
command: |
|
||||||
|
ls -R ~/build/coverage || echo "No coverage directory"
|
||||||
|
|
||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: coverage
|
path: coverage
|
||||||
destination: coverage
|
destination: coverage
|
||||||
|
|
||||||
|
build:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- run:
|
||||||
|
name: Legacy build aggregator
|
||||||
|
command: |
|
||||||
|
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
version: 2
|
||||||
|
build:
|
||||||
|
jobs:
|
||||||
|
- lint
|
||||||
|
- frontend-tests
|
||||||
|
- backend-tests
|
||||||
|
- coverage:
|
||||||
|
requires:
|
||||||
|
- frontend-tests
|
||||||
|
- backend-tests
|
||||||
|
- build:
|
||||||
|
requires:
|
||||||
|
- lint
|
||||||
|
- coverage
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ MAILER_INBOUND_EMAIL_DOMAIN=
|
|||||||
# mandrill for Mandrill
|
# mandrill for Mandrill
|
||||||
# postmark for Postmark
|
# postmark for Postmark
|
||||||
# sendgrid for Sendgrid
|
# sendgrid for Sendgrid
|
||||||
|
# ses for Amazon SES
|
||||||
RAILS_INBOUND_EMAIL_SERVICE=
|
RAILS_INBOUND_EMAIL_SERVICE=
|
||||||
# Use one of the following based on the email ingress service
|
# Use one of the following based on the email ingress service
|
||||||
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
||||||
@@ -114,6 +115,10 @@ RAILS_INBOUND_EMAIL_PASSWORD=
|
|||||||
MAILGUN_INGRESS_SIGNING_KEY=
|
MAILGUN_INGRESS_SIGNING_KEY=
|
||||||
MANDRILL_INGRESS_API_KEY=
|
MANDRILL_INGRESS_API_KEY=
|
||||||
|
|
||||||
|
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
|
||||||
|
# Configure only if the rails_inbound_email_service = ses
|
||||||
|
ACTION_MAILBOX_SES_SNS_TOPIC=
|
||||||
|
|
||||||
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
||||||
# Inbound webhook URL format:
|
# Inbound webhook URL format:
|
||||||
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
||||||
@@ -215,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
|||||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||||
# DD_TRACE_AGENT_URL=
|
# DD_TRACE_AGENT_URL=
|
||||||
|
|
||||||
|
|
||||||
# MaxMindDB API key to download GeoLite2 City database
|
# MaxMindDB API key to download GeoLite2 City database
|
||||||
# IP_LOOKUP_API_KEY=
|
# IP_LOOKUP_API_KEY=
|
||||||
|
|
||||||
@@ -256,6 +262,8 @@ AZURE_APP_SECRET=
|
|||||||
## Change these values to fine tune performance
|
## Change these values to fine tune performance
|
||||||
# control the concurrency setting of sidekiq
|
# control the concurrency setting of sidekiq
|
||||||
# SIDEKIQ_CONCURRENCY=10
|
# SIDEKIQ_CONCURRENCY=10
|
||||||
|
# Enable verbose logging each time a job is dequeued in Sidekiq
|
||||||
|
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||||
|
|
||||||
|
|
||||||
# AI powered features
|
# AI powered features
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
name: Run Chatwoot CE spec
|
name: Run Chatwoot CE spec
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -8,11 +10,58 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
# Separate linting jobs for faster feedback
|
||||||
runs-on: ubuntu-22.04
|
lint-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
- name: Run Rubocop
|
||||||
|
run: bundle exec rubocop --parallel
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 23
|
||||||
|
cache: 'pnpm'
|
||||||
|
- name: Install pnpm dependencies
|
||||||
|
run: pnpm i
|
||||||
|
- name: Run ESLint
|
||||||
|
run: pnpm run eslint
|
||||||
|
|
||||||
|
# Frontend tests run in parallel with backend
|
||||||
|
frontend-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 23
|
||||||
|
cache: 'pnpm'
|
||||||
|
- name: Install pnpm dependencies
|
||||||
|
run: pnpm i
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: pnpm run test:coverage
|
||||||
|
|
||||||
|
# Backend tests with parallelization
|
||||||
|
backend-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
ci_node_total: [16]
|
||||||
|
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg15
|
image: pgvector/pgvector:pg16
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ''
|
POSTGRES_PASSWORD: ''
|
||||||
@@ -20,8 +69,6 @@ jobs:
|
|||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
# needed because the postgres container does not provide a healthcheck
|
|
||||||
# tmpfs makes DB faster by using RAM
|
|
||||||
options: >-
|
options: >-
|
||||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@@ -29,7 +76,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
redis:
|
redis:
|
||||||
image: redis
|
image: redis:alpine
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: --entrypoint redis-server
|
options: --entrypoint redis-server
|
||||||
@@ -43,7 +90,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
bundler-cache: true
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -64,19 +111,36 @@ jobs:
|
|||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: bundle exec rake db:schema:load
|
run: bundle exec rake db:schema:load
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run backend tests (parallelized)
|
||||||
run: pnpm run test:coverage
|
|
||||||
|
|
||||||
# Run rails tests
|
|
||||||
- name: Run backend tests
|
|
||||||
run: |
|
run: |
|
||||||
bundle exec rspec --profile=10 --format documentation
|
# Get all spec files and split them using round-robin distribution
|
||||||
|
# This ensures slow tests are distributed evenly across all nodes
|
||||||
|
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||||
|
TESTS=""
|
||||||
|
|
||||||
|
for i in "${!SPEC_FILES[@]}"; do
|
||||||
|
# Assign spec to this node if: index % total == node_index
|
||||||
|
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
|
||||||
|
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$TESTS" ]; then
|
||||||
|
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
||||||
|
fi
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --openssl-legacy-provider
|
NODE_OPTIONS: --openssl-legacy-provider
|
||||||
|
|
||||||
- name: Upload rails log folder
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: rails-log-folder
|
name: rspec-results-${{ matrix.ci_node_index }}
|
||||||
|
path: tmp/rspec_results.json
|
||||||
|
|
||||||
|
- name: Upload rails log folder
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: rails-log-folder-${{ matrix.ci_node_index }}
|
||||||
path: log
|
path: log
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: false
|
push: false
|
||||||
load: false
|
load: false
|
||||||
cache-from: type=gha
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|||||||
@@ -99,3 +99,5 @@ CLAUDE.local.md
|
|||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
|
.pnpm-store/*
|
||||||
|
local/
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
|||||||
"**/target/**",
|
"**/target/**",
|
||||||
"**/templates/**",
|
"**/templates/**",
|
||||||
"**/testdata/**",
|
"**/testdata/**",
|
||||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||||
]
|
]
|
||||||
|
|
||||||
test_patterns = [
|
test_patterns = [
|
||||||
|
|||||||
+12
-1
@@ -7,6 +7,8 @@ plugins:
|
|||||||
require:
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
|
- ./rubocop/attachment_download.rb
|
||||||
|
- ./rubocop/one_class_per_file.rb
|
||||||
|
|
||||||
Layout/LineLength:
|
Layout/LineLength:
|
||||||
Max: 150
|
Max: 150
|
||||||
@@ -40,6 +42,12 @@ Style/SymbolArray:
|
|||||||
Style/OpenStructUse:
|
Style/OpenStructUse:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Chatwoot/AttachmentDownload:
|
||||||
|
Enabled: true
|
||||||
|
Exclude:
|
||||||
|
- 'spec/**/*'
|
||||||
|
- 'test/**/*'
|
||||||
|
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
@@ -87,7 +95,7 @@ Metrics/ModuleLength:
|
|||||||
Rails/HelperInstanceVariable:
|
Rails/HelperInstanceVariable:
|
||||||
Exclude:
|
Exclude:
|
||||||
- enterprise/app/helpers/captain/chat_helper.rb
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
|
- enterprise/app/helpers/captain/chat_response_helper.rb
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -205,6 +213,9 @@ UseFromEmail:
|
|||||||
CustomCopLocation:
|
CustomCopLocation:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
|
Style/OneClassPerFile:
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
AllCops:
|
AllCops:
|
||||||
NewCops: enable
|
NewCops: enable
|
||||||
Exclude:
|
Exclude:
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||||
- **Run Project**: `overmind start -f Procfile.dev`
|
- **Run Project**: `overmind start -f Procfile.dev`
|
||||||
|
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||||
|
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||||
|
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -37,11 +40,20 @@
|
|||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- MVP focus: Least code change, happy-path only
|
||||||
- No unnecessary defensive programming
|
- No unnecessary defensive programming
|
||||||
|
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||||
|
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||||
- Break down complex tasks into small, testable units
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
- Remove dead/unreachable/unused code
|
- Remove dead/unreachable/unused code
|
||||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||||
|
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||||
|
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||||
|
- Example: `feat(auth): add user authentication`
|
||||||
- Don't reference Claude in commit messages
|
- Don't reference Claude in commit messages
|
||||||
|
|
||||||
## Project-Specific
|
## Project-Specific
|
||||||
@@ -73,3 +85,4 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
- 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.
|
- 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.
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
|
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ gem 'telephone_number'
|
|||||||
gem 'time_diff'
|
gem 'time_diff'
|
||||||
gem 'tzinfo-data'
|
gem 'tzinfo-data'
|
||||||
gem 'valid_email2'
|
gem 'valid_email2'
|
||||||
gem 'octokit'
|
gem 'email-provider-info'
|
||||||
# compress javascript config.assets.js_compressor
|
# compress javascript config.assets.js_compressor
|
||||||
gem 'uglifier'
|
gem 'uglifier'
|
||||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||||
@@ -55,6 +55,9 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
|
|||||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||||
gem 'image_processing'
|
gem 'image_processing'
|
||||||
|
|
||||||
|
##-- for actionmailbox --##
|
||||||
|
gem 'aws-actionmailbox-ses', '~> 0'
|
||||||
|
|
||||||
##-- gems for database --#
|
##-- gems for database --#
|
||||||
gem 'groupdate'
|
gem 'groupdate'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
@@ -159,7 +162,7 @@ gem 'working_hours'
|
|||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
|
|
||||||
# Subscriptions, Billing
|
# Subscriptions, Billing
|
||||||
gem 'stripe'
|
gem 'stripe', '~> 18.0'
|
||||||
|
|
||||||
## - helper gems --##
|
## - helper gems --##
|
||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
@@ -188,11 +191,16 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.4.3'
|
gem 'ai-agents', '>= 0.7.0'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
|
gem 'ruby_llm', '>= 1.8.2'
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
|
# OpenTelemetry for LLM observability
|
||||||
|
gem 'opentelemetry-sdk'
|
||||||
|
gem 'opentelemetry-exporter-otlp'
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
@@ -207,7 +215,7 @@ group :production do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'annotate'
|
gem 'annotaterb'
|
||||||
gem 'bullet'
|
gem 'bullet'
|
||||||
gem 'letter_opener'
|
gem 'letter_opener'
|
||||||
gem 'scss_lint', require: false
|
gem 'scss_lint', require: false
|
||||||
|
|||||||
+62
-33
@@ -126,31 +126,41 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.4.3)
|
ai-agents (0.7.0)
|
||||||
ruby_llm (~> 1.3)
|
ruby_llm (~> 1.8.2)
|
||||||
annotate (3.2.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 3.2, < 8.0)
|
activerecord (>= 6.0.0)
|
||||||
rake (>= 10.4, < 14.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
activerecord (>= 5.0, < 7.7)
|
activerecord (>= 5.0, < 7.7)
|
||||||
activesupport (>= 5.0, < 7.7)
|
activesupport (>= 5.0, < 7.7)
|
||||||
aws-eventstream (1.2.0)
|
aws-actionmailbox-ses (0.1.0)
|
||||||
aws-partitions (1.760.0)
|
actionmailbox (>= 7.1.0)
|
||||||
aws-sdk-core (3.171.1)
|
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-sdk-sns (~> 1, >= 1.61.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-partitions (1.1198.0)
|
||||||
|
aws-sdk-core (3.240.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.64.0)
|
logger
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-kms (1.118.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sdk-core (~> 3, >= 3.239.1)
|
||||||
aws-sdk-s3 (1.122.0)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-s3 (1.208.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.234.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.5.2)
|
aws-sdk-sns (1.70.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.188.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
@@ -270,6 +280,7 @@ GEM
|
|||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
http (>= 3.0)
|
http (>= 3.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
|
email-provider-info (0.0.1)
|
||||||
email_reply_trimmer (0.1.13)
|
email_reply_trimmer (0.1.13)
|
||||||
erubi (1.13.0)
|
erubi (1.13.0)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
@@ -591,13 +602,10 @@ GEM
|
|||||||
rack (>= 1.2, < 4)
|
rack (>= 1.2, < 4)
|
||||||
snaky_hash (~> 2.0)
|
snaky_hash (~> 2.0)
|
||||||
version_gem (~> 1.1)
|
version_gem (~> 1.1)
|
||||||
octokit (10.0.0)
|
|
||||||
faraday (>= 1, < 3)
|
|
||||||
sawyer (~> 0.9)
|
|
||||||
oj (3.16.10)
|
oj (3.16.10)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.3)
|
omniauth (2.1.4)
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
logger
|
logger
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
@@ -620,6 +628,25 @@ GEM
|
|||||||
faraday (>= 1.0, < 3)
|
faraday (>= 1.0, < 3)
|
||||||
multi_json (>= 1.0)
|
multi_json (>= 1.0)
|
||||||
openssl (3.2.0)
|
openssl (3.2.0)
|
||||||
|
opentelemetry-api (1.7.0)
|
||||||
|
opentelemetry-common (0.23.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-exporter-otlp (0.31.1)
|
||||||
|
google-protobuf (>= 3.18)
|
||||||
|
googleapis-common-protos-types (~> 1.3)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-common (~> 0.20)
|
||||||
|
opentelemetry-sdk (~> 1.10)
|
||||||
|
opentelemetry-semantic_conventions
|
||||||
|
opentelemetry-registry (0.4.0)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-sdk (1.10.0)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-common (~> 0.20)
|
||||||
|
opentelemetry-registry (~> 0.2)
|
||||||
|
opentelemetry-semantic_conventions
|
||||||
|
opentelemetry-semantic_conventions (1.36.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
@@ -656,7 +683,7 @@ GEM
|
|||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-mini-profiler (3.2.0)
|
rack-mini-profiler (3.2.0)
|
||||||
rack (>= 1.2.0)
|
rack (>= 1.2.0)
|
||||||
rack-protection (4.1.1)
|
rack-protection (4.2.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
logger (>= 1.6.0)
|
logger (>= 1.6.0)
|
||||||
rack (>= 3.0.0, < 4)
|
rack (>= 3.0.0, < 4)
|
||||||
@@ -795,7 +822,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.5.1)
|
ruby_llm (1.8.2)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@@ -803,8 +830,9 @@ GEM
|
|||||||
faraday-net_http (>= 1)
|
faraday-net_http (>= 1)
|
||||||
faraday-retry (>= 1)
|
faraday-retry (>= 1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
|
ruby_llm-schema (~> 0.2.1)
|
||||||
zeitwerk (~> 2)
|
zeitwerk (~> 2)
|
||||||
ruby_llm-schema (0.1.0)
|
ruby_llm-schema (0.2.5)
|
||||||
ruby_parser (3.20.0)
|
ruby_parser (3.20.0)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sass (3.7.4)
|
sass (3.7.4)
|
||||||
@@ -820,9 +848,6 @@ GEM
|
|||||||
sprockets (> 3.0)
|
sprockets (> 3.0)
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
tilt
|
tilt
|
||||||
sawyer (0.9.2)
|
|
||||||
addressable (>= 2.3.5)
|
|
||||||
faraday (>= 0.17.3, < 3)
|
|
||||||
scout_apm (5.3.3)
|
scout_apm (5.3.3)
|
||||||
parser
|
parser
|
||||||
scss_lint (0.60.0)
|
scss_lint (0.60.0)
|
||||||
@@ -907,7 +932,7 @@ GEM
|
|||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (8.5.0)
|
stripe (18.0.1)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
@@ -996,10 +1021,11 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.4.3)
|
ai-agents (>= 0.7.0)
|
||||||
annotate
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
|
aws-actionmailbox-ses (~> 0)
|
||||||
aws-sdk-s3
|
aws-sdk-s3
|
||||||
azure-storage-blob!
|
azure-storage-blob!
|
||||||
barnes
|
barnes
|
||||||
@@ -1022,6 +1048,7 @@ DEPENDENCIES
|
|||||||
dotenv-rails (>= 3.0.0)
|
dotenv-rails (>= 3.0.0)
|
||||||
down
|
down
|
||||||
elastic-apm
|
elastic-apm
|
||||||
|
email-provider-info
|
||||||
email_reply_trimmer
|
email_reply_trimmer
|
||||||
facebook-messenger
|
facebook-messenger
|
||||||
factory_bot_rails (>= 6.4.3)
|
factory_bot_rails (>= 6.4.3)
|
||||||
@@ -1063,13 +1090,14 @@ DEPENDENCIES
|
|||||||
net-smtp (~> 0.3.4)
|
net-smtp (~> 0.3.4)
|
||||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||||
newrelic_rpm
|
newrelic_rpm
|
||||||
octokit
|
|
||||||
omniauth (>= 2.1.2)
|
omniauth (>= 2.1.2)
|
||||||
omniauth-google-oauth2 (>= 1.1.3)
|
omniauth-google-oauth2 (>= 1.1.3)
|
||||||
omniauth-oauth2
|
omniauth-oauth2
|
||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
omniauth-saml
|
omniauth-saml
|
||||||
opensearch-ruby
|
opensearch-ruby
|
||||||
|
opentelemetry-exporter-otlp
|
||||||
|
opentelemetry-sdk
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
@@ -1095,6 +1123,7 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
|
ruby_llm (>= 1.8.2)
|
||||||
ruby_llm-schema
|
ruby_llm-schema
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
@@ -1115,7 +1144,7 @@ DEPENDENCIES
|
|||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
stackprof
|
stackprof
|
||||||
stripe
|
stripe (~> 18.0)
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
tidewave
|
tidewave
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.4.0
|
4.8.0
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||||
"value": "none"
|
"value": "none"
|
||||||
|
},
|
||||||
|
"NODE_OPTIONS": {
|
||||||
|
"description": "Increase V8 heap for Vite build to avoid OOM",
|
||||||
|
"value": "--max-old-space-size=4096"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"formation": {
|
"formation": {
|
||||||
|
|||||||
@@ -103,3 +103,5 @@ class ContactInboxBuilder
|
|||||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
class Messages::MessageBuilder
|
class Messages::MessageBuilder
|
||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
|
include ::EmailHelper
|
||||||
|
include ::DataHelper
|
||||||
|
|
||||||
attr_reader :message
|
attr_reader :message
|
||||||
|
|
||||||
def initialize(user, conversation, params)
|
def initialize(user, conversation, params)
|
||||||
@@ -23,7 +26,7 @@ class Messages::MessageBuilder
|
|||||||
process_emails
|
process_emails
|
||||||
# When the message has no quoted content, it will just be rendered as a regular message
|
# When the message has no quoted content, it will just be rendered as a regular message
|
||||||
# The frontend is equipped to handle this case
|
# The frontend is equipped to handle this case
|
||||||
process_email_content if @account.feature_enabled?(:quoted_email_reply)
|
process_email_content
|
||||||
@message.save!
|
@message.save!
|
||||||
@message
|
@message
|
||||||
end
|
end
|
||||||
@@ -38,30 +41,12 @@ class Messages::MessageBuilder
|
|||||||
params = convert_to_hash(@params)
|
params = convert_to_hash(@params)
|
||||||
content_attributes = params.fetch(:content_attributes, {})
|
content_attributes = params.fetch(:content_attributes, {})
|
||||||
|
|
||||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||||
return content_attributes if content_attributes.is_a?(Hash)
|
return content_attributes if content_attributes.is_a?(Hash)
|
||||||
|
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Converts the given object to a hash.
|
|
||||||
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
|
|
||||||
# Otherwise, returns the object as-is.
|
|
||||||
def convert_to_hash(obj)
|
|
||||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
|
||||||
|
|
||||||
obj
|
|
||||||
end
|
|
||||||
|
|
||||||
# Attempts to parse a string as JSON.
|
|
||||||
# If successful, returns the parsed hash with symbolized names.
|
|
||||||
# If unsuccessful, returns nil.
|
|
||||||
def parse_json(content)
|
|
||||||
JSON.parse(content, symbolize_names: true)
|
|
||||||
rescue JSON::ParserError
|
|
||||||
{}
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_attachments
|
def process_attachments
|
||||||
return if @attachments.blank?
|
return if @attachments.blank?
|
||||||
|
|
||||||
@@ -110,12 +95,6 @@ class Messages::MessageBuilder
|
|||||||
email_string.gsub(/\s+/, '').split(',')
|
email_string.gsub(/\s+/, '').split(',')
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_email_addresses(all_emails)
|
|
||||||
all_emails&.each do |email|
|
|
||||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_type
|
def message_type
|
||||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||||
@@ -159,6 +138,7 @@ class Messages::MessageBuilder
|
|||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
|
content_attributes: content_attributes.presence,
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
@@ -178,14 +158,17 @@ class Messages::MessageBuilder
|
|||||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||||
normalized_content = normalize_email_body(@message.content)
|
normalized_content = normalize_email_body(@message.content)
|
||||||
|
|
||||||
|
# Process liquid templates in normalized content with code block protection
|
||||||
|
processed_content = process_liquid_in_email_body(normalized_content)
|
||||||
|
|
||||||
# Use custom HTML content if provided, otherwise generate from message content
|
# Use custom HTML content if provided, otherwise generate from message content
|
||||||
email_attributes[:html_content] = if custom_email_content_provided?
|
email_attributes[:html_content] = if custom_email_content_provided?
|
||||||
build_custom_html_content
|
build_custom_html_content
|
||||||
else
|
else
|
||||||
build_html_content(normalized_content)
|
build_html_content(processed_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
email_attributes[:text_content] = build_text_content(normalized_content)
|
email_attributes[:text_content] = build_text_content(processed_content)
|
||||||
email_attributes
|
email_attributes
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -204,22 +187,6 @@ class Messages::MessageBuilder
|
|||||||
text_content
|
text_content
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_indifferent_access(hash)
|
|
||||||
return {} if hash.blank?
|
|
||||||
|
|
||||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_email_body(content)
|
|
||||||
content.to_s.gsub("\r\n", "\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_email_html(content)
|
|
||||||
return '' if content.blank?
|
|
||||||
|
|
||||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def custom_email_content_provided?
|
def custom_email_content_provided?
|
||||||
@params[:email_html_content].present?
|
@params[:email_html_content].present?
|
||||||
end
|
end
|
||||||
@@ -232,4 +199,29 @@ class Messages::MessageBuilder
|
|||||||
|
|
||||||
html_content
|
html_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Liquid processing methods for email content
|
||||||
|
def process_liquid_in_email_body(content)
|
||||||
|
return content if content.blank?
|
||||||
|
return content unless should_process_liquid?
|
||||||
|
|
||||||
|
# Protect code blocks from liquid processing
|
||||||
|
modified_content = modified_liquid_content(content)
|
||||||
|
template = Liquid::Template.parse(modified_content)
|
||||||
|
template.render(drops_with_sender)
|
||||||
|
rescue Liquid::Error
|
||||||
|
content
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_process_liquid?
|
||||||
|
@message_type == 'outgoing' || @message_type == 'template'
|
||||||
|
end
|
||||||
|
|
||||||
|
def drops_with_sender
|
||||||
|
message_drops(@conversation).merge({
|
||||||
|
'agent' => UserDrop.new(sender)
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
|
|||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||||
|
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||||
|
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||||
update_attachment_file_type(attachment_obj)
|
update_attachment_file_type(attachment_obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
file_type = attachment['type'].to_sym
|
file_type = attachment['type'].to_sym
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
|
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||||
params.merge!(file_type_params(attachment))
|
params.merge!(file_type_params(attachment))
|
||||||
elsif file_type == :location
|
elsif file_type == :location
|
||||||
params.merge!(location_params(attachment))
|
params.merge!(location_params(attachment))
|
||||||
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def file_type_params(attachment)
|
def file_type_params(attachment)
|
||||||
|
# Handle different URL field names for different attachment types
|
||||||
|
url = case attachment['type'].to_sym
|
||||||
|
when :ig_story
|
||||||
|
attachment['payload']['story_media_url']
|
||||||
|
else
|
||||||
|
attachment['payload']['url']
|
||||||
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
external_url: attachment['payload']['url'],
|
external_url: url,
|
||||||
remote_file_url: attachment['payload']['url']
|
remote_file_url: url
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
|
|||||||
message.save!
|
message.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_ig_story_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
|
||||||
|
message.content_attributes[:image_type] = 'ig_story'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_ig_post_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
message.content_attributes[:image_type] = 'ig_post'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
# This is a placeholder method to be overridden by child classes
|
# This is a placeholder method to be overridden by child classes
|
||||||
def get_story_object_from_source_id(_source_id)
|
def get_story_object_from_source_id(_source_id)
|
||||||
{}
|
{}
|
||||||
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def unsupported_file_type?(attachment_type)
|
def unsupported_file_type?(attachment_type)
|
||||||
[:template, :unsupported_type].include? attachment_type.to_sym
|
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
class YearInReviewBuilder
|
||||||
|
attr_reader :account, :user_id, :year
|
||||||
|
|
||||||
|
def initialize(account:, user_id:, year:)
|
||||||
|
@account = account
|
||||||
|
@user_id = user_id
|
||||||
|
@year = year
|
||||||
|
end
|
||||||
|
|
||||||
|
def build
|
||||||
|
{
|
||||||
|
year: year,
|
||||||
|
total_conversations: total_conversations_count,
|
||||||
|
busiest_day: busiest_day_data,
|
||||||
|
support_personality: support_personality_data
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def year_range
|
||||||
|
@year_range ||= begin
|
||||||
|
start_time = Time.zone.local(year, 1, 1).beginning_of_day
|
||||||
|
end_time = Time.zone.local(year, 12, 31).end_of_day
|
||||||
|
start_time..end_time
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_conversations_count
|
||||||
|
account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def busiest_day_data
|
||||||
|
daily_counts = account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
|
||||||
|
.count
|
||||||
|
|
||||||
|
return nil if daily_counts.empty?
|
||||||
|
|
||||||
|
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
|
||||||
|
|
||||||
|
return nil if count.zero?
|
||||||
|
|
||||||
|
{
|
||||||
|
date: busiest_date.strftime('%b %d'),
|
||||||
|
count: count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def support_personality_data
|
||||||
|
response_time = average_response_time
|
||||||
|
|
||||||
|
return { avg_response_time_seconds: 0 } if response_time.nil?
|
||||||
|
|
||||||
|
{
|
||||||
|
avg_response_time_seconds: response_time.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def average_response_time
|
||||||
|
avg_time = account.reporting_events
|
||||||
|
.where(
|
||||||
|
name: 'first_response',
|
||||||
|
user_id: user_id,
|
||||||
|
created_at: year_range
|
||||||
|
)
|
||||||
|
.average(:value)
|
||||||
|
|
||||||
|
avg_time&.to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
before_action :agent_bot, except: [:index, :create]
|
before_action :agent_bot, except: [:index, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
|
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
|
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
|
||||||
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
def edit; end
|
def edit; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@article = @portal.articles.create!(article_params)
|
params_with_defaults = article_params
|
||||||
|
params_with_defaults[:status] ||= :draft
|
||||||
|
@article = @portal.articles.create!(params_with_defaults)
|
||||||
@article.associate_root_article(article_params[:associated_article_id])
|
@article.associate_root_article(article_params[:associated_article_id])
|
||||||
@article.draft!
|
|
||||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
||||||
|
include AttachmentConcern
|
||||||
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||||
|
|
||||||
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||||
@automation_rule.actions = params[:actions]
|
@automation_rule.actions = actions
|
||||||
@automation_rule.conditions = params[:conditions]
|
@automation_rule.conditions = params[:conditions]
|
||||||
|
|
||||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
|
||||||
|
|
||||||
@automation_rule.save!
|
@automation_rule.save!
|
||||||
process_attachments
|
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||||
@automation_rule
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
ActiveRecord::Base.transaction do
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
|
||||||
automation_rule_update
|
return render_could_not_create_error(error) if error
|
||||||
process_attachments
|
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@automation_rule.assign_attributes(automation_rules_permit)
|
||||||
|
@automation_rule.actions = actions if params[:actions]
|
||||||
|
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||||
|
@automation_rule.save!
|
||||||
|
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
render_could_not_create_error(@automation_rule.errors.messages)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
@automation_rule = new_rule
|
@automation_rule = new_rule
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_attachments
|
|
||||||
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
|
||||||
return if actions.blank?
|
|
||||||
|
|
||||||
actions.each do |action|
|
|
||||||
blob_id = action['action_params']
|
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
|
||||||
@automation_rule.files.attach(blob)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def automation_rule_update
|
|
||||||
@automation_rule.update!(automation_rules_permit)
|
|
||||||
@automation_rule.actions = params[:actions] if params[:actions]
|
|
||||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
|
||||||
@automation_rule.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
def automation_rules_permit
|
def automation_rules_permit
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :description, :event_name, :account_id, :active,
|
:name, :description, :event_name, :active,
|
||||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
|
||||||
before_action :type_matches?
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if type_matches?
|
case normalized_type
|
||||||
::BulkActionsJob.perform_later(
|
when 'Conversation'
|
||||||
account: @current_account,
|
enqueue_conversation_job
|
||||||
user: current_user,
|
head :ok
|
||||||
params: permitted_params
|
when 'Contact'
|
||||||
)
|
check_authorization_for_contact_action
|
||||||
|
enqueue_contact_job
|
||||||
head :ok
|
head :ok
|
||||||
else
|
else
|
||||||
render json: { success: false }, status: :unprocessable_entity
|
render json: { success: false }, status: :unprocessable_entity
|
||||||
@@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def type_matches?
|
def normalized_type
|
||||||
['Conversation'].include?(params[:type])
|
params[:type].to_s.camelize
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def enqueue_conversation_job
|
||||||
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
::BulkActionsJob.perform_later(
|
||||||
|
account: @current_account,
|
||||||
|
user: current_user,
|
||||||
|
params: conversation_params
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_contact_job
|
||||||
|
Contacts::BulkActionJob.perform_later(
|
||||||
|
@current_account.id,
|
||||||
|
current_user.id,
|
||||||
|
contact_params
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_contact_action?
|
||||||
|
params[:action_name] == 'delete'
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_authorization_for_contact_action
|
||||||
|
authorize(Contact, :destroy?) if delete_contact_action?
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_params
|
||||||
|
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
|
||||||
|
# and then remove this method in favor of a common params method.
|
||||||
|
base = params.permit(
|
||||||
|
:snoozed_until,
|
||||||
|
fields: [:status, :assignee_id, :team_id]
|
||||||
|
)
|
||||||
|
append_common_bulk_attributes(base)
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_params
|
||||||
|
# TODO: remove this method in favor of a common params method.
|
||||||
|
# once legacy conversation payloads are migrated.
|
||||||
|
append_common_bulk_attributes({})
|
||||||
|
end
|
||||||
|
|
||||||
|
def append_common_bulk_attributes(base_params)
|
||||||
|
# NOTE: Conversation payloads historically diverged per action. Going forward we
|
||||||
|
# want all objects to share a common contract: `{ action_name, action_attributes }`
|
||||||
|
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
|
||||||
|
base_params.merge(common)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:twilio_channel).permit(
|
params.require(:twilio_channel).permit(
|
||||||
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
||||||
# assigns agent/team to a conversation
|
# assigns agent/team to a conversation
|
||||||
def create
|
def create
|
||||||
if params.key?(:assignee_id)
|
if params.key?(:assignee_id) || agent_bot_assignment?
|
||||||
set_agent
|
set_agent
|
||||||
elsif params.key?(:team_id)
|
elsif params.key?(:team_id)
|
||||||
set_team
|
set_team
|
||||||
@@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_agent
|
def set_agent
|
||||||
@agent = Current.account.users.find_by(id: params[:assignee_id])
|
resource = Conversations::AssignmentService.new(
|
||||||
@conversation.assignee = @agent
|
conversation: @conversation,
|
||||||
@conversation.save!
|
assignee_id: params[:assignee_id],
|
||||||
render_agent
|
assignee_type: params[:assignee_type]
|
||||||
|
).perform
|
||||||
|
|
||||||
|
render_agent(resource)
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_agent
|
def render_agent(resource)
|
||||||
if @agent.nil?
|
case resource
|
||||||
render json: nil
|
when User
|
||||||
|
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
|
||||||
|
when AgentBot
|
||||||
|
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
|
||||||
else
|
else
|
||||||
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
|
render json: nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
@conversation.update!(team: @team)
|
@conversation.update!(team: @team)
|
||||||
render json: @team
|
render json: @team
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def agent_bot_assignment?
|
||||||
|
params[:assignee_type].to_s == 'AgentBot'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||||
|
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||||
|
DEFAULT_LANGUAGE = 'en'.freeze
|
||||||
|
|
||||||
|
before_action :fetch_inbox
|
||||||
|
before_action :validate_whatsapp_channel
|
||||||
|
|
||||||
|
def show
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return render json: { template_exists: false } unless template
|
||||||
|
|
||||||
|
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
|
||||||
|
render_template_status_response(status_result, template_name)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
template_params = extract_template_params
|
||||||
|
return render_missing_message_error if template_params[:message].blank?
|
||||||
|
|
||||||
|
# Delete existing template even though we are using a new one.
|
||||||
|
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
||||||
|
delete_existing_template_if_needed
|
||||||
|
|
||||||
|
result = create_template_via_provider(template_params)
|
||||||
|
render_template_creation_result(result)
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||||
|
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_inbox
|
||||||
|
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
|
authorize @inbox, :show?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_whatsapp_channel
|
||||||
|
return if @inbox.whatsapp?
|
||||||
|
|
||||||
|
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||||
|
status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_template_params
|
||||||
|
params.require(:template).permit(:message, :button_text, :language)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_missing_message_error
|
||||||
|
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_template_via_provider(template_params)
|
||||||
|
template_config = {
|
||||||
|
message: template_params[:message],
|
||||||
|
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||||
|
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||||
|
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||||
|
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inbox.channel.provider_service.create_csat_template(template_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_template_creation_result(result)
|
||||||
|
if result[:success]
|
||||||
|
render_successful_template_creation(result)
|
||||||
|
else
|
||||||
|
render_failed_template_creation(result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_successful_template_creation(result)
|
||||||
|
render json: {
|
||||||
|
template: {
|
||||||
|
name: result[:template_name],
|
||||||
|
template_id: result[:template_id],
|
||||||
|
status: 'PENDING',
|
||||||
|
language: result[:language] || DEFAULT_LANGUAGE
|
||||||
|
}
|
||||||
|
}, status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_failed_template_creation(result)
|
||||||
|
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||||
|
error_message = whatsapp_error[:user_message] || result[:error]
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: error_message,
|
||||||
|
details: whatsapp_error[:technical_details]
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_existing_template_if_needed
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return true if template.blank?
|
||||||
|
|
||||||
|
template_name = template['name']
|
||||||
|
return true if template_name.blank?
|
||||||
|
|
||||||
|
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
return true unless template_status[:success]
|
||||||
|
|
||||||
|
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
||||||
|
if deletion_result[:success]
|
||||||
|
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_template_status_response(status_result, template_name)
|
||||||
|
if status_result[:success]
|
||||||
|
render json: {
|
||||||
|
template_exists: true,
|
||||||
|
template_name: template_name,
|
||||||
|
status: status_result[:template][:status],
|
||||||
|
template_id: status_result[:template][:id]
|
||||||
|
}
|
||||||
|
else
|
||||||
|
render json: {
|
||||||
|
template_exists: false,
|
||||||
|
error: 'Template not found'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_whatsapp_error(response_body)
|
||||||
|
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
error_data = JSON.parse(response_body)
|
||||||
|
whatsapp_error = error_data['error'] || {}
|
||||||
|
|
||||||
|
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
|
||||||
|
technical_details = {
|
||||||
|
code: whatsapp_error['code'],
|
||||||
|
subcode: whatsapp_error['error_subcode'],
|
||||||
|
type: whatsapp_error['type'],
|
||||||
|
title: whatsapp_error['error_user_title']
|
||||||
|
}.compact
|
||||||
|
|
||||||
|
{ user_message: user_message, technical_details: technical_details }
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{ user_message: nil, technical_details: response_body }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def format_csat_config(config)
|
def format_csat_config(config)
|
||||||
{
|
formatted = {
|
||||||
display_type: config['display_type'] || 'emoji',
|
'display_type' => config['display_type'] || 'emoji',
|
||||||
message: config['message'] || '',
|
'message' => config['message'] || '',
|
||||||
survey_rules: {
|
:survey_rules => {
|
||||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
||||||
values: config.dig('survey_rules', 'values') || []
|
'values' => config.dig('survey_rules', 'values') || []
|
||||||
}
|
},
|
||||||
|
'button_text' => config['button_text'] || 'Please rate us',
|
||||||
|
'language' => config['language'] || 'en'
|
||||||
}
|
}
|
||||||
|
format_template_config(config, formatted)
|
||||||
|
formatted
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_template_config(config, formatted)
|
||||||
|
formatted['template'] = config['template'] if config['template'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||||
|
{ survey_rules: [:operator, { values: [] }],
|
||||||
|
template: [:name, :template_id, :created_at, :language] }] }]
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||||
|
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
||||||
params.permit(
|
|
||||||
*inbox_attributes,
|
|
||||||
channel: [:type, *channel_attributes]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def channel_type_from_params
|
def channel_type_from_params
|
||||||
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_channel?
|
def whatsapp_channel?
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
|
|
||||||
before_action :fetch_hook
|
|
||||||
before_action :ensure_hook_exists, except: [:destroy]
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
@hook.destroy!
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
def repositories
|
|
||||||
repositories = github_service.repositories
|
|
||||||
filtered_repos = repositories.map do |repo|
|
|
||||||
{
|
|
||||||
full_name: repo[:full_name] || repo.full_name,
|
|
||||||
private: repo[:private] || repo.private
|
|
||||||
}
|
|
||||||
end
|
|
||||||
render json: filtered_repos
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_repositories
|
|
||||||
repositories = github_service.search_repositories(params[:q])
|
|
||||||
filtered_repos = repositories.map do |repo|
|
|
||||||
{
|
|
||||||
full_name: repo[:full_name] || repo.full_name,
|
|
||||||
private: repo[:private] || repo.private
|
|
||||||
}
|
|
||||||
end
|
|
||||||
render json: filtered_repos
|
|
||||||
end
|
|
||||||
|
|
||||||
def assignees
|
|
||||||
assignees = github_service.assignees(repo_full_name)
|
|
||||||
filtered_assignees = assignees.map do |assignee|
|
|
||||||
{
|
|
||||||
login: assignee.login,
|
|
||||||
avatar_url: assignee.avatar_url
|
|
||||||
}
|
|
||||||
end
|
|
||||||
render json: filtered_assignees
|
|
||||||
end
|
|
||||||
|
|
||||||
def labels
|
|
||||||
labels = github_service.labels(repo_full_name)
|
|
||||||
filtered_labels = labels.map do |label|
|
|
||||||
{
|
|
||||||
name: label.name,
|
|
||||||
color: label.color
|
|
||||||
}
|
|
||||||
end
|
|
||||||
render json: filtered_labels
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_issues
|
|
||||||
issues = github_service.search_issues(repo_full_name, params[:q])
|
|
||||||
filtered_issues = issues.map do |issue|
|
|
||||||
{
|
|
||||||
number: issue.number,
|
|
||||||
title: issue.title,
|
|
||||||
html_url: issue.html_url
|
|
||||||
}
|
|
||||||
end
|
|
||||||
render json: filtered_issues
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_issue
|
|
||||||
issue_data = github_service.create_issue(
|
|
||||||
repo_full_name,
|
|
||||||
params[:title],
|
|
||||||
params[:body],
|
|
||||||
issue_options
|
|
||||||
)
|
|
||||||
|
|
||||||
github_issue = create_linked_issue(issue_data)
|
|
||||||
render json: issue_response(github_issue), status: :created
|
|
||||||
end
|
|
||||||
|
|
||||||
def link_issue
|
|
||||||
issue_data = github_service.issue(repo_full_name, params[:issue_number])
|
|
||||||
github_issue = create_linked_issue(issue_data)
|
|
||||||
render json: issue_response(github_issue), status: :created
|
|
||||||
end
|
|
||||||
|
|
||||||
def linked_issues
|
|
||||||
issues = GithubIssue.where(conversation_id: params[:conversation_id])
|
|
||||||
|
|
||||||
render json: issues.map do |issue|
|
|
||||||
{
|
|
||||||
id: issue.id,
|
|
||||||
issue_number: issue.issue_number,
|
|
||||||
title: issue.issue_title,
|
|
||||||
html_url: issue.html_url,
|
|
||||||
repo_full_name: issue.repo_full_name,
|
|
||||||
linked_by: {
|
|
||||||
id: issue.linked_by.id,
|
|
||||||
name: issue.linked_by.name
|
|
||||||
},
|
|
||||||
created_at: issue.created_at
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def unlink_issue
|
|
||||||
github_issue = GithubIssue.find(params[:id])
|
|
||||||
github_issue.destroy!
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def fetch_hook
|
|
||||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_hook_exists
|
|
||||||
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
|
|
||||||
end
|
|
||||||
|
|
||||||
def github_service
|
|
||||||
@github_service ||= Github::GithubService.new(hook: @hook)
|
|
||||||
end
|
|
||||||
|
|
||||||
def repo_full_name
|
|
||||||
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def issue_options
|
|
||||||
options = {}
|
|
||||||
options[:assignees] = params[:assignees] if params[:assignees].present?
|
|
||||||
options[:labels] = params[:labels] if params[:labels].present?
|
|
||||||
options
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_linked_issue(issue_data)
|
|
||||||
GithubIssue.create!(
|
|
||||||
conversation_id: params[:conversation_id],
|
|
||||||
account: Current.account,
|
|
||||||
repo_full_name: repo_full_name,
|
|
||||||
issue_number: issue_data.number,
|
|
||||||
issue_title: issue_data.title,
|
|
||||||
html_url: issue_data.html_url,
|
|
||||||
linked_by: Current.user
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def issue_response(github_issue)
|
|
||||||
{
|
|
||||||
id: github_issue.id,
|
|
||||||
issue_number: github_issue.issue_number,
|
|
||||||
title: github_issue.issue_title,
|
|
||||||
html_url: github_issue.html_url,
|
|
||||||
repo_full_name: github_issue.repo_full_name
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||||
|
include AttachmentConcern
|
||||||
|
|
||||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||||
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
||||||
|
|
||||||
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
@macro.actions = params[:actions]
|
@macro.actions = actions
|
||||||
|
|
||||||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
|
||||||
|
|
||||||
@macro.save!
|
@macro.save!
|
||||||
process_attachments
|
blobs.each { |blob| @macro.files.attach(blob) }
|
||||||
@macro
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@macro.update!(macros_with_user)
|
@macro.assign_attributes(macros_with_user)
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
process_attachments
|
@macro.actions = actions if params[:actions]
|
||||||
@macro.save!
|
@macro.save!
|
||||||
|
blobs.each { |blob| @macro.files.attach(blob) }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
render_could_not_create_error(@macro.errors.messages)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def process_attachments
|
|
||||||
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
|
||||||
return if actions.blank?
|
|
||||||
|
|
||||||
actions.each do |action|
|
|
||||||
blob_id = action['action_params']
|
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
|
||||||
@macro.files.attach(blob)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :account_id, :visibility,
|
:name, :visibility,
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def process_attached_logo
|
def process_attached_logo
|
||||||
blob_id = params[:blob_id]
|
blob_id = params[:blob_id]
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||||
@portal.logo.attach(blob)
|
@portal.logo.attach(blob)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||||
|
include Tiktok::IntegrationHelper
|
||||||
|
|
||||||
|
def create
|
||||||
|
redirect_url = Tiktok::AuthClient.authorize_url(
|
||||||
|
state: generate_tiktok_token(Current.account.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirect_url
|
||||||
|
render json: { success: true, url: redirect_url }
|
||||||
|
else
|
||||||
|
render json: { success: false }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_success(file_blob)
|
def render_success(file_blob)
|
||||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_error(message, status)
|
def render_error(message, status)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def webhook_params
|
def webhook_params
|
||||||
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_webhook
|
def fetch_webhook
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||||
|
conversation_required_attributes: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
|
@global_config = GlobalConfig.get(
|
||||||
|
'LOGO_THUMBNAIL',
|
||||||
|
'BRAND_NAME',
|
||||||
|
'WIDGET_BRAND_URL',
|
||||||
|
'MAXIMUM_FILE_UPLOAD_SIZE',
|
||||||
|
'INSTALLATION_NAME'
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_contact
|
def set_contact
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
|
||||||
|
def show
|
||||||
|
year = params[:year] || 2025
|
||||||
|
cache_key = "year_in_review_#{Current.account.id}_#{year}"
|
||||||
|
|
||||||
|
cached_data = Current.user.ui_settings&.dig(cache_key)
|
||||||
|
|
||||||
|
if cached_data.present?
|
||||||
|
render json: cached_data
|
||||||
|
else
|
||||||
|
builder = YearInReviewBuilder.new(
|
||||||
|
account: Current.account,
|
||||||
|
user_id: Current.user.id,
|
||||||
|
year: year
|
||||||
|
)
|
||||||
|
|
||||||
|
data = builder.build
|
||||||
|
|
||||||
|
ui_settings = Current.user.ui_settings || {}
|
||||||
|
ui_settings[cache_key] = data
|
||||||
|
Current.user.update(ui_settings: ui_settings)
|
||||||
|
|
||||||
|
render json: data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
module AttachmentConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def validate_and_prepare_attachments(actions, record = nil)
|
||||||
|
blobs = []
|
||||||
|
return [blobs, actions, nil] if actions.blank?
|
||||||
|
|
||||||
|
sanitized = actions.map do |action|
|
||||||
|
next action unless action[:action_name] == 'send_attachment'
|
||||||
|
|
||||||
|
result = process_attachment_action(action, record, blobs)
|
||||||
|
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
[blobs, sanitized, nil]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_attachment_action(action, record, blobs)
|
||||||
|
blob_id = action[:action_params].first
|
||||||
|
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
||||||
|
|
||||||
|
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
||||||
|
return action if blob_already_attached?(record, blob_id)
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def blob_already_attached?(record, blob_id)
|
||||||
|
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_accessible_for_bot?(account)
|
def account_accessible_for_bot?(account)
|
||||||
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
return if @resource.account_id == account.id
|
||||||
|
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||||
|
|
||||||
|
render_unauthorized('Bot is not authorized to access this account')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
class DashboardController < ActionController::Base
|
class DashboardController < ActionController::Base
|
||||||
include SwitchLocale
|
include SwitchLocale
|
||||||
|
|
||||||
|
GLOBAL_CONFIG_KEYS = %w[
|
||||||
|
LOGO
|
||||||
|
LOGO_DARK
|
||||||
|
LOGO_THUMBNAIL
|
||||||
|
INSTALLATION_NAME
|
||||||
|
WIDGET_BRAND_URL
|
||||||
|
TERMS_URL
|
||||||
|
BRAND_URL
|
||||||
|
BRAND_NAME
|
||||||
|
PRIVACY_URL
|
||||||
|
DISPLAY_MANIFEST
|
||||||
|
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
|
||||||
|
CHATWOOT_INBOX_TOKEN
|
||||||
|
API_CHANNEL_NAME
|
||||||
|
API_CHANNEL_THUMBNAIL
|
||||||
|
ANALYTICS_TOKEN
|
||||||
|
DIRECT_UPLOADS_ENABLED
|
||||||
|
MAXIMUM_FILE_UPLOAD_SIZE
|
||||||
|
HCAPTCHA_SITE_KEY
|
||||||
|
LOGOUT_REDIRECT_LINK
|
||||||
|
DISABLE_USER_PROFILE_UPDATE
|
||||||
|
DEPLOYMENT_ENV
|
||||||
|
INSTALLATION_PRICING_PLAN
|
||||||
|
].freeze
|
||||||
|
|
||||||
before_action :set_application_pack
|
before_action :set_application_pack
|
||||||
before_action :set_global_config
|
before_action :set_global_config
|
||||||
before_action :set_dashboard_scripts
|
before_action :set_dashboard_scripts
|
||||||
@@ -19,25 +44,7 @@ class DashboardController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(
|
@global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
|
||||||
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
|
||||||
'INSTALLATION_NAME',
|
|
||||||
'WIDGET_BRAND_URL', 'TERMS_URL',
|
|
||||||
'BRAND_URL', 'BRAND_NAME',
|
|
||||||
'PRIVACY_URL',
|
|
||||||
'DISPLAY_MANIFEST',
|
|
||||||
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
|
||||||
'CHATWOOT_INBOX_TOKEN',
|
|
||||||
'API_CHANNEL_NAME',
|
|
||||||
'API_CHANNEL_THUMBNAIL',
|
|
||||||
'ANALYTICS_TOKEN',
|
|
||||||
'DIRECT_UPLOADS_ENABLED',
|
|
||||||
'HCAPTCHA_SITE_KEY',
|
|
||||||
'LOGOUT_REDIRECT_LINK',
|
|
||||||
'DISABLE_USER_PROFILE_UPDATE',
|
|
||||||
'DEPLOYMENT_ENV',
|
|
||||||
'INSTALLATION_PRICING_PLAN'
|
|
||||||
).merge(app_config)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_dashboard_scripts
|
def set_dashboard_scripts
|
||||||
@@ -66,15 +73,24 @@ class DashboardController < ActionController::Base
|
|||||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||||
|
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
|
||||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
||||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||||
GIT_SHA: GIT_HASH
|
GIT_SHA: GIT_HASH,
|
||||||
|
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allowed_login_methods
|
||||||
|
methods = ['email']
|
||||||
|
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
||||||
|
methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false'
|
||||||
|
methods
|
||||||
|
end
|
||||||
|
|
||||||
def set_application_pack
|
def set_application_pack
|
||||||
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
||||||
'v3app'
|
'v3app'
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
class Github::CallbacksController < ApplicationController
|
|
||||||
include Github::IntegrationHelper
|
|
||||||
|
|
||||||
def show
|
|
||||||
# Validate account context early for all flows that require it
|
|
||||||
account if params[:code].present?
|
|
||||||
|
|
||||||
if params[:installation_id].present? && params[:code].present?
|
|
||||||
# Both installation and OAuth code present - handle both
|
|
||||||
handle_installation_with_oauth
|
|
||||||
elsif params[:installation_id].present?
|
|
||||||
# Only installation_id present - redirect to OAuth
|
|
||||||
handle_installation
|
|
||||||
else
|
|
||||||
# Only OAuth code present - handle authorization
|
|
||||||
handle_authorization
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Github callback error: #{e.message}")
|
|
||||||
redirect_to fallback_redirect_uri
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def handle_installation_with_oauth
|
|
||||||
# Handle both installation and OAuth in one go
|
|
||||||
installation_id = params[:installation_id]
|
|
||||||
|
|
||||||
@response = oauth_client.auth_code.get_token(
|
|
||||||
params[:code],
|
|
||||||
redirect_uri: "#{base_url}/github/callback"
|
|
||||||
)
|
|
||||||
|
|
||||||
handle_response(installation_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_installation
|
|
||||||
if params[:setup_action] == 'install'
|
|
||||||
installation_id = params[:installation_id]
|
|
||||||
|
|
||||||
redirect_to build_oauth_url(installation_id)
|
|
||||||
else
|
|
||||||
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
|
|
||||||
redirect_to github_integration_settings_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_authorization
|
|
||||||
@response = oauth_client.auth_code.get_token(
|
|
||||||
params[:code],
|
|
||||||
redirect_uri: "#{base_url}/github/callback"
|
|
||||||
)
|
|
||||||
|
|
||||||
handle_response
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_oauth_url(installation_id)
|
|
||||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
|
||||||
|
|
||||||
# Store installation_id in session for later use
|
|
||||||
session[:github_installation_id] = installation_id
|
|
||||||
|
|
||||||
# For now, redirect to a page that will initiate OAuth with proper account context
|
|
||||||
# This is a temporary solution until we have a proper account-agnostic setup
|
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def oauth_client
|
|
||||||
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
|
||||||
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
|
||||||
|
|
||||||
OAuth2::Client.new(
|
|
||||||
app_id,
|
|
||||||
app_secret,
|
|
||||||
{
|
|
||||||
site: 'https://github.com',
|
|
||||||
token_url: '/login/oauth/access_token',
|
|
||||||
authorize_url: '/login/oauth/authorize'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_response(installation_id = nil)
|
|
||||||
settings = build_hook_settings(installation_id)
|
|
||||||
hook = create_integration_hook(settings)
|
|
||||||
hook.save!
|
|
||||||
|
|
||||||
cleanup_session_data
|
|
||||||
redirect_to github_redirect_uri
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Github callback error: #{e.message}")
|
|
||||||
redirect_to fallback_redirect_uri
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_hook_settings(installation_id)
|
|
||||||
settings = {
|
|
||||||
token_type: parsed_body['token_type'],
|
|
||||||
scope: parsed_body['scope']
|
|
||||||
}
|
|
||||||
|
|
||||||
settings[:installation_id] = installation_id || session[:github_installation_id]
|
|
||||||
settings.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_integration_hook(settings)
|
|
||||||
account.hooks.new(
|
|
||||||
access_token: parsed_body['access_token'],
|
|
||||||
status: 'enabled',
|
|
||||||
app_id: 'github',
|
|
||||||
settings: settings
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def cleanup_session_data
|
|
||||||
session.delete(:github_installation_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def account
|
|
||||||
@account ||= account_from_state
|
|
||||||
end
|
|
||||||
|
|
||||||
def account_from_state
|
|
||||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
|
||||||
|
|
||||||
# Try signed GlobalID first (installation flow)
|
|
||||||
account = GlobalID::Locator.locate_signed(params[:state])
|
|
||||||
return account if account
|
|
||||||
|
|
||||||
# Fallback to JWT token (direct OAuth flow)
|
|
||||||
account_id = verify_github_token(params[:state])
|
|
||||||
return Account.find(account_id) if account_id
|
|
||||||
|
|
||||||
raise 'Invalid or expired state'
|
|
||||||
rescue StandardError
|
|
||||||
raise ActionController::BadRequest, 'Invalid account context'
|
|
||||||
end
|
|
||||||
|
|
||||||
def github_redirect_uri
|
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
|
|
||||||
end
|
|
||||||
|
|
||||||
def github_integration_settings_url
|
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
|
|
||||||
end
|
|
||||||
|
|
||||||
def fallback_redirect_uri
|
|
||||||
github_redirect_uri
|
|
||||||
rescue StandardError
|
|
||||||
# Fallback if no account context available
|
|
||||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
|
|
||||||
end
|
|
||||||
|
|
||||||
def parsed_body
|
|
||||||
@parsed_body ||= @response.response.parsed
|
|
||||||
end
|
|
||||||
|
|
||||||
def base_url
|
|
||||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -15,14 +15,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
errors = []
|
||||||
params['app_config'].each do |key, value|
|
params['app_config'].each do |key, value|
|
||||||
next unless @allowed_configs.include?(key)
|
next unless @allowed_configs.include?(key)
|
||||||
|
|
||||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||||
i.value = value
|
i.value = value
|
||||||
i.save!
|
errors.concat(i.errors.full_messages) unless i.save
|
||||||
|
end
|
||||||
|
|
||||||
|
if errors.any?
|
||||||
|
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
||||||
|
else
|
||||||
|
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||||
end
|
end
|
||||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -40,13 +46,16 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
|
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
|
||||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
|
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
||||||
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
@allowed_configs = mapping.fetch(
|
||||||
|
@config,
|
||||||
|
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
class Tiktok::CallbacksController < ApplicationController
|
||||||
|
include Tiktok::IntegrationHelper
|
||||||
|
|
||||||
|
def show
|
||||||
|
return handle_authorization_error if params[:error].present?
|
||||||
|
return handle_ungranted_scopes_error unless all_scopes_granted?
|
||||||
|
|
||||||
|
process_successful_authorization
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_error(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def all_scopes_granted?
|
||||||
|
granted_scopes = short_term_access_token[:scope].to_s.split(',')
|
||||||
|
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_successful_authorization
|
||||||
|
inbox, already_exists = find_or_create_inbox
|
||||||
|
|
||||||
|
if already_exists
|
||||||
|
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||||
|
else
|
||||||
|
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_error(error)
|
||||||
|
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
|
||||||
|
ChatwootExceptionTracker.new(error).capture_exception
|
||||||
|
|
||||||
|
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles the case when a user denies permissions or cancels the authorization flow
|
||||||
|
def handle_authorization_error
|
||||||
|
redirect_to_error_page(
|
||||||
|
error_type: params[:error] || 'access_denied',
|
||||||
|
code: params[:error_code],
|
||||||
|
error_message: params[:error_description] || 'User cancelled the Authorization'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles the case when a user partially accepted the required scopes
|
||||||
|
def handle_ungranted_scopes_error
|
||||||
|
redirect_to_error_page(
|
||||||
|
error_type: 'ungranted_scopes',
|
||||||
|
code: 400,
|
||||||
|
error_message: 'User did not grant all the required scopes'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Centralized method to redirect to error page with appropriate parameters
|
||||||
|
# This ensures consistent error handling across different error scenarios
|
||||||
|
# Frontend will handle the error page based on the error_type
|
||||||
|
def redirect_to_error_page(error_type:, code:, error_message:)
|
||||||
|
redirect_to app_new_tiktok_inbox_url(
|
||||||
|
account_id: account_id,
|
||||||
|
error_type: error_type,
|
||||||
|
code: code,
|
||||||
|
error_message: error_message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_inbox
|
||||||
|
business_details = tiktok_client.business_account_details
|
||||||
|
channel_tiktok = find_channel
|
||||||
|
channel_exists = channel_tiktok.present?
|
||||||
|
|
||||||
|
if channel_tiktok
|
||||||
|
update_channel(channel_tiktok, business_details)
|
||||||
|
else
|
||||||
|
channel_tiktok = create_channel_with_inbox(business_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
# reauthorized will also update cache keys for the associated inbox
|
||||||
|
channel_tiktok.reauthorized!
|
||||||
|
|
||||||
|
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
|
||||||
|
|
||||||
|
[channel_tiktok.inbox, channel_exists]
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_channel_with_inbox(business_details)
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
channel_tiktok = Channel::Tiktok.create!(
|
||||||
|
account: account,
|
||||||
|
business_id: short_term_access_token[:business_id],
|
||||||
|
access_token: short_term_access_token[:access_token],
|
||||||
|
refresh_token: short_term_access_token[:refresh_token],
|
||||||
|
expires_at: short_term_access_token[:expires_at],
|
||||||
|
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
account.inboxes.create!(
|
||||||
|
account: account,
|
||||||
|
channel: channel_tiktok,
|
||||||
|
name: business_details[:display_name].presence || business_details[:username]
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_tiktok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_channel
|
||||||
|
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_channel(channel_tiktok, business_details)
|
||||||
|
channel_tiktok.update!(
|
||||||
|
access_token: short_term_access_token[:access_token],
|
||||||
|
refresh_token: short_term_access_token[:refresh_token],
|
||||||
|
expires_at: short_term_access_token[:expires_at],
|
||||||
|
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_avatar(inbox, avatar_url)
|
||||||
|
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_id
|
||||||
|
@account_id ||= verify_tiktok_token(params[:state])
|
||||||
|
end
|
||||||
|
|
||||||
|
def account
|
||||||
|
@account ||= Account.find(account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def short_term_access_token
|
||||||
|
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tiktok_client
|
||||||
|
@tiktok_client ||= Tiktok::Client.new(
|
||||||
|
business_id: short_term_access_token[:business_id],
|
||||||
|
access_token: short_term_access_token[:access_token]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
class Webhooks::TiktokController < ActionController::API
|
||||||
|
before_action :verify_signature!
|
||||||
|
|
||||||
|
def events
|
||||||
|
event = JSON.parse(request_payload)
|
||||||
|
if echo_event?
|
||||||
|
# Add delay to prevent race condition where echo arrives before send message API completes
|
||||||
|
# This avoids duplicate messages when echo comes early during API processing
|
||||||
|
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
|
||||||
|
else
|
||||||
|
::Webhooks::TiktokEventsJob.perform_later(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def request_payload
|
||||||
|
@request_payload ||= request.body.read
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_signature!
|
||||||
|
signature_header = request.headers['Tiktok-Signature']
|
||||||
|
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||||
|
received_timestamp, received_signature = extract_signature_parts(signature_header)
|
||||||
|
|
||||||
|
return head :unauthorized unless client_secret && received_timestamp && received_signature
|
||||||
|
|
||||||
|
signature_payload = "#{received_timestamp}.#{request_payload}"
|
||||||
|
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
|
||||||
|
|
||||||
|
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
|
||||||
|
|
||||||
|
# Check timestamp delay (acceptable delay: 5 seconds)
|
||||||
|
current_timestamp = Time.current.to_i
|
||||||
|
delay = current_timestamp - received_timestamp
|
||||||
|
|
||||||
|
return head :unauthorized if delay > 5
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_signature_parts(signature_header)
|
||||||
|
return [nil, nil] if signature_header.blank?
|
||||||
|
|
||||||
|
keys = signature_header.split(',')
|
||||||
|
signature_parts = keys.map { |part| part.split('=') }.to_h
|
||||||
|
[signature_parts['t']&.to_i, signature_parts['s']]
|
||||||
|
end
|
||||||
|
|
||||||
|
def echo_event?
|
||||||
|
params[:event] == 'im_send_msg'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
|
@global_config = GlobalConfig.get(
|
||||||
|
'LOGO_THUMBNAIL',
|
||||||
|
'BRAND_NAME',
|
||||||
|
'WIDGET_BRAND_URL',
|
||||||
|
'DIRECT_UPLOADS_ENABLED',
|
||||||
|
'MAXIMUM_FILE_UPLOAD_SIZE',
|
||||||
|
'INSTALLATION_NAME'
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_web_widget
|
def set_web_widget
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
|
||||||
|
# This module contains helper methods for converting between different data types,
|
||||||
|
# normalizing hashes, and safely handling JSON operations.
|
||||||
|
module DataHelper
|
||||||
|
# Ensures a hash supports indifferent access (string or symbol keys).
|
||||||
|
# Returns an empty hash if the input is blank.
|
||||||
|
def ensure_indifferent_access(hash)
|
||||||
|
return {} if hash.blank?
|
||||||
|
|
||||||
|
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def convert_to_hash(obj)
|
||||||
|
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||||
|
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_parse_json(content)
|
||||||
|
JSON.parse(content, symbolize_names: true)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,19 @@ module EmailHelper
|
|||||||
domain.split('.').first
|
domain.split('.').first
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_email_html(content)
|
||||||
|
return '' if content.blank?
|
||||||
|
|
||||||
|
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raise a standard error if any email address is invalid
|
||||||
|
def validate_email_addresses(emails_to_test)
|
||||||
|
emails_to_test&.each do |email|
|
||||||
|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
||||||
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
||||||
# john+test@xyc.com is the same as john@xyc.com
|
# john+test@xyc.com is the same as john@xyc.com
|
||||||
@@ -21,6 +34,10 @@ module EmailHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalize_email_body(content)
|
||||||
|
content.to_s.gsub("\r\n", "\n")
|
||||||
|
end
|
||||||
|
|
||||||
def modified_liquid_content(email)
|
def modified_liquid_content(email)
|
||||||
# This regex is used to match the code blocks in the content
|
# This regex is used to match the code blocks in the content
|
||||||
# We don't want to process liquid in code blocks
|
# We don't want to process liquid in code blocks
|
||||||
@@ -29,7 +46,10 @@ module EmailHelper
|
|||||||
|
|
||||||
def message_drops(conversation)
|
def message_drops(conversation)
|
||||||
{
|
{
|
||||||
'contact' => ContactDrop.new(conversation.contact)
|
'contact' => ContactDrop.new(conversation.contact),
|
||||||
|
'conversation' => ConversationDrop.new(conversation),
|
||||||
|
'inbox' => InboxDrop.new(conversation.inbox),
|
||||||
|
'account' => AccountDrop.new(conversation.account)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
module Github::IntegrationHelper
|
|
||||||
# Generates a signed JWT token for Github integration
|
|
||||||
#
|
|
||||||
# @param account_id [Integer] The account ID to encode in the token
|
|
||||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
|
||||||
def generate_github_token(account_id)
|
|
||||||
return if github_client_secret.blank?
|
|
||||||
|
|
||||||
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Failed to generate Github token: #{e.message}")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def github_token_payload(account_id)
|
|
||||||
{
|
|
||||||
sub: account_id,
|
|
||||||
iat: Time.current.to_i
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verifies and decodes a Github JWT token
|
|
||||||
#
|
|
||||||
# @param token [String] The JWT token to verify
|
|
||||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
|
||||||
def verify_github_token(token)
|
|
||||||
return if token.blank? || github_client_secret.blank?
|
|
||||||
|
|
||||||
github_decode_token(token, github_client_secret)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def github_client_secret
|
|
||||||
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def github_decode_token(token, secret)
|
|
||||||
JWT.decode(token, secret, true, {
|
|
||||||
algorithm: 'HS256',
|
|
||||||
verify_expiration: true
|
|
||||||
}).first['sub']
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -9,6 +9,13 @@ captain:
|
|||||||
icon: 'icon-captain'
|
icon: 'icon-captain'
|
||||||
config_key: 'captain'
|
config_key: 'captain'
|
||||||
enterprise: true
|
enterprise: true
|
||||||
|
saml:
|
||||||
|
name: 'SAML SSO'
|
||||||
|
description: 'Configuration for controlling SAML Single Sign-On availability'
|
||||||
|
enabled: <%= ChatwootApp.enterprise? %>
|
||||||
|
icon: 'icon-lock-line'
|
||||||
|
config_key: 'saml'
|
||||||
|
enterprise: true
|
||||||
custom_branding:
|
custom_branding:
|
||||||
name: 'Custom Branding'
|
name: 'Custom Branding'
|
||||||
description: 'Apply your own branding to this installation.'
|
description: 'Apply your own branding to this installation.'
|
||||||
@@ -71,6 +78,12 @@ instagram:
|
|||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-instagram'
|
icon: 'icon-instagram'
|
||||||
config_key: 'instagram'
|
config_key: 'instagram'
|
||||||
|
tiktok:
|
||||||
|
name: 'TikTok'
|
||||||
|
description: 'Stay connected with your customers on TikTok'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-tiktok'
|
||||||
|
config_key: 'tiktok'
|
||||||
whatsapp:
|
whatsapp:
|
||||||
name: 'WhatsApp'
|
name: 'WhatsApp'
|
||||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Tiktok::IntegrationHelper
|
||||||
|
# Generates a signed JWT token for Tiktok integration
|
||||||
|
#
|
||||||
|
# @param account_id [Integer] The account ID to encode in the token
|
||||||
|
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||||
|
def generate_tiktok_token(account_id)
|
||||||
|
return if client_secret.blank?
|
||||||
|
|
||||||
|
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verifies and decodes a Tiktok JWT token
|
||||||
|
#
|
||||||
|
# @param token [String] The JWT token to verify
|
||||||
|
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||||
|
def verify_tiktok_token(token)
|
||||||
|
return if token.blank? || client_secret.blank?
|
||||||
|
|
||||||
|
decode_token(token, client_secret)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def client_secret
|
||||||
|
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_payload(account_id)
|
||||||
|
{
|
||||||
|
sub: account_id,
|
||||||
|
iat: Time.current.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode_token(token, secret)
|
||||||
|
JWT.decode(token, secret, true, {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
verify_expiration: true
|
||||||
|
}).first['sub']
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import AddAccountModal from './components/app/AddAccountModal.vue';
|
|
||||||
import LoadingState from './components/widgets/LoadingState.vue';
|
import LoadingState from './components/widgets/LoadingState.vue';
|
||||||
import NetworkNotification from './components/NetworkNotification.vue';
|
import NetworkNotification from './components/NetworkNotification.vue';
|
||||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||||
@@ -25,7 +24,6 @@ export default {
|
|||||||
name: 'App',
|
name: 'App',
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AddAccountModal,
|
|
||||||
LoadingState,
|
LoadingState,
|
||||||
NetworkNotification,
|
NetworkNotification,
|
||||||
UpdateBanner,
|
UpdateBanner,
|
||||||
@@ -51,7 +49,6 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showAddAccountModal: false,
|
|
||||||
latestChatwootVersion: null,
|
latestChatwootVersion: null,
|
||||||
reconnectService: null,
|
reconnectService: null,
|
||||||
};
|
};
|
||||||
@@ -64,21 +61,12 @@ export default {
|
|||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
accountUIFlags: 'accounts/getUIFlags',
|
||||||
}),
|
}),
|
||||||
hasAccounts() {
|
|
||||||
const { accounts = [] } = this.currentUser || {};
|
|
||||||
return accounts.length > 0;
|
|
||||||
},
|
|
||||||
hideOnOnboardingView() {
|
hideOnOnboardingView() {
|
||||||
return !isOnOnboardingView(this.$route);
|
return !isOnOnboardingView(this.$route);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
currentUser() {
|
|
||||||
if (!this.hasAccounts) {
|
|
||||||
this.showAddAccountModal = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
currentAccountId: {
|
currentAccountId: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler() {
|
handler() {
|
||||||
@@ -156,7 +144,6 @@ export default {
|
|||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
|
||||||
<WootSnackbarBox />
|
<WootSnackbarBox />
|
||||||
<NetworkNotification />
|
<NetworkNotification />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class TiktokChannel extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('tiktok', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
generateAuthorization(payload) {
|
||||||
|
return axios.post(`${this.url}/authorization`, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TiktokChannel();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Device } from '@twilio/voice-sdk';
|
||||||
|
import VoiceAPI from './voiceAPIClient';
|
||||||
|
|
||||||
|
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
|
||||||
|
|
||||||
|
class TwilioVoiceClient extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.device = null;
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeDevice(inboxId) {
|
||||||
|
this.destroyDevice();
|
||||||
|
|
||||||
|
const response = await VoiceAPI.getToken(inboxId);
|
||||||
|
const { token, account_id } = response || {};
|
||||||
|
if (!token) throw new Error('Invalid token');
|
||||||
|
|
||||||
|
this.device = new Device(token, {
|
||||||
|
allowIncomingWhileBusy: true,
|
||||||
|
disableAudioContextSounds: true,
|
||||||
|
appParams: { account_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.removeAllListeners();
|
||||||
|
this.device.on('connect', conn => {
|
||||||
|
this.activeConnection = conn;
|
||||||
|
conn.on('disconnect', this.onDisconnect);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
this.device.on('tokenWillExpire', async () => {
|
||||||
|
const r = await VoiceAPI.getToken(this.inboxId);
|
||||||
|
if (r?.token) this.device.updateToken(r.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
this.inboxId = inboxId;
|
||||||
|
|
||||||
|
return this.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasActiveConnection() {
|
||||||
|
return !!this.activeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
endClientCall() {
|
||||||
|
if (this.activeConnection) {
|
||||||
|
this.activeConnection.disconnect();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
if (this.device) {
|
||||||
|
this.device.disconnectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyDevice() {
|
||||||
|
if (this.device) {
|
||||||
|
this.device.destroy();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.device = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinClientCall({ to, conversationId }) {
|
||||||
|
if (!this.device || !this.initialized || !to) return null;
|
||||||
|
if (this.activeConnection) return this.activeConnection;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
To: to,
|
||||||
|
is_agent: 'true',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = await this.device.connect({ params });
|
||||||
|
this.activeConnection = connection;
|
||||||
|
|
||||||
|
connection.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect = () => {
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.dispatchEvent(createCallDisconnectedEvent());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TwilioVoiceClient();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../../ApiClient';
|
||||||
|
import ContactsAPI from '../../contacts';
|
||||||
|
|
||||||
|
class VoiceAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('voice', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveConference(inboxId, conversationId) {
|
||||||
|
return axios
|
||||||
|
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
params: { conversation_id: conversationId },
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
joinConference({ conversationId, inboxId, callSid }) {
|
||||||
|
return axios
|
||||||
|
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
call_sid: callSid,
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(inboxId) {
|
||||||
|
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
||||||
|
return axios
|
||||||
|
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new VoiceAPI();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
export const buildCompanyParams = (page, sort) => {
|
||||||
|
let params = `page=${page}`;
|
||||||
|
if (sort) {
|
||||||
|
params = `${params}&sort=${sort}`;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSearchParams = (query, page, sort) => {
|
||||||
|
let params = `q=${encodeURIComponent(query)}&page=${page}`;
|
||||||
|
if (sort) {
|
||||||
|
params = `${params}&sort=${sort}`;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CompanyAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('companies', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(params = {}) {
|
||||||
|
const { page = 1, sort = 'name' } = params;
|
||||||
|
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
|
||||||
|
return axios.get(requestURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
search(query = '', page = 1, sort = 'name') {
|
||||||
|
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
|
||||||
|
return axios.get(requestURL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CompanyAPI();
|
||||||
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return axios.post(`${this.url}/${contactId}/call`, {
|
||||||
|
inbox_id: inboxId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateContactLabels(contactId, labels) {
|
updateContactLabels(contactId, labels) {
|
||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
action_type: action,
|
action_type: action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTopupCheckout(credits) {
|
||||||
|
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
expect(accountAPI).toHaveProperty('delete');
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
expect(accountAPI).toHaveProperty('checkout');
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||||
|
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
||||||
|
expect(accountAPI).toHaveProperty('getLimits');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
{ action_type: 'undelete' }
|
{ action_type: 'undelete' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with credits', () => {
|
||||||
|
accountAPI.createTopupCheckout(1000);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with different credit amounts', () => {
|
||||||
|
const creditAmounts = [1000, 2500, 6000, 12000];
|
||||||
|
creditAmounts.forEach(credits => {
|
||||||
|
accountAPI.createTopupCheckout(credits);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#getLimits', () => {
|
||||||
|
accountAPI.getLimits();
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,10 +63,9 @@ class ConversationApi extends ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assignAgent({ conversationId, agentId }) {
|
assignAgent({ conversationId, agentId }) {
|
||||||
return axios.post(
|
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||||
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
assignee_id: agentId,
|
||||||
{}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTeam({ conversationId, teamId }) {
|
assignTeam({ conversationId, teamId }) {
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always include conversation_display_id when available for session tracking
|
||||||
|
if (conversationId) {
|
||||||
|
data.conversation_display_id = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For conversation-level events, only send conversation_display_id
|
||||||
if (this.conversation_events.includes(type)) {
|
if (this.conversation_events.includes(type)) {
|
||||||
data = {
|
data = {
|
||||||
conversation_display_id: conversationId,
|
conversation_display_id: conversationId,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import companyAPI, {
|
||||||
|
buildCompanyParams,
|
||||||
|
buildSearchParams,
|
||||||
|
} from '../companies';
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
describe('#CompanyAPI', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(companyAPI).toBeInstanceOf(ApiClient);
|
||||||
|
expect(companyAPI).toHaveProperty('get');
|
||||||
|
expect(companyAPI).toHaveProperty('show');
|
||||||
|
expect(companyAPI).toHaveProperty('create');
|
||||||
|
expect(companyAPI).toHaveProperty('update');
|
||||||
|
expect(companyAPI).toHaveProperty('delete');
|
||||||
|
expect(companyAPI).toHaveProperty('search');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API calls', () => {
|
||||||
|
const originalAxios = window.axios;
|
||||||
|
const axiosMock = {
|
||||||
|
post: vi.fn(() => Promise.resolve()),
|
||||||
|
get: vi.fn(() => Promise.resolve()),
|
||||||
|
patch: vi.fn(() => Promise.resolve()),
|
||||||
|
delete: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.axios = axiosMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.axios = originalAxios;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#get with default params', () => {
|
||||||
|
companyAPI.get({});
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies?page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#get with page and sort params', () => {
|
||||||
|
companyAPI.get({ page: 2, sort: 'domain' });
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies?page=2&sort=domain'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#get with descending sort', () => {
|
||||||
|
companyAPI.get({ page: 1, sort: '-created_at' });
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies?page=1&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with query', () => {
|
||||||
|
companyAPI.search('acme', 1, 'name');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=acme&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with special characters in query', () => {
|
||||||
|
companyAPI.search('acme & co', 2, 'domain');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with descending sort', () => {
|
||||||
|
companyAPI.search('test', 1, '-created_at');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with empty query', () => {
|
||||||
|
companyAPI.search('', 1, 'name');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#buildCompanyParams', () => {
|
||||||
|
it('returns correct string with page only', () => {
|
||||||
|
expect(buildCompanyParams(1)).toBe('page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with page and sort', () => {
|
||||||
|
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with different page', () => {
|
||||||
|
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with descending sort', () => {
|
||||||
|
expect(buildCompanyParams(1, '-created_at')).toBe(
|
||||||
|
'page=1&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string without sort parameter', () => {
|
||||||
|
expect(buildCompanyParams(2, '')).toBe('page=2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#buildSearchParams', () => {
|
||||||
|
it('returns correct string with all parameters', () => {
|
||||||
|
expect(buildSearchParams('acme', 1, 'name')).toBe(
|
||||||
|
'q=acme&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with special characters', () => {
|
||||||
|
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
|
||||||
|
'q=acme%20%26%20co&page=2&sort=domain'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with empty query', () => {
|
||||||
|
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string without sort parameter', () => {
|
||||||
|
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with descending sort', () => {
|
||||||
|
expect(buildSearchParams('company', 3, '-created_at')).toBe(
|
||||||
|
'q=company&page=3&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes special characters correctly', () => {
|
||||||
|
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
|
||||||
|
'q=test%40example.com&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,8 +92,10 @@ describe('#ConversationAPI', () => {
|
|||||||
it('#assignAgent', () => {
|
it('#assignAgent', () => {
|
||||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
`/api/v1/conversations/12/assignments?assignee_id=34`,
|
`/api/v1/conversations/12/assignments`,
|
||||||
{}
|
{
|
||||||
|
assignee_id: 34,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
import tiktokClient from '../channel/tiktokClient';
|
||||||
|
|
||||||
|
describe('#TiktokClient', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(tiktokClient).toBeInstanceOf(ApiClient);
|
||||||
|
expect(tiktokClient).toHaveProperty('generateAuthorization');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#generateAuthorization', () => {
|
||||||
|
const originalAxios = window.axios;
|
||||||
|
const originalPathname = window.location.pathname;
|
||||||
|
const axiosMock = {
|
||||||
|
post: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.axios = axiosMock;
|
||||||
|
window.history.pushState({}, '', '/app/accounts/1/settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.axios = originalAxios;
|
||||||
|
window.history.pushState({}, '', originalPathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts to the authorization endpoint', () => {
|
||||||
|
tiktokClient.generateAuthorization({ state: 'test-state' });
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/accounts/1/tiktok/authorization',
|
||||||
|
{ state: 'test-state' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class YearInReviewAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(year) {
|
||||||
|
return axios.get(`${this.url}`, {
|
||||||
|
params: { year },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new YearInReviewAPI();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: { type: Number, required: true },
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
domain: { type: String, default: '' },
|
||||||
|
contactsCount: { type: Number, default: 0 },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
avatarUrl: { type: String, default: '' },
|
||||||
|
updatedAt: { type: [String, Number], default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['showCompany']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const onClickViewDetails = () => emit('showCompany', props.id);
|
||||||
|
|
||||||
|
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
|
||||||
|
|
||||||
|
const avatarSource = computed(() => props.avatarUrl || null);
|
||||||
|
|
||||||
|
const formattedUpdatedAt = computed(() => {
|
||||||
|
if (!props.updatedAt) return '';
|
||||||
|
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardLayout layout="row" @click="onClickViewDetails">
|
||||||
|
<div class="flex items-center justify-start flex-1 gap-4">
|
||||||
|
<Avatar
|
||||||
|
:username="displayName"
|
||||||
|
:src="avatarSource"
|
||||||
|
class="shrink-0"
|
||||||
|
:name="name"
|
||||||
|
:size="48"
|
||||||
|
hide-offline-status
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||||
|
<span class="text-base font-medium truncate text-n-slate-12">
|
||||||
|
{{ displayName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="domain && description"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||||
|
<span class="truncate">{{ domain }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||||
|
<span
|
||||||
|
v-if="domain && !description"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||||
|
<span class="truncate">{{ domain }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="description" class="text-sm text-n-slate-11 truncate">
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="(description || domain) && contactsCount"
|
||||||
|
class="w-px h-3 bg-n-slate-6"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="contactsCount"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
||||||
|
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="updatedAt"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{{ formattedUpdatedAt }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup>
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import CompanySortMenu from './components/CompanySortMenu.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showSearch: { type: Boolean, default: true },
|
||||||
|
searchValue: { type: String, default: '' },
|
||||||
|
headerTitle: { type: String, required: true },
|
||||||
|
activeSort: { type: String, default: 'last_activity_at' },
|
||||||
|
activeOrdering: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['search', 'update:sort']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-10">
|
||||||
|
<div
|
||||||
|
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||||
|
>
|
||||||
|
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||||
|
{{ headerTitle }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<CompanySortMenu
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||||
|
<Input
|
||||||
|
:model-value="searchValue"
|
||||||
|
type="search"
|
||||||
|
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
|
||||||
|
:custom-input-class="[
|
||||||
|
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||||
|
]"
|
||||||
|
class="w-full"
|
||||||
|
@input="emit('search', $event.target.value)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-search"
|
||||||
|
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, toRef } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeSort: {
|
||||||
|
type: String,
|
||||||
|
default: 'name',
|
||||||
|
},
|
||||||
|
activeOrdering: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:sort']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isMenuOpen = ref(false);
|
||||||
|
|
||||||
|
const sortMenus = [
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.SORT_BY.OPTIONS.NAME'),
|
||||||
|
value: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.SORT_BY.OPTIONS.DOMAIN'),
|
||||||
|
value: 'domain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||||
|
value: 'created_at',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderingMenus = [
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.ORDER.OPTIONS.ASCENDING'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.ORDER.OPTIONS.DESCENDING'),
|
||||||
|
value: '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Converted the props to refs for better reactivity
|
||||||
|
const activeSort = toRef(props, 'activeSort');
|
||||||
|
|
||||||
|
const activeOrdering = toRef(props, 'activeOrdering');
|
||||||
|
|
||||||
|
const activeSortLabel = computed(() => {
|
||||||
|
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
|
||||||
|
return selectedMenu?.label || t('COMPANIES.SORT_BY.LABEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeOrderingLabel = computed(() => {
|
||||||
|
const selectedMenu = orderingMenus.find(
|
||||||
|
menu => menu.value === activeOrdering.value
|
||||||
|
);
|
||||||
|
return selectedMenu?.label || t('COMPANIES.ORDER.LABEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSortChange = value => {
|
||||||
|
emit('update:sort', { sort: value, order: props.activeOrdering });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrderChange = value => {
|
||||||
|
emit('update:sort', { sort: props.activeSort, order: value });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-arrow-down-up"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
|
||||||
|
@click="isMenuOpen = !isMenuOpen"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="isMenuOpen"
|
||||||
|
v-on-clickaway="() => (isMenuOpen = false)"
|
||||||
|
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ t('COMPANIES.SORT_BY.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SelectMenu
|
||||||
|
:model-value="activeSort"
|
||||||
|
:options="sortMenus"
|
||||||
|
:label="activeSortLabel"
|
||||||
|
@update:model-value="handleSortChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ t('COMPANIES.ORDER.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SelectMenu
|
||||||
|
:model-value="activeOrdering"
|
||||||
|
:options="orderingMenus"
|
||||||
|
:label="activeOrderingLabel"
|
||||||
|
@update:model-value="handleOrderChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup>
|
||||||
|
import CompanyHeader from './CompaniesHeader/CompanyHeader.vue';
|
||||||
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
searchValue: { type: String, default: '' },
|
||||||
|
headerTitle: { type: String, default: '' },
|
||||||
|
currentPage: { type: Number, default: 1 },
|
||||||
|
totalItems: { type: Number, default: 100 },
|
||||||
|
activeSort: { type: String, default: 'name' },
|
||||||
|
activeOrdering: { type: String, default: '' },
|
||||||
|
showPaginationFooter: { type: Boolean, default: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
||||||
|
|
||||||
|
const updateCurrentPage = page => {
|
||||||
|
emit('update:currentPage', page);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||||
|
<CompanyHeader
|
||||||
|
:search-value="searchValue"
|
||||||
|
:header-title="headerTitle"
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@search="emit('search', $event)"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
/>
|
||||||
|
<main class="flex-1 overflow-y-auto">
|
||||||
|
<div class="w-full mx-auto max-w-[60rem]">
|
||||||
|
<slot name="default" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||||
|
<PaginationFooter
|
||||||
|
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:items-per-page="25"
|
||||||
|
@update:current-page="updateCurrentPage"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
+38
-35
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
|
|||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||||
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
<Policy :permissions="['administrator']">
|
||||||
<Button
|
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
<Button
|
||||||
sm
|
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
link
|
sm
|
||||||
slate
|
link
|
||||||
class="hover:!no-underline text-n-slate-12"
|
slate
|
||||||
icon="i-lucide-chevron-down"
|
class="hover:!no-underline text-n-slate-12"
|
||||||
trailing-icon
|
icon="i-lucide-chevron-down"
|
||||||
@click="toggleDeleteSection()"
|
trailing-icon
|
||||||
/>
|
@click="toggleDeleteSection()"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||||
:class="
|
:class="
|
||||||
showDeleteSection
|
showDeleteSection
|
||||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="overflow-hidden min-h-0">
|
<div class="overflow-hidden min-h-0">
|
||||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||||
<Button
|
<Button
|
||||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||||
sm
|
sm
|
||||||
ruby
|
ruby
|
||||||
link
|
link
|
||||||
@click="openConfirmDeleteContactDialog()"
|
@click="openConfirmDeleteContactDialog()"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ConfirmContactDeleteDialog
|
||||||
<ConfirmContactDeleteDialog
|
ref="confirmDeleteContactDialogRef"
|
||||||
ref="confirmDeleteContactDialogRef"
|
:selected-contact="selectedContact"
|
||||||
:selected-contact="selectedContact"
|
/>
|
||||||
/>
|
</Policy>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
|||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||||
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import countries from 'shared/constants/countries';
|
import countries from 'shared/constants/countries';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -20,9 +21,17 @@ const props = defineProps({
|
|||||||
availabilityStatus: { type: String, default: null },
|
availabilityStatus: { type: String, default: null },
|
||||||
isExpanded: { type: Boolean, default: false },
|
isExpanded: { type: Boolean, default: false },
|
||||||
isUpdating: { type: Boolean, default: false },
|
isUpdating: { type: Boolean, default: false },
|
||||||
|
selectable: { type: Boolean, default: false },
|
||||||
|
isSelected: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
|
const emit = defineEmits([
|
||||||
|
'toggle',
|
||||||
|
'updateContact',
|
||||||
|
'showContact',
|
||||||
|
'select',
|
||||||
|
'avatarHover',
|
||||||
|
]);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -88,111 +97,148 @@ const onClickExpand = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onClickViewDetails = () => emit('showContact', props.id);
|
const onClickViewDetails = () => emit('showContact', props.id);
|
||||||
|
|
||||||
|
const toggleSelect = checked => {
|
||||||
|
emit('select', checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarHover = isHovered => {
|
||||||
|
emit('avatarHover', isHovered);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardLayout :key="id" layout="row">
|
<div class="relative">
|
||||||
<div class="flex items-center justify-start flex-1 gap-4">
|
<CardLayout
|
||||||
<Avatar
|
:key="id"
|
||||||
:name="name"
|
layout="row"
|
||||||
:src="thumbnail"
|
:class="{
|
||||||
:size="48"
|
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
|
||||||
:status="availabilityStatus"
|
}"
|
||||||
hide-offline-status
|
>
|
||||||
rounded-full
|
<div class="flex items-center justify-start flex-1 gap-4">
|
||||||
/>
|
<div
|
||||||
<div class="flex flex-col gap-0.5 flex-1">
|
class="relative"
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
@mouseenter="handleAvatarHover(true)"
|
||||||
<span class="text-base font-medium truncate text-n-slate-12">
|
@mouseleave="handleAvatarHover(false)"
|
||||||
{{ name }}
|
>
|
||||||
</span>
|
<Avatar
|
||||||
<span class="inline-flex items-center gap-1">
|
:name="name"
|
||||||
<span
|
:src="thumbnail"
|
||||||
v-if="additionalAttributes?.companyName"
|
:size="48"
|
||||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
:status="availabilityStatus"
|
||||||
/>
|
hide-offline-status
|
||||||
<span
|
rounded-full
|
||||||
v-if="additionalAttributes?.companyName"
|
|
||||||
class="text-sm truncate text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ additionalAttributes.companyName }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
|
|
||||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
|
||||||
<span class="text-sm text-n-slate-11">
|
|
||||||
{{ email }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
|
||||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
|
||||||
{{ phoneNumber }}
|
|
||||||
</span>
|
|
||||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
|
||||||
<span
|
|
||||||
v-if="countryDetails"
|
|
||||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
|
||||||
>
|
>
|
||||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
<template v-if="selectable" #overlay="{ size }">
|
||||||
{{ formattedLocation }}
|
<label
|
||||||
</span>
|
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
|
||||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||||
<Button
|
@click.stop
|
||||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
>
|
||||||
variant="link"
|
<Checkbox
|
||||||
size="xs"
|
:model-value="isSelected"
|
||||||
@click="onClickViewDetails"
|
@change="event => toggleSelect(event.target.checked)"
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex flex-col gap-0.5 flex-1">
|
||||||
</div>
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
<span class="text-base font-medium truncate text-n-slate-12">
|
||||||
<Button
|
{{ name }}
|
||||||
icon="i-lucide-chevron-down"
|
</span>
|
||||||
variant="ghost"
|
<span class="inline-flex items-center gap-1">
|
||||||
color="slate"
|
<span
|
||||||
size="xs"
|
v-if="additionalAttributes?.companyName"
|
||||||
:class="{ 'rotate-180': isExpanded }"
|
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||||
@click="onClickExpand"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template #after>
|
|
||||||
<div
|
|
||||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
|
||||||
:class="
|
|
||||||
isExpanded
|
|
||||||
? 'grid-rows-[1fr] opacity-100'
|
|
||||||
: 'grid-rows-[0fr] opacity-0'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
|
||||||
<ContactsForm
|
|
||||||
ref="contactsFormRef"
|
|
||||||
:contact-data="contactData"
|
|
||||||
@update="handleFormUpdate"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
:label="
|
|
||||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
|
||||||
"
|
|
||||||
size="sm"
|
|
||||||
:is-loading="isUpdating"
|
|
||||||
:disabled="isUpdating || isFormInvalid"
|
|
||||||
@click="handleUpdateContact"
|
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
v-if="additionalAttributes?.companyName"
|
||||||
|
class="text-sm truncate text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ additionalAttributes.companyName }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
|
||||||
|
>
|
||||||
|
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ email }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||||
|
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||||
|
{{ phoneNumber }}
|
||||||
|
</span>
|
||||||
|
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||||
|
<span
|
||||||
|
v-if="countryDetails"
|
||||||
|
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||||
|
>
|
||||||
|
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||||
|
{{ formattedLocation }}
|
||||||
|
</span>
|
||||||
|
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||||
|
variant="link"
|
||||||
|
size="xs"
|
||||||
|
@click="onClickViewDetails"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContactDeleteSection
|
|
||||||
:selected-contact="{
|
|
||||||
id: props.id,
|
|
||||||
name: props.name,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</CardLayout>
|
<Button
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
:class="{ 'rotate-180': isExpanded }"
|
||||||
|
@click="onClickExpand"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #after>
|
||||||
|
<div
|
||||||
|
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||||
|
:class="
|
||||||
|
isExpanded
|
||||||
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
|
: 'grid-rows-[0fr] opacity-0'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||||
|
<ContactsForm
|
||||||
|
ref="contactsFormRef"
|
||||||
|
:contact-data="contactData"
|
||||||
|
@update="handleFormUpdate"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||||
|
"
|
||||||
|
size="sm"
|
||||||
|
:is-loading="isUpdating"
|
||||||
|
:disabled="isUpdating || isFormInvalid"
|
||||||
|
@click="handleUpdateContact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ContactDeleteSection
|
||||||
|
:selected-contact="{
|
||||||
|
id: props.id,
|
||||||
|
name: props.name,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CardLayout>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
<VoiceCallButton
|
<VoiceCallButton
|
||||||
:phone="selectedContact?.phoneNumber"
|
:phone="selectedContact?.phoneNumber"
|
||||||
|
:contact-id="contactId"
|
||||||
:label="$t('CONTACT_PANEL.CALL')"
|
:label="$t('CONTACT_PANEL.CALL')"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
|||||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||||
INSTAGRAM: 'i-ri-instagram-line',
|
INSTAGRAM: 'i-ri-instagram-line',
|
||||||
|
TIKTOK: 'i-ri-tiktok-fill',
|
||||||
TWITTER: 'i-ri-twitter-x-fill',
|
TWITTER: 'i-ri-twitter-x-fill',
|
||||||
GITHUB: 'i-ri-github-fill',
|
GITHUB: 'i-ri-github-fill',
|
||||||
};
|
};
|
||||||
@@ -65,6 +66,7 @@ const defaultState = {
|
|||||||
facebook: '',
|
facebook: '',
|
||||||
github: '',
|
github: '',
|
||||||
instagram: '',
|
instagram: '',
|
||||||
|
tiktok: '',
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
twitter: '',
|
twitter: '',
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-1
@@ -40,7 +40,12 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
width="3xl"
|
||||||
|
overflow-y-auto
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
>
|
||||||
<ContactsForm
|
<ContactsForm
|
||||||
ref="contactsFormRef"
|
ref="contactsFormRef"
|
||||||
is-new-contact
|
is-new-contact
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
|||||||
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
||||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||||
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
|
|||||||
@click="updateContact"
|
@click="updateContact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Policy :permissions="['administrator']">
|
||||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
<div
|
||||||
>
|
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||||
<div class="flex flex-col gap-2">
|
>
|
||||||
<h6 class="text-base font-medium text-n-slate-12">
|
<div class="flex flex-col gap-2">
|
||||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
<h6 class="text-base font-medium text-n-slate-12">
|
||||||
</h6>
|
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||||
<span class="text-sm text-n-slate-11">
|
</h6>
|
||||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
<span class="text-sm text-n-slate-11">
|
||||||
</span>
|
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
|
color="ruby"
|
||||||
|
@click="openConfirmDeleteContactDialog"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<ConfirmContactDeleteDialog
|
||||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
ref="confirmDeleteContactDialogRef"
|
||||||
color="ruby"
|
:selected-contact="selectedContact"
|
||||||
@click="openConfirmDeleteContactDialog"
|
@go-to-contacts-list="emit('goToContactsList')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Policy>
|
||||||
<ConfirmContactDeleteDialog
|
|
||||||
ref="confirmDeleteContactDialogRef"
|
|
||||||
:selected-contact="selectedContact"
|
|
||||||
@go-to-contacts-list="emit('goToContactsList')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import {
|
|||||||
} from 'shared/helpers/CustomErrors';
|
} from 'shared/helpers/CustomErrors';
|
||||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||||
|
|
||||||
defineProps({ contacts: { type: Array, required: true } });
|
const props = defineProps({
|
||||||
|
contacts: { type: Array, required: true },
|
||||||
|
selectedContactIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggleContact']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -20,6 +28,9 @@ const route = useRoute();
|
|||||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||||
const expandedCardId = ref(null);
|
const expandedCardId = ref(null);
|
||||||
|
const hoveredAvatarId = ref(null);
|
||||||
|
|
||||||
|
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
|
||||||
|
|
||||||
const updateContact = async updatedData => {
|
const updateContact = async updatedData => {
|
||||||
try {
|
try {
|
||||||
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
|
|||||||
const toggleExpanded = id => {
|
const toggleExpanded = id => {
|
||||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSelected = id => selectedIdsSet.value.has(id);
|
||||||
|
|
||||||
|
const shouldShowSelection = id => {
|
||||||
|
return hoveredAvatarId.value === id || isSelected(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (id, value) => {
|
||||||
|
emit('toggleContact', { id, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarHover = (id, isHovered) => {
|
||||||
|
hoveredAvatarId.value = isHovered ? id : null;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
<div class="flex flex-col gap-4">
|
||||||
<ContactsCard
|
<div v-for="contact in contacts" :key="contact.id" class="relative">
|
||||||
v-for="contact in contacts"
|
<ContactsCard
|
||||||
:id="contact.id"
|
:id="contact.id"
|
||||||
:key="contact.id"
|
:name="contact.name"
|
||||||
:name="contact.name"
|
:email="contact.email"
|
||||||
:email="contact.email"
|
:thumbnail="contact.thumbnail"
|
||||||
:thumbnail="contact.thumbnail"
|
:phone-number="contact.phoneNumber"
|
||||||
:phone-number="contact.phoneNumber"
|
:additional-attributes="contact.additionalAttributes"
|
||||||
:additional-attributes="contact.additionalAttributes"
|
:availability-status="contact.availabilityStatus"
|
||||||
:availability-status="contact.availabilityStatus"
|
:is-expanded="expandedCardId === contact.id"
|
||||||
:is-expanded="expandedCardId === contact.id"
|
:is-updating="isUpdating"
|
||||||
:is-updating="isUpdating"
|
:selectable="shouldShowSelection(contact.id)"
|
||||||
@toggle="toggleExpanded(contact.id)"
|
:is-selected="isSelected(contact.id)"
|
||||||
@update-contact="updateContact"
|
@toggle="toggleExpanded(contact.id)"
|
||||||
@show-contact="onClickViewDetails"
|
@update-contact="updateContact"
|
||||||
/>
|
@show-contact="onClickViewDetails"
|
||||||
|
@select="value => handleSelect(contact.id, value)"
|
||||||
|
@avatar-hover="value => handleAvatarHover(contact.id, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, useAttrs } from 'vue';
|
import { computed, ref, useAttrs } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
phone: { type: String, default: '' },
|
phone: { type: String, default: '' },
|
||||||
|
contactId: { type: [String, Number], required: true },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
size: { type: String, default: 'sm' },
|
size: { type: String, default: 'sm' },
|
||||||
@@ -18,10 +22,17 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||||
@@ -32,20 +43,62 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|||||||
// Unified behavior: hide when no phone
|
// Unified behavior: hide when no phone
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const isInitiatingCall = computed(() => {
|
||||||
|
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||||
|
});
|
||||||
|
|
||||||
const onClick = () => {
|
const navigateToConversation = conversationId => {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
if (conversationId && accountId) {
|
||||||
|
const path = frontendURL(
|
||||||
|
conversationUrl({
|
||||||
|
accountId,
|
||||||
|
id: conversationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.push({ path });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCall = async inboxId => {
|
||||||
|
if (isInitiatingCall.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await store.dispatch('contacts/initiateCall', {
|
||||||
|
contactId: props.contactId,
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||||
|
|
||||||
|
// Add call to store immediately so widget shows
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callDirection: 'outbound',
|
||||||
|
});
|
||||||
|
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
|
navigateToConversation(response?.conversation_id);
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error?.message;
|
||||||
|
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
if (voiceInboxes.value.length > 1) {
|
if (voiceInboxes.value.length > 1) {
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
const [inbox] = voiceInboxes.value;
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickInbox = () => {
|
const onPickInbox = async inbox => {
|
||||||
// Placeholder until actual call wiring happens
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -55,6 +108,8 @@ const onPickInbox = () => {
|
|||||||
v-if="shouldRender"
|
v-if="shouldRender"
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
|
:disabled="isInitiatingCall"
|
||||||
|
:is-loading="isInitiatingCall"
|
||||||
:label="label"
|
:label="label"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:size="size"
|
:size="size"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
@@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<ButtonGroup
|
||||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
sm
|
sm
|
||||||
class="!rounded-full"
|
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-2': isContactSidebarOpen,
|
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
|
||||||
}"
|
}"
|
||||||
icon="i-ph-user-bold"
|
icon="i-ph-user-bold"
|
||||||
@click="handleConversationSidebarToggle"
|
@click="handleConversationSidebarToggle"
|
||||||
@@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
class="!rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
|
||||||
}"
|
|
||||||
sm
|
sm
|
||||||
|
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
|
||||||
|
isCopilotPanelOpen,
|
||||||
|
}"
|
||||||
icon="i-woot-captain"
|
icon="i-woot-captain"
|
||||||
@click="handleCopilotSidebarToggle"
|
@click="handleCopilotSidebarToggle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attribute: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
|
const iconByType = {
|
||||||
|
text: 'i-lucide-align-justify',
|
||||||
|
checkbox: 'i-lucide-circle-check-big',
|
||||||
|
list: 'i-lucide-list',
|
||||||
|
date: 'i-lucide-calendar',
|
||||||
|
link: 'i-lucide-link',
|
||||||
|
number: 'i-lucide-hash',
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeIcon = computed(() => {
|
||||||
|
const typeKey = props.attribute.type?.toLowerCase();
|
||||||
|
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||||
|
{{ attribute.label }}
|
||||||
|
</h4>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon :icon="attributeIcon" class="size-4" />
|
||||||
|
<span class="text-sm">{{ attribute.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-3 bg-n-weak" />
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||||
|
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<AttributeBadge
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.type"
|
||||||
|
:type="badge.type"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="badges.length > 0"
|
||||||
|
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('edit', attribute)"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('delete', attribute)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
|
{{ attribute.attribute_description || attribute.description || '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'resolution',
|
||||||
|
validator: value => ['pre-chat', 'resolution'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const attributeConfig = {
|
||||||
|
'pre-chat': {
|
||||||
|
colorClass: 'text-n-blue-11',
|
||||||
|
icon: 'i-lucide-message-circle',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
colorClass: 'text-n-teal-11',
|
||||||
|
icon: 'i-lucide-circle-check-big',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = computed(
|
||||||
|
() => attributeConfig[props.type] || attributeConfig.resolution
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||||
|
>
|
||||||
|
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||||
|
<span class="text-xs" :class="config.colorClass">{{
|
||||||
|
t(config.labelKey)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -19,8 +19,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
enableVariables: { type: Boolean, default: false },
|
enableVariables: { type: Boolean, default: false },
|
||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
|
signature: { type: String, default: '' },
|
||||||
|
allowSignature: { type: Boolean, default: false },
|
||||||
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -98,8 +102,12 @@ watch(
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:enable-variables="enableVariables"
|
:enable-variables="enableVariables"
|
||||||
:enable-canned-responses="enableCannedResponses"
|
:enable-canned-responses="enableCannedResponses"
|
||||||
:enabled-menu-options="enabledMenuOptions"
|
|
||||||
:enable-captain-tools="enableCaptainTools"
|
:enable-captain-tools="enableCaptainTools"
|
||||||
|
:signature="signature"
|
||||||
|
:allow-signature="allowSignature"
|
||||||
|
:send-with-signature="sendWithSignature"
|
||||||
|
:channel-type="channelType"
|
||||||
|
:medium="medium"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@@ -131,19 +139,6 @@ watch(
|
|||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
::v-deep {
|
::v-deep {
|
||||||
.ProseMirror-menubar-wrapper {
|
.ProseMirror-menubar-wrapper {
|
||||||
@apply gap-2 !important;
|
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
|
||||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
|
|
||||||
|
|
||||||
.ProseMirror-menuitem {
|
|
||||||
@apply h-5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-icon {
|
|
||||||
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ProseMirror.ProseMirror-woot-style {
|
.ProseMirror.ProseMirror-woot-style {
|
||||||
p {
|
p {
|
||||||
@apply first:mt-0 !important;
|
@apply first:mt-0 !important;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
showBackdrop: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -25,14 +29,24 @@ defineProps({
|
|||||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
v-if="showBackdrop"
|
||||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||||
>
|
>
|
||||||
<slot name="empty-state-item" />
|
<slot name="empty-state-item" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
|
class="flex flex-col items-center justify-end w-full h-full pb-20"
|
||||||
|
:class="{
|
||||||
|
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
|
||||||
|
showBackdrop,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center justify-center gap-6">
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-6"
|
||||||
|
:class="{
|
||||||
|
'mt-48': !showBackdrop,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<div class="flex flex-col items-center justify-center gap-3">
|
<div class="flex flex-col items-center justify-center gap-3">
|
||||||
<h2
|
<h2
|
||||||
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
||||||
@@ -40,6 +54,7 @@ defineProps({
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
|
v-if="subtitle"
|
||||||
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
||||||
>
|
>
|
||||||
{{ subtitle }}
|
{{ subtitle }}
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ const previewArticle = () => {
|
|||||||
@apply mr-0;
|
@apply mr-0;
|
||||||
|
|
||||||
.ProseMirror-icon {
|
.ProseMirror-icon {
|
||||||
@apply p-0 mt-1 !mr-0;
|
@apply p-0 mt-0 !mr-0;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 20px !important;
|
width: 20px !important;
|
||||||
|
|||||||
+5
-2
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'dashboard/helper/portalHelper';
|
} from 'dashboard/helper/portalHelper';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
@@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
@click="previewArticle"
|
@click="previewArticle"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center">
|
<ButtonGroup class="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||||
|
no-animation
|
||||||
:is-loading="isArticlePublishing"
|
:is-loading="isArticlePublishing"
|
||||||
:disabled="
|
:disabled="
|
||||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||||
@@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
|
no-animation
|
||||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||||
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
||||||
/>
|
/>
|
||||||
@@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import {
|
import {
|
||||||
searchContacts,
|
searchContacts,
|
||||||
createNewContact,
|
createNewContact,
|
||||||
@@ -226,6 +228,8 @@ const keyboardEvents = {
|
|||||||
action: () => {
|
action: () => {
|
||||||
if (showComposeNewConversation.value) {
|
if (showComposeNewConversation.value) {
|
||||||
showComposeNewConversation.value = false;
|
showComposeNewConversation.value = false;
|
||||||
|
emit('close');
|
||||||
|
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+29
-13
@@ -4,10 +4,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useEventListener } from '@vueuse/core';
|
||||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||||
@@ -50,12 +51,6 @@ const EmojiInput = defineAsyncComponent(
|
|||||||
() => import('shared/components/emoji/EmojiInput.vue')
|
() => import('shared/components/emoji/EmojiInput.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
const signatureToApply = computed(() =>
|
|
||||||
props.isEmailOrWebWidgetInbox
|
|
||||||
? props.messageSignature
|
|
||||||
: extractTextFromMarkdown(props.messageSignature)
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setSignatureFlagForInbox,
|
setSignatureFlagForInbox,
|
||||||
@@ -80,19 +75,26 @@ const isRegularMessageMode = computed(() => {
|
|||||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||||
|
|
||||||
|
const shouldShowSignatureButton = computed(() => {
|
||||||
|
return (
|
||||||
|
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const setSignature = () => {
|
const setSignature = () => {
|
||||||
if (signatureToApply.value) {
|
if (props.messageSignature) {
|
||||||
if (sendWithSignature.value) {
|
if (sendWithSignature.value) {
|
||||||
emit('addSignature', signatureToApply.value);
|
emit('addSignature', props.messageSignature);
|
||||||
} else {
|
} else {
|
||||||
emit('removeSignature', signatureToApply.value);
|
emit('removeSignature', props.messageSignature);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMessageSignature = () => {
|
const toggleMessageSignature = () => {
|
||||||
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
||||||
setSignature();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Added this watch to dynamically set signature on target inbox change.
|
// Added this watch to dynamically set signature on target inbox change.
|
||||||
@@ -102,7 +104,7 @@ watch(
|
|||||||
() => props.hasSelectedInbox,
|
() => props.hasSelectedInbox,
|
||||||
newValue => {
|
newValue => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
|
if (newValue && !isVoiceInbox.value) setSignature();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -162,6 +164,20 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
|
const onPaste = e => {
|
||||||
|
if (!props.isEmailOrWebWidgetInbox) return;
|
||||||
|
|
||||||
|
const files = e.clipboardData?.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
const { name, type, size } = file;
|
||||||
|
onFileUpload({ file, name, type, size });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEventListener(document, 'paste', onPaste);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -221,7 +237,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
<Button
|
<Button
|
||||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
v-if="shouldShowSignatureButton"
|
||||||
icon="i-lucide-signature"
|
icon="i-lucide-signature"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ const removeAttachment = id => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 p-4">
|
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-if="filteredImageAttachments.length > 0"
|
v-if="filteredImageAttachments.length > 0"
|
||||||
class="flex flex-wrap gap-3"
|
class="flex flex-wrap gap-3"
|
||||||
|
|||||||
+36
-12
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
extractTextFromMarkdown,
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
buildContactableInboxesList,
|
buildContactableInboxesList,
|
||||||
@@ -87,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
|||||||
|
|
||||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||||
|
|
||||||
|
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||||
|
|
||||||
|
const effectiveChannelType = computed(() =>
|
||||||
|
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||||
|
);
|
||||||
|
|
||||||
const validationRules = computed(() => ({
|
const validationRules = computed(() => ({
|
||||||
selectedContact: { required },
|
selectedContact: { required },
|
||||||
targetInbox: { required },
|
targetInbox: { required },
|
||||||
@@ -194,27 +200,36 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
|
|
||||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
|
state.message = '';
|
||||||
emit('updateTargetInbox', { ...rest });
|
emit('updateTargetInbox', { ...rest });
|
||||||
showInboxesDropdown.value = false;
|
showInboxesDropdown.value = false;
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeSignatureFromMessage = () => {
|
||||||
|
// Always remove the signature from message content when inbox/contact is removed
|
||||||
|
// to ensure no leftover signature content remains
|
||||||
|
if (props.messageSignature) {
|
||||||
|
state.message = removeSignature(
|
||||||
|
state.message,
|
||||||
|
props.messageSignature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeTargetInbox = value => {
|
const removeTargetInbox = value => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
// Remove the signature from message content
|
removeSignatureFromMessage();
|
||||||
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
|
state.message = '';
|
||||||
if (props.sendWithSignature) {
|
|
||||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
|
||||||
? props.messageSignature
|
|
||||||
: extractTextFromMarkdown(props.messageSignature);
|
|
||||||
state.message = removeSignature(state.message, signatureToRemove);
|
|
||||||
}
|
|
||||||
emit('updateTargetInbox', value);
|
emit('updateTargetInbox', value);
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
|
removeSignatureFromMessage();
|
||||||
emit('clearSelectedContact');
|
emit('clearSelectedContact');
|
||||||
|
state.message = '';
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,11 +238,19 @@ const onClickInsertEmoji = emoji => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSignature = signature => {
|
const handleAddSignature = signature => {
|
||||||
state.message = appendSignature(state.message, signature);
|
state.message = appendSignature(
|
||||||
|
state.message,
|
||||||
|
signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSignature = signature => {
|
const handleRemoveSignature = signature => {
|
||||||
state.message = removeSignature(state.message, signature);
|
state.message = removeSignature(
|
||||||
|
state.message,
|
||||||
|
signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachFile = files => {
|
const handleAttachFile = files => {
|
||||||
@@ -351,9 +374,10 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
v-model="state.message"
|
v-model="state.message"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:has-attachments="state.attachedFiles.length > 0"
|
||||||
|
:channel-type="inboxChannelType"
|
||||||
|
:medium="targetInbox?.medium || ''"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
|
|||||||
+25
-98
@@ -1,122 +1,49 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed, nextTick } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
|
||||||
appendSignature,
|
|
||||||
extractTextFromMarkdown,
|
|
||||||
removeSignature,
|
|
||||||
} from 'dashboard/helper/editorHelper';
|
|
||||||
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|
||||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
|
||||||
hasErrors: { type: Boolean, default: false },
|
hasErrors: { type: Boolean, default: false },
|
||||||
hasAttachments: { type: Boolean, default: false },
|
hasAttachments: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const modelValue = defineModel({
|
const modelValue = defineModel({
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = ref({
|
|
||||||
hasSlashCommand: false,
|
|
||||||
showMentions: false,
|
|
||||||
mentionSearchKey: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const plainTextSignature = computed(() =>
|
|
||||||
extractTextFromMarkdown(props.messageSignature)
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
modelValue,
|
|
||||||
newValue => {
|
|
||||||
if (props.isEmailOrWebWidgetInbox) return;
|
|
||||||
|
|
||||||
const bodyWithoutSignature = newValue
|
|
||||||
? removeSignature(newValue, plainTextSignature.value)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Check if message starts with slash
|
|
||||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
|
||||||
|
|
||||||
// Update slash command and mentions state
|
|
||||||
state.value = {
|
|
||||||
...state.value,
|
|
||||||
hasSlashCommand: startsWithSlash,
|
|
||||||
showMentions: startsWithSlash,
|
|
||||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideMention = () => {
|
|
||||||
state.value.showMentions = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceText = async message => {
|
|
||||||
// Only append signature on replace if sendWithSignature is true
|
|
||||||
const finalMessage = props.sendWithSignature
|
|
||||||
? appendSignature(message, plainTextSignature.value)
|
|
||||||
: message;
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
modelValue.value = finalMessage;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||||
<template v-if="isEmailOrWebWidgetInbox">
|
<Editor
|
||||||
<Editor
|
:key="editorKey"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||||
:class="
|
:class="
|
||||||
hasErrors
|
hasErrors
|
||||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
enable-variables
|
enable-variables
|
||||||
:show-character-count="false"
|
:show-character-count="false"
|
||||||
/>
|
:signature="messageSignature"
|
||||||
</template>
|
allow-signature
|
||||||
<template v-else>
|
:send-with-signature="sendWithSignature"
|
||||||
<TextArea
|
:channel-type="channelType"
|
||||||
v-model="modelValue"
|
:medium="medium"
|
||||||
:placeholder="
|
/>
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
|
||||||
:custom-text-area-class="
|
|
||||||
hasErrors
|
|
||||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
auto-height
|
|
||||||
allow-signature
|
|
||||||
:signature="messageSignature"
|
|
||||||
:send-with-signature="sendWithSignature"
|
|
||||||
>
|
|
||||||
<CannedResponse
|
|
||||||
v-if="state.showMentions && state.hasSlashCommand"
|
|
||||||
v-on-clickaway="hideMention"
|
|
||||||
class="normal-editor__canned-box"
|
|
||||||
:search-key="state.mentionSearchKey"
|
|
||||||
@replace="replaceText"
|
|
||||||
/>
|
|
||||||
</TextArea>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
|||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
trailingIcon: { type: Boolean, default: false },
|
trailingIcon: { type: Boolean, default: false },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
|
noAnimation: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
@@ -145,7 +146,7 @@ const STYLE_CONFIG = {
|
|||||||
solid:
|
solid:
|
||||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||||
outline:
|
outline:
|
||||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
@@ -179,12 +180,18 @@ const STYLE_CONFIG = {
|
|||||||
md: 'text-sm font-medium',
|
md: 'text-sm font-medium',
|
||||||
lg: 'text-base',
|
lg: 'text-base',
|
||||||
},
|
},
|
||||||
|
clickAnimation: {
|
||||||
|
xs: 'active:enabled:scale-[0.97]',
|
||||||
|
sm: 'active:enabled:scale-[0.97]',
|
||||||
|
md: 'active:enabled:scale-[0.98]',
|
||||||
|
lg: 'active:enabled:scale-[0.98]',
|
||||||
|
},
|
||||||
justify: {
|
justify: {
|
||||||
start: 'justify-start',
|
start: 'justify-start',
|
||||||
center: 'justify-center',
|
center: 'justify-center',
|
||||||
end: 'justify-end',
|
end: 'justify-end',
|
||||||
},
|
},
|
||||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClasses = computed(() => {
|
const variantClasses = computed(() => {
|
||||||
@@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
|
|||||||
|
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const animationClasses = computed(() => {
|
||||||
|
return props.noAnimation
|
||||||
|
? ''
|
||||||
|
: STYLE_CONFIG.clickAnimation[computedSize.value];
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
|
|||||||
[STYLE_CONFIG.base]: true,
|
[STYLE_CONFIG.base]: true,
|
||||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||||
|
[animationClasses]: true,
|
||||||
[STYLE_CONFIG.justify[computedJustify]]: true,
|
[STYLE_CONFIG.justify[computedJustify]]: true,
|
||||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
noAnimation: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
noAnimation
|
||||||
|
? ''
|
||||||
|
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
|
||||||
|
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentPage: {
|
currentPage: {
|
||||||
@@ -56,11 +62,36 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
showAssistantSwitcher: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const { shouldShowPaywall } = usePolicy();
|
const { shouldShowPaywall } = usePolicy();
|
||||||
|
|
||||||
|
const showAssistantSwitcherDropdown = ref(false);
|
||||||
|
const createAssistantDialogRef = ref(null);
|
||||||
|
|
||||||
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||||
|
|
||||||
|
const currentAssistantId = computed(() => route.params.assistantId);
|
||||||
|
const isFetchingAssistants = computed(() => uiFlags.value?.fetchingList);
|
||||||
|
|
||||||
|
const activeAssistantName = computed(() => {
|
||||||
|
return (
|
||||||
|
assistants.value?.find(
|
||||||
|
assistant => assistant.id === Number(currentAssistantId.value)
|
||||||
|
)?.name || t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const showPaywall = computed(() => {
|
const showPaywall = computed(() => {
|
||||||
return shouldShowPaywall(props.featureFlag);
|
return shouldShowPaywall(props.featureFlag);
|
||||||
});
|
});
|
||||||
@@ -72,6 +103,15 @@ const handleButtonClick = () => {
|
|||||||
const handlePageChange = event => {
|
const handlePageChange = event => {
|
||||||
emit('update:currentPage', event);
|
emit('update:currentPage', event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAssistantSwitcher = () => {
|
||||||
|
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAssistant = () => {
|
||||||
|
showAssistantSwitcherDropdown.value = false;
|
||||||
|
createAssistantDialogRef.value.dialogRef.open();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,39 +121,86 @@ const handlePageChange = event => {
|
|||||||
<div
|
<div
|
||||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-3 items-center">
|
||||||
<BackButton v-if="backUrl" :to="backUrl" />
|
<BackButton v-if="backUrl" :back-url="backUrl" />
|
||||||
<slot name="headerTitle">
|
|
||||||
<span class="text-xl font-medium text-n-slate-12">
|
|
||||||
{{ headerTitle }}
|
|
||||||
</span>
|
|
||||||
</slot>
|
|
||||||
<div
|
<div
|
||||||
v-if="!isEmpty && showKnowMore"
|
v-if="showAssistantSwitcher && !showPaywall"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
<div class="flex items-center gap-2">
|
||||||
<slot name="knowMore" />
|
<span
|
||||||
|
v-if="!isFetchingAssistants"
|
||||||
|
class="text-xl font-medium truncate text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ activeAssistantName }}
|
||||||
|
</span>
|
||||||
|
<div class="relative group">
|
||||||
|
<OnClickOutside
|
||||||
|
@trigger="showAssistantSwitcherDropdown = false"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
:disabled="isFetchingAssistants"
|
||||||
|
:is-loading="isFetchingAssistants"
|
||||||
|
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
||||||
|
@click="toggleAssistantSwitcher"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AssistantSwitcher
|
||||||
|
v-if="showAssistantSwitcherDropdown"
|
||||||
|
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||||
|
@close="showAssistantSwitcherDropdown = false"
|
||||||
|
@create-assistant="handleCreateAssistant"
|
||||||
|
/>
|
||||||
|
</OnClickOutside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
v-if="showAssistantSwitcher && !showPaywall && headerTitle"
|
||||||
|
class="w-0.5 h-4 rounded-2xl bg-n-weak"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="headerTitle"
|
||||||
|
class="text-xl font-medium text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ headerTitle }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="!isEmpty && showKnowMore"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||||
|
<slot name="knowMore" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="flex gap-2">
|
||||||
v-if="!showPaywall && buttonLabel"
|
<slot name="search" />
|
||||||
v-on-clickaway="() => emit('close')"
|
<div
|
||||||
class="relative group/campaign-button"
|
v-if="!showPaywall && buttonLabel"
|
||||||
>
|
v-on-clickaway="() => emit('close')"
|
||||||
<Policy :permissions="buttonPolicy">
|
class="relative group/captain-button"
|
||||||
<Button
|
>
|
||||||
:label="buttonLabel"
|
<Policy :permissions="buttonPolicy">
|
||||||
icon="i-lucide-plus"
|
<Button
|
||||||
size="sm"
|
:label="buttonLabel"
|
||||||
class="group-hover/campaign-button:brightness-110"
|
icon="i-lucide-plus"
|
||||||
@click="handleButtonClick"
|
size="sm"
|
||||||
/>
|
class="group-hover/captain-button:brightness-110"
|
||||||
</Policy>
|
@click="handleButtonClick"
|
||||||
<slot name="action" />
|
/>
|
||||||
|
</Policy>
|
||||||
|
<slot name="action" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<slot name="subHeader" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
@@ -143,5 +230,6 @@ const handlePageChange = event => {
|
|||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
||||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
isFetching: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isEmpty: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
currentPage: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
totalCount: {
|
|
||||||
type: Number,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
itemsPerPage: {
|
|
||||||
type: Number,
|
|
||||||
default: 25,
|
|
||||||
},
|
|
||||||
showPaginationFooter: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
breadcrumbItems: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:currentPage']);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handlePageChange = event => {
|
|
||||||
emit('update:currentPage', event);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBreadcrumbClick = item => {
|
|
||||||
router.push({
|
|
||||||
name: item.routeName,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
|
||||||
>
|
|
||||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
|
||||||
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
|
|
||||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
|
||||||
</header>
|
|
||||||
<main class="flex gap-16 w-full flex-1 pb-16">
|
|
||||||
<section
|
|
||||||
v-if="$slots.body || $slots.emptyState || isFetching"
|
|
||||||
class="flex flex-col w-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="isFetching"
|
|
||||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="isEmpty">
|
|
||||||
<slot name="emptyState" />
|
|
||||||
</div>
|
|
||||||
<slot v-else name="body" />
|
|
||||||
</section>
|
|
||||||
<section v-if="$slots.controls" class="flex w-full">
|
|
||||||
<slot name="controls" />
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
|
|
||||||
<PaginationFooter
|
|
||||||
:current-page="currentPage"
|
|
||||||
:total-items="totalCount"
|
|
||||||
:items-per-page="itemsPerPage"
|
|
||||||
@update:current-page="handlePageChange"
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -76,12 +76,11 @@ const handleAction = ({ action, value }) => {
|
|||||||
<template>
|
<template>
|
||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
<router-link
|
<h6
|
||||||
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
|
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||||
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
|
||||||
>
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</router-link>
|
</h6>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import MessageList from './MessageList.vue';
|
import MessageList from './MessageList.vue';
|
||||||
@@ -29,6 +29,16 @@ const resetConversation = () => {
|
|||||||
newMessage.value = '';
|
newMessage.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Watch for assistant ID changes and reset conversation
|
||||||
|
watch(
|
||||||
|
() => assistantId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (oldId && newId !== oldId) {
|
||||||
|
resetConversation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!newMessage.value.trim() || isLoading.value) return;
|
if (!newMessage.value.trim() || isLoading.value) return;
|
||||||
|
|
||||||
@@ -65,16 +75,17 @@ const sendMessage = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
|
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-8 px-6">
|
||||||
<div class="flex justify-between items-center mb-1">
|
<div class="flex justify-between items-center mb-1">
|
||||||
<h3 class="text-lg font-medium">
|
<h3 class="text-lg font-medium">
|
||||||
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
||||||
</h3>
|
</h3>
|
||||||
<NextButton
|
<NextButton
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
sm
|
||||||
|
slate
|
||||||
icon="i-lucide-rotate-ccw"
|
icon="i-lucide-rotate-ccw"
|
||||||
@click="resetConversation"
|
@click="resetConversation"
|
||||||
/>
|
/>
|
||||||
@@ -87,17 +98,17 @@ const sendMessage = async () => {
|
|||||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
|
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
|
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
<NextButton
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
sm
|
||||||
:disabled="!newMessage.trim()"
|
:disabled="!newMessage.trim()"
|
||||||
icon="i-lucide-send"
|
icon="i-lucide-send"
|
||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
|
|||||||
@@ -64,20 +64,23 @@ const bulkCheckboxState = computed({
|
|||||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-model="bulkCheckboxState"
|
v-model="bulkCheckboxState"
|
||||||
:indeterminate="isIndeterminate"
|
:indeterminate="isIndeterminate"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
|
<span
|
||||||
|
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
|
||||||
|
>
|
||||||
{{ selectAllLabel }}
|
{{ selectAllLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||||
{{ selectedCountLabel }}
|
{{ selectedCountLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
|
<slot name="secondary-actions" />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-4 w-px bg-n-strong" />
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<slot name="actions" :selected-count="selectedCount">
|
<slot name="actions" :selected-count="selectedCount">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
|
|||||||
|
|
||||||
const getMessageStyle = sender =>
|
const getMessageStyle = sender =>
|
||||||
isUserMessage(sender)
|
isUserMessage(sender)
|
||||||
? 'bg-n-strong text-n-white'
|
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
|
||||||
: 'bg-n-solid-iris text-n-slate-12';
|
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
|
<div
|
||||||
|
ref="messageContainer"
|
||||||
|
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(message, index) in messages"
|
v-for="(message, index) in messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
|
|||||||
:class="getMessageAlignment(message.sender)"
|
:class="getMessageAlignment(message.sender)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-start gap-1.5"
|
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
|
||||||
:class="getMessageDirection(message.sender)"
|
:class="getMessageDirection(message.sender)"
|
||||||
>
|
>
|
||||||
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
|
<Avatar
|
||||||
|
:name="getAvatarName(message.sender)"
|
||||||
|
rounded-full
|
||||||
|
:size="24"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="max-w-[80%] rounded-lg p-3 text-sm"
|
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
|
||||||
:class="getMessageStyle(message.sender)"
|
:class="getMessageStyle(message.sender)"
|
||||||
>
|
>
|
||||||
<div class="break-words" v-html="formatMessage(message.content)" />
|
<div v-html="formatMessage(message.content)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -59,6 +60,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
showActions: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||||
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
|
|||||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||||
{{ answer }}
|
{{ answer }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
<div
|
||||||
<div class="inline-flex items-center">
|
v-if="!compact"
|
||||||
<span
|
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
>
|
||||||
>
|
<Policy v-if="showActions" :permissions="['administrator']">
|
||||||
<i class="i-woot-captain" />
|
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||||
{{ assistant?.name || '' }}
|
<Button
|
||||||
</span>
|
v-if="status === 'pending'"
|
||||||
<div
|
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||||
v-if="documentable"
|
icon="i-lucide-circle-check-big"
|
||||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
sm
|
||||||
>
|
link
|
||||||
|
class="hover:!no-underline"
|
||||||
|
@click="
|
||||||
|
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
link
|
||||||
|
class="hover:!no-underline"
|
||||||
|
@click="
|
||||||
|
handleAssistantAction({
|
||||||
|
action: 'edit',
|
||||||
|
value: 'edit',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
sm
|
||||||
|
ruby
|
||||||
|
link
|
||||||
|
class="hover:!no-underline"
|
||||||
|
@click="
|
||||||
|
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Policy>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
:class="{ 'justify-between w-full': !showActions }"
|
||||||
|
>
|
||||||
|
<div class="inline-flex items-center gap-3 min-w-0">
|
||||||
<span
|
<span
|
||||||
v-if="documentable.type === 'Captain::Document'"
|
v-if="status === 'approved'"
|
||||||
class="inline-flex items-center gap-1 truncate over"
|
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<i class="i-ph-files-light text-base" />
|
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||||
<span class="max-w-96 truncate" :title="documentable.name">
|
{{ assistant?.name || '' }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="documentable"
|
||||||
|
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="documentable.type === 'Captain::Document'"
|
||||||
|
icon="i-ph-files-light"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="documentable.type === 'User'"
|
||||||
|
icon="i-ph-user-circle-plus"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="documentable.type === 'Conversation'"
|
||||||
|
icon="i-ph-chat-circle-dots"
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="documentable.type === 'Captain::Document'"
|
||||||
|
class="truncate"
|
||||||
|
:title="documentable.name"
|
||||||
|
>
|
||||||
{{ documentable.name }}
|
{{ documentable.name }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="documentable.type === 'User'"
|
|
||||||
class="inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<i class="i-ph-user-circle-plus text-base" />
|
|
||||||
<span
|
<span
|
||||||
class="max-w-96 truncate"
|
v-else-if="documentable.type === 'User'"
|
||||||
|
class="truncate"
|
||||||
:title="documentable.available_name"
|
:title="documentable.available_name"
|
||||||
>
|
>
|
||||||
{{ documentable.available_name }}
|
{{ documentable.available_name }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span
|
||||||
<span
|
v-else-if="documentable.type === 'Conversation'"
|
||||||
v-else-if="documentable.type === 'Conversation'"
|
class="hover:underline truncate cursor-pointer"
|
||||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
role="button"
|
||||||
role="button"
|
@click="handleDocumentableClick"
|
||||||
@click="handleDocumentableClick"
|
>
|
||||||
>
|
|
||||||
<i class="i-ph-chat-circle-dots text-base" />
|
|
||||||
<span class="group-hover:underline">
|
|
||||||
{{
|
{{
|
||||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||||
id: documentable.display_id,
|
id: documentable.display_id,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span v-else />
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="status !== 'approved'"
|
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
|
||||||
>
|
>
|
||||||
<i
|
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||||
class="i-ph-stack text-base"
|
{{ timestamp }}
|
||||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
|
||||||
/>
|
|
||||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
|
||||||
>
|
|
||||||
<i class="i-ph-calendar-dot" />
|
|
||||||
{{ timestamp }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardLayout>
|
</CardLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
assistantId: {
|
|
||||||
type: [String, Number],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update']);
|
|
||||||
const { t } = useI18n();
|
|
||||||
const isFilterOpen = ref(false);
|
|
||||||
|
|
||||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
|
||||||
const assistantOptions = computed(() => [
|
|
||||||
{
|
|
||||||
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
|
|
||||||
value: 'all',
|
|
||||||
action: 'filter',
|
|
||||||
},
|
|
||||||
...assistants.value.map(assistant => ({
|
|
||||||
value: assistant.id,
|
|
||||||
label: assistant.name,
|
|
||||||
action: 'filter',
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const selectedAssistantLabel = computed(() => {
|
|
||||||
const assistant = assistantOptions.value.find(
|
|
||||||
option => option.value === props.assistantId
|
|
||||||
);
|
|
||||||
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
|
|
||||||
selected: assistant ? assistant.label : '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAssistantFilterChange = ({ value }) => {
|
|
||||||
isFilterOpen.value = false;
|
|
||||||
emit('update', value);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<OnClickOutside @trigger="isFilterOpen = false">
|
|
||||||
<Button
|
|
||||||
:label="selectedAssistantLabel"
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
trailing-icon
|
|
||||||
class="max-w-48"
|
|
||||||
@click="isFilterOpen = !isFilterOpen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DropdownMenu
|
|
||||||
v-if="isFilterOpen"
|
|
||||||
:menu-items="assistantOptions"
|
|
||||||
class="mt-2"
|
|
||||||
@action="handleAssistantFilterChange"
|
|
||||||
/>
|
|
||||||
</OnClickOutside>
|
|
||||||
</template>
|
|
||||||
+13
-3
@@ -18,7 +18,7 @@ const props = defineProps({
|
|||||||
validator: value => ['create', 'edit'].includes(value),
|
validator: value => ['create', 'edit'].includes(value),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close', 'created']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
@@ -35,8 +35,18 @@ const i18nKey = computed(
|
|||||||
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAssistant = assistantDetails =>
|
const createAssistant = async assistantDetails => {
|
||||||
store.dispatch('captainAssistants/create', assistantDetails);
|
try {
|
||||||
|
const newAssistant = await store.dispatch(
|
||||||
|
'captainAssistants/create',
|
||||||
|
assistantDetails
|
||||||
|
);
|
||||||
|
emit('created', newAssistant);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async updatedAssistant => {
|
const handleSubmit = async updatedAssistant => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
-333
@@ -1,333 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { reactive, computed, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
|
||||||
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
mode: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
validator: value => ['edit', 'create'].includes(value),
|
|
||||||
},
|
|
||||||
assistant: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['submit']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const formState = {
|
|
||||||
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
productName: '',
|
|
||||||
welcomeMessage: '',
|
|
||||||
handoffMessage: '',
|
|
||||||
resolutionMessage: '',
|
|
||||||
instructions: '',
|
|
||||||
features: {
|
|
||||||
conversationFaqs: false,
|
|
||||||
memories: false,
|
|
||||||
citations: false,
|
|
||||||
},
|
|
||||||
temperature: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
|
||||||
|
|
||||||
const validationRules = {
|
|
||||||
name: { required, minLength: minLength(1) },
|
|
||||||
description: { required, minLength: minLength(1) },
|
|
||||||
productName: { required, minLength: minLength(1) },
|
|
||||||
welcomeMessage: { minLength: minLength(1) },
|
|
||||||
handoffMessage: { minLength: minLength(1) },
|
|
||||||
resolutionMessage: { minLength: minLength(1) },
|
|
||||||
instructions: { minLength: minLength(1) },
|
|
||||||
};
|
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
|
||||||
|
|
||||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
|
||||||
|
|
||||||
const getErrorMessage = field => {
|
|
||||||
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formErrors = computed(() => ({
|
|
||||||
name: getErrorMessage('name'),
|
|
||||||
description: getErrorMessage('description'),
|
|
||||||
productName: getErrorMessage('productName'),
|
|
||||||
welcomeMessage: getErrorMessage('welcomeMessage'),
|
|
||||||
handoffMessage: getErrorMessage('handoffMessage'),
|
|
||||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
|
||||||
instructions: getErrorMessage('instructions'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updateStateFromAssistant = assistant => {
|
|
||||||
const { config = {} } = assistant;
|
|
||||||
state.name = assistant.name;
|
|
||||||
state.description = assistant.description;
|
|
||||||
state.productName = config.product_name;
|
|
||||||
state.welcomeMessage = config.welcome_message;
|
|
||||||
state.handoffMessage = config.handoff_message;
|
|
||||||
state.resolutionMessage = config.resolution_message;
|
|
||||||
state.instructions = config.instructions;
|
|
||||||
state.features = {
|
|
||||||
conversationFaqs: config.feature_faq || false,
|
|
||||||
memories: config.feature_memory || false,
|
|
||||||
citations: config.feature_citation || false,
|
|
||||||
};
|
|
||||||
state.temperature = config.temperature || 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBasicInfoUpdate = async () => {
|
|
||||||
const result = await Promise.all([
|
|
||||||
v$.value.name.$validate(),
|
|
||||||
v$.value.description.$validate(),
|
|
||||||
v$.value.productName.$validate(),
|
|
||||||
]).then(results => results.every(Boolean));
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: state.name,
|
|
||||||
description: state.description,
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
product_name: state.productName,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSystemMessagesUpdate = async () => {
|
|
||||||
const result = await Promise.all([
|
|
||||||
v$.value.welcomeMessage.$validate(),
|
|
||||||
v$.value.handoffMessage.$validate(),
|
|
||||||
v$.value.resolutionMessage.$validate(),
|
|
||||||
]).then(results => results.every(Boolean));
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
welcome_message: state.welcomeMessage,
|
|
||||||
handoff_message: state.handoffMessage,
|
|
||||||
resolution_message: state.resolutionMessage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstructionsUpdate = async () => {
|
|
||||||
const result = await v$.value.instructions.$validate();
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
temperature: state.temperature || 1,
|
|
||||||
instructions: state.instructions,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFeaturesUpdate = () => {
|
|
||||||
const payload = {
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
feature_faq: state.features.conversationFaqs,
|
|
||||||
feature_memory: state.features.memories,
|
|
||||||
feature_citation: state.features.citations,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.assistant,
|
|
||||||
newAssistant => {
|
|
||||||
if (props.mode === 'edit' && newAssistant) {
|
|
||||||
updateStateFromAssistant(newAssistant);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
|
||||||
<!-- Basic Information Section -->
|
|
||||||
<Accordion
|
|
||||||
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
|
|
||||||
is-open
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
|
||||||
<Input
|
|
||||||
v-model="state.name"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
|
|
||||||
:message="formErrors.name"
|
|
||||||
:message-type="formErrors.name ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Editor
|
|
||||||
v-model="state.description"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
|
||||||
:message="formErrors.description"
|
|
||||||
:message-type="formErrors.description ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
v-model="state.productName"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
|
|
||||||
:message="formErrors.productName"
|
|
||||||
:message-type="formErrors.productName ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
@click="handleBasicInfoUpdate"
|
|
||||||
>
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<!-- Instructions Section -->
|
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<Editor
|
|
||||||
v-model="state.instructions"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
|
||||||
:message="formErrors.instructions"
|
|
||||||
:max-length="20000"
|
|
||||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 mt-4">
|
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
v-model="state.temperature"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-n-slate-11 italic">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
|
||||||
@click="handleInstructionsUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<!-- Greeting Messages Section -->
|
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
|
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
|
||||||
<Editor
|
|
||||||
v-model="state.handoffMessage"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
|
||||||
:placeholder="
|
|
||||||
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="formErrors.handoffMessage"
|
|
||||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Editor
|
|
||||||
v-model="state.resolutionMessage"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
|
|
||||||
:placeholder="
|
|
||||||
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="formErrors.resolutionMessage"
|
|
||||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
|
||||||
@click="handleSystemMessagesUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
|
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="state.features.conversationFaqs"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
|
|
||||||
}}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.features.memories" type="checkbox" />
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.features.citations" type="checkbox" />
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
|
||||||
@click="handleFeaturesUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
+1
@@ -118,6 +118,7 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
:message="formErrors.description"
|
:message="formErrors.description"
|
||||||
:message-type="formErrors.description ? 'error' : 'info'"
|
:message-type="formErrors.description ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+38
-2
@@ -3,6 +3,8 @@ import { reactive, computed, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { minLength } from '@vuelidate/validators';
|
import { minLength } from '@vuelidate/validators';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
@@ -17,10 +19,16 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submit']);
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isCloudFeatureEnabled } = useAccount();
|
||||||
|
|
||||||
|
const isCaptainV2Enabled = computed(() =>
|
||||||
|
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||||
|
);
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
handoffMessage: '',
|
handoffMessage: '',
|
||||||
resolutionMessage: '',
|
resolutionMessage: '',
|
||||||
|
instructions: '',
|
||||||
temperature: 1,
|
temperature: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +37,7 @@ const state = reactive({ ...initialState });
|
|||||||
const validationRules = {
|
const validationRules = {
|
||||||
handoffMessage: { minLength: minLength(1) },
|
handoffMessage: { minLength: minLength(1) },
|
||||||
resolutionMessage: { minLength: minLength(1) },
|
resolutionMessage: { minLength: minLength(1) },
|
||||||
|
instructions: { minLength: minLength(1) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
@@ -40,20 +49,30 @@ const getErrorMessage = field => {
|
|||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
handoffMessage: getErrorMessage('handoffMessage'),
|
handoffMessage: getErrorMessage('handoffMessage'),
|
||||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||||
|
instructions: getErrorMessage('instructions'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updateStateFromAssistant = assistant => {
|
const updateStateFromAssistant = assistant => {
|
||||||
const { config = {} } = assistant;
|
const { config = {} } = assistant;
|
||||||
state.handoffMessage = config.handoff_message;
|
state.handoffMessage = config.handoff_message;
|
||||||
state.resolutionMessage = config.resolution_message;
|
state.resolutionMessage = config.resolution_message;
|
||||||
|
state.instructions = config.instructions;
|
||||||
state.temperature = config.temperature || 1;
|
state.temperature = config.temperature || 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSystemMessagesUpdate = async () => {
|
const handleSystemMessagesUpdate = async () => {
|
||||||
const result = await Promise.all([
|
const validations = [
|
||||||
v$.value.handoffMessage.$validate(),
|
v$.value.handoffMessage.$validate(),
|
||||||
v$.value.resolutionMessage.$validate(),
|
v$.value.resolutionMessage.$validate(),
|
||||||
]).then(results => results.every(Boolean));
|
];
|
||||||
|
|
||||||
|
if (!isCaptainV2Enabled.value) {
|
||||||
|
validations.push(v$.value.instructions.$validate());
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Promise.all(validations).then(results =>
|
||||||
|
results.every(Boolean)
|
||||||
|
);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -65,6 +84,10 @@ const handleSystemMessagesUpdate = async () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isCaptainV2Enabled.value) {
|
||||||
|
payload.config.instructions = state.instructions;
|
||||||
|
}
|
||||||
|
|
||||||
emit('submit', payload);
|
emit('submit', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +108,7 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.handoffMessage"
|
:message="formErrors.handoffMessage"
|
||||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
@@ -93,6 +117,18 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.resolutionMessage"
|
:message="formErrors.resolutionMessage"
|
||||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
v-if="!isCaptainV2Enabled"
|
||||||
|
v-model="state.instructions"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||||
|
:message="formErrors.instructions"
|
||||||
|
:max-length="20000"
|
||||||
|
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+12
-1
@@ -8,6 +8,13 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
|||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import DocumentForm from './DocumentForm.vue';
|
import DocumentForm from './DocumentForm.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
assistantId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -48,7 +55,11 @@ defineExpose({ dialogRef });
|
|||||||
:show-confirm-button="false"
|
:show-confirm-button="false"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
|
<DocumentForm
|
||||||
|
:assistant-id="assistantId"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
<template #footer />
|
<template #footer />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user