Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b63cfad315 | ||
|
|
9f511b577f | ||
|
|
808ecc00d2 | ||
|
|
287b072d4e | ||
|
|
218b672498 | ||
|
|
eeb17cd27c |
+135
-303
@@ -1,88 +1,97 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@6.1.0
|
||||
qlty-orb: qltysh/qlty-orb@0.0
|
||||
|
||||
# Shared defaults for setup steps
|
||||
# Ruby CircleCI 2.0 configuration file
|
||||
#
|
||||
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
|
||||
#
|
||||
version: 2
|
||||
defaults: &defaults
|
||||
working_directory: ~/build
|
||||
machine:
|
||||
image: ubuntu-2204:2024.05.1
|
||||
resource_class: large
|
||||
docker:
|
||||
# specify the version you desire here
|
||||
- image: cimg/ruby:3.2.2-browsers
|
||||
|
||||
# Specify service dependencies here if necessary
|
||||
# CircleCI maintains a library of pre-built images
|
||||
# documented at https://circleci.com/docs/2.0/circleci-images/
|
||||
- image: cimg/postgres:15.3
|
||||
- image: cimg/redis:6.2.6
|
||||
environment:
|
||||
RAILS_LOG_TO_STDOUT: false
|
||||
COVERAGE: true
|
||||
LOG_LEVEL: warn
|
||||
- RAILS_LOG_TO_STDOUT: false
|
||||
- COVERAGE: true
|
||||
- LOG_LEVEL: warn
|
||||
parallelism: 4
|
||||
resource_class: large
|
||||
|
||||
jobs:
|
||||
# Separate job for linting (no parallelism needed)
|
||||
lint:
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
# Install minimal system dependencies for linting
|
||||
- run:
|
||||
name: Install System Dependencies
|
||||
name: Configure Bundler
|
||||
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
|
||||
echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
|
||||
source $BASH_ENV
|
||||
gem install bundler
|
||||
|
||||
- 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
|
||||
name: Which bundler?
|
||||
command: bundle -v
|
||||
|
||||
- run:
|
||||
name: Install Application Dependencies
|
||||
name: Swap node versions
|
||||
command: |
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
set +e
|
||||
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
||||
nvm install v20
|
||||
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
|
||||
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
|
||||
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
# Run bundler
|
||||
# Load installed gems from cache if possible, bundle install then save cache
|
||||
# Multiple caches are used to increase the chance of a cache hit
|
||||
|
||||
# Swagger verification
|
||||
- restore_cache:
|
||||
keys:
|
||||
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
|
||||
|
||||
- run: bundle install --frozen --path ~/.bundle
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/.bundle
|
||||
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
|
||||
|
||||
# Only necessary if app uses webpacker or yarn in some other way
|
||||
- restore_cache:
|
||||
keys:
|
||||
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
|
||||
- chatwoot-yarn-
|
||||
|
||||
- run:
|
||||
name: yarn
|
||||
command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn
|
||||
|
||||
# Store yarn / webpacker cache
|
||||
- save_cache:
|
||||
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
|
||||
paths:
|
||||
- ~/.cache/yarn
|
||||
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
chmod +x ~/tmp/cc-test-reporter
|
||||
- persist_to_workspace:
|
||||
root: ~/tmp
|
||||
paths:
|
||||
- cc-test-reporter
|
||||
|
||||
# verify swagger specification
|
||||
- run:
|
||||
name: Verify swagger API specification
|
||||
command: |
|
||||
@@ -92,283 +101,106 @@ jobs:
|
||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# Bundle audit
|
||||
# Database setup
|
||||
- run: bundle exec rake db:create
|
||||
- run: bundle exec rake db:schema:load
|
||||
|
||||
- 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
|
||||
command: bundle exec rubocop
|
||||
|
||||
# - run:
|
||||
# name: Brakeman
|
||||
# command: bundle exec brakeman
|
||||
|
||||
# ESLint linting
|
||||
- run:
|
||||
name: eslint
|
||||
command: pnpm run eslint
|
||||
|
||||
# Separate job for frontend tests
|
||||
frontend-tests:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
command: yarn run eslint
|
||||
|
||||
# Run frontend tests
|
||||
- run:
|
||||
name: Run frontend tests (with coverage)
|
||||
command: pnpm run test:coverage
|
||||
|
||||
- run:
|
||||
name: Move coverage files if they exist
|
||||
name: Run frontend tests
|
||||
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: 18
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
|
||||
mkdir -p ~/tmp/test-results/frontend_specs
|
||||
~/tmp/cc-test-reporter before-build
|
||||
TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
|
||||
yarn test:coverage --profile 10 \
|
||||
--out ~/tmp/test-results/yarn.xml \
|
||||
-- ${TESTFILES}
|
||||
- run:
|
||||
name: Add PostgreSQL repository and update
|
||||
name: Code Climate Test Coverage
|
||||
command: |
|
||||
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
||||
sudo apt-get update -y
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
- run:
|
||||
name: Install System Dependencies
|
||||
command: |
|
||||
sudo apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
||||
libpq-dev \
|
||||
redis-server \
|
||||
postgresql-common \
|
||||
postgresql-16 \
|
||||
postgresql-16-pgvector \
|
||||
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
|
||||
|
||||
# Install and configure OpenSearch
|
||||
- run:
|
||||
name: Install OpenSearch
|
||||
command: |
|
||||
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
|
||||
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
|
||||
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
|
||||
sudo mv opensearch-2.11.0 /opt/opensearch
|
||||
|
||||
- run:
|
||||
name: Configure and Start OpenSearch
|
||||
command: |
|
||||
# Configure OpenSearch for single-node testing
|
||||
cat > /opt/opensearch/config/opensearch.yml \<< EOF
|
||||
cluster.name: chatwoot-test
|
||||
node.name: node-1
|
||||
network.host: 0.0.0.0
|
||||
http.port: 9200
|
||||
discovery.type: single-node
|
||||
plugins.security.disabled: true
|
||||
EOF
|
||||
|
||||
# Set ownership and permissions
|
||||
sudo chown -R $USER:$USER /opt/opensearch
|
||||
|
||||
# Start OpenSearch in background
|
||||
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
|
||||
|
||||
- run:
|
||||
name: Wait for OpenSearch to be ready
|
||||
command: |
|
||||
echo "Waiting for OpenSearch to start..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
|
||||
echo "OpenSearch is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
echo "OpenSearch failed to start"
|
||||
exit 1
|
||||
|
||||
# Configure environment and database
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
command: |
|
||||
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
|
||||
sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql
|
||||
chmod 644 ${PWD}/.circleci/setup_chatwoot.sql
|
||||
mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/
|
||||
sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql
|
||||
cp .env.example .env
|
||||
sed -i '/^FRONTEND_URL/d' .env
|
||||
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
|
||||
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
|
||||
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
|
||||
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
|
||||
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
||||
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
|
||||
|
||||
# Database setup
|
||||
- run:
|
||||
name: Run DB migrations
|
||||
command: bundle exec rails db:chatwoot_prepare
|
||||
|
||||
# Run backend tests (parallelized)
|
||||
# Run rails tests
|
||||
- run:
|
||||
name: Run backend tests
|
||||
command: |
|
||||
mkdir -p ~/tmp/test-results/rspec
|
||||
mkdir -p ~/tmp/test-artifacts
|
||||
mkdir -p ~/build/coverage/backend
|
||||
|
||||
# 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 \
|
||||
mkdir -p coverage
|
||||
~/tmp/cc-test-reporter before-build
|
||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||
bundle exec rspec --format progress \
|
||||
--format RspecJunitFormatter \
|
||||
--out ~/tmp/test-results/rspec.xml \
|
||||
-- $TESTS
|
||||
-- ${TESTFILES}
|
||||
no_output_timeout: 30m
|
||||
|
||||
# Store test results for better splitting in future runs
|
||||
- store_test_results:
|
||||
path: ~/tmp/test-results
|
||||
|
||||
- run:
|
||||
name: Move coverage files if they exist
|
||||
name: Code Climate Test Coverage
|
||||
command: |
|
||||
if [ -d "coverage" ]; then
|
||||
mkdir -p ~/build/coverage
|
||||
cp -r coverage ~/build/coverage/backend || true
|
||||
fi
|
||||
when: always
|
||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
- persist_to_workspace:
|
||||
root: ~/build
|
||||
root: coverage
|
||||
paths:
|
||||
- coverage
|
||||
- codeclimate.*.json
|
||||
# collect reports
|
||||
- store_test_results:
|
||||
path: ~/tmp/test-results
|
||||
- store_artifacts:
|
||||
path: ~/tmp/test-artifacts
|
||||
- store_artifacts:
|
||||
path: log
|
||||
|
||||
# Collect coverage from all jobs
|
||||
coverage:
|
||||
<<: *defaults
|
||||
upload-coverage:
|
||||
working_directory: ~/build
|
||||
docker:
|
||||
# specify the version you desire here
|
||||
- image: circleci/ruby:3.0.2-node-browsers
|
||||
environment:
|
||||
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: ~/build
|
||||
|
||||
# Qlty coverage publish
|
||||
- qlty-orb/coverage_publish:
|
||||
files: |
|
||||
coverage/frontend/lcov.info
|
||||
|
||||
- run:
|
||||
name: List coverage directory contents
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
ls -R ~/build/coverage || echo "No coverage directory"
|
||||
|
||||
- store_artifacts:
|
||||
path: coverage
|
||||
destination: coverage
|
||||
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
chmod +x ~/tmp/cc-test-reporter
|
||||
- persist_to_workspace:
|
||||
root: ~/tmp
|
||||
paths:
|
||||
- cc-test-reporter
|
||||
- run:
|
||||
name: Legacy build aggregator
|
||||
name: Upload coverage results to Code Climate
|
||||
command: |
|
||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build:
|
||||
|
||||
commit:
|
||||
jobs:
|
||||
- lint
|
||||
- frontend-tests
|
||||
- backend-tests
|
||||
- coverage:
|
||||
- build
|
||||
- upload-coverage:
|
||||
requires:
|
||||
- frontend-tests
|
||||
- backend-tests
|
||||
- build:
|
||||
requires:
|
||||
- lint
|
||||
- coverage
|
||||
- build
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE USER chatwoot CREATEDB;
|
||||
ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD';
|
||||
ALTER ROLE chatwoot SUPERUSER;
|
||||
|
||||
UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1';
|
||||
DROP DATABASE template1;
|
||||
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE';
|
||||
UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1';
|
||||
|
||||
\c template1;
|
||||
VACUUM FREEZE;
|
||||
@@ -0,0 +1,58 @@
|
||||
version: '2'
|
||||
plugins:
|
||||
rubocop:
|
||||
enabled: false
|
||||
channel: rubocop-0-73
|
||||
eslint:
|
||||
enabled: false
|
||||
csslint:
|
||||
enabled: true
|
||||
scss-lint:
|
||||
enabled: true
|
||||
brakeman:
|
||||
enabled: false
|
||||
checks:
|
||||
similar-code:
|
||||
enabled: false
|
||||
method-count:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 32
|
||||
file-lines:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 300
|
||||
method-lines:
|
||||
config:
|
||||
threshold: 50
|
||||
exclude_patterns:
|
||||
- 'spec/'
|
||||
- '**/specs/'
|
||||
- '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/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'
|
||||
@@ -4,15 +4,5 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||
|
||||
# Do the set up required for chatwoot app
|
||||
WORKDIR /workspace
|
||||
|
||||
# Copy dependency files first for better caching
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY Gemfile Gemfile.lock ./
|
||||
|
||||
# Install dependencies (will be cached if files don't change)
|
||||
RUN pnpm install --frozen-lockfile && \
|
||||
gem install bundler && \
|
||||
bundle install --jobs=$(nproc)
|
||||
|
||||
# Copy source code after dependencies are installed
|
||||
COPY . /workspace
|
||||
RUN yarn && gem install bundler && bundle install
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
ARG VARIANT="ubuntu-22.04"
|
||||
|
||||
ARG VARIANT
|
||||
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG NODE_VERSION
|
||||
ARG RUBY_VERSION
|
||||
ARG USER_UID
|
||||
ARG USER_GID
|
||||
ARG PNPM_VERSION="10.2.0"
|
||||
ENV PNPM_VERSION ${PNPM_VERSION}
|
||||
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
|
||||
|
||||
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
||||
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
||||
@@ -19,80 +15,61 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
||||
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
||||
fi
|
||||
|
||||
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
&& apt-get update \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
build-essential \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
gnupg \
|
||||
tar \
|
||||
tzdata \
|
||||
postgresql-client \
|
||||
libpq-dev \
|
||||
git \
|
||||
imagemagick \
|
||||
libyaml-dev \
|
||||
curl \
|
||||
ca-certificates \
|
||||
tmux \
|
||||
nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
build-essential \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
gnupg2 \
|
||||
tar \
|
||||
tzdata \
|
||||
postgresql-client \
|
||||
libpq-dev \
|
||||
yarn \
|
||||
git \
|
||||
imagemagick \
|
||||
tmux \
|
||||
zsh \
|
||||
git-flow \
|
||||
npm \
|
||||
libyaml-dev
|
||||
|
||||
# Install rbenv and ruby for root user first
|
||||
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
|
||||
# Install rbenv and ruby
|
||||
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
||||
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
|
||||
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
|
||||
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
|
||||
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \
|
||||
RUN git clone https://github.com/rbenv/ruby-build.git && \
|
||||
PREFIX=/usr/local ./ruby-build/install.sh
|
||||
|
||||
RUN rbenv install $RUBY_VERSION && \
|
||||
rbenv global $RUBY_VERSION && \
|
||||
rbenv versions
|
||||
|
||||
# Set up rbenv for vscode user
|
||||
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
|
||||
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
|
||||
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
|
||||
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
|
||||
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
|
||||
|
||||
# Install overmind and gh in single layer
|
||||
# Install overmind
|
||||
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
||||
&& gunzip overmind.gz \
|
||||
&& mv overmind /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/overmind \
|
||||
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gh \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
&& sudo mv overmind /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/overmind
|
||||
|
||||
|
||||
# Install gh
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||
&& sudo apt update \
|
||||
&& sudo apt install gh
|
||||
|
||||
|
||||
# Do the set up required for chatwoot app
|
||||
WORKDIR /workspace
|
||||
RUN chown vscode:vscode /workspace
|
||||
COPY . /workspace
|
||||
|
||||
# set up node js, pnpm and claude code in single layer
|
||||
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
|
||||
&& npm cache clean --force
|
||||
# set up ruby
|
||||
COPY Gemfile Gemfile.lock ./
|
||||
RUN gem install bundler && bundle install
|
||||
|
||||
# Switch to vscode user
|
||||
USER vscode
|
||||
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
|
||||
|
||||
# Copy dependency files first for better caching
|
||||
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies as vscode user
|
||||
RUN eval "$(rbenv init -)" \
|
||||
&& gem install bundler -N \
|
||||
&& bundle install --jobs=$(nproc) \
|
||||
&& pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code after dependencies are installed
|
||||
COPY --chown=vscode:vscode . /workspace
|
||||
# set up node js
|
||||
RUN npm install n -g && \
|
||||
n $NODE_VERSION
|
||||
RUN npm install --global yarn
|
||||
RUN yarn
|
||||
|
||||
@@ -4,26 +4,17 @@
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/zsh",
|
||||
"extensions.showRecommendationsOnlyOnDemand": true,
|
||||
"editor.formatOnSave": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/tmp": true,
|
||||
"**/log": true,
|
||||
"**/coverage": true,
|
||||
"**/public/packs": true
|
||||
}
|
||||
"terminal.integrated.shell.linux": "/bin/zsh"
|
||||
},
|
||||
|
||||
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"Shopify.ruby-lsp",
|
||||
"rebornix.Ruby",
|
||||
"misogi.ruby-rubocop",
|
||||
"wingrunr21.vscode-ruby",
|
||||
"davidpallinder.rails-test-runner",
|
||||
"eamodio.gitlens",
|
||||
"github.copilot",
|
||||
"mrmlnc.vscode-duplicate"
|
||||
],
|
||||
@@ -32,15 +23,15 @@
|
||||
// 5432 postgres
|
||||
// 6379 redis
|
||||
// 1025,8025 mailhog
|
||||
"forwardPorts": [8025, 3000, 3036],
|
||||
"forwardPorts": [8025, 3000, 3035],
|
||||
|
||||
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
|
||||
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Rails Server"
|
||||
},
|
||||
"3036": {
|
||||
"label": "Vite Dev Server"
|
||||
"3035": {
|
||||
"label": "Webpack Dev Server"
|
||||
},
|
||||
"8025": {
|
||||
"label": "Mailhog UI"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Docker Compose file for building the base image in GitHub Actions
|
||||
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
|
||||
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
base:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile.base
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '24.13.0'
|
||||
RUBY_VERSION: '3.4.4'
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: '1000'
|
||||
USER_GID: '1000'
|
||||
image: ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||
@@ -5,17 +5,30 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
base:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile.base
|
||||
args:
|
||||
VARIANT: "ubuntu-22.04"
|
||||
NODE_VERSION: "20.9.0"
|
||||
RUBY_VERSION: "3.2.2"
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
image: base:latest
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '24.13.0'
|
||||
RUBY_VERSION: '3.4.4'
|
||||
VARIANT: "ubuntu-22.04"
|
||||
NODE_VERSION: "20.9.0"
|
||||
RUBY_VERSION: "3.2.2"
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: '1000'
|
||||
USER_GID: '1000'
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
@@ -27,7 +40,7 @@ services:
|
||||
network_mode: service:db
|
||||
|
||||
db:
|
||||
image: pgvector/pgvector:pg16
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
@@ -2,15 +2,12 @@ cp .env.example .env
|
||||
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
|
||||
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
|
||||
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
|
||||
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
|
||||
|
||||
# Setup Claude Code API key if available
|
||||
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
|
||||
mkdir -p ~/.claude
|
||||
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
|
||||
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
|
||||
chmod +x ~/.claude/anthropic_key.sh
|
||||
fi
|
||||
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
|
||||
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
|
||||
# uncomment the webpacker env variable
|
||||
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
|
||||
# fix the error with webpacker
|
||||
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
|
||||
|
||||
# codespaces make the ports public
|
||||
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
|
||||
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
|
||||
|
||||
+6
-27
@@ -1,18 +1,6 @@
|
||||
# Learn about the various environment variables at
|
||||
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
|
||||
|
||||
# Used to verify the integrity of signed cookies. so ensure a secure value is set
|
||||
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
|
||||
# Use `rake secret` to generate this variable
|
||||
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
|
||||
|
||||
# Active Record Encryption keys (required for MFA/2FA functionality)
|
||||
# Generate these keys by running: rails db:encryption:init
|
||||
# IMPORTANT: Use different keys for each environment (development, staging, production)
|
||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
|
||||
|
||||
# Replace with the URL you are planning to use for your app
|
||||
FRONTEND_URL=http://0.0.0.0:3000
|
||||
# To use a dedicated URL for help center pages
|
||||
@@ -92,8 +80,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
# Comment out the following environment variables if required by your SMTP server
|
||||
# SMTP_TLS=
|
||||
# SMTP_SSL=
|
||||
# SMTP_OPEN_TIMEOUT
|
||||
# SMTP_READ_TIMEOUT
|
||||
|
||||
# Mail Incoming
|
||||
# This is the domain set for the reply emails when conversation continuity is enabled
|
||||
@@ -105,7 +91,6 @@ MAILER_INBOUND_EMAIL_DOMAIN=
|
||||
# mandrill for Mandrill
|
||||
# postmark for Postmark
|
||||
# sendgrid for Sendgrid
|
||||
# ses for Amazon SES
|
||||
RAILS_INBOUND_EMAIL_SERVICE=
|
||||
# Use one of the following based on the email ingress service
|
||||
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
||||
@@ -115,10 +100,6 @@ RAILS_INBOUND_EMAIL_PASSWORD=
|
||||
MAILGUN_INGRESS_SIGNING_KEY=
|
||||
MANDRILL_INGRESS_API_KEY=
|
||||
|
||||
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
|
||||
# Configure only if the rails_inbound_email_service = ses
|
||||
ACTION_MAILBOX_SES_SNS_TOPIC=
|
||||
|
||||
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
||||
# Inbound webhook URL format:
|
||||
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
||||
@@ -203,6 +184,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# SENTRY_DSN=
|
||||
|
||||
|
||||
# MICROSOFT CLARITY
|
||||
# MS_CLARITY_TOKEN=xxxxxxxxx
|
||||
|
||||
# GOOGLE_TAG_MANAGER
|
||||
# GOOGLE_TAG = GTM-XXXXXXX
|
||||
|
||||
## Scout
|
||||
## https://scoutapm.com/docs/ruby/configuration
|
||||
# SCOUT_KEY=YOURKEY
|
||||
@@ -220,7 +207,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||
# DD_TRACE_AGENT_URL=
|
||||
|
||||
|
||||
# MaxMindDB API key to download GeoLite2 City database
|
||||
# IP_LOOKUP_API_KEY=
|
||||
|
||||
@@ -229,8 +215,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
# RACK_ATTACK_LIMIT=300
|
||||
# ENABLE_RACK_ATTACK_WIDGET_API=true
|
||||
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
|
||||
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
@@ -262,8 +246,6 @@ AZURE_APP_SECRET=
|
||||
## Change these values to fine tune performance
|
||||
# control the concurrency setting of sidekiq
|
||||
# SIDEKIQ_CONCURRENCY=10
|
||||
# Enable verbose logging each time a job is dequeued in Sidekiq
|
||||
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||
|
||||
|
||||
# AI powered features
|
||||
@@ -274,6 +256,3 @@ AZURE_APP_SECRET=
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
# REDIS_VELMA_SIZE=10
|
||||
|
||||
+11
-193
@@ -2,38 +2,16 @@ module.exports = {
|
||||
extends: [
|
||||
'airbnb-base/legacy',
|
||||
'prettier',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vitest-globals/recommended',
|
||||
// use recommended-legacy when upgrading the plugin to v4
|
||||
'plugin:@intlify/vue-i18n/recommended',
|
||||
'plugin:vue/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
'plugin:cypress/recommended',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.spec.{j,t}s?(x)'],
|
||||
env: {
|
||||
'vitest-globals/env': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.story.vue'],
|
||||
rules: {
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: ['Variant', 'Story'],
|
||||
},
|
||||
],
|
||||
// Story files can have static strings, it doesn't need to handle i18n always.
|
||||
'vue/no-bare-strings-in-template': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
parser: '@babel/eslint-parser',
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['html', 'prettier', 'babel'],
|
||||
rules: {
|
||||
'prettier/prettier': ['error'],
|
||||
camelcase: 'off',
|
||||
@@ -49,161 +27,6 @@ module.exports = {
|
||||
'import/no-unresolved': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/next-tick-style': ['error', 'callback'],
|
||||
'vue/block-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['script', 'template', 'style'],
|
||||
},
|
||||
],
|
||||
'vue/component-name-in-template-casing': [
|
||||
'error',
|
||||
'PascalCase',
|
||||
{
|
||||
registeredComponentsOnly: true,
|
||||
},
|
||||
],
|
||||
'vue/component-options-name-casing': ['error', 'PascalCase'],
|
||||
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||
'vue/define-emits-declaration': ['error'],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['defineProps', 'defineEmits'],
|
||||
defineExposeLast: false,
|
||||
},
|
||||
],
|
||||
'vue/define-props-declaration': ['error', 'runtime'],
|
||||
'vue/match-component-import-name': ['error'],
|
||||
'vue/no-bare-strings-in-template': [
|
||||
'error',
|
||||
{
|
||||
allowlist: [
|
||||
'(',
|
||||
')',
|
||||
',',
|
||||
'.',
|
||||
'&',
|
||||
'+',
|
||||
'-',
|
||||
'=',
|
||||
'*',
|
||||
'/',
|
||||
'#',
|
||||
'%',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
'<',
|
||||
'>',
|
||||
'⌘',
|
||||
'📄',
|
||||
'🎉',
|
||||
'🚀',
|
||||
'💬',
|
||||
'👥',
|
||||
'📥',
|
||||
'🔖',
|
||||
'❌',
|
||||
'✅',
|
||||
'\u00b7',
|
||||
'\u2022',
|
||||
'\u2010',
|
||||
'\u2013',
|
||||
'\u2014',
|
||||
'\u2212',
|
||||
'|',
|
||||
],
|
||||
attributes: {
|
||||
'/.+/': [
|
||||
'title',
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-roledescription',
|
||||
'aria-valuetext',
|
||||
],
|
||||
input: ['placeholder'],
|
||||
},
|
||||
directives: ['v-text'],
|
||||
},
|
||||
],
|
||||
'vue/no-empty-component-block': 'error',
|
||||
'vue/no-multiple-objects-in-class': 'error',
|
||||
'vue/no-root-v-if': 'warn',
|
||||
'vue/no-static-inline-styles': [
|
||||
'error',
|
||||
{
|
||||
allowBinding: false,
|
||||
},
|
||||
],
|
||||
'vue/no-template-target-blank': [
|
||||
'error',
|
||||
{
|
||||
allowReferrer: false,
|
||||
enforceDynamicLinks: 'always',
|
||||
},
|
||||
],
|
||||
'vue/no-required-prop-with-default': [
|
||||
'error',
|
||||
{
|
||||
autofix: false,
|
||||
},
|
||||
],
|
||||
'vue/no-this-in-before-route-enter': 'error',
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: [
|
||||
'^woot-',
|
||||
'^fluent-',
|
||||
'^multiselect',
|
||||
'^router-link',
|
||||
'^router-view',
|
||||
'^ninja-keys',
|
||||
'^FormulateForm',
|
||||
'^FormulateInput',
|
||||
'^highlightjs',
|
||||
],
|
||||
},
|
||||
],
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/prefer-true-attribute-shorthand': 'error',
|
||||
'vue/no-useless-v-bind': [
|
||||
'error',
|
||||
{
|
||||
ignoreIncludesComment: false,
|
||||
ignoreStringEscape: false,
|
||||
},
|
||||
],
|
||||
'vue/no-v-text': 'error',
|
||||
'vue/padding-line-between-blocks': ['error', 'always'],
|
||||
'vue/prefer-separate-static-class': 'error',
|
||||
'vue/require-explicit-slots': 'error',
|
||||
'vue/require-macro-variable-name': [
|
||||
'error',
|
||||
{
|
||||
defineProps: 'props',
|
||||
defineEmits: 'emit',
|
||||
defineSlots: 'slots',
|
||||
useSlots: 'slots',
|
||||
useAttrs: 'attrs',
|
||||
},
|
||||
],
|
||||
'vue/no-unused-properties': [
|
||||
'error',
|
||||
{
|
||||
groups: ['props'],
|
||||
deepData: false,
|
||||
ignorePublicMembers: false,
|
||||
unreferencedOptions: [],
|
||||
},
|
||||
],
|
||||
'vue/max-attributes-per-line': [
|
||||
'error',
|
||||
{
|
||||
@@ -232,25 +55,20 @@ module.exports = {
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error',
|
||||
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
|
||||
'@intlify/vue-i18n/no-unused-keys': [
|
||||
'warn',
|
||||
{
|
||||
extensions: ['.js', '.vue'],
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: './app/javascript/*/i18n/**.json',
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: 'config/webpack/resolve.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
jest: true,
|
||||
node: true,
|
||||
},
|
||||
globals: {
|
||||
bus: true,
|
||||
vi: true,
|
||||
},
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 966 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 934 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB |
@@ -1,28 +0,0 @@
|
||||
name: Auto-assign PR to Author
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Auto-assign PR to author
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
await github.rest.issues.addAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
assignees: [author]
|
||||
});
|
||||
|
||||
console.log(`Assigned PR #${pull_number} to ${author}`);
|
||||
@@ -6,11 +6,6 @@ name: Deploy Check
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Frontend Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run eslint
|
||||
run: pnpm run eslint
|
||||
|
||||
- name: Run frontend tests with coverage
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
pnpm run test:coverage
|
||||
@@ -25,5 +25,13 @@ jobs:
|
||||
with:
|
||||
issue-inactive-days: '30'
|
||||
issue-lock-reason: 'resolved'
|
||||
issue-comment: >
|
||||
This issue has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
pr-inactive-days: '30'
|
||||
pr-lock-reason: 'resolved'
|
||||
pr-comment: >
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
|
||||
@@ -5,11 +5,6 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# #
|
||||
# # Linux nightly installer action
|
||||
# # This action will try to install and setup
|
||||
# # chatwoot on an Ubuntu 22.04 machine using
|
||||
# # chatwoot on an Ubuntu 20.04 machine using
|
||||
# # the linux installer script.
|
||||
# #
|
||||
# # This is set to run daily at midnight.
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
|
||||
- name: get installer
|
||||
|
||||
@@ -19,5 +19,6 @@ jobs:
|
||||
|
||||
- name: Build the Codespace Base Image
|
||||
run: |
|
||||
docker compose -f .devcontainer/docker-compose.base.yml build base
|
||||
docker-compose -f .devcontainer/docker-compose.yml build base
|
||||
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# #
|
||||
# # This action will publish Chatwoot EE docker image.
|
||||
# # This is set to run against merges to develop, master
|
||||
# # and when tags are created.
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot EE docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Chatwoot edition
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t $TAG \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect $TAG
|
||||
@@ -5,7 +5,6 @@
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot CE docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -15,30 +14,20 @@ on:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
@@ -49,97 +38,26 @@ jobs:
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
|
||||
|
||||
- name: Set Docker Tags
|
||||
- name: set docker tag
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: replace docker tag if master
|
||||
if: github.ref_name == 'master'
|
||||
run: |
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t $TAG \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect $TAG
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAG }}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# #
|
||||
# # This action will strip the enterprise folder
|
||||
# # and run the spec.
|
||||
# # This is set to run against every PR.
|
||||
# #
|
||||
|
||||
name: Run Chatwoot CE spec
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -10,65 +14,20 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Separate linting jobs for faster feedback
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
- name: Run Rubocop
|
||||
run: bundle exec rubocop --parallel
|
||||
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
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: 24
|
||||
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]
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
image: postgres:15.3
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_PASSWORD: ""
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
# tmpfs makes DB faster by using RAM
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
@@ -76,71 +35,53 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:alpine
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run backend tests (parallelized)
|
||||
run: |
|
||||
# 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=""
|
||||
- name: yarn check-files
|
||||
run: yarn install --check-files
|
||||
|
||||
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
|
||||
# Run rails tests
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
bundle exec rspec --profile=10 --format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
if [ -n "$TESTS" ]; then
|
||||
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
||||
fi
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
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
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: rails-log-folder
|
||||
path: log
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
name: Run MFA Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
# Only run if MFA test keys are available
|
||||
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
POSTGRES_HOST: localhost
|
||||
# Active Record encryption keys required for MFA - test keys only, not for production use
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Install pgvector extension
|
||||
run: |
|
||||
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run MFA-related backend tests
|
||||
run: |
|
||||
bundle exec rspec \
|
||||
spec/services/mfa/token_service_spec.rb \
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Run MFA-related tests in user_spec
|
||||
run: |
|
||||
# Run specific MFA-related tests from user_spec
|
||||
bundle exec rspec spec/models/user_spec.rb \
|
||||
-e "two factor" \
|
||||
-e "2FA" \
|
||||
-e "MFA" \
|
||||
-e "otp" \
|
||||
-e "backup code" \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test logs
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: mfa-test-logs
|
||||
path: |
|
||||
log/test.log
|
||||
tmp/screenshots/
|
||||
@@ -0,0 +1,85 @@
|
||||
# #
|
||||
# # This workflow will run specs related to response bot
|
||||
# # This can only be activated in installations Where vector extension is available.
|
||||
# #
|
||||
|
||||
name: Run Response Bot spec
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: ankane/pgvector
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ""
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
# tmpfs makes DB faster by using RAM
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Enable ResponseBotService in installation
|
||||
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
|
||||
|
||||
# Run Response Bot specs
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
bundle exec rspec \
|
||||
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
|
||||
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
|
||||
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
|
||||
spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: rails-log-folder
|
||||
path: log
|
||||
@@ -5,14 +5,9 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -24,29 +19,23 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
|
||||
- name: pnpm
|
||||
run: pnpm install
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: setup env
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Run asset compile
|
||||
run: bundle exec rake assets:precompile
|
||||
env:
|
||||
RAILS_ENV: production
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Size Check
|
||||
run: pnpm run size
|
||||
run: yarn run size
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Test Docker Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
load: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
+3
-28
@@ -32,16 +32,6 @@ master.key
|
||||
|
||||
public/uploads
|
||||
public/packs*
|
||||
public/assets/administrate*
|
||||
public/assets/action*.js
|
||||
public/assets/activestorage*.js
|
||||
public/assets/trix*
|
||||
public/assets/belongs_to*.js
|
||||
public/assets/manifest*.js
|
||||
public/assets/manifest*.js
|
||||
public/assets/*.js.gz
|
||||
public/assets/secretField*
|
||||
public/assets/.sprockets-manifest-*.json
|
||||
|
||||
# VIM files
|
||||
*.swp
|
||||
@@ -71,6 +61,8 @@ test/cypress/videos/*
|
||||
/config/master.key
|
||||
/config/*.enc
|
||||
|
||||
#ignore files under .vscode directory
|
||||
.vscode
|
||||
|
||||
# yalc for local testing
|
||||
.yalc
|
||||
@@ -83,21 +75,4 @@ yalc.lock
|
||||
yarn-debug.log*
|
||||
.yarn-integrity
|
||||
|
||||
# Vite Ruby
|
||||
/public/vite*
|
||||
# Vite uses dotenv and suggests to ignore local-only env files. See
|
||||
# https://vitejs.dev/guide/env-and-mode.html#env-files
|
||||
*.local
|
||||
|
||||
|
||||
# TextEditors & AI Agents config files
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
/storybook-static
|
||||
+3
-3
@@ -4,8 +4,8 @@
|
||||
# lint js and vue files
|
||||
npx --no-install lint-staged
|
||||
|
||||
# lint only staged ruby files that still exist (not deleted)
|
||||
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true
|
||||
# lint only staged ruby files
|
||||
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
|
||||
|
||||
# stage rubocop changes to files
|
||||
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true
|
||||
git diff --name-only --cached | xargs git add
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
*
|
||||
!configs
|
||||
!configs/**
|
||||
!hooks
|
||||
!hooks/**
|
||||
!qlty.toml
|
||||
!.gitignore
|
||||
@@ -1,2 +0,0 @@
|
||||
ignored:
|
||||
- DL3008
|
||||
@@ -1 +0,0 @@
|
||||
source-path=SCRIPTDIR
|
||||
@@ -1,8 +0,0 @@
|
||||
rules:
|
||||
document-start: disable
|
||||
quoted-strings:
|
||||
required: only-when-needed
|
||||
extra-allowed: ["{|}"]
|
||||
key-duplicates: {}
|
||||
octal-values:
|
||||
forbid-implicit-octal: true
|
||||
@@ -1,84 +0,0 @@
|
||||
# This file was automatically generated by `qlty init`.
|
||||
# You can modify it to suit your needs.
|
||||
# We recommend you to commit this file to your repository.
|
||||
#
|
||||
# This configuration is used by both Qlty CLI and Qlty Cloud.
|
||||
#
|
||||
# Qlty CLI -- Code quality toolkit for developers
|
||||
# Qlty Cloud -- Fully automated Code Health Platform
|
||||
#
|
||||
# Try Qlty Cloud: https://qlty.sh
|
||||
#
|
||||
# For a guide to configuration, visit https://qlty.sh/d/config
|
||||
# Or for a full reference, visit https://qlty.sh/d/qlty-toml
|
||||
config_version = "0"
|
||||
|
||||
exclude_patterns = [
|
||||
"*_min.*",
|
||||
"*-min.*",
|
||||
"*.min.*",
|
||||
"**/.yarn/**",
|
||||
"**/*.d.ts",
|
||||
"**/assets/**",
|
||||
"**/bower_components/**",
|
||||
"**/build/**",
|
||||
"**/cache/**",
|
||||
"**/config/**",
|
||||
"**/db/**",
|
||||
"**/deps/**",
|
||||
"**/dist/**",
|
||||
"**/extern/**",
|
||||
"**/external/**",
|
||||
"**/generated/**",
|
||||
"**/Godeps/**",
|
||||
"**/gradlew/**",
|
||||
"**/mvnw/**",
|
||||
"**/node_modules/**",
|
||||
"**/protos/**",
|
||||
"**/seed/**",
|
||||
"**/target/**",
|
||||
"**/templates/**",
|
||||
"**/testdata/**",
|
||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||
]
|
||||
|
||||
test_patterns = [
|
||||
"**/test/**",
|
||||
"**/spec/**",
|
||||
"**/*.test.*",
|
||||
"**/*.spec.*",
|
||||
"**/*_test.*",
|
||||
"**/*_spec.*",
|
||||
"**/test_*.*",
|
||||
"**/spec_*.*",
|
||||
]
|
||||
|
||||
[smells]
|
||||
mode = "comment"
|
||||
|
||||
[smells.boolean_logic]
|
||||
threshold = 4
|
||||
|
||||
[smells.file_complexity]
|
||||
threshold = 66
|
||||
enabled = true
|
||||
|
||||
[smells.return_statements]
|
||||
threshold = 4
|
||||
|
||||
[smells.nested_control_flow]
|
||||
threshold = 4
|
||||
|
||||
[smells.function_parameters]
|
||||
threshold = 4
|
||||
|
||||
[smells.function_complexity]
|
||||
threshold = 5
|
||||
|
||||
[smells.duplication]
|
||||
enabled = true
|
||||
threshold = 20
|
||||
|
||||
[[source]]
|
||||
name = "default"
|
||||
default = true
|
||||
+9
-191
@@ -1,14 +1,9 @@
|
||||
plugins:
|
||||
require:
|
||||
- rubocop-performance
|
||||
- rubocop-rails
|
||||
- rubocop-rspec
|
||||
- rubocop-factory_bot
|
||||
|
||||
require:
|
||||
- ./rubocop/use_from_email.rb
|
||||
- ./rubocop/custom_cop_location.rb
|
||||
- ./rubocop/attachment_download.rb
|
||||
- ./rubocop/one_class_per_file.rb
|
||||
|
||||
Layout/LineLength:
|
||||
Max: 150
|
||||
@@ -18,67 +13,44 @@ Metrics/ClassLength:
|
||||
Exclude:
|
||||
- 'app/models/message.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 19
|
||||
Exclude:
|
||||
- 'enterprise/lib/captain/agent.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 50
|
||||
|
||||
Max: 25
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
||||
Style/ExponentialNotation:
|
||||
Enabled: false
|
||||
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: false
|
||||
|
||||
Style/SymbolArray:
|
||||
Enabled: false
|
||||
|
||||
Style/OpenStructUse:
|
||||
Enabled: false
|
||||
|
||||
Chatwoot/AttachmentDownload:
|
||||
Enabled: true
|
||||
Exclude:
|
||||
- 'spec/**/*'
|
||||
- 'test/**/*'
|
||||
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/services/email_templates/db_resolver_service.rb'
|
||||
- 'app/dispatchers/dispatcher.rb'
|
||||
|
||||
Style/GlobalVars:
|
||||
Exclude:
|
||||
- 'config/initializers/01_redis.rb'
|
||||
- 'config/initializers/rack_attack.rb'
|
||||
- 'lib/redis/alfred.rb'
|
||||
- 'lib/global_config.rb'
|
||||
|
||||
Style/ClassVars:
|
||||
Exclude:
|
||||
- 'app/services/email_templates/db_resolver_service.rb'
|
||||
|
||||
Lint/MissingSuper:
|
||||
Exclude:
|
||||
- 'app/drops/base_drop.rb'
|
||||
|
||||
Lint/SymbolConversion:
|
||||
Enabled: false
|
||||
|
||||
Lint/EmptyBlock:
|
||||
Exclude:
|
||||
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
|
||||
|
||||
Lint/OrAssignmentToConstant:
|
||||
Exclude:
|
||||
- 'lib/redis/config.rb'
|
||||
|
||||
Metrics/BlockLength:
|
||||
Max: 30
|
||||
Exclude:
|
||||
@@ -86,16 +58,10 @@ Metrics/BlockLength:
|
||||
- '**/routes.rb'
|
||||
- 'config/environments/*'
|
||||
- db/schema.rb
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- lib/seeders/message_seeder.rb
|
||||
- spec/support/slack_stubs.rb
|
||||
|
||||
Rails/HelperInstanceVariable:
|
||||
Exclude:
|
||||
- enterprise/app/helpers/captain/chat_helper.rb
|
||||
- enterprise/app/helpers/captain/chat_response_helper.rb
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
@@ -105,101 +71,74 @@ Rails/ApplicationController:
|
||||
- 'app/controllers/platform_controller.rb'
|
||||
- 'app/controllers/public_controller.rb'
|
||||
- 'app/controllers/survey/responses_controller.rb'
|
||||
|
||||
Rails/FindEach:
|
||||
Enabled: true
|
||||
Include:
|
||||
- 'app/**/*.rb'
|
||||
|
||||
Rails/CompactBlank:
|
||||
Enabled: false
|
||||
|
||||
Rails/EnvironmentVariableAccess:
|
||||
Enabled: false
|
||||
|
||||
Rails/TimeZoneAssignment:
|
||||
Enabled: false
|
||||
|
||||
Rails/RedundantPresenceValidationOnBelongsTo:
|
||||
Enabled: false
|
||||
|
||||
Rails/InverseOf:
|
||||
Exclude:
|
||||
- enterprise/app/models/captain/assistant.rb
|
||||
|
||||
Rails/UniqueValidationWithoutIndex:
|
||||
Exclude:
|
||||
- app/models/canned_response.rb
|
||||
- app/models/telegram_bot.rb
|
||||
- enterprise/app/models/captain_inbox.rb
|
||||
- 'app/models/channel/twitter_profile.rb'
|
||||
- 'app/models/webhook.rb'
|
||||
- 'app/models/contact.rb'
|
||||
|
||||
Style/ClassAndModuleChildren:
|
||||
EnforcedStyle: compact
|
||||
Exclude:
|
||||
- 'config/application.rb'
|
||||
- 'config/initializers/monkey_patches/*'
|
||||
|
||||
Style/MapToHash:
|
||||
Enabled: false
|
||||
|
||||
Style/HashSyntax:
|
||||
Enabled: true
|
||||
EnforcedStyle: no_mixed_keys
|
||||
EnforcedShorthandSyntax: never
|
||||
|
||||
RSpec/NestedGroups:
|
||||
Enabled: true
|
||||
Max: 4
|
||||
|
||||
RSpec/MessageSpies:
|
||||
Enabled: false
|
||||
|
||||
RSpec/StubbedMock:
|
||||
Enabled: false
|
||||
|
||||
RSpec/FactoryBot/SyntaxMethods:
|
||||
Enabled: false
|
||||
Naming/VariableNumber:
|
||||
Enabled: false
|
||||
|
||||
Naming/MemoizedInstanceVariableName:
|
||||
Exclude:
|
||||
- 'app/models/message.rb'
|
||||
|
||||
Style/GuardClause:
|
||||
Exclude:
|
||||
- 'app/builders/account_builder.rb'
|
||||
- 'app/models/attachment.rb'
|
||||
- 'app/models/message.rb'
|
||||
|
||||
Metrics/AbcSize:
|
||||
Max: 26
|
||||
Exclude:
|
||||
- 'app/controllers/concerns/auth_helper.rb'
|
||||
|
||||
Rails/UniqueValidationWithoutIndex:
|
||||
Exclude:
|
||||
- 'app/models/channel/twitter_profile.rb'
|
||||
- 'app/models/webhook.rb'
|
||||
- 'app/models/contact.rb'
|
||||
- 'app/models/integrations/hook.rb'
|
||||
- 'app/models/canned_response.rb'
|
||||
- 'app/models/telegram_bot.rb'
|
||||
|
||||
Rails/RenderInline:
|
||||
Exclude:
|
||||
- 'app/controllers/swagger_controller.rb'
|
||||
|
||||
Rails/ThreeStateBooleanColumn:
|
||||
Exclude:
|
||||
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
||||
|
||||
RSpec/IndexedLet:
|
||||
Enabled: false
|
||||
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
|
||||
# we should bring this down
|
||||
RSpec/MultipleExpectations:
|
||||
Max: 7
|
||||
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 14
|
||||
|
||||
@@ -213,9 +152,6 @@ UseFromEmail:
|
||||
CustomCopLocation:
|
||||
Enabled: true
|
||||
|
||||
Style/OneClassPerFile:
|
||||
Enabled: true
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
Exclude:
|
||||
@@ -230,121 +166,3 @@ AllCops:
|
||||
- 'tmp/**/*'
|
||||
- 'storage/**/*'
|
||||
- 'db/migrate/20230426130150_init_schema.rb'
|
||||
|
||||
FactoryBot/SyntaxMethods:
|
||||
Enabled: false
|
||||
|
||||
# Disable new rules causing errors
|
||||
Layout/LeadingCommentSpace:
|
||||
Enabled: false
|
||||
|
||||
Style/ReturnNilInPredicateMethodDefinition:
|
||||
Enabled: false
|
||||
|
||||
Style/RedundantParentheses:
|
||||
Enabled: false
|
||||
|
||||
Performance/StringIdentifierArgument:
|
||||
Enabled: false
|
||||
|
||||
Layout/EmptyLinesAroundExceptionHandlingKeywords:
|
||||
Enabled: false
|
||||
|
||||
Lint/LiteralAsCondition:
|
||||
Enabled: false
|
||||
|
||||
Style/RedundantReturn:
|
||||
Enabled: false
|
||||
|
||||
Layout/SpaceAroundOperators:
|
||||
Enabled: false
|
||||
|
||||
Rails/EnvLocal:
|
||||
Enabled: false
|
||||
|
||||
Rails/WhereRange:
|
||||
Enabled: false
|
||||
|
||||
Lint/UselessConstantScoping:
|
||||
Enabled: false
|
||||
|
||||
Style/MultipleComparison:
|
||||
Enabled: false
|
||||
|
||||
Bundler/OrderedGems:
|
||||
Enabled: false
|
||||
|
||||
RSpec/ExampleWording:
|
||||
Enabled: false
|
||||
|
||||
RSpec/ReceiveMessages:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/AssociationStyle:
|
||||
Enabled: false
|
||||
|
||||
Rails/EnumSyntax:
|
||||
Enabled: false
|
||||
|
||||
Lint/RedundantTypeConversion:
|
||||
Enabled: false
|
||||
|
||||
# Additional rules to disable
|
||||
Rails/RedundantActiveRecordAllMethod:
|
||||
Enabled: false
|
||||
|
||||
Layout/TrailingEmptyLines:
|
||||
Enabled: true
|
||||
|
||||
Style/SafeNavigationChainLength:
|
||||
Enabled: false
|
||||
|
||||
Lint/SafeNavigationConsistency:
|
||||
Enabled: false
|
||||
|
||||
Lint/CopDirectiveSyntax:
|
||||
Enabled: false
|
||||
|
||||
# Final set of rules to disable
|
||||
FactoryBot/ExcessiveCreateList:
|
||||
Enabled: false
|
||||
|
||||
RSpec/MissingExpectationTargetMethod:
|
||||
Enabled: false
|
||||
|
||||
Performance/InefficientHashSearch:
|
||||
Enabled: false
|
||||
|
||||
Style/RedundantSelfAssignmentBranch:
|
||||
Enabled: false
|
||||
|
||||
Style/YAMLFileRead:
|
||||
Enabled: false
|
||||
|
||||
Layout/ExtraSpacing:
|
||||
Enabled: false
|
||||
|
||||
Style/RedundantFilterChain:
|
||||
Enabled: false
|
||||
|
||||
Performance/MapMethodChain:
|
||||
Enabled: false
|
||||
|
||||
Rails/RootPathnameMethods:
|
||||
Enabled: false
|
||||
|
||||
Style/SuperArguments:
|
||||
Enabled: false
|
||||
|
||||
# Final remaining rules to disable
|
||||
Rails/Delegate:
|
||||
Enabled: false
|
||||
|
||||
Style/CaseLikeIf:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/RedundantFactoryOption:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.4
|
||||
3.2.2
|
||||
|
||||
@@ -283,4 +283,3 @@ exclude:
|
||||
- 'app/javascript/widget/assets/scss/sdk.css'
|
||||
- 'app/assets/stylesheets/administrate/reset/_normalize.scss'
|
||||
- 'app/javascript/shared/assets/stylesheets/*.scss'
|
||||
- 'app/javascript/dashboard/assets/scss/_woot.scss'
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const path = require('path');
|
||||
const resolve = require('../config/webpack/resolve');
|
||||
|
||||
// Chatwoot's webpack.config.js
|
||||
process.env.NODE_ENV = 'development';
|
||||
const custom = require('../config/webpack/environment');
|
||||
|
||||
module.exports = {
|
||||
stories: [
|
||||
'../stories/**/*.stories.mdx',
|
||||
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
{
|
||||
name: '@storybook/addon-docs',
|
||||
options: {
|
||||
vueDocgenOptions: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '../'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
{
|
||||
/**
|
||||
* Fix Storybook issue with PostCSS@8
|
||||
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
|
||||
*/
|
||||
name: '@storybook/addon-postcss',
|
||||
options: {
|
||||
postcssLoaderOptions: {
|
||||
implementation: require('postcss'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
webpackFinal: config => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
modules: custom.resolvedModules.map(i => i.value),
|
||||
},
|
||||
};
|
||||
|
||||
newConfig.module.rules.push({
|
||||
test: /\.scss$/,
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
|
||||
include: path.resolve(__dirname, '../app/javascript'),
|
||||
});
|
||||
|
||||
return newConfig;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { addDecorator } from '@storybook/vue';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import Vuelidate from 'vuelidate';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
||||
|
||||
import WootUiKit from '../app/javascript/dashboard/components';
|
||||
import i18n from '../app/javascript/dashboard/i18n';
|
||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
|
||||
|
||||
import '../app/javascript/dashboard/assets/scss/storybook.scss';
|
||||
|
||||
Vue.use(VueI18n);
|
||||
Vue.use(Vuelidate);
|
||||
Vue.use(WootUiKit);
|
||||
Vue.use(Vuex);
|
||||
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
|
||||
Vue.component('multiselect', Multiselect);
|
||||
Vue.component('fluent-icon', FluentIcon);
|
||||
|
||||
const store = new Vuex.Store({});
|
||||
const i18nConfig = new VueI18n({
|
||||
locale: 'en',
|
||||
messages: i18n,
|
||||
});
|
||||
|
||||
addDecorator(() => ({
|
||||
template: '<story/>',
|
||||
i18n: i18nConfig,
|
||||
store,
|
||||
beforeCreate: function() {
|
||||
this.$root._i18n = this.$i18n;
|
||||
},
|
||||
}));
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
../../AGENTS.md
|
||||
@@ -1,88 +0,0 @@
|
||||
# Chatwoot Development Guidelines
|
||||
|
||||
## Build / Test / Lint
|
||||
|
||||
- **Setup**: `bundle install && pnpm install`
|
||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
||||
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||
- **Run Project**: `overmind start -f Procfile.dev`
|
||||
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Ruby**: Follow RuboCop rules (150 character max line length)
|
||||
- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended)
|
||||
- **Vue Components**: Use PascalCase
|
||||
- **Events**: Use camelCase
|
||||
- **I18n**: No bare strings in templates; use i18n
|
||||
- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`)
|
||||
- **Models**: Validate presence/uniqueness, add proper indexes
|
||||
- **Type Safety**: Use PropTypes in Vue, strong params in Rails
|
||||
- **Naming**: Use clear, descriptive names with consistent casing
|
||||
- **Vue API**: Always use Composition API with `<script setup>` at the top
|
||||
|
||||
## Styling
|
||||
|
||||
- **Tailwind Only**:
|
||||
- Do not write custom CSS
|
||||
- Do not use scoped CSS
|
||||
- Do not use inline styles
|
||||
- Always use Tailwind utility classes
|
||||
- **Colors**: Refer to `tailwind.config.js` for color definitions
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- MVP focus: Least code change, happy-path only
|
||||
- No unnecessary defensive programming
|
||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||
- Break down complex tasks into small, testable units
|
||||
- Iterate after confirmation
|
||||
- Avoid writing specs unless explicitly asked
|
||||
- Remove dead/unreachable/unused code
|
||||
- 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
|
||||
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
- Only update `en.yml` and `en.json`
|
||||
- Other languages are handled by the community
|
||||
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
|
||||
- **Frontend**:
|
||||
- Use `components-next/` for message bubbles (the rest is being deprecated)
|
||||
|
||||
## Ruby Best Practices
|
||||
|
||||
- Use compact `module/class` definitions; avoid nested styles
|
||||
|
||||
## Enterprise Edition Notes
|
||||
|
||||
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
|
||||
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
|
||||
- Follow the Enterprise development practices documented here:
|
||||
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
|
||||
|
||||
Practical checklist for any change impacting core logic or public APIs
|
||||
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
|
||||
- If adding new endpoints, services, or models, consider whether Enterprise needs:
|
||||
- An override (e.g., `enterprise/app/...`), or
|
||||
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
|
||||
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
|
||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||
- 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/`.
|
||||
@@ -1,10 +1,10 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.4.4'
|
||||
ruby '3.2.2'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||
gem 'rails', '~> 7.1'
|
||||
gem 'rails', '~> 7.0.8.1'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -21,7 +21,6 @@ gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
@@ -34,8 +33,6 @@ gem 'liquid'
|
||||
gem 'commonmarker'
|
||||
# Validate Data against JSON Schema
|
||||
gem 'json_schemer'
|
||||
# used in swagger build
|
||||
gem 'json_refs'
|
||||
# Rack middleware for blocking & throttling abusive requests
|
||||
gem 'rack-attack', '>= 6.7.0'
|
||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||
@@ -52,12 +49,9 @@ gem 'aws-sdk-s3', require: false
|
||||
# original gem isn't maintained actively
|
||||
# we wanted updated version of faraday which is a dependency for slack-ruby-client
|
||||
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
|
||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'image_processing'
|
||||
|
||||
##-- for actionmailbox --##
|
||||
gem 'aws-actionmailbox-ses', '~> 0'
|
||||
|
||||
##-- gems for database --#
|
||||
gem 'groupdate'
|
||||
gem 'pg'
|
||||
@@ -66,15 +60,11 @@ gem 'redis-namespace'
|
||||
# super fast record imports in bulk
|
||||
gem 'activerecord-import'
|
||||
|
||||
gem 'searchkick'
|
||||
gem 'opensearch-ruby'
|
||||
gem 'faraday_middleware-aws-sigv4'
|
||||
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'dotenv-rails'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'vite_rails'
|
||||
gem 'webpacker'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
@@ -82,15 +72,12 @@ gem 'barnes'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.2'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
@@ -100,44 +87,42 @@ gem 'wisper', '2.0.0'
|
||||
##--- gems for channels ---##
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
# facebook client
|
||||
gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client', '~> 2.7.0'
|
||||
gem 'slack-ruby-client', '~> 2.2.0'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
|
||||
gem 'google-cloud-dialogflow-v2'
|
||||
gem 'grpc'
|
||||
# Translate integrations
|
||||
# 'google-cloud-translate' gem depends on faraday 2.0 version
|
||||
# this dependency breaks the slack-ruby-client gem
|
||||
gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
gem 'google-cloud-translate-v3'
|
||||
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
# ref application.rb
|
||||
gem 'datadog', '~> 2.0', require: false
|
||||
gem 'ddtrace', require: false
|
||||
gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
gem 'scout_apm', require: false
|
||||
gem 'sentry-rails', '>= 5.19.0', require: false
|
||||
gem 'sentry-rails', '>= 5.14.0', require: false
|
||||
gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.15.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.3.1'
|
||||
gem 'sidekiq', '>= 7.2.4'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
# for sidekiq healthcheck
|
||||
gem 'sidekiq_alive'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
gem 'web-push', '>= 3.0.1'
|
||||
gem 'web-push'
|
||||
|
||||
##-- geocoding / parse location from ip --##
|
||||
# http://www.rubygeocoder.com/
|
||||
@@ -153,7 +138,9 @@ gem 'procore-sift'
|
||||
# parse email
|
||||
gem 'email_reply_trimmer'
|
||||
|
||||
gem 'html2text'
|
||||
# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
|
||||
# and this gem hasn't been updated for a while.
|
||||
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
|
||||
|
||||
# to calculate working hours
|
||||
gem 'working_hours'
|
||||
@@ -162,7 +149,7 @@ gem 'working_hours'
|
||||
gem 'pg_search'
|
||||
|
||||
# Subscriptions, Billing
|
||||
gem 'stripe', '~> 18.0'
|
||||
gem 'stripe'
|
||||
|
||||
## - helper gems --##
|
||||
## to populate db with sample data
|
||||
@@ -178,8 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-saml'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
|
||||
## Gems for reponse bot
|
||||
@@ -189,38 +175,24 @@ gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.7.0'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
# OpenTelemetry for LLM observability
|
||||
gem 'opentelemetry-sdk'
|
||||
gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
group :production do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
# for heroku autoscaling
|
||||
gem 'judoscale-rails', require: false
|
||||
gem 'judoscale-sidekiq', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem 'annotaterb'
|
||||
gem 'annotate'
|
||||
gem 'bullet'
|
||||
gem 'letter_opener'
|
||||
gem 'scss_lint', require: false
|
||||
gem 'web-console', '>= 4.2.1'
|
||||
|
||||
# used in swagger build
|
||||
gem 'json_refs'
|
||||
|
||||
# When we want to squash migrations
|
||||
gem 'squasher'
|
||||
|
||||
@@ -228,19 +200,18 @@ group :development do
|
||||
gem 'rack-mini-profiler', '>= 3.2.0', require: false
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
|
||||
gem 'tidewave'
|
||||
gem 'meta_request', '>= 0.8.0'
|
||||
end
|
||||
|
||||
group :test do
|
||||
# Cypress in rails.
|
||||
gem 'cypress-on-rails'
|
||||
# fast cleaning of database
|
||||
gem 'database_cleaner'
|
||||
# mock http calls
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
gem 'test-prof'
|
||||
gem 'simplecov_json_formatter', require: false
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -257,15 +228,14 @@ group :development, :test do
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails', '>= 6.1.5'
|
||||
gem 'rspec-rails'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'rubocop-rspec', require: false
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'simplecov', '0.17.1', require: false
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+316
-518
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
|
||||
setup:
|
||||
gem install bundler
|
||||
bundle install
|
||||
pnpm install
|
||||
yarn install
|
||||
|
||||
db_create:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
|
||||
@@ -30,7 +30,7 @@ server:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
|
||||
|
||||
burn:
|
||||
bundle && pnpm install
|
||||
bundle && yarn
|
||||
|
||||
run:
|
||||
@if [ -f ./.overmind.sock ]; then \
|
||||
@@ -41,15 +41,8 @@ run:
|
||||
|
||||
force_run:
|
||||
rm -f ./.overmind.sock
|
||||
rm -f tmp/pids/*.pid
|
||||
overmind start -f Procfile.dev
|
||||
|
||||
force_run_tunnel:
|
||||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||
rm -f ./.overmind.sock
|
||||
rm -f tmp/pids/*.pid
|
||||
overmind start -f Procfile.tunnel
|
||||
|
||||
debug:
|
||||
overmind connect backend
|
||||
|
||||
@@ -59,4 +52,4 @@ debug_worker:
|
||||
docker:
|
||||
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||
|
||||
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
|
||||
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
backend: bin/rails s -p 3000
|
||||
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
|
||||
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
||||
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||
vite: bin/vite dev
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
backend: RAILS_ENV=test bin/rails s -p 5050
|
||||
vite: bin/vite dev
|
||||
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
|
||||
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
|
||||
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
||||
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||
vite: bin/vite build --watch
|
||||
@@ -1,13 +1,22 @@
|
||||
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/>
|
||||
<img src="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/>
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
___
|
||||
|
||||
# Chatwoot
|
||||
# Chatwoot
|
||||
|
||||
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
<p>
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
|
||||
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
|
||||
</a>
|
||||
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
|
||||
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
|
||||
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
|
||||
@@ -19,71 +28,41 @@ The modern customer support platform, an open-source alternative to Intercom, Ze
|
||||
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
|
||||
</p>
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282255783-ee8a50c9-f42d-4752-8201-2d59965a663d.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282255784-3d1994ec-d895-4ff5-ac68-d819987e1869.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
<p>
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
|
||||
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
|
||||
</a>
|
||||
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
|
||||
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
|
||||
</a>
|
||||
</p>
|
||||
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
|
||||
|
||||
<img src="./.github/screenshots/dashboard.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="./.github/screenshots/dashboard-dark.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
## Features
|
||||
|
||||
---
|
||||
Chatwoot supports the following conversation channels:
|
||||
|
||||
Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels.
|
||||
- **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
|
||||
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
|
||||
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
|
||||
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
|
||||
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
|
||||
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
|
||||
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
|
||||
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
|
||||
- **API Channel**: Build custom communication channels using our API channel.
|
||||
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
|
||||
|
||||
### ✨ Captain – AI Agent for Support
|
||||
And more.
|
||||
|
||||
Supercharge your support with Captain, Chatwoot’s AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs).
|
||||
|
||||
### 💬 Omnichannel Support Desk
|
||||
|
||||
Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc.
|
||||
|
||||
### 📚 Help center portal
|
||||
|
||||
Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues.
|
||||
|
||||
### 🗂️ Other features
|
||||
|
||||
#### Collaboration & Productivity
|
||||
|
||||
- Private Notes and @mentions for internal team discussions.
|
||||
- Labels to organize and categorize conversations.
|
||||
- Keyboard Shortcuts and a Command Bar for quick navigation.
|
||||
- Canned Responses to reply faster to frequently asked questions.
|
||||
- Auto-Assignment to route conversations based on agent availability.
|
||||
- Multi-lingual Support to serve customers in multiple languages.
|
||||
- Custom Views and Filters for better inbox organization.
|
||||
- Business Hours and Auto-Responders to manage response expectations.
|
||||
- Teams and Automation tools for scaling support workflows.
|
||||
- Agent Capacity Management to balance workload across the team.
|
||||
|
||||
#### Customer Data & Segmentation
|
||||
- Contact Management with profiles and interaction history.
|
||||
- Contact Segments and Notes for targeted communication.
|
||||
- Campaigns to proactively engage customers.
|
||||
- Custom Attributes for storing additional customer data.
|
||||
- Pre-Chat Forms to collect user information before starting conversations.
|
||||
|
||||
#### Integrations
|
||||
- Slack Integration to manage conversations directly from Slack.
|
||||
- Dialogflow Integration for chatbot automation.
|
||||
- Dashboard Apps to embed internal tools within Chatwoot.
|
||||
- Shopify Integration to view and manage customer orders right within Chatwoot.
|
||||
- Use Google Translate to translate messages from your customers in realtime.
|
||||
- Create and manage Linear tickets within Chatwoot.
|
||||
|
||||
#### Reports & Insights
|
||||
- Live View of ongoing conversations for real-time monitoring.
|
||||
- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility.
|
||||
- CSAT Reports to measure customer satisfaction.
|
||||
- Downloadable Reports for offline analysis and reporting.
|
||||
Other features include:
|
||||
|
||||
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
|
||||
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
|
||||
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
|
||||
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
|
||||
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
|
||||
- **Conversation Labels**: Use conversation labels to create custom workflows.
|
||||
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
|
||||
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
|
||||
- **Multi-lingual support**: Chatwoot supports 10+ languages.
|
||||
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoot’s webhooks and APIs.
|
||||
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -119,21 +98,23 @@ Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
|
||||
|
||||
### Other deployment options
|
||||
|
||||
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
|
||||
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
|
||||
|
||||
## Security
|
||||
|
||||
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
|
||||
|
||||
## Community
|
||||
|
||||
## Community? Questions? Support ?
|
||||
|
||||
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
|
||||
|
||||
## Contributors
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
|
||||
|
||||
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 2017-2024, Chatwoot Inc - Released under the MIT License.
|
||||
|
||||
@@ -2,8 +2,5 @@
|
||||
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
||||
|
||||
require_relative 'config/application'
|
||||
# Load Enterprise Edition rake tasks if they exist
|
||||
enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
|
||||
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
|
||||
|
||||
Rails.application.load_tasks
|
||||
|
||||
+4
-4
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
|
||||
We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
|
||||
|
||||
If you have any questions about the process, contact security@chatwoot.com.
|
||||
|
||||
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible.
|
||||
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible.
|
||||
|
||||
> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new)
|
||||
> Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
|
||||
- Brute force attacks
|
||||
- DNSSEC
|
||||
|
||||
If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new).
|
||||
If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.10.1
|
||||
3.3.1
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.5.0
|
||||
2.7.0
|
||||
|
||||
@@ -36,10 +36,6 @@
|
||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||
"value": "none"
|
||||
},
|
||||
"NODE_OPTIONS": {
|
||||
"description": "Increase V8 heap for Vite build to avoid OOM",
|
||||
"value": "--max-old-space-size=4096"
|
||||
}
|
||||
},
|
||||
"formation": {
|
||||
@@ -52,7 +48,7 @@
|
||||
"size": "basic"
|
||||
}
|
||||
},
|
||||
"stack": "heroku-24",
|
||||
"stack": "heroku-20",
|
||||
"image": "heroku/ruby",
|
||||
"addons": [
|
||||
{
|
||||
@@ -62,7 +58,7 @@
|
||||
"plan": "heroku-postgresql:essential-0"
|
||||
}
|
||||
],
|
||||
"stack": "heroku-24",
|
||||
"stack": "heroku-20",
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "heroku/nodejs"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
# We don't want to update the name of the identified original contact.
|
||||
|
||||
class ContactIdentifyAction
|
||||
include UrlHelper
|
||||
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
|
||||
|
||||
def perform
|
||||
@@ -105,14 +104,7 @@ class ContactIdentifyAction
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
def enqueue_avatar_job
|
||||
return unless params[:avatar_url].present? && !@contact.avatar.attached?
|
||||
return unless url_valid?(params[:avatar_url])
|
||||
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url])
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached?
|
||||
end
|
||||
|
||||
def merge_contact(base_contact, merge_contact)
|
||||
|
||||
@@ -54,7 +54,7 @@ class ContactMergeAction
|
||||
# attributes in base contact are given preference
|
||||
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
|
||||
|
||||
@mergee_contact.reload.destroy!
|
||||
@mergee_contact.destroy!
|
||||
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
|
||||
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
|
||||
@base_contact.update!(merged_attributes)
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
//= link administrate/application.css
|
||||
//= link administrate/application.js
|
||||
//= link administrate-field-active_storage/application.css
|
||||
//= link dashboardChart.js
|
||||
//= link secretField.js
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// eslint-disable-next-line
|
||||
function prepareData(data) {
|
||||
var labels = [];
|
||||
var dataSet = [];
|
||||
data.forEach(item => {
|
||||
labels.push(item[0]);
|
||||
dataSet.push(item[1]);
|
||||
});
|
||||
return { labels, dataSet };
|
||||
}
|
||||
|
||||
function getChartOptions() {
|
||||
var fontFamily =
|
||||
'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
return {
|
||||
responsive: true,
|
||||
legend: { labels: { fontFamily } },
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
barPercentage: 1.26,
|
||||
ticks: { fontFamily },
|
||||
gridLines: { display: false },
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
ticks: { fontFamily },
|
||||
gridLines: { display: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
function drawSuperAdminDashboard(data) {
|
||||
var ctx = document.getElementById('dashboard-chart').getContext('2d');
|
||||
var chartData = prepareData(data);
|
||||
// eslint-disable-next-line
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Conversations',
|
||||
data: chartData.dataSet,
|
||||
backgroundColor: '#1f93ff',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: getChartOptions(),
|
||||
});
|
||||
}
|
||||
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
|
||||
if (!textElement) return;
|
||||
|
||||
if (textElement.dataset.secretMasked === 'false') {
|
||||
const maskedLength = secretField.dataset.secretText?.length || 10;
|
||||
textElement.textContent = '•'.repeat(maskedLength);
|
||||
textElement.textContent = '•'.repeat(10);
|
||||
textElement.dataset.secretMasked = 'true';
|
||||
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
|
||||
|
||||
@@ -33,13 +32,3 @@ function copySecretField(e) {
|
||||
|
||||
navigator.clipboard.writeText(secretField.dataset.secretText);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
|
||||
const span = field.querySelector('[data-secret-masked]');
|
||||
if (span && span.dataset.secretMasked === 'true') {
|
||||
const len = field.dataset.secretText?.length || 10;
|
||||
span.textContent = '•'.repeat(len);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
button:not(.reset-base),
|
||||
input[type='button']:not(.reset-base),
|
||||
input[type='reset']:not(.reset-base),
|
||||
input[type='submit']:not(.reset-base),
|
||||
.button:not(.reset-base) {
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="reset"],
|
||||
input[type="submit"],
|
||||
.button {
|
||||
appearance: none;
|
||||
background-color: $color-woot;
|
||||
border: 0;
|
||||
border-radius: $base-border-radius;
|
||||
color: $white;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
display: inline-block;
|
||||
font-size: $font-size-small;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: $font-weight-medium;
|
||||
|
||||
@@ -46,25 +46,17 @@
|
||||
|
||||
.cell-data__secret-field {
|
||||
align-items: center;
|
||||
color: $hint-grey;
|
||||
display: flex;
|
||||
|
||||
span {
|
||||
flex: 0 0 auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-secret-toggler],
|
||||
[data-secret-copier] {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0;
|
||||
button {
|
||||
margin-left: 5px;
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
.icon-container {
|
||||
margin-right: 2px;
|
||||
|
||||
}
|
||||
|
||||
.value-container {
|
||||
|
||||
@@ -86,5 +86,8 @@ $swift-ease-out-duration: .4s !default;
|
||||
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
|
||||
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
|
||||
|
||||
// Ionicons
|
||||
$ionicons-font-path: '~ionicons/fonts';
|
||||
|
||||
// Transitions
|
||||
$transition-ease-in: all 0.250s ease-in;
|
||||
|
||||
@@ -32,7 +32,12 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def validate_email
|
||||
Account::SignUpEmailValidationService.new(@email).perform
|
||||
address = ValidEmail2::Address.new(@email)
|
||||
if address.valid? # && !address.disposable?
|
||||
true
|
||||
else
|
||||
raise InvalidEmail.new(valid: address.valid?)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_user
|
||||
|
||||
@@ -52,5 +52,3 @@ class AgentBuilder
|
||||
}.compact))
|
||||
end
|
||||
end
|
||||
|
||||
AgentBuilder.prepend_mod_with('AgentBuilder')
|
||||
|
||||
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
|
||||
@contact_inbox.lock!
|
||||
|
||||
# We won't send campaigns if a conversation is already present
|
||||
raise 'Conversation already present' if @contact_inbox.reload.conversations.present?
|
||||
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
||||
|
||||
@@ -59,49 +59,10 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def create_contact_inbox
|
||||
attrs = {
|
||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
||||
contact_id: @contact.id,
|
||||
inbox_id: @inbox.id,
|
||||
source_id: @source_id
|
||||
}
|
||||
|
||||
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
|
||||
update_old_contact_inbox
|
||||
retry
|
||||
end
|
||||
|
||||
def update_old_contact_inbox
|
||||
# The race condition occurs when there’s a contact inbox with the
|
||||
# same source ID but linked to a different contact. This can happen
|
||||
# if the agent updates the contact’s email or phone number, or
|
||||
# if the contact is merged with another.
|
||||
#
|
||||
# We update the old contact inbox source_id to a random value to
|
||||
# avoid disrupting the current flow. However, the root cause of
|
||||
# this issue is a flaw in the contact inbox model design.
|
||||
# Contact inbox is essentially tracking a session and is not
|
||||
# needed for non-live chat channels.
|
||||
raise ActiveRecord::RecordNotUnique unless allowed_channels?
|
||||
|
||||
contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
|
||||
return if contact_inbox.blank?
|
||||
|
||||
contact_inbox.update!(source_id: new_source_id)
|
||||
end
|
||||
|
||||
def new_source_id
|
||||
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
|
||||
"whatsapp:#{@source_id}#{rand(100)}"
|
||||
else
|
||||
"#{rand(10)}#{@source_id}"
|
||||
end
|
||||
end
|
||||
|
||||
def allowed_channels?
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
||||
|
||||
@@ -63,33 +63,9 @@ class ContactInboxWithContactBuilder
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
|
||||
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
|
||||
|
||||
contact
|
||||
end
|
||||
|
||||
def instagram_channel?
|
||||
inbox.channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
# There might be existing contact_inboxes created through Channel::FacebookPage
|
||||
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
|
||||
# while still reusing contacts if found in Facebook channels so that we can create
|
||||
# new conversations with the same contact.
|
||||
def find_contact_by_instagram_source_id(instagram_id)
|
||||
return if instagram_id.blank?
|
||||
|
||||
existing_contact_inbox = ContactInbox.joins(:inbox)
|
||||
.where(source_id: instagram_id)
|
||||
.where(
|
||||
'inboxes.channel_type = ? AND inboxes.account_id = ?',
|
||||
'Channel::FacebookPage',
|
||||
account.id
|
||||
).first
|
||||
|
||||
existing_contact_inbox&.contact
|
||||
end
|
||||
|
||||
def find_contact_by_identifier(identifier)
|
||||
return if identifier.blank?
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
class Email::BaseBuilder
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
@channel ||= inbox.channel
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= message.conversation
|
||||
end
|
||||
|
||||
def custom_sender_name
|
||||
message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications')
|
||||
end
|
||||
|
||||
def sender_name(sender_email)
|
||||
# Friendly: <agent_name> from <business_name>
|
||||
# Professional: <business_name>
|
||||
if inbox.friendly?
|
||||
I18n.t(
|
||||
'conversations.reply.email.header.friendly_name',
|
||||
sender_name: custom_sender_name,
|
||||
business_name: business_name,
|
||||
from_email: sender_email
|
||||
)
|
||||
else
|
||||
I18n.t(
|
||||
'conversations.reply.email.header.professional_name',
|
||||
business_name: business_name,
|
||||
from_email: sender_email
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def business_name
|
||||
inbox.business_name || inbox.sanitized_name
|
||||
end
|
||||
|
||||
def account_support_email
|
||||
# Parse the email to ensure it's in the correct format, the user
|
||||
# can save it in the format "Name <email@domain.com>"
|
||||
parse_email(account.support_email)
|
||||
end
|
||||
|
||||
def parse_email(email_string)
|
||||
Mail::Address.new(email_string).address
|
||||
end
|
||||
end
|
||||
@@ -1,51 +0,0 @@
|
||||
class Email::FromBuilder < Email::BaseBuilder
|
||||
pattr_initialize [:inbox!, :message!]
|
||||
|
||||
def build
|
||||
return sender_name(account_support_email) unless inbox.email?
|
||||
|
||||
from_email = case email_channel_type
|
||||
when :standard_imap_smtp,
|
||||
:google_oauth,
|
||||
:microsoft_oauth,
|
||||
:forwarding_own_smtp
|
||||
channel.email
|
||||
when :imap_chatwoot_smtp,
|
||||
:forwarding_chatwoot_smtp
|
||||
channel.verified_for_sending ? channel.email : account_support_email
|
||||
else
|
||||
account_support_email
|
||||
end
|
||||
|
||||
sender_name(from_email)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def email_channel_type
|
||||
return :google_oauth if channel.google?
|
||||
return :microsoft_oauth if channel.microsoft?
|
||||
return :standard_imap_smtp if imap_and_smtp_enabled?
|
||||
return :imap_chatwoot_smtp if imap_enabled_without_smtp?
|
||||
return :forwarding_own_smtp if forwarding_with_own_smtp?
|
||||
return :forwarding_chatwoot_smtp if forwarding_without_smtp?
|
||||
|
||||
:unknown
|
||||
end
|
||||
|
||||
def imap_and_smtp_enabled?
|
||||
channel.imap_enabled && channel.smtp_enabled
|
||||
end
|
||||
|
||||
def imap_enabled_without_smtp?
|
||||
channel.imap_enabled && !channel.smtp_enabled
|
||||
end
|
||||
|
||||
def forwarding_with_own_smtp?
|
||||
!channel.imap_enabled && channel.smtp_enabled
|
||||
end
|
||||
|
||||
def forwarding_without_smtp?
|
||||
!channel.imap_enabled && !channel.smtp_enabled
|
||||
end
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
class Email::ReplyToBuilder < Email::BaseBuilder
|
||||
pattr_initialize [:inbox!, :message!]
|
||||
|
||||
def build
|
||||
reply_to = if inbox.email?
|
||||
channel.email
|
||||
elsif inbound_email_enabled?
|
||||
"reply+#{conversation.uuid}@#{account.inbound_email_domain}"
|
||||
else
|
||||
account_support_email
|
||||
end
|
||||
|
||||
sender_name(reply_to)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbound_email_enabled?
|
||||
account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present?
|
||||
end
|
||||
end
|
||||
@@ -1,201 +0,0 @@
|
||||
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
|
||||
attr_reader :messaging
|
||||
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
super()
|
||||
@messaging = messaging
|
||||
@inbox = inbox
|
||||
@outgoing_echo = outgoing_echo
|
||||
end
|
||||
|
||||
def perform
|
||||
return if @inbox.channel.reauthorization_required?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
end
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attachments
|
||||
@messaging[:message][:attachments] || {}
|
||||
end
|
||||
|
||||
def message_type
|
||||
@outgoing_echo ? :outgoing : :incoming
|
||||
end
|
||||
|
||||
def message_identifier
|
||||
message[:mid]
|
||||
end
|
||||
|
||||
def message_source_id
|
||||
@outgoing_echo ? recipient_id : sender_id
|
||||
end
|
||||
|
||||
def message_is_unsupported?
|
||||
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@messaging[:sender][:id]
|
||||
end
|
||||
|
||||
def recipient_id
|
||||
@messaging[:recipient][:id]
|
||||
end
|
||||
|
||||
def message
|
||||
@messaging[:message]
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
find_conversation_scope.order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_scope
|
||||
Conversation.where(conversation_params)
|
||||
end
|
||||
|
||||
def find_or_build_for_multiple_conversations
|
||||
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@messaging[:message][:text]
|
||||
end
|
||||
|
||||
def story_reply_attributes
|
||||
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
|
||||
end
|
||||
|
||||
def message_reply_attributes
|
||||
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
|
||||
end
|
||||
|
||||
def build_message
|
||||
# Duplicate webhook events may be sent for the same message
|
||||
# when a user is connected to the Instagram account through both Messenger and Instagram login.
|
||||
# There is chance for echo events to be sent for the same message.
|
||||
# Therefore, we need to check if the message already exists before creating it.
|
||||
return if message_already_exists?
|
||||
|
||||
return if message_content.blank? && all_unsupported_files?
|
||||
|
||||
@message = conversation.messages.create!(message_params)
|
||||
save_story_id
|
||||
|
||||
attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def save_story_id
|
||||
return if story_reply_attributes.blank?
|
||||
|
||||
@message.save_story_info(story_reply_attributes)
|
||||
create_story_reply_attachment(story_reply_attributes['url'])
|
||||
end
|
||||
|
||||
def create_story_reply_attachment(story_url)
|
||||
return if story_url.blank?
|
||||
|
||||
attachment = @message.attachments.new(
|
||||
file_type: :ig_story,
|
||||
account_id: @message.account_id,
|
||||
external_url: story_url
|
||||
)
|
||||
attachment.save!
|
||||
begin
|
||||
attach_file(attachment, story_url)
|
||||
rescue Down::Error, StandardError => e
|
||||
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
|
||||
end
|
||||
@message.content_attributes[:image_type] = 'ig_story_reply'
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: additional_conversation_attributes
|
||||
))
|
||||
end
|
||||
|
||||
def additional_conversation_attributes
|
||||
{}
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
params = {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
status: @outgoing_echo ? :delivered : :sent,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
sender: @outgoing_echo ? nil : contact,
|
||||
content_attributes: {
|
||||
in_reply_to_external_id: message_reply_attributes
|
||||
}
|
||||
}
|
||||
|
||||
params[:content_attributes][:external_echo] = true if @outgoing_echo
|
||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||
params
|
||||
end
|
||||
|
||||
def message_already_exists?
|
||||
find_message_by_source_id(@messaging[:message][:mid]).present?
|
||||
end
|
||||
|
||||
def find_message_by_source_id(source_id)
|
||||
return unless source_id
|
||||
|
||||
@message = Message.find_by(source_id: source_id)
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
return if attachments.empty?
|
||||
|
||||
attachments_type = attachments.pluck(:type).uniq.first
|
||||
unsupported_file_type?(attachments_type)
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
true
|
||||
end
|
||||
|
||||
# Abstract methods to be implemented by subclasses
|
||||
def get_story_object_from_source_id(source_id)
|
||||
raise NotImplementedError
|
||||
end
|
||||
end
|
||||
@@ -1,42 +1,200 @@
|
||||
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
|
||||
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
|
||||
# Assumptions
|
||||
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
|
||||
# based on this we are showing "not sent from chatwoot" message in frontend
|
||||
# Hence there is no need to set user_id in message for outgoing echo messages.
|
||||
|
||||
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
attr_reader :messaging
|
||||
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
super(messaging, inbox, outgoing_echo: outgoing_echo)
|
||||
super()
|
||||
@messaging = messaging
|
||||
@inbox = inbox
|
||||
@outgoing_echo = outgoing_echo
|
||||
end
|
||||
|
||||
def perform
|
||||
return if @inbox.channel.reauthorization_required?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
end
|
||||
rescue Koala::Facebook::AuthenticationError => e
|
||||
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
|
||||
Rails.logger.error e
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_story_object_from_source_id(source_id)
|
||||
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
|
||||
|
||||
response = HTTParty.get(url)
|
||||
|
||||
return JSON.parse(response.body).with_indifferent_access if response.success?
|
||||
|
||||
# Create message first if it doesn't exist
|
||||
@message ||= conversation.messages.create!(message_params)
|
||||
handle_error_response(response)
|
||||
nil
|
||||
def attachments
|
||||
@messaging[:message][:attachments] || {}
|
||||
end
|
||||
|
||||
def handle_error_response(response)
|
||||
parsed_response = JSON.parse(response.body)
|
||||
error_code = parsed_response.dig('error', 'code')
|
||||
def message_type
|
||||
@outgoing_echo ? :outgoing : :incoming
|
||||
end
|
||||
|
||||
# https://developers.facebook.com/docs/messenger-platform/error-codes
|
||||
# Access token has expired or become invalid.
|
||||
channel.authorization_error! if error_code == 190
|
||||
def message_identifier
|
||||
message[:mid]
|
||||
end
|
||||
|
||||
# There was a problem scraping data from the provided link.
|
||||
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
|
||||
if error_code == 1_609_005
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
def message_source_id
|
||||
@outgoing_echo ? recipient_id : sender_id
|
||||
end
|
||||
|
||||
def message_is_unsupported?
|
||||
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@messaging[:sender][:id]
|
||||
end
|
||||
|
||||
def recipient_id
|
||||
@messaging[:recipient][:id]
|
||||
end
|
||||
|
||||
def message
|
||||
@messaging[:message]
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def instagram_direct_message_conversation
|
||||
Conversation.where(conversation_params)
|
||||
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
end
|
||||
|
||||
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
|
||||
end
|
||||
|
||||
def base_uri
|
||||
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
|
||||
def find_or_build_for_multiple_conversations
|
||||
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
|
||||
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@messaging[:message][:text]
|
||||
end
|
||||
|
||||
def story_reply_attributes
|
||||
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
|
||||
end
|
||||
|
||||
def message_reply_attributes
|
||||
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
|
||||
end
|
||||
|
||||
def build_message
|
||||
return if @outgoing_echo && already_sent_from_chatwoot?
|
||||
return if message_content.blank? && all_unsupported_files?
|
||||
|
||||
@message = conversation.messages.create!(message_params)
|
||||
save_story_id
|
||||
|
||||
attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def save_story_id
|
||||
return if story_reply_attributes.blank?
|
||||
|
||||
@message.save_story_info(story_reply_attributes)
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message' }
|
||||
))
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
params = {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
sender: @outgoing_echo ? nil : contact,
|
||||
content_attributes: {
|
||||
in_reply_to_external_id: message_reply_attributes
|
||||
}
|
||||
}
|
||||
|
||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||
params
|
||||
end
|
||||
|
||||
def already_sent_from_chatwoot?
|
||||
cw_message = conversation.messages.where(
|
||||
source_id: @messaging[:message][:mid]
|
||||
).first
|
||||
|
||||
cw_message.present?
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
return if attachments.empty?
|
||||
|
||||
attachments_type = attachments.pluck(:type).uniq.first
|
||||
unsupported_file_type?(attachments_type)
|
||||
end
|
||||
|
||||
### Sample response
|
||||
# {
|
||||
# "object": "instagram",
|
||||
# "entry": [
|
||||
# {
|
||||
# "id": "<IGID>",// ig id of the business
|
||||
# "time": 1569262486134,
|
||||
# "messaging": [
|
||||
# {
|
||||
# "sender": {
|
||||
# "id": "<IGSID>"
|
||||
# },
|
||||
# "recipient": {
|
||||
# "id": "<IGID>"
|
||||
# },
|
||||
# "timestamp": 1569262485349,
|
||||
# "message": {
|
||||
# "mid": "<MESSAGE_ID>",
|
||||
# "text": "<MESSAGE_CONTENT>"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ],
|
||||
# }
|
||||
end
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
super(messaging, inbox, outgoing_echo: outgoing_echo)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_story_object_from_source_id(source_id)
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
k.get_object(source_id, fields: %w[story from]) || {}
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
{}
|
||||
end
|
||||
|
||||
def find_conversation_scope
|
||||
Conversation.where(conversation_params)
|
||||
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
|
||||
end
|
||||
|
||||
def additional_conversation_attributes
|
||||
{ type: 'instagram_direct_message' }
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,5 @@
|
||||
class Messages::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
include ::EmailHelper
|
||||
include ::DataHelper
|
||||
|
||||
attr_reader :message
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
@@ -10,7 +7,6 @@ class Messages::MessageBuilder
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
@@ -24,9 +20,6 @@ class Messages::MessageBuilder
|
||||
@message = @conversation.messages.build(message_params)
|
||||
process_attachments
|
||||
process_emails
|
||||
# When the message has no quoted content, it will just be rendered as a regular message
|
||||
# The frontend is equipped to handle this case
|
||||
process_email_content
|
||||
@message.save!
|
||||
@message
|
||||
end
|
||||
@@ -41,12 +34,30 @@ class Messages::MessageBuilder
|
||||
params = convert_to_hash(@params)
|
||||
content_attributes = params.fetch(:content_attributes, {})
|
||||
|
||||
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return content_attributes if content_attributes.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
|
||||
# Converts the given object to a hash.
|
||||
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
|
||||
# Otherwise, returns the object as-is.
|
||||
def convert_to_hash(obj)
|
||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||
|
||||
obj
|
||||
end
|
||||
|
||||
# Attempts to parse a string as JSON.
|
||||
# If successful, returns the parsed hash with symbolized names.
|
||||
# If unsuccessful, returns nil.
|
||||
def parse_json(content)
|
||||
JSON.parse(content, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return if @attachments.blank?
|
||||
|
||||
@@ -81,20 +92,18 @@ class Messages::MessageBuilder
|
||||
@message.content_attributes[:to_emails] = to_emails
|
||||
end
|
||||
|
||||
def process_email_content
|
||||
return unless should_process_email_content?
|
||||
|
||||
@message.content_attributes ||= {}
|
||||
email_attributes = build_email_attributes
|
||||
@message.content_attributes[:email] = email_attributes
|
||||
end
|
||||
|
||||
def process_email_string(email_string)
|
||||
return [] if email_string.blank?
|
||||
|
||||
email_string.gsub(/\s+/, '').split(',')
|
||||
end
|
||||
|
||||
def validate_email_addresses(all_emails)
|
||||
all_emails&.each do |email|
|
||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
end
|
||||
end
|
||||
|
||||
def message_type
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
@@ -138,90 +147,10 @@ class Messages::MessageBuilder
|
||||
private: @private,
|
||||
sender: sender,
|
||||
content_type: @params[:content_type],
|
||||
content_attributes: content_attributes.presence,
|
||||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id],
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
end
|
||||
|
||||
def email_inbox?
|
||||
@conversation.inbox&.inbox_type == 'Email'
|
||||
end
|
||||
|
||||
def should_process_email_content?
|
||||
email_inbox? && !@private && @message.content.present?
|
||||
end
|
||||
|
||||
def build_email_attributes
|
||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||
normalized_content = normalize_email_body(@message.content)
|
||||
|
||||
# Process liquid templates in normalized content with code block protection
|
||||
processed_content = process_liquid_in_email_body(normalized_content)
|
||||
|
||||
# Use custom HTML content if provided, otherwise generate from message content
|
||||
email_attributes[:html_content] = if custom_email_content_provided?
|
||||
build_custom_html_content
|
||||
else
|
||||
build_html_content(processed_content)
|
||||
end
|
||||
|
||||
email_attributes[:text_content] = build_text_content(processed_content)
|
||||
email_attributes
|
||||
end
|
||||
|
||||
def build_html_content(normalized_content)
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
rendered_html = render_email_html(normalized_content)
|
||||
html_content[:full] = rendered_html
|
||||
html_content[:reply] = rendered_html
|
||||
html_content
|
||||
end
|
||||
|
||||
def build_text_content(normalized_content)
|
||||
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
|
||||
text_content[:full] = normalized_content
|
||||
text_content[:reply] = normalized_content
|
||||
text_content
|
||||
end
|
||||
|
||||
def custom_email_content_provided?
|
||||
@params[:email_html_content].present?
|
||||
end
|
||||
|
||||
def build_custom_html_content
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
|
||||
html_content[:full] = @params[:email_html_content]
|
||||
html_content[:reply] = @params[:email_html_content]
|
||||
|
||||
html_content
|
||||
end
|
||||
|
||||
# Liquid processing methods for email content
|
||||
def process_liquid_in_email_body(content)
|
||||
return content if content.blank?
|
||||
return content unless should_process_liquid?
|
||||
|
||||
# Protect code blocks from liquid processing
|
||||
modified_content = modified_liquid_content(content)
|
||||
template = Liquid::Template.parse(modified_content)
|
||||
template.render(drops_with_sender)
|
||||
rescue Liquid::Error
|
||||
content
|
||||
end
|
||||
|
||||
def should_process_liquid?
|
||||
@message_type == 'outgoing' || @message_type == 'template'
|
||||
end
|
||||
|
||||
def drops_with_sender
|
||||
message_drops(@conversation).merge({
|
||||
'agent' => UserDrop.new(sender)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||
|
||||
@@ -9,8 +9,6 @@ class Messages::Messenger::MessageBuilder
|
||||
attachment_obj.save!
|
||||
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_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)
|
||||
end
|
||||
|
||||
@@ -29,7 +27,7 @@ class Messages::Messenger::MessageBuilder
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||
if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
@@ -41,17 +39,9 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
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: url,
|
||||
remote_file_url: url
|
||||
external_url: attachment['payload']['url'],
|
||||
remote_file_url: attachment['payload']['url']
|
||||
}
|
||||
end
|
||||
|
||||
@@ -78,29 +68,26 @@ class Messages::Messenger::MessageBuilder
|
||||
message.save!
|
||||
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
|
||||
def get_story_object_from_source_id(_source_id)
|
||||
def get_story_object_from_source_id(source_id)
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
k.get_object(source_id, fields: %w[story from]) || {}
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
{}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||
[:template, :unsupported_type].include? attachment_type.to_sym
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,8 +25,6 @@ class NotificationBuilder
|
||||
def build_notification
|
||||
# Create conversation_creation notification only if user is subscribed to it
|
||||
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
|
||||
# skip notifications for blocked conversations except for user mentions
|
||||
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
|
||||
|
||||
user.notifications.create!(
|
||||
notification_type: notification_type,
|
||||
|
||||
@@ -2,38 +2,52 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
load_data
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
end
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
end
|
||||
|
||||
def build_agent_stats(account_user)
|
||||
user_id = account_user.user_id
|
||||
{
|
||||
id: user_id,
|
||||
conversations_count: conversations_count[user_id] || 0,
|
||||
resolved_conversations_count: resolved_count[user_id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[user_id],
|
||||
avg_first_response_time: avg_first_response_time[user_id],
|
||||
avg_reply_time: avg_reply_time[user_id]
|
||||
}
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:user_id
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.each_with_object([]) do |account_user, arr|
|
||||
arr << {
|
||||
id: account_user.user_id,
|
||||
conversations_count: @grouped_conversations_count[account_user.user_id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
|
||||
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,56 +1,17 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
# Override this method
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
class V2::Reports::ChannelSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversations_by_channel_and_status
|
||||
account.conversations
|
||||
.joins(:inbox)
|
||||
.where(created_at: range)
|
||||
.group('inboxes.channel_type', 'conversations.status')
|
||||
.count
|
||||
.each_with_object({}) do |((channel_type, status), count), grouped|
|
||||
grouped[channel_type] ||= {}
|
||||
grouped[channel_type][status] = count
|
||||
end
|
||||
end
|
||||
|
||||
def build_channel_stats(status_counts)
|
||||
open_count = status_counts['open'] || 0
|
||||
resolved_count = status_counts['resolved'] || 0
|
||||
pending_count = status_counts['pending'] || 0
|
||||
snoozed_count = status_counts['snoozed'] || 0
|
||||
|
||||
{
|
||||
open: open_count,
|
||||
resolved: resolved_count,
|
||||
pending: pending_count,
|
||||
snoozed: snoozed_count,
|
||||
total: open_count + resolved_count + pending_count + snoozed_count
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
class V2::Reports::Conversations::BaseReportBuilder
|
||||
pattr_initialize :account, :params
|
||||
|
||||
private
|
||||
|
||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
||||
COUNT_METRICS = %w[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
def builder_class(metric)
|
||||
case metric
|
||||
when *AVG_METRICS
|
||||
V2::Reports::Timeseries::AverageReportBuilder
|
||||
when *COUNT_METRICS
|
||||
V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
|
||||
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
def summary
|
||||
{
|
||||
conversations_count: count('conversations_count'),
|
||||
incoming_messages_count: count('incoming_messages_count'),
|
||||
outgoing_messages_count: count('outgoing_messages_count'),
|
||||
avg_first_response_time: count('avg_first_response_time'),
|
||||
avg_resolution_time: count('avg_resolution_time'),
|
||||
resolutions_count: count('resolutions_count'),
|
||||
reply_time: count('reply_time')
|
||||
}
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
{
|
||||
bot_resolutions_count: count('bot_resolutions_count'),
|
||||
bot_handoffs_count: count('bot_handoffs_count')
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count(metric)
|
||||
builder_class(metric).new(account, builder_params(metric)).aggregate_value
|
||||
end
|
||||
|
||||
def builder_params(metric)
|
||||
params.merge({ metric: metric })
|
||||
end
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
def timeseries
|
||||
perform_action(:timeseries)
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
perform_action(:aggregate_value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_action(method_name)
|
||||
return builder.new(account, params).public_send(method_name) if builder.present?
|
||||
|
||||
log_invalid_metric
|
||||
end
|
||||
|
||||
def builder
|
||||
builder_class(params[:metric])
|
||||
end
|
||||
end
|
||||
@@ -1,68 +0,0 @@
|
||||
class V2::Reports::FirstResponseTimeDistributionBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
build_distribution
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_distribution
|
||||
results = fetch_aggregated_counts
|
||||
map_to_channel_types(results)
|
||||
end
|
||||
|
||||
def fetch_aggregated_counts
|
||||
ReportingEvent
|
||||
.where(account_id: account.id, name: 'first_response')
|
||||
.where(range_condition)
|
||||
.group(:inbox_id)
|
||||
.select(
|
||||
:inbox_id,
|
||||
bucket_case_statements
|
||||
)
|
||||
end
|
||||
|
||||
def bucket_case_statements
|
||||
<<~SQL.squish
|
||||
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
|
||||
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
|
||||
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
|
||||
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
|
||||
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
|
||||
SQL
|
||||
end
|
||||
|
||||
def range_condition
|
||||
range.present? ? { created_at: range } : {}
|
||||
end
|
||||
|
||||
def inbox_channel_types
|
||||
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
|
||||
end
|
||||
|
||||
def map_to_channel_types(results)
|
||||
results.each_with_object({}) do |row, hash|
|
||||
channel_type = inbox_channel_types[row.inbox_id]
|
||||
next unless channel_type
|
||||
|
||||
hash[channel_type] ||= empty_buckets
|
||||
hash[channel_type]['0-1h'] += row.bucket_0_1h
|
||||
hash[channel_type]['1-4h'] += row.bucket_1_4h
|
||||
hash[channel_type]['4-8h'] += row.bucket_4_8h
|
||||
hash[channel_type]['8-24h'] += row.bucket_8_24h
|
||||
hash[channel_type]['24h+'] += row.bucket_24h_plus
|
||||
end
|
||||
end
|
||||
|
||||
def empty_buckets
|
||||
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
|
||||
end
|
||||
end
|
||||
@@ -1,65 +0,0 @@
|
||||
class V2::Reports::InboxLabelMatrixBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
{
|
||||
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
|
||||
matrix: build_matrix
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_inboxes
|
||||
@filtered_inboxes ||= begin
|
||||
inboxes = account.inboxes
|
||||
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
|
||||
inboxes.order(:name).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def filtered_labels
|
||||
@filtered_labels ||= begin
|
||||
labels = account.labels
|
||||
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
|
||||
labels.order(:title).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_filter
|
||||
filter = { account_id: account.id }
|
||||
filter[:created_at] = range if range.present?
|
||||
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
|
||||
filter
|
||||
end
|
||||
|
||||
def fetch_grouped_counts
|
||||
label_names = filtered_labels.map(&:title)
|
||||
return {} if label_names.empty?
|
||||
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
|
||||
.where(tags: { name: label_names })
|
||||
.group('conversations.inbox_id', 'tags.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def build_matrix
|
||||
counts = fetch_grouped_counts
|
||||
filtered_inboxes.map do |inbox|
|
||||
filtered_labels.map do |label|
|
||||
counts[[inbox.id, label.title]] || 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,47 +0,0 @@
|
||||
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
end
|
||||
end
|
||||
|
||||
def build_inbox_stats(inbox)
|
||||
{
|
||||
id: inbox.id,
|
||||
conversations_count: conversations_count[inbox.id] || 0,
|
||||
resolved_conversations_count: resolved_count[inbox.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[inbox.id],
|
||||
avg_first_response_time: avg_first_response_time[inbox.id],
|
||||
avg_reply_time: avg_reply_time[inbox.id]
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,112 +0,0 @@
|
||||
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :account, :params
|
||||
|
||||
# rubocop:disable Lint/MissingSuper
|
||||
# the parent class has no initialize
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||
end
|
||||
# rubocop:enable Lint/MissingSuper
|
||||
|
||||
def build
|
||||
labels = account.labels.to_a
|
||||
return [] if labels.empty?
|
||||
|
||||
report_data = collect_report_data
|
||||
labels.map { |label| build_label_report(label, report_data) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collect_report_data
|
||||
conversation_filter = build_conversation_filter
|
||||
use_business_hours = use_business_hours?
|
||||
|
||||
{
|
||||
conversation_counts: fetch_conversation_counts(conversation_filter),
|
||||
resolved_counts: fetch_resolved_counts,
|
||||
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
|
||||
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
|
||||
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
|
||||
}
|
||||
end
|
||||
|
||||
def build_label_report(label, report_data)
|
||||
{
|
||||
id: label.id,
|
||||
name: label.title,
|
||||
conversations_count: report_data[:conversation_counts][label.title] || 0,
|
||||
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
|
||||
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
|
||||
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
|
||||
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
|
||||
}
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
end
|
||||
|
||||
def build_conversation_filter
|
||||
conversation_filter = { account_id: account.id }
|
||||
conversation_filter[:created_at] = range if range.present?
|
||||
|
||||
conversation_filter
|
||||
end
|
||||
|
||||
def fetch_conversation_counts(conversation_filter)
|
||||
fetch_counts(conversation_filter)
|
||||
end
|
||||
|
||||
def fetch_resolved_counts
|
||||
# Count resolution events, not conversations currently in resolved status
|
||||
# Filter by reporting_event.created_at, not conversation.created_at
|
||||
reporting_event_filter = { name: 'conversation_resolved', account_id: account.id }
|
||||
reporting_event_filter[:created_at] = range if range.present?
|
||||
|
||||
ReportingEvent
|
||||
.joins(conversation: { taggings: :tag })
|
||||
.where(
|
||||
reporting_event_filter.merge(
|
||||
taggings: { taggable_type: 'Conversation', context: 'labels' }
|
||||
)
|
||||
)
|
||||
.group('tags.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def fetch_counts(conversation_filter)
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(
|
||||
taggable_type: 'Conversation',
|
||||
context: 'labels',
|
||||
conversations: conversation_filter
|
||||
)
|
||||
.select('tags.name, COUNT(taggings.*) AS count')
|
||||
.group('tags.name')
|
||||
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
|
||||
end
|
||||
|
||||
def fetch_metrics(conversation_filter, event_name, use_business_hours)
|
||||
ReportingEvent
|
||||
.joins(conversation: { taggings: :tag })
|
||||
.where(
|
||||
conversations: conversation_filter,
|
||||
name: event_name,
|
||||
taggings: { taggable_type: 'Conversation', context: 'labels' }
|
||||
)
|
||||
.group('tags.name')
|
||||
.order('tags.name')
|
||||
.select(
|
||||
'tags.name',
|
||||
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
|
||||
)
|
||||
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
|
||||
end
|
||||
end
|
||||
@@ -1,79 +0,0 @@
|
||||
class V2::Reports::OutgoingMessagesCountBuilder
|
||||
include DateRangeHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
send("build_by_#{params[:group_by]}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_messages
|
||||
account.messages.outgoing.unscope(:order).where(created_at: range)
|
||||
end
|
||||
|
||||
def build_by_agent
|
||||
counts = base_messages
|
||||
.where(sender_type: 'User')
|
||||
.where.not(sender_id: nil)
|
||||
.group(:sender_id)
|
||||
.count
|
||||
|
||||
user_names = account.users.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |user_id, count|
|
||||
user = user_names[user_id]
|
||||
{ id: user_id, name: user&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_team
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.where.not(conversations: { team_id: nil })
|
||||
.group('conversations.team_id')
|
||||
.count
|
||||
|
||||
team_names = account.teams.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |team_id, count|
|
||||
team = team_names[team_id]
|
||||
{ id: team_id, name: team&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_inbox
|
||||
counts = base_messages
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
|
||||
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |inbox_id, count|
|
||||
inbox = inbox_names[inbox_id]
|
||||
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_label
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
|
||||
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
|
||||
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
|
||||
.group('tags.name')
|
||||
.count
|
||||
|
||||
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
|
||||
|
||||
counts.map do |label_name, count|
|
||||
label = label_ids[label_name]
|
||||
{ id: label&.id, name: label_name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,37 +1,49 @@
|
||||
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
end
|
||||
end
|
||||
|
||||
def build_team_stats(team)
|
||||
{
|
||||
id: team.id,
|
||||
conversations_count: conversations_count[team.id] || 0,
|
||||
resolved_conversations_count: resolved_count[team.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[team.id],
|
||||
avg_first_response_time: avg_first_response_time[team.id],
|
||||
avg_reply_time: avg_reply_time[team.id]
|
||||
}
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
'conversations.team_id'
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.each_with_object([]) do |team, arr|
|
||||
arr << {
|
||||
id: team.id,
|
||||
conversations_count: @grouped_conversations_count[team.id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[team.id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[team.id],
|
||||
avg_reply_time: @grouped_avg_reply_time[team.id]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
event_date, average_time = element
|
||||
arr << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}
|
||||
metric_to_event_name[params[:metric].to_sym]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,46 +0,0 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def scope
|
||||
case params[:type].to_sym
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
inbox
|
||||
when :agent
|
||||
user
|
||||
when :label
|
||||
label
|
||||
when :team
|
||||
team
|
||||
end
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
|
||||
def user
|
||||
@user ||= account.users.find(params[:id])
|
||||
end
|
||||
|
||||
def label
|
||||
@label ||= account.labels.find(params[:id])
|
||||
end
|
||||
|
||||
def team
|
||||
@team ||= account.teams.find(params[:id])
|
||||
end
|
||||
|
||||
def group_by
|
||||
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
end
|
||||
@@ -1,78 +0,0 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
||||
# because it converts the date to 12:00 AM server timezone.
|
||||
# The desired output should be 12:00 AM in the specified timezone.
|
||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
send("scope_for_#{metric}")
|
||||
end
|
||||
|
||||
def scope_for_conversations_count
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
end
|
||||
|
||||
def scope_for_incoming_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_outgoing_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_bot_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
||||
# It converts timestamps to the target timezone before grouping, which means
|
||||
# the same event can fall into different day buckets depending on timezone
|
||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
end
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
@@ -2,9 +2,10 @@ class RoomChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
# TODO: should we only do ensure stream if current account is present?
|
||||
# for now going ahead with guard clauses in update_subscription and broadcast_presence
|
||||
|
||||
ensure_stream
|
||||
current_user
|
||||
current_account
|
||||
ensure_stream
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
end
|
||||
@@ -21,12 +22,12 @@ class RoomChannel < ApplicationCable::Channel
|
||||
|
||||
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
|
||||
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
|
||||
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
|
||||
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
|
||||
end
|
||||
|
||||
def ensure_stream
|
||||
stream_from pubsub_token
|
||||
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
|
||||
@pubsub_token = params[:pubsub_token]
|
||||
stream_from @pubsub_token
|
||||
end
|
||||
|
||||
def update_subscription
|
||||
@@ -35,15 +36,11 @@ class RoomChannel < ApplicationCable::Channel
|
||||
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
|
||||
end
|
||||
|
||||
def pubsub_token
|
||||
@pubsub_token ||= params[:pubsub_token]
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= if params[:user_id].blank?
|
||||
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
|
||||
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
|
||||
else
|
||||
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
|
||||
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
before_action :agent_bot, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -29,20 +29,15 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reset_access_token
|
||||
@agent_bot.access_token.regenerate_token
|
||||
@agent_bot.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_bot
|
||||
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
|
||||
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
|
||||
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
|
||||
end
|
||||
|
||||
def process_avatar_from_url
|
||||
|
||||
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def update
|
||||
@agent.update!(agent_params.slice(:name).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -67,16 +67,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agent = agents.find(params[:id])
|
||||
end
|
||||
|
||||
def account_user_attributes
|
||||
[:role, :availability, :auto_offline]
|
||||
end
|
||||
|
||||
def allowed_agent_params
|
||||
[:name, :email, :role, :availability, :auto_offline]
|
||||
end
|
||||
|
||||
def agent_params
|
||||
params.require(:agent).permit(allowed_agent_params)
|
||||
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
|
||||
end
|
||||
|
||||
def new_agent_params
|
||||
@@ -109,5 +101,3 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user