Merge branch 'develop' into feat/new-auth-screens
This commit is contained in:
commit
8101fa42bd
1499 changed files with 65853 additions and 8259 deletions
|
@ -15,8 +15,11 @@ defaults: &defaults
|
||||||
- image: cimg/postgres:14.1
|
- image: cimg/postgres:14.1
|
||||||
- image: cimg/redis:6.2.6
|
- image: cimg/redis:6.2.6
|
||||||
environment:
|
environment:
|
||||||
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
|
|
||||||
- RAILS_LOG_TO_STDOUT: false
|
- RAILS_LOG_TO_STDOUT: false
|
||||||
|
- COVERAGE: true
|
||||||
|
- LOG_LEVEL: warn
|
||||||
|
parallelism: 4
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
|
@ -88,6 +91,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/5.3.0/openapi-generator-cli-5.3.0.jar > ~/tmp/openapi-generator-cli-5.3.0.jar
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/5.3.0/openapi-generator-cli-5.3.0.jar > ~/tmp/openapi-generator-cli-5.3.0.jar
|
||||||
java -jar ~/tmp/openapi-generator-cli-5.3.0.jar validate -i swagger/swagger.json
|
java -jar ~/tmp/openapi-generator-cli-5.3.0.jar validate -i swagger/swagger.json
|
||||||
|
|
||||||
# Database setup
|
# Database setup
|
||||||
- run: yarn install --check-files
|
- run: yarn install --check-files
|
||||||
- run: bundle exec rake db:create
|
- run: bundle exec rake db:create
|
||||||
|
@ -101,6 +105,10 @@ jobs:
|
||||||
name: Rubocop
|
name: Rubocop
|
||||||
command: bundle exec rubocop
|
command: bundle exec rubocop
|
||||||
|
|
||||||
|
# - run:
|
||||||
|
# name: Brakeman
|
||||||
|
# command: bundle exec brakeman
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: eslint
|
name: eslint
|
||||||
command: yarn run eslint
|
command: yarn run eslint
|
||||||
|
@ -109,34 +117,77 @@ jobs:
|
||||||
- run:
|
- run:
|
||||||
name: Run backend tests
|
name: Run backend tests
|
||||||
command: |
|
command: |
|
||||||
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 --format documentation
|
mkdir -p ~/tmp/test-results/rspec
|
||||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
|
mkdir -p ~/tmp/test-artifacts
|
||||||
- persist_to_workspace:
|
mkdir -p coverage
|
||||||
root: ~/tmp
|
~/tmp/cc-test-reporter before-build
|
||||||
paths:
|
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||||
- codeclimate.backend.json
|
bundle exec rspec --profile 10 \
|
||||||
|
--out test-results/rspec/rspec.xml \
|
||||||
|
-- ${TESTFILES}
|
||||||
|
- run:
|
||||||
|
name: Code Climate Test Coverage
|
||||||
|
command: |
|
||||||
|
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Run frontend tests
|
name: Run frontend tests
|
||||||
command: |
|
command: |
|
||||||
yarn test:coverage
|
mkdir -p ~/tmp/test-results/frontend_specs
|
||||||
~/tmp/cc-test-reporter format-coverage -t lcov -o ~/tmp/codeclimate.frontend.json buildreports/lcov.info
|
~/tmp/cc-test-reporter before-build
|
||||||
- persist_to_workspace:
|
TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
|
||||||
root: ~/tmp
|
yarn test:coverage --profile 10 \
|
||||||
paths:
|
--out test-results/frontend_specs/rspec.xml \
|
||||||
- codeclimate.frontend.json
|
-- ${TESTFILES}
|
||||||
|
- run:
|
||||||
|
name: Code Climate Test Coverage
|
||||||
|
command: |
|
||||||
|
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
|
||||||
|
|
||||||
|
- persist_to_workspace:
|
||||||
|
root: coverage
|
||||||
|
paths:
|
||||||
|
- codeclimate.*.json
|
||||||
# collect reports
|
# collect reports
|
||||||
- store_test_results:
|
- store_test_results:
|
||||||
path: ~/tmp/test-results
|
path: ~/tmp/test-results
|
||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: ~/tmp/test-results
|
path: ~/tmp/test-artifacts
|
||||||
destination: test-results
|
|
||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: log
|
path: log
|
||||||
|
|
||||||
|
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:
|
||||||
|
- attach_workspace:
|
||||||
|
at: ~/build
|
||||||
|
- 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
|
||||||
- run:
|
- run:
|
||||||
name: Upload coverage results to Code Climate
|
name: Upload coverage results to Code Climate
|
||||||
command: |
|
command: |
|
||||||
~/tmp/cc-test-reporter sum-coverage ~/tmp/codeclimate.*.json -p 2 -o ~/tmp/codeclimate.total.json
|
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
|
||||||
~/tmp/cc-test-reporter upload-coverage -i ~/tmp/codeclimate.total.json
|
|
||||||
|
workflows:
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
commit:
|
||||||
|
jobs:
|
||||||
|
- build
|
||||||
|
- upload-coverage:
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
|
|
||||||
|
|
|
@ -53,3 +53,4 @@ exclude_patterns:
|
||||||
- 'app/javascript/dashboard/i18n/index.js'
|
- 'app/javascript/dashboard/i18n/index.js'
|
||||||
- 'app/javascript/widget/i18n/index.js'
|
- 'app/javascript/widget/i18n/index.js'
|
||||||
- 'app/javascript/survey/i18n/index.js'
|
- 'app/javascript/survey/i18n/index.js'
|
||||||
|
- 'app/javascript/shared/constants/locales.js'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# pre-build stage
|
|
||||||
ARG VARIANT=3
|
ARG VARIANT=ubuntu-20.04
|
||||||
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
|
FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
|
||||||
|
|
||||||
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
|
||||||
ARG USER_UID=1000
|
ARG USER_UID=1000
|
||||||
|
@ -11,23 +11,36 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
|
||||||
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# [Option] Install Node.js
|
|
||||||
ARG INSTALL_NODE="true"
|
|
||||||
ARG NODE_VERSION="lts/*"
|
|
||||||
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
|
||||||
|
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& apt-get -y install --no-install-recommends \
|
&& apt-get -y install --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
gnupg2 \
|
||||||
tar \
|
tar \
|
||||||
tzdata \
|
tzdata \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
|
libpq-dev \
|
||||||
yarn \
|
yarn \
|
||||||
git \
|
git \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
tmux \
|
tmux \
|
||||||
zsh
|
zsh \
|
||||||
|
git-flow \
|
||||||
|
npm
|
||||||
|
|
||||||
|
# Install rbenv and ruby
|
||||||
|
ARG RUBY_VERSION="3.0.4"
|
||||||
|
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 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
|
||||||
|
|
||||||
# Install overmind
|
# Install overmind
|
||||||
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
|
||||||
|
@ -35,11 +48,25 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
|
||||||
&& sudo mv overmind /usr/local/bin \
|
&& sudo mv overmind /usr/local/bin \
|
||||||
&& chmod +x /usr/local/bin/overmind
|
&& 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
|
# Do the set up required for chatwoot app
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY . /workspace
|
COPY . /workspace
|
||||||
RUN yarn
|
|
||||||
|
|
||||||
|
# set up ruby
|
||||||
COPY Gemfile Gemfile.lock ./
|
COPY Gemfile Gemfile.lock ./
|
||||||
RUN gem install bundler && bundle install
|
RUN gem install bundler && bundle install
|
||||||
|
|
||||||
|
# set up node js
|
||||||
|
RUN npm install npm@latest -g && \
|
||||||
|
npm install n -g && \
|
||||||
|
n latest
|
||||||
|
RUN npm install --global yarn
|
||||||
|
RUN yarn
|
||||||
|
|
|
@ -23,17 +23,18 @@
|
||||||
// 5432 postgres
|
// 5432 postgres
|
||||||
// 6379 redis
|
// 6379 redis
|
||||||
// 1025,8025 mailhog
|
// 1025,8025 mailhog
|
||||||
"forwardPorts": [8025],
|
"forwardPorts": [8025, 3000, 3035],
|
||||||
//your application may need to listen on all interfaces (0.0.0.0) not just localhost for it to be available externally. Defaults to []
|
|
||||||
"appPort": [3000, 3035],
|
|
||||||
|
|
||||||
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
|
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"3000": {
|
"3000": {
|
||||||
"label": "Rails Server"
|
"label": "Rails Server"
|
||||||
},
|
},
|
||||||
|
"3035": {
|
||||||
|
"label": "Webpack Dev Server"
|
||||||
|
},
|
||||||
"8025": {
|
"8025": {
|
||||||
"label": "Mailhog UI"
|
"label": "Mailhog UI"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,3 +6,8 @@ sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.de
|
||||||
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
|
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
|
||||||
# uncomment the webpacker env variable
|
# uncomment the webpacker env variable
|
||||||
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
|
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 3035:public 8025:public -c $CODESPACE_NAME
|
||||||
|
|
22
.env.example
22
.env.example
|
@ -38,6 +38,9 @@ REDIS_SENTINEL_MASTER_NAME=
|
||||||
# REDIS_OPENSSL_VERIFY_MODE=none
|
# REDIS_OPENSSL_VERIFY_MODE=none
|
||||||
|
|
||||||
# Postgres Database config variables
|
# Postgres Database config variables
|
||||||
|
# You can leave POSTGRES_DATABASE blank. The default name of
|
||||||
|
# the database in the production environment is chatwoot_production
|
||||||
|
# POSTGRES_DATABASE=
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_USERNAME=postgres
|
POSTGRES_USERNAME=postgres
|
||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=
|
||||||
|
@ -48,7 +51,6 @@ RAILS_MAX_THREADS=5
|
||||||
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
|
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
|
||||||
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
|
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
|
||||||
|
|
||||||
|
|
||||||
#SMTP domain key is set up for HELO checking
|
#SMTP domain key is set up for HELO checking
|
||||||
SMTP_DOMAIN=chatwoot.com
|
SMTP_DOMAIN=chatwoot.com
|
||||||
# the default value is set "mailhog" and is used by docker-compose for development environments,
|
# the default value is set "mailhog" and is used by docker-compose for development environments,
|
||||||
|
@ -93,7 +95,6 @@ AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_REGION=
|
AWS_REGION=
|
||||||
|
|
||||||
|
|
||||||
# Log settings
|
# Log settings
|
||||||
# Disable if you want to write logs to a file
|
# Disable if you want to write logs to a file
|
||||||
RAILS_LOG_TO_STDOUT=true
|
RAILS_LOG_TO_STDOUT=true
|
||||||
|
@ -130,7 +131,6 @@ ANDROID_BUNDLE_ID=com.chatwoot.app
|
||||||
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
|
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
|
||||||
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
|
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
|
||||||
|
|
||||||
|
|
||||||
### Smart App Banner
|
### Smart App Banner
|
||||||
# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
|
# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
|
||||||
# You can find your app-id in https://itunesconnect.apple.com
|
# You can find your app-id in https://itunesconnect.apple.com
|
||||||
|
@ -147,8 +147,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||||
## Bot Customizations
|
## Bot Customizations
|
||||||
USE_INBOX_AVATAR_FOR_BOT=true
|
USE_INBOX_AVATAR_FOR_BOT=true
|
||||||
|
|
||||||
|
|
||||||
### APM and Error Monitoring configurations
|
### APM and Error Monitoring configurations
|
||||||
|
## Elastic APM
|
||||||
|
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
|
||||||
|
# ELASTIC_APM_SERVER_URL=
|
||||||
|
# ELASTIC_APM_SECRET_TOKEN=
|
||||||
|
|
||||||
## Sentry
|
## Sentry
|
||||||
# SENTRY_DSN=
|
# SENTRY_DSN=
|
||||||
|
|
||||||
|
@ -169,7 +173,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||||
# DD_TRACE_AGENT_URL=
|
# DD_TRACE_AGENT_URL=
|
||||||
|
|
||||||
|
|
||||||
## IP look up configuration
|
## IP look up configuration
|
||||||
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
|
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
|
||||||
## works only on accounts with ip look up feature enabled
|
## works only on accounts with ip look up feature enabled
|
||||||
|
@ -181,7 +184,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||||
## To prevent and throttle abusive requests
|
## To prevent and throttle abusive requests
|
||||||
# ENABLE_RACK_ATTACK=true
|
# ENABLE_RACK_ATTACK=true
|
||||||
|
|
||||||
|
|
||||||
## Running chatwoot as an API only server
|
## Running chatwoot as an API only server
|
||||||
## setting this value to true will disable the frontend dashboard endpoints
|
## setting this value to true will disable the frontend dashboard endpoints
|
||||||
# CW_API_ONLY_SERVER=false
|
# CW_API_ONLY_SERVER=false
|
||||||
|
@ -195,3 +197,11 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||||
# If you want to use official mobile app,
|
# If you want to use official mobile app,
|
||||||
# the notifications would be relayed via a Chatwoot server
|
# the notifications would be relayed via a Chatwoot server
|
||||||
ENABLE_PUSH_RELAY_SERVER=true
|
ENABLE_PUSH_RELAY_SERVER=true
|
||||||
|
|
||||||
|
# Stripe API key
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Set to true if you want to upload files to cloud storage using the signed url
|
||||||
|
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
|
||||||
|
DIRECT_UPLOADS_ENABLED=
|
||||||
|
|
43
.eslintrc.js
43
.eslintrc.js
|
@ -1,5 +1,10 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'],
|
extends: [
|
||||||
|
'airbnb-base/legacy',
|
||||||
|
'prettier',
|
||||||
|
'plugin:vue/recommended',
|
||||||
|
'plugin:storybook/recommended',
|
||||||
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: 'babel-eslint',
|
parser: 'babel-eslint',
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
|
@ -19,18 +24,32 @@ module.exports = {
|
||||||
'jsx-a11y/label-has-for': 'off',
|
'jsx-a11y/label-has-for': 'off',
|
||||||
'jsx-a11y/anchor-is-valid': 'off',
|
'jsx-a11y/anchor-is-valid': 'off',
|
||||||
'import/no-unresolved': 'off',
|
'import/no-unresolved': 'off',
|
||||||
'vue/max-attributes-per-line': ['error', {
|
'vue/max-attributes-per-line': [
|
||||||
'singleline': 20,
|
'error',
|
||||||
'multiline': {
|
{
|
||||||
'max': 1,
|
singleline: 20,
|
||||||
'allowFirstLine': false
|
multiline: {
|
||||||
|
max: 1,
|
||||||
|
allowFirstLine: false,
|
||||||
},
|
},
|
||||||
}],
|
},
|
||||||
'vue/html-self-closing': 'off',
|
],
|
||||||
"vue/no-v-html": 'off',
|
'vue/html-self-closing': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
html: {
|
||||||
|
void: 'always',
|
||||||
|
normal: 'always',
|
||||||
|
component: 'always',
|
||||||
|
},
|
||||||
|
svg: 'always',
|
||||||
|
math: 'always',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'vue/no-v-html': 'off',
|
||||||
'vue/singleline-html-element-content-newline': 'off',
|
'vue/singleline-html-element-content-newline': 'off',
|
||||||
'import/extensions': ['off'],
|
'import/extensions': ['off'],
|
||||||
'no-console': 'error'
|
'no-console': 'error',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
'import/resolver': {
|
'import/resolver': {
|
||||||
|
@ -41,12 +60,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
node: true,
|
|
||||||
jest: true,
|
jest: true,
|
||||||
jasmine: true
|
node: true,
|
||||||
},
|
},
|
||||||
globals: {
|
globals: {
|
||||||
__WEBPACK_ENV__: true,
|
|
||||||
bus: true,
|
bus: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
36
.github/workflows/lock.yml
vendored
Normal file
36
.github/workflows/lock.yml
vendored
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# We often have cases where users would comment over stale closed Github Issues.
|
||||||
|
# This creates unnecessary noise for the original reporter and makes it harder for triaging.
|
||||||
|
# This action locks the closed threads once it is inactive for over a month.
|
||||||
|
|
||||||
|
name: 'Lock Threads'
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 * * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: lock
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
action:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/lock-threads@v3
|
||||||
|
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.
|
58
.github/workflows/nightly_installer.yml
vendored
Normal file
58
.github/workflows/nightly_installer.yml
vendored
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# #
|
||||||
|
# #
|
||||||
|
# # Linux nightly installer action
|
||||||
|
# # This action will try to install and setup
|
||||||
|
# # chatwoot on an Ubuntu 20.04 machine using
|
||||||
|
# # the linux installer script.
|
||||||
|
# #
|
||||||
|
# # This is set to run daily at midnight.
|
||||||
|
# #
|
||||||
|
|
||||||
|
name: Run Linux nightly installer
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
nightly:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: get installer
|
||||||
|
run: |
|
||||||
|
wget https://get.chatwoot.app/linux/install.sh
|
||||||
|
chmod +x install.sh
|
||||||
|
|
||||||
|
- name: create input file
|
||||||
|
run: |
|
||||||
|
echo "no" > input
|
||||||
|
echo "yes" >> input
|
||||||
|
|
||||||
|
- name: Run the installer
|
||||||
|
run: |
|
||||||
|
sudo ./install.sh --install < input
|
||||||
|
|
||||||
|
# temp fix for postgresql not starting
|
||||||
|
# automatically in gh action env
|
||||||
|
- name: start postgresql service
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
sudo service postgresql start
|
||||||
|
|
||||||
|
#re-running the installer again
|
||||||
|
- name: Run the installer again
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
sudo ./install.sh --install < input
|
||||||
|
|
||||||
|
|
||||||
|
# disabling http verify for now as http
|
||||||
|
# access to port 3000 fails in gh action env
|
||||||
|
# - name: Verify
|
||||||
|
# if: always()
|
||||||
|
# run: |
|
||||||
|
# sudo netstat -ntlp | grep 3000
|
||||||
|
# sudo systemctl restart chatwoot.target
|
||||||
|
# curl http://localhost:3000/api
|
||||||
|
|
|
@ -183,3 +183,5 @@ AllCops:
|
||||||
- db/migrate/20200503151130_add_account_feature_flag.rb
|
- db/migrate/20200503151130_add_account_feature_flag.rb
|
||||||
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
|
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
|
||||||
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
|
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
|
||||||
|
- db/migrate/20220809104508_revert_cascading_indexes.rb
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Vuex from 'vuex';
|
||||||
import VueI18n from 'vue-i18n';
|
import VueI18n from 'vue-i18n';
|
||||||
import Vuelidate from 'vuelidate';
|
import Vuelidate from 'vuelidate';
|
||||||
import Multiselect from 'vue-multiselect';
|
import Multiselect from 'vue-multiselect';
|
||||||
|
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
||||||
|
|
||||||
import WootUiKit from '../app/javascript/dashboard/components';
|
import WootUiKit from '../app/javascript/dashboard/components';
|
||||||
import i18n from '../app/javascript/dashboard/i18n';
|
import i18n from '../app/javascript/dashboard/i18n';
|
||||||
|
@ -15,6 +16,7 @@ Vue.use(Vuelidate);
|
||||||
Vue.use(WootUiKit);
|
Vue.use(WootUiKit);
|
||||||
Vue.use(Vuex);
|
Vue.use(Vuex);
|
||||||
Vue.component('multiselect', Multiselect);
|
Vue.component('multiselect', Multiselect);
|
||||||
|
Vue.component('fluent-icon', FluentIcon);
|
||||||
|
|
||||||
const store = new Vuex.Store({});
|
const store = new Vuex.Store({});
|
||||||
const i18nConfig = new VueI18n({
|
const i18nConfig = new VueI18n({
|
||||||
|
|
23
Gemfile
23
Gemfile
|
@ -4,7 +4,7 @@ ruby '3.0.4'
|
||||||
|
|
||||||
##-- base gems for rails --##
|
##-- base gems for rails --##
|
||||||
gem 'rack-cors', require: 'rack/cors'
|
gem 'rack-cors', require: 'rack/cors'
|
||||||
gem 'rails'
|
gem 'rails', '~>6.1'
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
gem 'bootsnap', require: false
|
gem 'bootsnap', require: false
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ gem 'wisper', '2.0.0'
|
||||||
# TODO: bump up gem to 2.0
|
# TODO: bump up gem to 2.0
|
||||||
gem 'facebook-messenger'
|
gem 'facebook-messenger'
|
||||||
gem 'line-bot-api'
|
gem 'line-bot-api'
|
||||||
gem 'twilio-ruby', '~> 5.32.0'
|
gem 'twilio-ruby', '~> 5.66'
|
||||||
# twitty will handle subscription of twitter account events
|
# twitty will handle subscription of twitter account events
|
||||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||||
gem 'twitty'
|
gem 'twitty'
|
||||||
|
@ -89,12 +89,9 @@ gem 'slack-ruby-client'
|
||||||
# for dialogflow integrations
|
# for dialogflow integrations
|
||||||
gem 'google-cloud-dialogflow'
|
gem 'google-cloud-dialogflow'
|
||||||
|
|
||||||
##--- gems for debugging and error reporting ---##
|
|
||||||
# static analysis
|
|
||||||
gem 'brakeman'
|
|
||||||
|
|
||||||
##-- apm and error monitoring ---#
|
##-- apm and error monitoring ---#
|
||||||
gem 'ddtrace'
|
gem 'ddtrace'
|
||||||
|
gem 'elastic-apm'
|
||||||
gem 'newrelic_rpm'
|
gem 'newrelic_rpm'
|
||||||
gem 'scout_apm'
|
gem 'scout_apm'
|
||||||
gem 'sentry-rails', '~> 5.3'
|
gem 'sentry-rails', '~> 5.3'
|
||||||
|
@ -128,6 +125,16 @@ gem 'html2text'
|
||||||
# to calculate working hours
|
# to calculate working hours
|
||||||
gem 'working_hours'
|
gem 'working_hours'
|
||||||
|
|
||||||
|
# full text search for articles
|
||||||
|
gem 'pg_search'
|
||||||
|
|
||||||
|
# Subscriptions, Billing
|
||||||
|
gem 'stripe'
|
||||||
|
|
||||||
|
## - helper gems --##
|
||||||
|
## to populate db with sample data
|
||||||
|
gem 'faker'
|
||||||
|
|
||||||
group :production, :staging do
|
group :production, :staging do
|
||||||
# we dont want request timing out in development while using byebug
|
# we dont want request timing out in development while using byebug
|
||||||
gem 'rack-timeout'
|
gem 'rack-timeout'
|
||||||
|
@ -157,11 +164,13 @@ end
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem 'active_record_query_trace'
|
gem 'active_record_query_trace'
|
||||||
|
##--- gems for debugging and error reporting ---##
|
||||||
|
# static analysis
|
||||||
|
gem 'brakeman'
|
||||||
gem 'bundle-audit', require: false
|
gem 'bundle-audit', require: false
|
||||||
gem 'byebug', platform: :mri
|
gem 'byebug', platform: :mri
|
||||||
gem 'climate_control'
|
gem 'climate_control'
|
||||||
gem 'factory_bot_rails'
|
gem 'factory_bot_rails'
|
||||||
gem 'faker'
|
|
||||||
gem 'listen'
|
gem 'listen'
|
||||||
gem 'mock_redis'
|
gem 'mock_redis'
|
||||||
gem 'pry-rails'
|
gem 'pry-rails'
|
||||||
|
|
437
Gemfile.lock
437
Gemfile.lock
|
@ -9,63 +9,63 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (6.1.5.1)
|
actioncable (6.1.6.1)
|
||||||
actionpack (= 6.1.5.1)
|
actionpack (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (6.1.5.1)
|
actionmailbox (6.1.6.1)
|
||||||
actionpack (= 6.1.5.1)
|
actionpack (= 6.1.6.1)
|
||||||
activejob (= 6.1.5.1)
|
activejob (= 6.1.6.1)
|
||||||
activerecord (= 6.1.5.1)
|
activerecord (= 6.1.6.1)
|
||||||
activestorage (= 6.1.5.1)
|
activestorage (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
actionmailer (6.1.5.1)
|
actionmailer (6.1.6.1)
|
||||||
actionpack (= 6.1.5.1)
|
actionpack (= 6.1.6.1)
|
||||||
actionview (= 6.1.5.1)
|
actionview (= 6.1.6.1)
|
||||||
activejob (= 6.1.5.1)
|
activejob (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (6.1.5.1)
|
actionpack (6.1.6.1)
|
||||||
actionview (= 6.1.5.1)
|
actionview (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
rack (~> 2.0, >= 2.0.9)
|
rack (~> 2.0, >= 2.0.9)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||||
actiontext (6.1.5.1)
|
actiontext (6.1.6.1)
|
||||||
actionpack (= 6.1.5.1)
|
actionpack (= 6.1.6.1)
|
||||||
activerecord (= 6.1.5.1)
|
activerecord (= 6.1.6.1)
|
||||||
activestorage (= 6.1.5.1)
|
activestorage (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (6.1.5.1)
|
actionview (6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||||
active_record_query_trace (1.8)
|
active_record_query_trace (1.8)
|
||||||
activejob (6.1.5.1)
|
activejob (6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (6.1.5.1)
|
activemodel (6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
activerecord (6.1.5.1)
|
activerecord (6.1.6.1)
|
||||||
activemodel (= 6.1.5.1)
|
activemodel (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
activerecord-import (1.3.0)
|
activerecord-import (1.4.0)
|
||||||
activerecord (>= 4.2)
|
activerecord (>= 4.2)
|
||||||
activestorage (6.1.5.1)
|
activestorage (6.1.6.1)
|
||||||
actionpack (= 6.1.5.1)
|
actionpack (= 6.1.6.1)
|
||||||
activejob (= 6.1.5.1)
|
activejob (= 6.1.6.1)
|
||||||
activerecord (= 6.1.5.1)
|
activerecord (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
mini_mime (>= 1.1.0)
|
mini_mime (>= 1.1.0)
|
||||||
activesupport (6.1.5.1)
|
activesupport (6.1.6.1)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
|
@ -91,20 +91,20 @@ GEM
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
attr_extras (6.2.5)
|
attr_extras (6.2.5)
|
||||||
aws-eventstream (1.2.0)
|
aws-eventstream (1.2.0)
|
||||||
aws-partitions (1.556.0)
|
aws-partitions (1.605.0)
|
||||||
aws-sdk-core (3.126.2)
|
aws-sdk-core (3.131.2)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.525.0)
|
aws-partitions (~> 1, >= 1.525.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
jmespath (~> 1.0)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.54.0)
|
aws-sdk-kms (1.57.0)
|
||||||
aws-sdk-core (~> 3, >= 3.126.0)
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.112.0)
|
aws-sdk-s3 (1.114.0)
|
||||||
aws-sdk-core (~> 3, >= 3.126.0)
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.4)
|
||||||
aws-sigv4 (1.4.0)
|
aws-sigv4 (1.5.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-storage-blob (2.0.3)
|
azure-storage-blob (2.0.3)
|
||||||
azure-storage-common (~> 2.0)
|
azure-storage-common (~> 2.0)
|
||||||
|
@ -117,31 +117,31 @@ GEM
|
||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
statsd-ruby (~> 1.1)
|
statsd-ruby (~> 1.1)
|
||||||
bcrypt (3.1.16)
|
bcrypt (3.1.18)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.10.3)
|
bootsnap (1.12.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (5.2.1)
|
brakeman (5.2.3)
|
||||||
browser (5.3.1)
|
browser (5.3.1)
|
||||||
builder (3.2.4)
|
builder (3.2.4)
|
||||||
bullet (7.0.1)
|
bullet (7.0.2)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
bundle-audit (0.1.0)
|
bundle-audit (0.1.0)
|
||||||
bundler-audit
|
bundler-audit
|
||||||
bundler-audit (0.9.0.1)
|
bundler-audit (0.9.1)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
climate_control (1.0.1)
|
climate_control (1.1.1)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.4)
|
commonmarker (0.23.5)
|
||||||
concurrent-ruby (1.1.10)
|
concurrent-ruby (1.1.10)
|
||||||
connection_pool (2.2.5)
|
connection_pool (2.2.5)
|
||||||
crack (0.4.5)
|
crack (0.4.5)
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
cypress-on-rails (1.12.1)
|
cypress-on-rails (1.13.1)
|
||||||
rack
|
rack
|
||||||
database_cleaner (2.0.1)
|
database_cleaner (2.0.1)
|
||||||
database_cleaner-active_record (~> 2.0.0)
|
database_cleaner-active_record (~> 2.0.0)
|
||||||
|
@ -151,10 +151,12 @@ GEM
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
datetime_picker_rails (0.0.7)
|
datetime_picker_rails (0.0.7)
|
||||||
momentjs-rails (>= 2.8.1)
|
momentjs-rails (>= 2.8.1)
|
||||||
ddtrace (0.54.2)
|
ddtrace (1.2.0)
|
||||||
debase-ruby_core_source (<= 0.10.14)
|
debase-ruby_core_source (= 0.10.16)
|
||||||
|
libddprof (~> 0.6.0.1.0)
|
||||||
|
libddwaf (~> 1.3.0.2.0)
|
||||||
msgpack
|
msgpack
|
||||||
debase-ruby_core_source (0.10.14)
|
debase-ruby_core_source (0.10.16)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
devise (4.8.1)
|
devise (4.8.1)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
|
@ -176,10 +178,13 @@ GEM
|
||||||
dotenv-rails (2.7.6)
|
dotenv-rails (2.7.6)
|
||||||
dotenv (= 2.7.6)
|
dotenv (= 2.7.6)
|
||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
down (5.3.0)
|
down (5.3.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
ecma-re-validator (0.4.0)
|
ecma-re-validator (0.4.0)
|
||||||
regexp_parser (~> 2.2)
|
regexp_parser (~> 2.2)
|
||||||
|
elastic-apm (4.5.1)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
http (>= 3.0)
|
||||||
email_reply_trimmer (0.1.13)
|
email_reply_trimmer (0.1.13)
|
||||||
erubi (1.10.0)
|
erubi (1.10.0)
|
||||||
et-orbi (1.2.7)
|
et-orbi (1.2.7)
|
||||||
|
@ -188,36 +193,63 @@ GEM
|
||||||
facebook-messenger (2.0.1)
|
facebook-messenger (2.0.1)
|
||||||
httparty (~> 0.13, >= 0.13.7)
|
httparty (~> 0.13, >= 0.13.7)
|
||||||
rack (>= 1.4.5)
|
rack (>= 1.4.5)
|
||||||
factory_bot (6.2.0)
|
factory_bot (6.2.1)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
factory_bot_rails (6.2.0)
|
factory_bot_rails (6.2.0)
|
||||||
factory_bot (~> 6.2.0)
|
factory_bot (~> 6.2.0)
|
||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (2.19.0)
|
faker (2.21.0)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (1.0.1)
|
faraday (1.10.0)
|
||||||
multipart-post (>= 1.2, < 3)
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.0)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (1.0.1)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
faraday_middleware (1.2.0)
|
faraday_middleware (1.2.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fcm (1.0.5)
|
fcm (1.0.8)
|
||||||
faraday (~> 1)
|
faraday (>= 1.0.0, < 3.0)
|
||||||
|
googleauth (~> 1)
|
||||||
ffi (1.15.5)
|
ffi (1.15.5)
|
||||||
|
ffi-compiler (1.0.1)
|
||||||
|
ffi (>= 1.0.0)
|
||||||
|
rake
|
||||||
flag_shih_tzu (0.3.23)
|
flag_shih_tzu (0.3.23)
|
||||||
foreman (0.87.2)
|
foreman (0.87.2)
|
||||||
fugit (1.5.3)
|
fugit (1.5.3)
|
||||||
et-orbi (~> 1, >= 1.2.7)
|
et-orbi (~> 1, >= 1.2.7)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
gapic-common (0.3.4)
|
gapic-common (0.10.0)
|
||||||
google-protobuf (~> 3.12, >= 3.12.2)
|
faraday (>= 1.9, < 3.a)
|
||||||
googleapis-common-protos (>= 1.3.9, < 2.0)
|
faraday-retry (>= 1.0, < 3.a)
|
||||||
googleapis-common-protos-types (>= 1.0.4, < 2.0)
|
google-protobuf (~> 3.14)
|
||||||
googleauth (~> 0.9)
|
googleapis-common-protos (>= 1.3.12, < 2.a)
|
||||||
grpc (~> 1.25)
|
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||||
geocoder (1.7.3)
|
googleauth (~> 1.0)
|
||||||
|
grpc (~> 1.36)
|
||||||
|
geocoder (1.8.0)
|
||||||
gli (2.21.0)
|
gli (2.21.0)
|
||||||
globalid (1.0.0)
|
globalid (1.0.0)
|
||||||
activesupport (>= 5.0)
|
activesupport (>= 5.0)
|
||||||
google-apis-core (0.4.2)
|
google-apis-core (0.7.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
@ -226,23 +258,27 @@ GEM
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
rexml
|
||||||
webrick
|
webrick
|
||||||
google-apis-iamcredentials_v1 (0.10.0)
|
google-apis-iamcredentials_v1 (0.13.0)
|
||||||
google-apis-core (>= 0.4, < 2.a)
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
google-apis-storage_v1 (0.11.0)
|
google-apis-storage_v1 (0.18.0)
|
||||||
google-apis-core (>= 0.4, < 2.a)
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
google-cloud-core (1.6.0)
|
google-cloud-core (1.6.0)
|
||||||
google-cloud-env (~> 1.0)
|
google-cloud-env (~> 1.0)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-dialogflow (1.2.0)
|
google-cloud-dialogflow (1.5.0)
|
||||||
google-cloud-core (~> 1.5)
|
google-cloud-core (~> 1.6)
|
||||||
google-cloud-dialogflow-v2 (~> 0.1)
|
google-cloud-dialogflow-v2 (>= 0.15, < 2.a)
|
||||||
google-cloud-dialogflow-v2 (0.6.4)
|
google-cloud-dialogflow-v2 (0.17.0)
|
||||||
gapic-common (~> 0.3)
|
gapic-common (>= 0.10, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.5.0)
|
google-cloud-location (>= 0.0, < 2.a)
|
||||||
faraday (>= 0.17.3, < 2.0)
|
google-cloud-env (1.6.0)
|
||||||
|
faraday (>= 0.17.3, < 3.0)
|
||||||
google-cloud-errors (1.2.0)
|
google-cloud-errors (1.2.0)
|
||||||
google-cloud-storage (1.36.1)
|
google-cloud-location (0.2.0)
|
||||||
|
gapic-common (>= 0.10, < 2.a)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-storage (1.37.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
|
@ -250,32 +286,32 @@ GEM
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
google-protobuf (3.19.4)
|
google-protobuf (3.21.2)
|
||||||
google-protobuf (3.19.4-x86_64-darwin)
|
google-protobuf (3.21.2-x86_64-darwin)
|
||||||
google-protobuf (3.19.4-x86_64-linux)
|
google-protobuf (3.21.2-x86_64-linux)
|
||||||
googleapis-common-protos (1.3.12)
|
googleapis-common-protos (1.3.12)
|
||||||
google-protobuf (~> 3.14)
|
google-protobuf (~> 3.14)
|
||||||
googleapis-common-protos-types (~> 1.2)
|
googleapis-common-protos-types (~> 1.2)
|
||||||
grpc (~> 1.27)
|
grpc (~> 1.27)
|
||||||
googleapis-common-protos-types (1.3.0)
|
googleapis-common-protos-types (1.3.2)
|
||||||
google-protobuf (~> 3.14)
|
google-protobuf (~> 3.14)
|
||||||
googleauth (0.17.1)
|
googleauth (1.2.0)
|
||||||
faraday (>= 0.17.3, < 2.0)
|
faraday (>= 0.17.3, < 3.a)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
memoist (~> 0.16)
|
memoist (~> 0.16)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
signet (~> 0.15)
|
signet (>= 0.16, < 2.a)
|
||||||
groupdate (6.0.1)
|
groupdate (6.1.0)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
grpc (1.43.1)
|
grpc (1.47.0)
|
||||||
google-protobuf (~> 3.18)
|
google-protobuf (~> 3.19)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.43.1-universal-darwin)
|
grpc (1.47.0-x86_64-darwin)
|
||||||
google-protobuf (~> 3.18)
|
google-protobuf (~> 3.19)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
grpc (1.43.1-x86_64-linux)
|
grpc (1.47.0-x86_64-linux)
|
||||||
google-protobuf (~> 3.18)
|
google-protobuf (~> 3.19)
|
||||||
googleapis-common-protos-types (~> 1.0)
|
googleapis-common-protos-types (~> 1.0)
|
||||||
haikunator (1.1.1)
|
haikunator (1.1.1)
|
||||||
hairtrigger (0.2.25)
|
hairtrigger (0.2.25)
|
||||||
|
@ -288,14 +324,20 @@ GEM
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
html2text (0.2.1)
|
html2text (0.2.1)
|
||||||
nokogiri (~> 1.6)
|
nokogiri (~> 1.6)
|
||||||
|
http (5.1.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
http-cookie (~> 1.0)
|
||||||
|
http-form_data (~> 2.2)
|
||||||
|
llhttp-ffi (~> 0.4.0)
|
||||||
http-accept (1.7.0)
|
http-accept (1.7.0)
|
||||||
http-cookie (1.0.4)
|
http-cookie (1.0.5)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
|
http-form_data (2.3.0)
|
||||||
httparty (0.20.0)
|
httparty (0.20.0)
|
||||||
mime-types (~> 3.0)
|
mime-types (~> 3.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
i18n (1.10.0)
|
i18n (1.11.0)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.12.2)
|
image_processing (1.12.2)
|
||||||
mini_magick (>= 4.9.5, < 5)
|
mini_magick (>= 4.9.5, < 5)
|
||||||
|
@ -303,20 +345,20 @@ GEM
|
||||||
jbuilder (2.11.5)
|
jbuilder (2.11.5)
|
||||||
actionview (>= 5.0.0)
|
actionview (>= 5.0.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
jmespath (1.6.0)
|
jmespath (1.6.1)
|
||||||
jquery-rails (4.4.0)
|
jquery-rails (4.5.0)
|
||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.6.1)
|
json (2.6.2)
|
||||||
json_refs (0.1.7)
|
json_refs (0.1.7)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.19)
|
json_schemer (0.2.21)
|
||||||
ecma-re-validator (~> 0.3)
|
ecma-re-validator (~> 0.3)
|
||||||
hana (~> 1.3)
|
hana (~> 1.3)
|
||||||
regexp_parser (~> 2.0)
|
regexp_parser (~> 2.0)
|
||||||
uri_template (~> 0.7)
|
uri_template (~> 0.7)
|
||||||
jwt (2.3.0)
|
jwt (2.4.1)
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
kaminari-actionview (= 1.2.2)
|
kaminari-actionview (= 1.2.2)
|
||||||
|
@ -329,21 +371,34 @@ GEM
|
||||||
activerecord
|
activerecord
|
||||||
kaminari-core (= 1.2.2)
|
kaminari-core (= 1.2.2)
|
||||||
kaminari-core (1.2.2)
|
kaminari-core (1.2.2)
|
||||||
koala (3.1.0)
|
koala (3.2.0)
|
||||||
addressable
|
addressable
|
||||||
faraday (< 2)
|
faraday (< 2)
|
||||||
json (>= 1.8)
|
json (>= 1.8)
|
||||||
rexml
|
rexml
|
||||||
launchy (2.5.0)
|
launchy (2.5.0)
|
||||||
addressable (~> 2.7)
|
addressable (~> 2.7)
|
||||||
letter_opener (1.7.0)
|
letter_opener (1.8.1)
|
||||||
launchy (~> 2.2)
|
launchy (>= 2.2, < 3)
|
||||||
line-bot-api (1.23.0)
|
libddprof (0.6.0.1.0)
|
||||||
liquid (5.1.0)
|
libddprof (0.6.0.1.0-x86_64-linux)
|
||||||
|
libddwaf (1.3.0.2.0)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
libddwaf (1.3.0.2.0-arm64-darwin)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
libddwaf (1.3.0.2.0-x86_64-darwin)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
libddwaf (1.3.0.2.0-x86_64-linux)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
line-bot-api (1.25.0)
|
||||||
|
liquid (5.3.0)
|
||||||
listen (3.7.1)
|
listen (3.7.1)
|
||||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
rb-inotify (~> 0.9, >= 0.9.10)
|
rb-inotify (~> 0.9, >= 0.9.10)
|
||||||
loofah (2.17.0)
|
llhttp-ffi (0.4.0)
|
||||||
|
ffi-compiler (~> 1.0)
|
||||||
|
rake (~> 13.0)
|
||||||
|
loofah (2.18.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.5.9)
|
nokogiri (>= 1.5.9)
|
||||||
mail (2.7.1)
|
mail (2.7.1)
|
||||||
|
@ -358,36 +413,39 @@ GEM
|
||||||
mini_magick (4.11.0)
|
mini_magick (4.11.0)
|
||||||
mini_mime (1.1.2)
|
mini_mime (1.1.2)
|
||||||
mini_portile2 (2.8.0)
|
mini_portile2 (2.8.0)
|
||||||
minitest (5.15.0)
|
minitest (5.16.2)
|
||||||
mock_redis (0.30.0)
|
mock_redis (0.32.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
momentjs-rails (2.29.1.1)
|
momentjs-rails (2.29.1.1)
|
||||||
railties (>= 3.1)
|
railties (>= 3.1)
|
||||||
msgpack (1.4.5)
|
msgpack (1.5.3)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
multipart-post (2.1.1)
|
multipart-post (2.2.3)
|
||||||
net-http-persistent (4.0.1)
|
net-http-persistent (4.0.1)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
netrc (0.11.0)
|
netrc (0.11.0)
|
||||||
newrelic_rpm (8.7.0)
|
newrelic_rpm (8.9.0)
|
||||||
nio4r (2.5.8)
|
nio4r (2.5.8)
|
||||||
nokogiri (1.13.6)
|
nokogiri (1.13.7)
|
||||||
mini_portile2 (~> 2.8.0)
|
mini_portile2 (~> 2.8.0)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.13.6-arm64-darwin)
|
nokogiri (1.13.7-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.13.6-x86_64-darwin)
|
nokogiri (1.13.7-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.13.6-x86_64-linux)
|
nokogiri (1.13.7-x86_64-linux)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (0.5.8)
|
oauth (0.5.10)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
parallel (1.21.0)
|
parallel (1.22.1)
|
||||||
parser (3.1.1.0)
|
parser (3.1.2.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
pg (1.3.2)
|
pg (1.4.1)
|
||||||
|
pg_search (2.3.6)
|
||||||
|
activerecord (>= 5.2)
|
||||||
|
activesupport (>= 5.2)
|
||||||
procore-sift (0.16.0)
|
procore-sift (0.16.0)
|
||||||
rails (> 4.2.0)
|
rails (> 4.2.0)
|
||||||
pry (0.14.1)
|
pry (0.14.1)
|
||||||
|
@ -395,46 +453,46 @@ GEM
|
||||||
method_source (~> 1.0)
|
method_source (~> 1.0)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (4.0.6)
|
public_suffix (4.0.7)
|
||||||
puma (5.6.4)
|
puma (5.6.4)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.2.0)
|
pundit (2.2.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.6.0)
|
racc (1.6.0)
|
||||||
rack (2.2.3.1)
|
rack (2.2.4)
|
||||||
rack-attack (6.6.0)
|
rack-attack (6.6.1)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.0, < 3)
|
||||||
rack-cors (1.1.1)
|
rack-cors (1.1.1)
|
||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-proxy (0.7.2)
|
rack-proxy (0.7.2)
|
||||||
rack
|
rack
|
||||||
rack-test (1.1.0)
|
rack-test (2.0.2)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.3)
|
||||||
rack-timeout (0.6.0)
|
rack-timeout (0.6.3)
|
||||||
rails (6.1.5.1)
|
rails (6.1.6.1)
|
||||||
actioncable (= 6.1.5.1)
|
actioncable (= 6.1.6.1)
|
||||||
actionmailbox (= 6.1.5.1)
|
actionmailbox (= 6.1.6.1)
|
||||||
actionmailer (= 6.1.5.1)
|
actionmailer (= 6.1.6.1)
|
||||||
actionpack (= 6.1.5.1)
|
actionpack (= 6.1.6.1)
|
||||||
actiontext (= 6.1.5.1)
|
actiontext (= 6.1.6.1)
|
||||||
actionview (= 6.1.5.1)
|
actionview (= 6.1.6.1)
|
||||||
activejob (= 6.1.5.1)
|
activejob (= 6.1.6.1)
|
||||||
activemodel (= 6.1.5.1)
|
activemodel (= 6.1.6.1)
|
||||||
activerecord (= 6.1.5.1)
|
activerecord (= 6.1.6.1)
|
||||||
activestorage (= 6.1.5.1)
|
activestorage (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 6.1.5.1)
|
railties (= 6.1.6.1)
|
||||||
sprockets-rails (>= 2.0.0)
|
sprockets-rails (>= 2.0.0)
|
||||||
rails-dom-testing (2.0.3)
|
rails-dom-testing (2.0.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.4.2)
|
rails-html-sanitizer (1.4.3)
|
||||||
loofah (~> 2.3)
|
loofah (~> 2.3)
|
||||||
railties (6.1.5.1)
|
railties (6.1.6.1)
|
||||||
actionpack (= 6.1.5.1)
|
actionpack (= 6.1.6.1)
|
||||||
activesupport (= 6.1.5.1)
|
activesupport (= 6.1.6.1)
|
||||||
method_source
|
method_source
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
|
@ -443,11 +501,11 @@ GEM
|
||||||
rb-fsevent (0.11.1)
|
rb-fsevent (0.11.1)
|
||||||
rb-inotify (0.10.1)
|
rb-inotify (0.10.1)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
redis (4.6.0)
|
redis (4.7.1)
|
||||||
redis-namespace (1.8.1)
|
redis-namespace (1.8.2)
|
||||||
redis (>= 3.0.4)
|
redis (>= 3.0.4)
|
||||||
regexp_parser (2.2.1)
|
regexp_parser (2.5.0)
|
||||||
representable (3.1.1)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
|
@ -466,7 +524,7 @@ GEM
|
||||||
rspec-expectations (3.11.0)
|
rspec-expectations (3.11.0)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.11.0)
|
rspec-support (~> 3.11.0)
|
||||||
rspec-mocks (3.11.0)
|
rspec-mocks (3.11.1)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.11.0)
|
rspec-support (~> 3.11.0)
|
||||||
rspec-rails (5.0.3)
|
rspec-rails (5.0.3)
|
||||||
|
@ -478,26 +536,27 @@ GEM
|
||||||
rspec-mocks (~> 3.10)
|
rspec-mocks (~> 3.10)
|
||||||
rspec-support (~> 3.10)
|
rspec-support (~> 3.10)
|
||||||
rspec-support (3.11.0)
|
rspec-support (3.11.0)
|
||||||
rubocop (1.25.1)
|
rubocop (1.31.2)
|
||||||
|
json (~> 2.3)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.1.0.0)
|
parser (>= 3.1.0.0)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 1.8, < 3.0)
|
||||||
rexml
|
rexml (>= 3.2.5, < 4.0)
|
||||||
rubocop-ast (>= 1.15.1, < 2.0)
|
rubocop-ast (>= 1.18.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 1.4.0, < 3.0)
|
unicode-display_width (>= 1.4.0, < 3.0)
|
||||||
rubocop-ast (1.16.0)
|
rubocop-ast (1.19.1)
|
||||||
parser (>= 3.1.1.0)
|
parser (>= 3.1.1.0)
|
||||||
rubocop-performance (1.13.2)
|
rubocop-performance (1.14.2)
|
||||||
rubocop (>= 1.7.0, < 2.0)
|
rubocop (>= 1.7.0, < 2.0)
|
||||||
rubocop-ast (>= 0.4.0)
|
rubocop-ast (>= 0.4.0)
|
||||||
rubocop-rails (2.13.2)
|
rubocop-rails (2.15.2)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.7.0, < 2.0)
|
rubocop (>= 1.7.0, < 2.0)
|
||||||
rubocop-rspec (2.8.0)
|
rubocop-rspec (2.12.1)
|
||||||
rubocop (~> 1.19)
|
rubocop (~> 1.31)
|
||||||
ruby-progressbar (1.11.0)
|
ruby-progressbar (1.11.0)
|
||||||
ruby-vips (2.1.4)
|
ruby-vips (2.1.4)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
|
@ -505,7 +564,7 @@ GEM
|
||||||
ruby2ruby (2.4.4)
|
ruby2ruby (2.4.4)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_parser (3.18.1)
|
ruby_parser (3.19.1)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sassc (2.4.0)
|
sassc (2.4.0)
|
||||||
ffi (~> 1.9)
|
ffi (~> 1.9)
|
||||||
|
@ -515,37 +574,37 @@ GEM
|
||||||
sprockets (> 3.0)
|
sprockets (> 3.0)
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
tilt
|
tilt
|
||||||
scout_apm (5.1.1)
|
scout_apm (5.2.0)
|
||||||
parser
|
parser
|
||||||
seed_dump (3.3.1)
|
seed_dump (3.3.1)
|
||||||
activerecord (>= 4)
|
activerecord (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
selectize-rails (0.12.6)
|
selectize-rails (0.12.6)
|
||||||
semantic_range (3.0.0)
|
semantic_range (3.0.0)
|
||||||
sentry-rails (5.3.0)
|
sentry-rails (5.3.1)
|
||||||
railties (>= 5.0)
|
railties (>= 5.0)
|
||||||
sentry-ruby-core (~> 5.3.0)
|
sentry-ruby-core (~> 5.3.1)
|
||||||
sentry-ruby (5.3.0)
|
sentry-ruby (5.3.1)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
sentry-ruby-core (= 5.3.0)
|
sentry-ruby-core (= 5.3.1)
|
||||||
sentry-ruby-core (5.3.0)
|
sentry-ruby-core (5.3.1)
|
||||||
concurrent-ruby
|
concurrent-ruby
|
||||||
sentry-sidekiq (5.3.0)
|
sentry-sidekiq (5.3.1)
|
||||||
sentry-ruby-core (~> 5.3.0)
|
sentry-ruby-core (~> 5.3.1)
|
||||||
sidekiq (>= 3.0)
|
sidekiq (>= 3.0)
|
||||||
sexp_processor (4.16.0)
|
sexp_processor (4.16.1)
|
||||||
shoulda-matchers (5.1.0)
|
shoulda-matchers (5.1.0)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
sidekiq (6.4.1)
|
sidekiq (6.4.2)
|
||||||
connection_pool (>= 2.2.2)
|
connection_pool (>= 2.2.2)
|
||||||
rack (~> 2.0)
|
rack (~> 2.0)
|
||||||
redis (>= 4.2.0)
|
redis (>= 4.2.0)
|
||||||
sidekiq-cron (1.4.0)
|
sidekiq-cron (1.6.0)
|
||||||
fugit (~> 1)
|
fugit (~> 1)
|
||||||
sidekiq (>= 4.2.1)
|
sidekiq (>= 4.2.1)
|
||||||
signet (0.16.0)
|
signet (0.17.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.3, < 2.0)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 3.0)
|
||||||
multi_json (~> 1.10)
|
multi_json (~> 1.10)
|
||||||
simplecov (0.17.1)
|
simplecov (0.17.1)
|
||||||
|
@ -563,7 +622,7 @@ GEM
|
||||||
spring-watcher-listen (2.0.1)
|
spring-watcher-listen (2.0.1)
|
||||||
listen (>= 2.7, < 4.0)
|
listen (>= 2.7, < 4.0)
|
||||||
spring (>= 1.2, < 3.0)
|
spring (>= 1.2, < 3.0)
|
||||||
sprockets (4.0.3)
|
sprockets (4.1.1)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
rack (> 1, < 3)
|
rack (> 1, < 3)
|
||||||
sprockets-rails (3.4.2)
|
sprockets-rails (3.4.2)
|
||||||
|
@ -572,31 +631,32 @@ GEM
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
squasher (0.6.2)
|
squasher (0.6.2)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
telephone_number (1.4.13)
|
stripe (6.5.0)
|
||||||
|
telephone_number (1.4.16)
|
||||||
thor (1.2.1)
|
thor (1.2.1)
|
||||||
tilt (2.0.10)
|
tilt (2.0.10)
|
||||||
time_diff (0.3.0)
|
time_diff (0.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
i18n
|
i18n
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
twilio-ruby (5.32.0)
|
twilio-ruby (5.68.0)
|
||||||
faraday (~> 1.0.0)
|
faraday (>= 0.9, < 3.0)
|
||||||
jwt (>= 1.5, <= 2.5)
|
jwt (>= 1.5, <= 2.5)
|
||||||
nokogiri (>= 1.6, < 2.0)
|
nokogiri (>= 1.6, < 2.0)
|
||||||
twitty (0.1.4)
|
twitty (0.1.4)
|
||||||
oauth
|
oauth
|
||||||
tzinfo (2.0.4)
|
tzinfo (2.0.4)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
tzinfo-data (1.2021.5)
|
tzinfo-data (1.2022.1)
|
||||||
tzinfo (>= 1.0.0)
|
tzinfo (>= 1.0.0)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
uglifier (4.2.0)
|
uglifier (4.2.0)
|
||||||
execjs (>= 0.3.0, < 3)
|
execjs (>= 0.3.0, < 3)
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.8)
|
unf_ext (0.0.8.2)
|
||||||
unicode-display_width (2.1.0)
|
unicode-display_width (2.2.0)
|
||||||
uniform_notifier (1.14.2)
|
uniform_notifier (1.16.0)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (4.0.3)
|
valid_email2 (4.0.3)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
|
@ -628,7 +688,7 @@ GEM
|
||||||
working_hours (1.4.1)
|
working_hours (1.4.1)
|
||||||
activesupport (>= 3.2)
|
activesupport (>= 3.2)
|
||||||
tzinfo
|
tzinfo
|
||||||
zeitwerk (2.5.4)
|
zeitwerk (2.6.0)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
arm64-darwin-20
|
arm64-darwin-20
|
||||||
|
@ -664,6 +724,7 @@ DEPENDENCIES
|
||||||
devise_token_auth
|
devise_token_auth
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
down (~> 5.0)
|
down (~> 5.0)
|
||||||
|
elastic-apm
|
||||||
email_reply_trimmer
|
email_reply_trimmer
|
||||||
facebook-messenger
|
facebook-messenger
|
||||||
factory_bot_rails
|
factory_bot_rails
|
||||||
|
@ -694,6 +755,7 @@ DEPENDENCIES
|
||||||
mock_redis
|
mock_redis
|
||||||
newrelic_rpm
|
newrelic_rpm
|
||||||
pg
|
pg
|
||||||
|
pg_search
|
||||||
procore-sift
|
procore-sift
|
||||||
pry-rails
|
pry-rails
|
||||||
puma
|
puma
|
||||||
|
@ -701,7 +763,7 @@ DEPENDENCIES
|
||||||
rack-attack
|
rack-attack
|
||||||
rack-cors
|
rack-cors
|
||||||
rack-timeout
|
rack-timeout
|
||||||
rails
|
rails (~> 6.1)
|
||||||
redis
|
redis
|
||||||
redis-namespace
|
redis-namespace
|
||||||
responders
|
responders
|
||||||
|
@ -724,9 +786,10 @@ DEPENDENCIES
|
||||||
spring
|
spring
|
||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
|
stripe
|
||||||
telephone_number
|
telephone_number
|
||||||
time_diff
|
time_diff
|
||||||
twilio-ruby (~> 5.32.0)
|
twilio-ruby (~> 5.66)
|
||||||
twitty
|
twitty
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
uglifier
|
uglifier
|
||||||
|
@ -742,4 +805,4 @@ RUBY VERSION
|
||||||
ruby 3.0.4p208
|
ruby 3.0.4p208
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.3.14
|
2.3.17
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
___
|
___
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/80f9e1a7c72d186289ad/maintainability" alt="Maintainability"></a>
|
<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">
|
<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/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>
|
<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>
|
||||||
|
@ -26,6 +26,7 @@ ___
|
||||||
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
|
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
|
||||||
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
|
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
|
||||||
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
|
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
|
||||||
|
<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>
|
</p>
|
||||||
|
|
||||||
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
|
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
|
||||||
|
|
49
SECURITY.md
49
SECURITY.md
|
@ -1,30 +1,55 @@
|
||||||
# Security Policy
|
Chatwoot is looking forward to working with security researchers worldwide to keep Chatwoot and our users safe. If you have found an issue in our systems/applications, please reach out to us.
|
||||||
Chatwoot is looking forward to working with security researchers across the world to keep Chatwoot and our users safe. If you have found an issue in our systems/applications, please reach out to us.
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
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).
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
If you have any questions about the process, feel free to reach out to 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 Huntr as quickly as possible.
|
||||||
|
|
||||||
|
> 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 |
|
||||||
|
| ------- | -------------- |
|
||||||
|
| latest | ️✅ |
|
||||||
|
| <latest | ❌ |
|
||||||
|
|
||||||
|
|
||||||
## Out of scope
|
## Vulnerabilities we care about 🫣
|
||||||
|
> Note: Please do not perform testing against Chatwoot production services. Use a `self-hosted instance` to perform tests.
|
||||||
|
- Remote command execution
|
||||||
|
- SQL Injection
|
||||||
|
- Authentication bypass
|
||||||
|
- Privilege Escalation
|
||||||
|
- Cross-site scripting (XSS)
|
||||||
|
- Performing limited admin actions without authorization
|
||||||
|
- CSRF
|
||||||
|
|
||||||
Please do not perform testing against Chatwoot production services. Use a self hosted instance to perform tests.
|
You can learn more about our triaging process [here](https://www.chatwoot.com/docs/contributing-guide/security-reports).
|
||||||
|
|
||||||
We consider the following to be out of scope, though there may be exceptions.
|
## Non-Qualifying Vulnerabilities
|
||||||
|
|
||||||
|
We consider the following out of scope, though there may be exceptions.
|
||||||
|
|
||||||
- Missing HTTP security headers
|
- Missing HTTP security headers
|
||||||
- Self XSS
|
- Incomplete/Missing SPF/DKIM
|
||||||
- HTTP Host Header XSS without working proof-of-concept
|
- Reports from automated tools or scanners
|
||||||
|
- Theoretical attacks without proof of exploitability
|
||||||
|
- Social engineering
|
||||||
|
- Reflected file download
|
||||||
|
- Physical attacks
|
||||||
|
- Weak SSL/TLS/SSH algorithms or protocols
|
||||||
|
- Attacks involving physical access to a user's device or a device or network that's already seriously compromised (e.g., man-in-the-middle).
|
||||||
|
- The user attacks themselves
|
||||||
- Incomplete/Missing SPF/DKIM
|
- Incomplete/Missing SPF/DKIM
|
||||||
- Denial of Service attacks
|
- Denial of Service attacks
|
||||||
|
- Brute force attacks
|
||||||
- DNSSEC
|
- DNSSEC
|
||||||
- Social Engineering attacks
|
|
||||||
|
|
||||||
If you are not sure about the scope, please create a report.
|
If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
|
||||||
|
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
|
|
1
VERSION_CW
Normal file
1
VERSION_CW
Normal file
|
@ -0,0 +1 @@
|
||||||
|
2.2.0
|
1
VERSION_CWCTL
Normal file
1
VERSION_CWCTL
Normal file
|
@ -0,0 +1 @@
|
||||||
|
2.1.0
|
1
app.json
1
app.json
|
@ -48,6 +48,7 @@
|
||||||
"size": "FREE"
|
"size": "FREE"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"stack": "heroku-20",
|
||||||
"image": "heroku/ruby",
|
"image": "heroku/ruby",
|
||||||
"addons": [ "heroku-redis", "heroku-postgresql"],
|
"addons": [ "heroku-redis", "heroku-postgresql"],
|
||||||
"buildpacks": [
|
"buildpacks": [
|
||||||
|
|
|
@ -1,7 +1,16 @@
|
||||||
|
# retain_original_contact_name: false / true
|
||||||
|
# In case of setUser we want to update the name of the identified contact,
|
||||||
|
# which is the default behaviour
|
||||||
|
#
|
||||||
|
# But, In case of contact merge during prechat form contact update.
|
||||||
|
# We don't want to update the name of the identified original contact.
|
||||||
|
|
||||||
class ContactIdentifyAction
|
class ContactIdentifyAction
|
||||||
pattr_initialize [:contact!, :params!]
|
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
@attributes_to_update = [:identifier, :name, :email, :phone_number]
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
merge_if_existing_identified_contact
|
merge_if_existing_identified_contact
|
||||||
merge_if_existing_email_contact
|
merge_if_existing_email_contact
|
||||||
|
@ -18,49 +27,89 @@ class ContactIdentifyAction
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_if_existing_identified_contact
|
def merge_if_existing_identified_contact
|
||||||
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
|
return unless merge_contacts?(existing_identified_contact, :identifier)
|
||||||
|
|
||||||
|
process_contact_merge(existing_identified_contact)
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_if_existing_email_contact
|
def merge_if_existing_email_contact
|
||||||
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
|
return unless merge_contacts?(existing_email_contact, :email)
|
||||||
|
|
||||||
|
process_contact_merge(existing_email_contact)
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_if_existing_phone_number_contact
|
def merge_if_existing_phone_number_contact
|
||||||
@contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact)
|
return unless merge_contacts?(existing_phone_number_contact, :phone_number)
|
||||||
|
return unless mergable_phone_contact?
|
||||||
|
|
||||||
|
process_contact_merge(existing_phone_number_contact)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_contact_merge(mergee_contact)
|
||||||
|
@contact = merge_contact(mergee_contact, @contact)
|
||||||
|
@attributes_to_update.delete(:name) if retain_original_contact_name
|
||||||
end
|
end
|
||||||
|
|
||||||
def existing_identified_contact
|
def existing_identified_contact
|
||||||
return if params[:identifier].blank?
|
return if params[:identifier].blank?
|
||||||
|
|
||||||
@existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier])
|
@existing_identified_contact ||= account.contacts.find_by(identifier: params[:identifier])
|
||||||
end
|
end
|
||||||
|
|
||||||
def existing_email_contact
|
def existing_email_contact
|
||||||
return if params[:email].blank?
|
return if params[:email].blank?
|
||||||
|
|
||||||
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
|
@existing_email_contact ||= account.contacts.find_by(email: params[:email])
|
||||||
end
|
end
|
||||||
|
|
||||||
def existing_phone_number_contact
|
def existing_phone_number_contact
|
||||||
return if params[:phone_number].blank?
|
return if params[:phone_number].blank?
|
||||||
|
|
||||||
@existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number])
|
@existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number])
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_contacts?(existing_contact, _contact)
|
def merge_contacts?(existing_contact, key)
|
||||||
existing_contact && existing_contact.id != @contact.id
|
return if existing_contact.blank?
|
||||||
|
|
||||||
|
return true if params[:identifier].blank?
|
||||||
|
|
||||||
|
# we want to prevent merging contacts with different identifiers
|
||||||
|
if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier]
|
||||||
|
# we will remove attribute from update list
|
||||||
|
@attributes_to_update.delete(key)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# case: contact 1: email: 1@test.com, phone: 123456789
|
||||||
|
# params: email: 2@test.com, phone: 123456789
|
||||||
|
# we don't want to overwrite 1@test.com since email parameter takes higer priority
|
||||||
|
def mergable_phone_contact?
|
||||||
|
return true if params[:email].blank?
|
||||||
|
|
||||||
|
if existing_phone_number_contact.email.present? && existing_phone_number_contact.email != params[:email]
|
||||||
|
@attributes_to_update.delete(:phone_number)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_contact
|
def update_contact
|
||||||
|
@contact.attributes = params.slice(*@attributes_to_update).reject do |_k, v|
|
||||||
|
v.blank?
|
||||||
|
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })
|
||||||
# blank identifier or email will throw unique index error
|
# blank identifier or email will throw unique index error
|
||||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||||
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v|
|
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||||
v.blank?
|
@contact.save!
|
||||||
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes }))
|
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||||
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_contact(base_contact, merge_contact)
|
def merge_contact(base_contact, merge_contact)
|
||||||
|
return base_contact if base_contact.id == merge_contact.id
|
||||||
|
|
||||||
ContactMergeAction.new(
|
ContactMergeAction.new(
|
||||||
account: account,
|
account: account,
|
||||||
base_contact: base_contact,
|
base_contact: base_contact,
|
||||||
|
@ -69,14 +118,14 @@ class ContactIdentifyAction
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_attributes
|
def custom_attributes
|
||||||
params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes
|
return @contact.custom_attributes if params[:custom_attributes].blank?
|
||||||
|
|
||||||
|
(@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys)
|
||||||
end
|
end
|
||||||
|
|
||||||
def additional_attributes
|
def additional_attributes
|
||||||
if params[:additional_attributes]
|
return @contact.additional_attributes if params[:additional_attributes].blank?
|
||||||
@contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys)
|
|
||||||
else
|
(@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys)
|
||||||
@contact.additional_attributes
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class AccountBuilder
|
class AccountBuilder
|
||||||
include CustomExceptions::Account
|
include CustomExceptions::Account
|
||||||
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin]
|
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
if @user.nil?
|
if @user.nil?
|
||||||
|
|
|
@ -9,12 +9,15 @@ class Campaigns::CampaignConversationBuilder
|
||||||
@contact_inbox.lock!
|
@contact_inbox.lock!
|
||||||
|
|
||||||
# We won't send campaigns if a conversation is already present
|
# We won't send campaigns if a conversation is already present
|
||||||
return if @contact_inbox.reload.conversations.present?
|
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
|
||||||
|
|
||||||
@conversation = ::Conversation.create!(conversation_params)
|
@conversation = ::Conversation.create!(conversation_params)
|
||||||
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
||||||
end
|
end
|
||||||
@conversation
|
@conversation
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.info(e.message)
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -23,7 +23,7 @@ class ContactBuilder
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_contact_avatar(contact)
|
def update_contact_avatar(contact)
|
||||||
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
|
::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_contact
|
def create_contact
|
||||||
|
|
|
@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||||
end
|
end
|
||||||
ensure_contact_avatar
|
ensure_contact_avatar
|
||||||
rescue Koala::Facebook::AuthenticationError
|
rescue Koala::Facebook::AuthenticationError
|
||||||
Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}"
|
@inbox.channel.authorization_error!
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||||
true
|
true
|
||||||
|
@ -58,7 +58,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||||
return if contact_params[:remote_avatar_url].blank?
|
return if contact_params[:remote_avatar_url].blank?
|
||||||
return if @contact.avatar.attached?
|
return if @contact.avatar.attached?
|
||||||
|
|
||||||
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url])
|
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation
|
def conversation
|
||||||
|
|
|
@ -73,6 +73,10 @@ class Messages::MessageBuilder
|
||||||
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
|
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def template_params
|
||||||
|
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
|
||||||
|
end
|
||||||
|
|
||||||
def message_sender
|
def message_sender
|
||||||
return if @params[:sender_type] != 'AgentBot'
|
return if @params[:sender_type] != 'AgentBot'
|
||||||
|
|
||||||
|
@ -91,6 +95,6 @@ class Messages::MessageBuilder
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id]
|
echo_id: @params[:echo_id]
|
||||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
|
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -54,6 +54,9 @@ class Messages::Messenger::MessageBuilder
|
||||||
def fetch_story_link(attachment)
|
def fetch_story_link(attachment)
|
||||||
message = attachment.message
|
message = attachment.message
|
||||||
result = get_story_object_from_source_id(message.source_id)
|
result = get_story_object_from_source_id(message.source_id)
|
||||||
|
|
||||||
|
return if result.blank?
|
||||||
|
|
||||||
story_id = result['story']['mention']['id']
|
story_id = result['story']['mention']['id']
|
||||||
story_sender = result['from']['username']
|
story_sender = result['from']['username']
|
||||||
message.content_attributes[:story_sender] = story_sender
|
message.content_attributes[:story_sender] = story_sender
|
||||||
|
@ -68,6 +71,11 @@ class Messages::Messenger::MessageBuilder
|
||||||
rescue Koala::Facebook::AuthenticationError
|
rescue Koala::Facebook::AuthenticationError
|
||||||
@inbox.channel.authorization_error!
|
@inbox.channel.authorization_error!
|
||||||
raise
|
raise
|
||||||
|
rescue Koala::Facebook::ClientError => e
|
||||||
|
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||||
|
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||||
|
Rails.logger.error e
|
||||||
|
{}
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||||
{}
|
{}
|
||||||
|
|
|
@ -15,6 +15,9 @@ class NotificationBuilder
|
||||||
|
|
||||||
def user_subscribed_to_notification?
|
def user_subscribed_to_notification?
|
||||||
notification_setting = user.notification_settings.find_by(account_id: account.id)
|
notification_setting = user.notification_settings.find_by(account_id: account.id)
|
||||||
|
# added for the case where an assignee might be removed from the account but remains in conversation
|
||||||
|
return if notification_setting.blank?
|
||||||
|
|
||||||
return true if notification_setting.public_send("email_#{notification_type}?")
|
return true if notification_setting.public_send("email_#{notification_type}?")
|
||||||
return true if notification_setting.public_send("push_#{notification_type}?")
|
return true if notification_setting.public_send("push_#{notification_type}?")
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ class NotificationSubscriptionBuilder
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_identifier_subscription
|
def build_identifier_subscription
|
||||||
@identifier_subscription = user.notification_subscriptions.create(params.merge(identifier: identifier))
|
@identifier_subscription = user.notification_subscriptions.create!(params.merge(identifier: identifier))
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_identifier_subscription
|
def update_identifier_subscription
|
||||||
|
|
|
@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:name, :description, :outgoing_url)
|
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -39,7 +39,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||||
# TODO: move this to a builder and combine the save account user method into a builder
|
# TODO: move this to a builder and combine the save account user method into a builder
|
||||||
# ensure the account user association is also created in a single transaction
|
# ensure the account user association is also created in a single transaction
|
||||||
def create_user
|
def create_user
|
||||||
return if @user
|
return @user.send_confirmation_instructions if @user
|
||||||
|
|
||||||
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
|
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
|
||||||
end
|
end
|
||||||
|
@ -72,6 +72,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_limit
|
def validate_limit
|
||||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
|
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
57
app/controllers/api/v1/accounts/articles_controller.rb
Normal file
57
app/controllers/api/v1/accounts/articles_controller.rb
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :portal
|
||||||
|
before_action :check_authorization
|
||||||
|
before_action :fetch_article, except: [:index, :create]
|
||||||
|
before_action :set_current_page, only: [:index]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@articles_count = @portal.articles.count
|
||||||
|
@articles = @portal.articles
|
||||||
|
@articles = @articles.search(list_params) if list_params.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@article = @portal.articles.create!(article_params)
|
||||||
|
@article.associate_root_article(article_params[:associated_article_id])
|
||||||
|
@article.draft!
|
||||||
|
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@article.update!(article_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@article.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_article
|
||||||
|
@article = @portal.articles.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def portal
|
||||||
|
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def article_params
|
||||||
|
params.require(:article).permit(
|
||||||
|
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
|
||||||
|
{ tags: [] }]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_params
|
||||||
|
params.permit(:locale, :query, :page, :category_slug, :status, :author_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_current_page
|
||||||
|
@current_page = params[:page] || 1
|
||||||
|
end
|
||||||
|
end
|
|
@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||||
def create
|
def create
|
||||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||||
@automation_rule.actions = params[:actions]
|
@automation_rule.actions = params[:actions]
|
||||||
|
@automation_rule.conditions = params[:conditions]
|
||||||
|
|
||||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
||||||
|
|
||||||
|
@ -31,9 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||||
|
|
||||||
def update
|
def update
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@automation_rule.update!(automation_rules_permit)
|
automation_rule_update
|
||||||
@automation_rule.actions = params[:actions] if params[:actions]
|
|
||||||
@automation_rule.save!
|
|
||||||
process_attachments
|
process_attachments
|
||||||
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
@ -50,7 +49,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||||
def clone
|
def clone
|
||||||
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
|
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
|
||||||
new_rule = automation_rule.dup
|
new_rule = automation_rule.dup
|
||||||
new_rule.save
|
new_rule.save!
|
||||||
@automation_rule = new_rule
|
@automation_rule = new_rule
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -67,10 +66,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def automation_rule_update
|
||||||
|
@automation_rule.update!(automation_rules_permit)
|
||||||
|
@automation_rule.actions = params[:actions] if params[:actions]
|
||||||
|
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||||
|
@automation_rule.save!
|
||||||
|
end
|
||||||
|
|
||||||
def automation_rules_permit
|
def automation_rules_permit
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :description, :event_name, :account_id, :active,
|
:name, :description, :event_name, :account_id, :active,
|
||||||
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
|
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -90,9 +90,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_avatar(facebook_inbox, page_id)
|
def set_avatar(facebook_inbox, page_id)
|
||||||
avatar_file = Down.download(
|
avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large"
|
||||||
"http://graph.facebook.com/#{page_id}/picture?type=large"
|
Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url)
|
||||||
)
|
|
||||||
facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,17 +1,29 @@
|
||||||
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
|
||||||
before_action :portal
|
before_action :portal
|
||||||
|
before_action :check_authorization
|
||||||
before_action :fetch_category, except: [:index, :create]
|
before_action :fetch_category, except: [:index, :create]
|
||||||
|
before_action :set_current_page, only: [:index]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@categories = @portal.categories
|
@categories = @portal.categories.search(params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@category = @portal.categories.create!(category_params)
|
@category = @portal.categories.create!(category_params)
|
||||||
|
@category.related_categories << related_categories_records
|
||||||
|
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
|
||||||
|
|
||||||
|
@category.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@category.update!(category_params)
|
@category.update!(category_params)
|
||||||
|
@category.related_categories = related_categories_records if related_categories_records.any?
|
||||||
|
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
|
||||||
|
|
||||||
|
@category.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
@ -29,9 +41,17 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def related_categories_records
|
||||||
|
@portal.categories.where(id: params[:category][:related_category_ids])
|
||||||
|
end
|
||||||
|
|
||||||
def category_params
|
def category_params
|
||||||
params.require(:category).permit(
|
params.require(:category).permit(
|
||||||
:name, :description, :position
|
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_current_page
|
||||||
|
@current_page = params[:page] || 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -27,6 +27,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||||
end
|
end
|
||||||
|
|
||||||
def phone_number
|
def phone_number
|
||||||
|
return if permitted_params[:phone_number].blank?
|
||||||
|
|
||||||
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
|
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -38,10 +40,11 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||||
@twilio_channel = Current.account.twilio_sms.create!(
|
@twilio_channel = Current.account.twilio_sms.create!(
|
||||||
account_sid: permitted_params[:account_sid],
|
account_sid: permitted_params[:account_sid],
|
||||||
auth_token: permitted_params[:auth_token],
|
auth_token: permitted_params[:auth_token],
|
||||||
|
messaging_service_sid: permitted_params[:messaging_service_sid].presence,
|
||||||
phone_number: phone_number,
|
phone_number: phone_number,
|
||||||
medium: medium
|
medium: medium
|
||||||
)
|
)
|
||||||
@inbox = Current.account.inboxes.create(
|
@inbox = Current.account.inboxes.create!(
|
||||||
name: permitted_params[:name],
|
name: permitted_params[:name],
|
||||||
channel: @twilio_channel
|
channel: @twilio_channel
|
||||||
)
|
)
|
||||||
|
@ -49,7 +52,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:twilio_channel).permit(
|
params.require(:twilio_channel).permit(
|
||||||
:account_id, :phone_number, :account_sid, :auth_token, :name, :medium
|
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||||
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
||||||
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
|
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
|
||||||
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
|
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@ -25,7 +25,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||||
|
|
||||||
contacts = resolved_contacts.where(
|
contacts = resolved_contacts.where(
|
||||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||||
search: "%#{params[:q]}%"
|
search: "%#{params[:q].strip}%"
|
||||||
)
|
)
|
||||||
@contacts_count = contacts.count
|
@contacts_count = contacts.count
|
||||||
@contacts = fetch_contacts_with_conversation_count(contacts)
|
@contacts = fetch_contacts_with_conversation_count(contacts)
|
||||||
|
@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||||
|
|
||||||
def create
|
def create
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@contact = Current.account.contacts.new(contact_params)
|
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
|
||||||
@contact.save!
|
@contact.save!
|
||||||
@contact_inbox = build_contact_inbox
|
@contact_inbox = build_contact_inbox
|
||||||
|
process_avatar
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@contact.assign_attributes(contact_update_params)
|
@contact.assign_attributes(contact_update_params)
|
||||||
@contact.save!
|
@contact.save!
|
||||||
|
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def avatar
|
||||||
|
@contact.avatar.purge if @contact.avatar.attached?
|
||||||
|
@contact
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# TODO: Move this to a finder class
|
# TODO: Move this to a finder class
|
||||||
|
@ -128,22 +135,22 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||||
|
|
||||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
source_id = params[:source_id] || SecureRandom.uuid
|
source_id = params[:source_id] || SecureRandom.uuid
|
||||||
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
|
ContactInbox.create!(contact: @contact, inbox: inbox, source_id: source_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_params
|
def permitted_params
|
||||||
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
|
params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_custom_attributes
|
def contact_custom_attributes
|
||||||
return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes]
|
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
|
||||||
|
|
||||||
@contact.custom_attributes
|
@contact.custom_attributes
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_update_params
|
def contact_update_params
|
||||||
# we want the merged custom attributes not the original one
|
# we want the merged custom attributes not the original one
|
||||||
contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
|
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_include_contact_inboxes
|
def set_include_contact_inboxes
|
||||||
|
@ -158,6 +165,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||||
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_avatar
|
||||||
|
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||||
|
end
|
||||||
|
|
||||||
def render_error(error, error_status)
|
def render_error(error, error_status)
|
||||||
render json: error, status: error_status
|
render json: error, status: error_status
|
||||||
end
|
end
|
||||||
|
|
|
@ -42,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@inbox.update(permitted_params.except(:channel))
|
@inbox.update!(permitted_params.except(:channel))
|
||||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
update_inbox_working_hours
|
||||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||||
|
|
||||||
# Inbox update doesn't necessarily need channel attributes
|
# Inbox update doesn't necessarily need channel attributes
|
||||||
return if permitted_params(channel_attributes)[:channel].blank?
|
return if permitted_params(channel_attributes)[:channel].blank?
|
||||||
|
|
||||||
if @inbox.inbox_type == 'Email'
|
if @inbox.inbox_type == 'Email'
|
||||||
|
begin
|
||||||
validate_email_channel(channel_attributes)
|
validate_email_channel(channel_attributes)
|
||||||
|
rescue StandardError => e
|
||||||
|
render json: { message: e }, status: :unprocessable_entity and return
|
||||||
|
end
|
||||||
@inbox.channel.reauthorized!
|
@inbox.channel.reauthorized!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -58,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||||
update_channel_feature_flags
|
update_channel_feature_flags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_inbox_working_hours
|
||||||
|
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||||
|
end
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
@agent_bot = @inbox.agent_bot
|
@agent_bot = @inbox.agent_bot
|
||||||
end
|
end
|
||||||
|
@ -89,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox_name(channel)
|
|
||||||
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
|
|
||||||
|
|
||||||
permitted_params[:name]
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_channel
|
def create_channel
|
||||||
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
|
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
|
||||||
|
|
||||||
|
@ -109,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||||
@inbox.channel.save!
|
@inbox.channel.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def inbox_attributes
|
||||||
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
|
||||||
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
*inbox_attributes,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
|
||||||
channel: [:type, *channel_attributes]
|
channel: [:type, *channel_attributes]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -129,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||||
}[permitted_params[:channel][:type]]
|
}[permitted_params[:channel][:type]]
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_channels_method
|
|
||||||
{
|
|
||||||
'web_widget' => Current.account.web_widgets,
|
|
||||||
'api' => Current.account.api_channels,
|
|
||||||
'email' => Current.account.email_channels,
|
|
||||||
'line' => Current.account.line_channels,
|
|
||||||
'telegram' => Current.account.telegram_channels,
|
|
||||||
'whatsapp' => Current.account.whatsapp_channels,
|
|
||||||
'sms' => Current.account.sms_channels
|
|
||||||
}[permitted_params[:channel][:type]]
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||||
|
@ -148,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_limit
|
|
||||||
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
|
|
||||||
|
|
||||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||||
|
|
57
app/controllers/api/v1/accounts/macros_controller.rb
Normal file
57
app/controllers/api/v1/accounts/macros_controller.rb
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :check_authorization
|
||||||
|
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@macros = Macro.with_visibility(current_user, params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||||
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
|
@macro.actions = params[:actions]
|
||||||
|
|
||||||
|
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
||||||
|
|
||||||
|
@macro.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@macro.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@macro.update!(macros_with_user)
|
||||||
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
|
@macro.save!
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error e
|
||||||
|
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute
|
||||||
|
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(
|
||||||
|
:name, :account_id, :visibility,
|
||||||
|
actions: [:action_name, { action_params: [] }]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def macros_with_user
|
||||||
|
permitted_params.merge(updated_by_id: current_user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_macro
|
||||||
|
@macro = Current.account.macros.find_by(id: params[:id])
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,18 +1,35 @@
|
||||||
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||||
|
include ::FileTypeHelper
|
||||||
|
|
||||||
before_action :fetch_portal, except: [:index, :create]
|
before_action :fetch_portal, except: [:index, :create]
|
||||||
|
before_action :check_authorization
|
||||||
|
before_action :set_current_page, only: [:index]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@portals = Current.account.portals
|
@portals = Current.account.portals
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def add_members
|
||||||
|
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
|
||||||
|
@portal.members << agents
|
||||||
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@portal = Current.account.portals.create!(portal_params)
|
@portal = Current.account.portals.build(portal_params)
|
||||||
|
@portal.save!
|
||||||
|
process_attached_logo
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@portal.update!(portal_params)
|
ActiveRecord::Base.transaction do
|
||||||
|
@portal.update!(portal_params) if params[:portal].present?
|
||||||
|
process_attached_logo
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error e
|
||||||
|
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
@ -20,6 +37,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def archive
|
||||||
|
@portal.update(archive: true)
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_attached_logo
|
||||||
|
@portal.logo.attach(params[:logo])
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_portal
|
def fetch_portal
|
||||||
|
@ -32,7 +58,16 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||||
|
|
||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived
|
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale,
|
||||||
|
{ allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def portal_member_params
|
||||||
|
params.require(:portal).permit(:account_id, member_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_current_page
|
||||||
|
@current_page = params[:page] || 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_team
|
before_action :fetch_team
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
|
before_action :validate_member_id_params, only: [:create, :update, :destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@team_members = @team.team_members.map(&:user)
|
@team_members = @team.team_members.map(&:user)
|
||||||
|
@ -45,4 +46,10 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
|
||||||
def fetch_team
|
def fetch_team
|
||||||
@team = Current.account.teams.find(params[:team_id])
|
@team = Current.account.teams.find(params[:team_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_member_id_params
|
||||||
|
invalid_ids = params[:user_ids].map(&:to_i) - @team.account.user_ids
|
||||||
|
|
||||||
|
render json: { error: 'Invalid User IDs' }, status: :unauthorized and return if invalid_ids.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -19,6 +19,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||||
user_full_name: account_params[:user_full_name],
|
user_full_name: account_params[:user_full_name],
|
||||||
email: account_params[:email],
|
email: account_params[:email],
|
||||||
user_password: account_params[:password],
|
user_password: account_params[:password],
|
||||||
|
locale: account_params[:locale],
|
||||||
user: current_user
|
user: current_user
|
||||||
).perform
|
).perform
|
||||||
if @user
|
if @user
|
||||||
|
|
|
@ -36,46 +36,15 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||||
contact_id: @contact.id,
|
contact_id: @contact.id,
|
||||||
contact_inbox_id: @contact_inbox.id,
|
contact_inbox_id: @contact_inbox.id,
|
||||||
additional_attributes: {
|
additional_attributes: {
|
||||||
|
browser_language: browser.accept_language&.first&.code,
|
||||||
browser: browser_params,
|
browser: browser_params,
|
||||||
referer: permitted_params[:message][:referer_url],
|
initiated_at: timestamp_params,
|
||||||
initiated_at: timestamp_params
|
referer: permitted_params[:message][:referer_url]
|
||||||
},
|
},
|
||||||
custom_attributes: permitted_params[:custom_attributes].presence || {}
|
custom_attributes: permitted_params[:custom_attributes].presence || {}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_contact(email)
|
|
||||||
contact_with_email = @current_account.contacts.find_by(email: email)
|
|
||||||
if contact_with_email
|
|
||||||
@contact = ::ContactMergeAction.new(
|
|
||||||
account: @current_account,
|
|
||||||
base_contact: contact_with_email,
|
|
||||||
mergee_contact: @contact
|
|
||||||
).perform
|
|
||||||
else
|
|
||||||
@contact.update!(email: email)
|
|
||||||
update_contact_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_contact_phone_number(phone_number)
|
|
||||||
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
|
|
||||||
if contact_with_phone_number
|
|
||||||
@contact = ::ContactMergeAction.new(
|
|
||||||
account: @current_account,
|
|
||||||
base_contact: contact_with_phone_number,
|
|
||||||
mergee_contact: @contact
|
|
||||||
).perform
|
|
||||||
else
|
|
||||||
@contact.update!(phone_number: phone_number)
|
|
||||||
update_contact_name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_contact_name
|
|
||||||
@contact.update!(name: contact_name) if contact_name.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def contact_email
|
def contact_email
|
||||||
permitted_params.dig(:contact, :email)&.downcase
|
permitted_params.dig(:contact, :email)&.downcase
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,14 +1,27 @@
|
||||||
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||||
before_action :process_hmac, only: [:update]
|
include WidgetHelper
|
||||||
|
|
||||||
|
before_action :validate_hmac, only: [:set_user]
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
contact_identify_action = ContactIdentifyAction.new(
|
identify_contact(@contact)
|
||||||
contact: @contact,
|
end
|
||||||
params: permitted_params.to_h.deep_symbolize_keys
|
|
||||||
)
|
def set_user
|
||||||
@contact = contact_identify_action.perform
|
contact = nil
|
||||||
|
|
||||||
|
if a_different_contact?
|
||||||
|
@contact_inbox, @widget_auth_token = build_contact_inbox_with_token(@web_widget)
|
||||||
|
contact = @contact_inbox.contact
|
||||||
|
else
|
||||||
|
contact = @contact
|
||||||
|
end
|
||||||
|
|
||||||
|
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||||
|
|
||||||
|
identify_contact(contact)
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO : clean up this with proper routes delete contacts/custom_attributes
|
# TODO : clean up this with proper routes delete contacts/custom_attributes
|
||||||
|
@ -20,12 +33,23 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def process_hmac
|
def identify_contact(contact)
|
||||||
|
contact_identify_action = ContactIdentifyAction.new(
|
||||||
|
contact: contact,
|
||||||
|
params: permitted_params.to_h.deep_symbolize_keys,
|
||||||
|
discard_invalid_attrs: true
|
||||||
|
)
|
||||||
|
@contact = contact_identify_action.perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def a_different_contact?
|
||||||
|
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_hmac
|
||||||
return unless should_verify_hmac?
|
return unless should_verify_hmac?
|
||||||
|
|
||||||
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
|
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
|
||||||
|
|
||||||
@contact_inbox.update(hmac_verified: true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_verify_hmac?
|
def should_verify_hmac?
|
||||||
|
|
|
@ -9,13 +9,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
process_update_contact
|
process_update_contact
|
||||||
@conversation = create_conversation
|
@conversation = create_conversation
|
||||||
conversation.messages.create(message_params)
|
conversation.messages.create!(message_params)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_update_contact
|
def process_update_contact
|
||||||
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
|
@contact = ContactIdentifyAction.new(
|
||||||
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
|
contact: @contact,
|
||||||
|
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
|
||||||
|
retain_original_contact_name: true
|
||||||
|
).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_last_seen
|
def update_last_seen
|
||||||
|
@ -56,7 +59,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||||
|
|
||||||
unless conversation.resolved?
|
unless conversation.resolved?
|
||||||
conversation.status = :resolved
|
conversation.status = :resolved
|
||||||
conversation.save
|
conversation.save!
|
||||||
end
|
end
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,7 +15,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||||
def update
|
def update
|
||||||
if @message.content_type == 'input_email'
|
if @message.content_type == 'input_email'
|
||||||
@message.update!(submitted_email: contact_email)
|
@message.update!(submitted_email: contact_email)
|
||||||
update_contact(contact_email)
|
ContactIdentifyAction.new(
|
||||||
|
contact: @contact,
|
||||||
|
params: { email: contact_email }
|
||||||
|
).perform
|
||||||
else
|
else
|
||||||
@message.update!(message_update_params[:message])
|
@message.update!(message_update_params[:message])
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include DeviseTokenAuth::Concerns::SetUserByToken
|
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||||
include RequestExceptionHandler
|
include RequestExceptionHandler
|
||||||
include Pundit
|
include Pundit::Authorization
|
||||||
include SwitchLocale
|
include SwitchLocale
|
||||||
|
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base
|
||||||
Current.user = @user
|
Current.user = @user
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_subscription
|
|
||||||
@subscription ||= Current.account.subscription
|
|
||||||
end
|
|
||||||
|
|
||||||
def pundit_user
|
def pundit_user
|
||||||
{
|
{
|
||||||
user: Current.user,
|
user: Current.user,
|
||||||
|
|
|
@ -8,6 +8,8 @@ module EnsureCurrentAccountHelper
|
||||||
|
|
||||||
def ensure_current_account
|
def ensure_current_account
|
||||||
account = Account.find(params[:account_id])
|
account = Account.find(params[:account_id])
|
||||||
|
ensure_account_is_active?(account)
|
||||||
|
|
||||||
if current_user
|
if current_user
|
||||||
account_accessible_for_user?(account)
|
account_accessible_for_user?(account)
|
||||||
elsif @resource.is_a?(AgentBot)
|
elsif @resource.is_a?(AgentBot)
|
||||||
|
@ -25,4 +27,8 @@ module EnsureCurrentAccountHelper
|
||||||
def account_accessible_for_bot?(account)
|
def account_accessible_for_bot?(account)
|
||||||
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ensure_account_is_active?(account)
|
||||||
|
render_unauthorized('Account is suspended') unless account.active?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
20
app/controllers/concerns/meta_token_verify_concern.rb
Normal file
20
app/controllers/concerns/meta_token_verify_concern.rb
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions,
|
||||||
|
# This concern handles the token verification step.
|
||||||
|
|
||||||
|
module MetaTokenVerifyConcern
|
||||||
|
def verify
|
||||||
|
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
|
||||||
|
if valid_token?(params['hub.verify_token'])
|
||||||
|
Rails.logger.info("#{service.capitalize} webhook verified")
|
||||||
|
render json: params['hub.challenge']
|
||||||
|
else
|
||||||
|
render status: :unauthorized, json: { error: 'Error; wrong verify token' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def valid_token?(_token)
|
||||||
|
raise 'Overwrite this method your controller'
|
||||||
|
end
|
||||||
|
end
|
|
@ -5,7 +5,9 @@ module WebsiteTokenHelper
|
||||||
|
|
||||||
def set_web_widget
|
def set_web_widget
|
||||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||||
@current_account = @web_widget.account
|
@current_account = @web_widget.inbox.account
|
||||||
|
|
||||||
|
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_contact
|
def set_contact
|
||||||
|
|
|
@ -4,6 +4,7 @@ class DashboardController < ActionController::Base
|
||||||
before_action :set_global_config
|
before_action :set_global_config
|
||||||
around_action :switch_locale
|
around_action :switch_locale
|
||||||
before_action :ensure_installation_onboarding, only: [:index]
|
before_action :ensure_installation_onboarding, only: [:index]
|
||||||
|
before_action :redirect_to_custom_domain_page
|
||||||
|
|
||||||
layout 'vueapp'
|
layout 'vueapp'
|
||||||
|
|
||||||
|
@ -13,8 +14,7 @@ class DashboardController < ActionController::Base
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(
|
@global_config = GlobalConfig.get(
|
||||||
'LOGO',
|
'LOGO', 'LOGO_THUMBNAIL',
|
||||||
'LOGO_THUMBNAIL',
|
|
||||||
'INSTALLATION_NAME',
|
'INSTALLATION_NAME',
|
||||||
'WIDGET_BRAND_URL',
|
'WIDGET_BRAND_URL',
|
||||||
'TERMS_URL',
|
'TERMS_URL',
|
||||||
|
@ -29,7 +29,8 @@ class DashboardController < ActionController::Base
|
||||||
'DIRECT_UPLOADS_ENABLED',
|
'DIRECT_UPLOADS_ENABLED',
|
||||||
'HCAPTCHA_SITE_KEY',
|
'HCAPTCHA_SITE_KEY',
|
||||||
'LOGOUT_REDIRECT_LINK',
|
'LOGOUT_REDIRECT_LINK',
|
||||||
'DISABLE_USER_PROFILE_UPDATE'
|
'DISABLE_USER_PROFILE_UPDATE',
|
||||||
|
'DEPLOYMENT_ENV'
|
||||||
).merge(app_config)
|
).merge(app_config)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -37,13 +38,23 @@ class DashboardController < ActionController::Base
|
||||||
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
|
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def redirect_to_custom_domain_page
|
||||||
|
custom_domain = request.host
|
||||||
|
portal = Portal.find_by(custom_domain: custom_domain)
|
||||||
|
|
||||||
|
return unless portal
|
||||||
|
|
||||||
|
redirect_to "/hc/#{portal.slug}"
|
||||||
|
end
|
||||||
|
|
||||||
def app_config
|
def app_config
|
||||||
{
|
{
|
||||||
APP_VERSION: Chatwoot.config[:version],
|
APP_VERSION: Chatwoot.config[:version],
|
||||||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
FACEBOOK_API_VERSION: 'v13.0'
|
FACEBOOK_API_VERSION: 'v14.0',
|
||||||
|
IS_ENTERPRISE: ChatwootApp.enterprise?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,7 +23,7 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
|
||||||
|
|
||||||
def authenticate_resource_with_sso_token
|
def authenticate_resource_with_sso_token
|
||||||
@token = @resource.create_token
|
@token = @resource.create_token
|
||||||
@resource.save
|
@resource.save!
|
||||||
|
|
||||||
sign_in(:user, @resource, store: false, bypass: false)
|
sign_in(:user, @resource, store: false, bypass: false)
|
||||||
# invalidate the token after the user is signed in
|
# invalidate the token after the user is signed in
|
||||||
|
|
|
@ -27,6 +27,6 @@ class Platform::Api::V1::AccountsController < PlatformController
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
params.permit(:name)
|
params.permit(:name, :locale)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,7 +14,7 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||||
|
|
||||||
def login
|
def login
|
||||||
encoded_email = ERB::Util.url_encode(@resource.email)
|
encoded_email = ERB::Util.url_encode(@resource.email)
|
||||||
render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
|
render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
39
app/controllers/public/api/v1/portals/articles_controller.rb
Normal file
39
app/controllers/public/api/v1/portals/articles_controller.rb
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
class Public::Api::V1::Portals::ArticlesController < PublicController
|
||||||
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
|
before_action :set_portal
|
||||||
|
before_action :set_category
|
||||||
|
before_action :set_article, only: [:show]
|
||||||
|
layout 'portal'
|
||||||
|
|
||||||
|
def index
|
||||||
|
@articles = @portal.articles
|
||||||
|
@articles = @articles.search(list_params) if list_params.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_article
|
||||||
|
@article = @category.articles.find(params[:id])
|
||||||
|
@parsed_content = render_article_content(@article.content)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_category
|
||||||
|
@category = @portal.categories.find_by!(slug: params[:category_slug])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_portal
|
||||||
|
@portal = @portals.find_by!(slug: params[:slug], archived: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_params
|
||||||
|
params.permit(:query)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_article_content(content)
|
||||||
|
# rubocop:disable Rails/OutputSafety
|
||||||
|
CommonMarker.render_html(content).html_safe
|
||||||
|
# rubocop:enable Rails/OutputSafety
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,22 @@
|
||||||
|
class Public::Api::V1::Portals::CategoriesController < PublicController
|
||||||
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
|
before_action :set_portal
|
||||||
|
before_action :set_category, only: [:show]
|
||||||
|
layout 'portal'
|
||||||
|
|
||||||
|
def index
|
||||||
|
@categories = @portal.categories
|
||||||
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_category
|
||||||
|
@category = @portal.categories.find_by!(locale: params[:locale], slug: params[:category_slug])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_portal
|
||||||
|
@portal = @portals.find_by!(slug: params[:slug], archived: false)
|
||||||
|
end
|
||||||
|
end
|
13
app/controllers/public/api/v1/portals_controller.rb
Normal file
13
app/controllers/public/api/v1/portals_controller.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class Public::Api::V1::PortalsController < PublicController
|
||||||
|
before_action :ensure_custom_domain_request, only: [:show]
|
||||||
|
before_action :set_portal
|
||||||
|
layout 'portal'
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_portal
|
||||||
|
@portal = @portals.find_by!(slug: params[:slug], archived: false)
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,4 +3,19 @@
|
||||||
class PublicController < ActionController::Base
|
class PublicController < ActionController::Base
|
||||||
include RequestExceptionHandler
|
include RequestExceptionHandler
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_custom_domain_request
|
||||||
|
custom_domain = request.host
|
||||||
|
|
||||||
|
@portals = ::Portal.where(custom_domain: custom_domain)
|
||||||
|
|
||||||
|
return if @portals.present?
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "Domain: #{custom_domain} is not registered with us. \
|
||||||
|
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||||
|
}, status: :unauthorized and return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -41,4 +41,9 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
||||||
|
|
||||||
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
|
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
|
||||||
# for more information
|
# for more information
|
||||||
|
|
||||||
|
def seed
|
||||||
|
Internal::SeedAccountJob.perform_later(requested_resource)
|
||||||
|
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@ class Twilio::CallbackController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params # rubocop:disable Metrics/MethodLength
|
||||||
params.permit(
|
params.permit(
|
||||||
:ApiVersion,
|
:ApiVersion,
|
||||||
:SmsSid,
|
:SmsSid,
|
||||||
|
@ -25,7 +25,8 @@ class Twilio::CallbackController < ApplicationController
|
||||||
:ToCountry,
|
:ToCountry,
|
||||||
:FromState,
|
:FromState,
|
||||||
:MediaUrl0,
|
:MediaUrl0,
|
||||||
:MediaContentType0
|
:MediaContentType0,
|
||||||
|
:MessagingServiceSid
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -44,12 +44,12 @@ class Twitter::CallbacksController < Twitter::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_inbox
|
def create_inbox
|
||||||
twitter_profile = account.twitter_profiles.create(
|
twitter_profile = account.twitter_profiles.create!(
|
||||||
twitter_access_token: parsed_body['oauth_token'],
|
twitter_access_token: parsed_body['oauth_token'],
|
||||||
twitter_access_token_secret: parsed_body['oauth_token_secret'],
|
twitter_access_token_secret: parsed_body['oauth_token_secret'],
|
||||||
profile_id: parsed_body['user_id']
|
profile_id: parsed_body['user_id']
|
||||||
)
|
)
|
||||||
account.inboxes.create(
|
account.inboxes.create!(
|
||||||
name: parsed_body['screen_name'],
|
name: parsed_body['screen_name'],
|
||||||
channel: twitter_profile
|
channel: twitter_profile
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,15 +1,5 @@
|
||||||
class Webhooks::InstagramController < ApplicationController
|
class Webhooks::InstagramController < ActionController::API
|
||||||
skip_before_action :authenticate_user!, raise: false
|
include MetaTokenVerifyConcern
|
||||||
skip_before_action :set_current_user
|
|
||||||
|
|
||||||
def verify
|
|
||||||
if valid_instagram_token?(params['hub.verify_token'])
|
|
||||||
Rails.logger.info('Instagram webhook verified')
|
|
||||||
render json: params['hub.challenge']
|
|
||||||
else
|
|
||||||
render json: { error: 'Error; wrong verify token', status: 403 }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def events
|
def events
|
||||||
Rails.logger.info('Instagram webhook received events')
|
Rails.logger.info('Instagram webhook received events')
|
||||||
|
@ -24,7 +14,7 @@ class Webhooks::InstagramController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def valid_instagram_token?(token)
|
def valid_token?(token)
|
||||||
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
|
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
class Webhooks::WhatsappController < ActionController::API
|
class Webhooks::WhatsappController < ActionController::API
|
||||||
|
include MetaTokenVerifyConcern
|
||||||
|
|
||||||
def process_payload
|
def process_payload
|
||||||
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def valid_token?(token)
|
||||||
|
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||||
|
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
|
||||||
|
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
|
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
|
||||||
class WidgetsController < ActionController::Base
|
class WidgetsController < ActionController::Base
|
||||||
|
include WidgetHelper
|
||||||
|
|
||||||
before_action :set_global_config
|
before_action :set_global_config
|
||||||
before_action :set_web_widget
|
before_action :set_web_widget
|
||||||
|
before_action :ensure_account_is_active
|
||||||
before_action :set_token
|
before_action :set_token
|
||||||
before_action :set_contact
|
before_action :set_contact
|
||||||
before_action :build_contact
|
before_action :build_contact
|
||||||
|
@ -40,11 +43,12 @@ class WidgetsController < ActionController::Base
|
||||||
def build_contact
|
def build_contact
|
||||||
return if @contact.present?
|
return if @contact.present?
|
||||||
|
|
||||||
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
|
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
|
||||||
@contact = @contact_inbox.contact
|
@contact = @contact_inbox.contact
|
||||||
|
end
|
||||||
|
|
||||||
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
def ensure_account_is_active
|
||||||
@token = ::Widget::TokenService.new(payload: payload).generate_token
|
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
def additional_attributes
|
def additional_attributes
|
||||||
|
|
|
@ -17,6 +17,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||||
users: CountField,
|
users: CountField,
|
||||||
conversations: CountField,
|
conversations: CountField,
|
||||||
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
|
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
|
||||||
|
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
|
||||||
account_users: Field::HasMany
|
account_users: Field::HasMany
|
||||||
}.merge(enterprise_attribute_types).freeze
|
}.merge(enterprise_attribute_types).freeze
|
||||||
|
|
||||||
|
@ -31,6 +32,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||||
locale
|
locale
|
||||||
users
|
users
|
||||||
conversations
|
conversations
|
||||||
|
status
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
# SHOW_PAGE_ATTRIBUTES
|
# SHOW_PAGE_ATTRIBUTES
|
||||||
|
@ -42,6 +44,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||||
created_at
|
created_at
|
||||||
updated_at
|
updated_at
|
||||||
locale
|
locale
|
||||||
|
status
|
||||||
conversations
|
conversations
|
||||||
account_users
|
account_users
|
||||||
] + enterprise_show_page_attributes).freeze
|
] + enterprise_show_page_attributes).freeze
|
||||||
|
@ -53,6 +56,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||||
FORM_ATTRIBUTES = (%i[
|
FORM_ATTRIBUTES = (%i[
|
||||||
name
|
name
|
||||||
locale
|
locale
|
||||||
|
status
|
||||||
] + enterprise_form_attributes).freeze
|
] + enterprise_form_attributes).freeze
|
||||||
|
|
||||||
# COLLECTION_FILTERS
|
# COLLECTION_FILTERS
|
||||||
|
|
|
@ -6,7 +6,7 @@ class ConversationDrop < BaseDrop
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_name
|
def contact_name
|
||||||
@obj.try(:contact).name.capitalize || 'Customer'
|
@obj.try(:contact).name.try(:capitalize) || 'Customer'
|
||||||
end
|
end
|
||||||
|
|
||||||
def recent_messages
|
def recent_messages
|
||||||
|
|
|
@ -2,6 +2,8 @@ require 'administrate/field/base'
|
||||||
|
|
||||||
class AvatarField < Administrate::Field::Base
|
class AvatarField < Administrate::Field::Base
|
||||||
def avatar_url
|
def avatar_url
|
||||||
data.presence&.gsub('?d=404', '?d=mp')
|
return data.presence if data.presence
|
||||||
|
|
||||||
|
resource.is_a?(User) ? '/assets/administrate/user/avatar.png' : '/assets/administrate/bot/avatar.png'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,11 @@ class ConversationFinder
|
||||||
attr_reader :current_user, :current_account, :params
|
attr_reader :current_user, :current_account, :params
|
||||||
|
|
||||||
DEFAULT_STATUS = 'open'.freeze
|
DEFAULT_STATUS = 'open'.freeze
|
||||||
|
SORT_OPTIONS = {
|
||||||
|
latest: 'latest',
|
||||||
|
sort_on_created_at: 'sort_on_created_at',
|
||||||
|
last_user_message_at: 'last_user_message_at'
|
||||||
|
}.with_indifferent_access
|
||||||
|
|
||||||
# assumptions
|
# assumptions
|
||||||
# inbox_id if not given, take from all conversations, else specific to inbox
|
# inbox_id if not given, take from all conversations, else specific to inbox
|
||||||
|
@ -133,10 +138,7 @@ class ConversationFinder
|
||||||
@conversations = @conversations.includes(
|
@conversations = @conversations.includes(
|
||||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
||||||
)
|
)
|
||||||
if params[:conversation_type] == 'mention'
|
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
|
||||||
@conversations.page(current_page)
|
@conversations.send(sort_by).page(current_page)
|
||||||
else
|
|
||||||
@conversations.latest.page(current_page)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
module Api::V1::InboxesHelper
|
module Api::V1::InboxesHelper
|
||||||
|
def inbox_name(channel)
|
||||||
|
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
|
||||||
|
|
||||||
|
permitted_params[:name]
|
||||||
|
end
|
||||||
|
|
||||||
def validate_email_channel(attributes)
|
def validate_email_channel(attributes)
|
||||||
channel_data = permitted_params(attributes)[:channel]
|
channel_data = permitted_params(attributes)[:channel]
|
||||||
|
|
||||||
|
@ -19,8 +25,7 @@ module Api::V1::InboxesHelper
|
||||||
enable_ssl: channel_data[:imap_enable_ssl] }
|
enable_ssl: channel_data[:imap_enable_ssl] }
|
||||||
end
|
end
|
||||||
|
|
||||||
Mail.connection do # rubocop:disable:block
|
check_imap_connection(channel_data)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_smtp(channel_data)
|
def validate_smtp(channel_data)
|
||||||
|
@ -32,10 +37,29 @@ module Api::V1::InboxesHelper
|
||||||
check_smtp_connection(channel_data, smtp)
|
check_smtp_connection(channel_data, smtp)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_imap_connection(channel_data)
|
||||||
|
Mail.connection {} # rubocop:disable:block
|
||||||
|
rescue SocketError => e
|
||||||
|
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
|
||||||
|
rescue Net::IMAP::NoResponseError => e
|
||||||
|
raise StandardError, I18n.t('errors.inboxes.imap.no_response_error')
|
||||||
|
rescue Errno::EHOSTUNREACH => e
|
||||||
|
raise StandardError, I18n.t('errors.inboxes.imap.host_unreachable_error')
|
||||||
|
rescue Net::OpenTimeout => e
|
||||||
|
raise StandardError,
|
||||||
|
I18n.t('errors.inboxes.imap.connection_timed_out_error', address: channel_data[:imap_address], port: channel_data[:imap_port])
|
||||||
|
rescue Net::IMAP::Error => e
|
||||||
|
raise StandardError, I18n.t('errors.inboxes.imap.connection_closed_error')
|
||||||
|
rescue StandardError => e
|
||||||
|
raise StandardError, e.message
|
||||||
|
ensure
|
||||||
|
ChatwootExceptionTracker.new(e).capture_exception if e.present?
|
||||||
|
end
|
||||||
|
|
||||||
def check_smtp_connection(channel_data, smtp)
|
def check_smtp_connection(channel_data, smtp)
|
||||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||||
smtp.finish unless smtp&.nil?
|
smtp.finish
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_smtp_encryption(channel_data, smtp)
|
def set_smtp_encryption(channel_data, smtp)
|
||||||
|
@ -74,4 +98,22 @@ module Api::V1::InboxesHelper
|
||||||
context.verify_mode = openssl_verify_mode
|
context.verify_mode = openssl_verify_mode
|
||||||
context
|
context
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def account_channels_method
|
||||||
|
{
|
||||||
|
'web_widget' => Current.account.web_widgets,
|
||||||
|
'api' => Current.account.api_channels,
|
||||||
|
'email' => Current.account.email_channels,
|
||||||
|
'line' => Current.account.line_channels,
|
||||||
|
'telegram' => Current.account.telegram_channels,
|
||||||
|
'whatsapp' => Current.account.whatsapp_channels,
|
||||||
|
'sms' => Current.account.sms_channels
|
||||||
|
}[permitted_params[:channel][:type]]
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_limit
|
||||||
|
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
|
||||||
|
|
||||||
|
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
9
app/helpers/widget_helper.rb
Normal file
9
app/helpers/widget_helper.rb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
module WidgetHelper
|
||||||
|
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
|
||||||
|
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
|
||||||
|
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
|
||||||
|
token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||||
|
|
||||||
|
[contact_inbox, token]
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,7 +2,7 @@
|
||||||
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
|
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
|
||||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
<router-view></router-view>
|
<router-view />
|
||||||
</transition>
|
</transition>
|
||||||
<add-account-modal
|
<add-account-modal
|
||||||
:show="showAddAccountModal"
|
:show="showAddAccountModal"
|
||||||
|
|
|
@ -15,6 +15,11 @@ class ApiClient {
|
||||||
|
|
||||||
baseUrl() {
|
baseUrl() {
|
||||||
let url = this.apiVersion;
|
let url = this.apiVersion;
|
||||||
|
|
||||||
|
if (this.options.enterprise) {
|
||||||
|
url = `/enterprise${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.options.accountScoped) {
|
if (this.options.accountScoped) {
|
||||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||||
'/app/accounts'
|
'/app/accounts'
|
||||||
|
|
|
@ -71,6 +71,10 @@ class ContactAPI extends ApiClient {
|
||||||
custom_attributes: customAttributes,
|
custom_attributes: customAttributes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroyAvatar(contactId) {
|
||||||
|
return axios.delete(`${this.url}/${contactId}/avatar`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ContactAPI();
|
export default new ContactAPI();
|
||||||
|
|
18
app/javascript/dashboard/api/enterprise/account.js
Normal file
18
app/javascript/dashboard/api/enterprise/account.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class EnterpriseAccountAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('', { accountScoped: true, enterprise: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkout() {
|
||||||
|
return axios.post(`${this.url}checkout`);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription() {
|
||||||
|
return axios.post(`${this.url}subscription`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new EnterpriseAccountAPI();
|
|
@ -0,0 +1,31 @@
|
||||||
|
import accountAPI from '../account';
|
||||||
|
import ApiClient from '../../ApiClient';
|
||||||
|
import describeWithAPIMock from '../../specs/apiSpecHelper';
|
||||||
|
|
||||||
|
describe('#enterpriseAccountAPI', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(accountAPI).toBeInstanceOf(ApiClient);
|
||||||
|
expect(accountAPI).toHaveProperty('get');
|
||||||
|
expect(accountAPI).toHaveProperty('show');
|
||||||
|
expect(accountAPI).toHaveProperty('create');
|
||||||
|
expect(accountAPI).toHaveProperty('update');
|
||||||
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
|
});
|
||||||
|
|
||||||
|
describeWithAPIMock('API calls', context => {
|
||||||
|
it('#checkout', () => {
|
||||||
|
accountAPI.checkout();
|
||||||
|
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/checkout'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#subscription', () => {
|
||||||
|
accountAPI.subscription();
|
||||||
|
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/subscription'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
51
app/javascript/dashboard/api/helpCenter/articles.js
Normal file
51
app/javascript/dashboard/api/helpCenter/articles.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/* global axios */
|
||||||
|
|
||||||
|
import PortalsAPI from './portals';
|
||||||
|
|
||||||
|
class ArticlesAPI extends PortalsAPI {
|
||||||
|
constructor() {
|
||||||
|
super('articles', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
getArticles({
|
||||||
|
pageNumber,
|
||||||
|
portalSlug,
|
||||||
|
locale,
|
||||||
|
status,
|
||||||
|
author_id,
|
||||||
|
category_slug,
|
||||||
|
}) {
|
||||||
|
let baseUrl = `${this.url}/${portalSlug}/articles?page=${pageNumber}&locale=${locale}`;
|
||||||
|
if (status !== undefined) baseUrl += `&status=${status}`;
|
||||||
|
if (author_id) baseUrl += `&author_id=${author_id}`;
|
||||||
|
if (category_slug) baseUrl += `&category_slug=${category_slug}`;
|
||||||
|
return axios.get(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
getArticle({ id, portalSlug }) {
|
||||||
|
return axios.get(`${this.url}/${portalSlug}/articles/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateArticle({ portalSlug, articleId, articleObj }) {
|
||||||
|
return axios.patch(
|
||||||
|
`${this.url}/${portalSlug}/articles/${articleId}`,
|
||||||
|
articleObj
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createArticle({ portalSlug, articleObj }) {
|
||||||
|
const { content, title, author_id, category_id } = articleObj;
|
||||||
|
return axios.post(`${this.url}/${portalSlug}/articles`, {
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
author_id,
|
||||||
|
category_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteArticle({ articleId, portalSlug }) {
|
||||||
|
return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ArticlesAPI();
|
30
app/javascript/dashboard/api/helpCenter/categories.js
Normal file
30
app/javascript/dashboard/api/helpCenter/categories.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/* global axios */
|
||||||
|
|
||||||
|
import PortalsAPI from './portals';
|
||||||
|
|
||||||
|
class CategoriesAPI extends PortalsAPI {
|
||||||
|
constructor() {
|
||||||
|
super('categories', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get({ portalSlug }) {
|
||||||
|
return axios.get(`${this.url}/${portalSlug}/categories`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create({ portalSlug, categoryObj }) {
|
||||||
|
return axios.post(`${this.url}/${portalSlug}/categories`, categoryObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
update({ portalSlug, categoryId, categoryObj }) {
|
||||||
|
return axios.patch(
|
||||||
|
`${this.url}/${portalSlug}/categories/${categoryId}`,
|
||||||
|
categoryObj
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete({ portalSlug, categoryId }) {
|
||||||
|
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CategoriesAPI();
|
18
app/javascript/dashboard/api/helpCenter/portals.js
Normal file
18
app/javascript/dashboard/api/helpCenter/portals.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class PortalsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('portals', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePortal({ portalSlug, portalObj }) {
|
||||||
|
return axios.patch(`${this.url}/${portalSlug}`, portalObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePortal(portalSlug) {
|
||||||
|
return axios.delete(`${this.url}/${portalSlug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PortalsAPI;
|
|
@ -10,6 +10,7 @@ export const buildCreatePayload = ({
|
||||||
files,
|
files,
|
||||||
ccEmails = '',
|
ccEmails = '',
|
||||||
bccEmails = '',
|
bccEmails = '',
|
||||||
|
templateParams,
|
||||||
}) => {
|
}) => {
|
||||||
let payload;
|
let payload;
|
||||||
if (files && files.length !== 0) {
|
if (files && files.length !== 0) {
|
||||||
|
@ -32,6 +33,7 @@ export const buildCreatePayload = ({
|
||||||
content_attributes: contentAttributes,
|
content_attributes: contentAttributes,
|
||||||
cc_emails: ccEmails,
|
cc_emails: ccEmails,
|
||||||
bcc_emails: bccEmails,
|
bcc_emails: bccEmails,
|
||||||
|
template_params: templateParams,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
|
@ -51,6 +53,7 @@ class MessageApi extends ApiClient {
|
||||||
files,
|
files,
|
||||||
ccEmails = '',
|
ccEmails = '',
|
||||||
bccEmails = '',
|
bccEmails = '',
|
||||||
|
templateParams,
|
||||||
}) {
|
}) {
|
||||||
return axios({
|
return axios({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
@ -63,6 +66,7 @@ class MessageApi extends ApiClient {
|
||||||
files,
|
files,
|
||||||
ccEmails,
|
ccEmails,
|
||||||
bccEmails,
|
bccEmails,
|
||||||
|
templateParams,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
66
app/javascript/dashboard/api/specs/article.spec.js
Normal file
66
app/javascript/dashboard/api/specs/article.spec.js
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import articlesAPI from '../helpCenter/articles';
|
||||||
|
import ApiClient from 'dashboard/api/helpCenter/portals';
|
||||||
|
import describeWithAPIMock from './apiSpecHelper';
|
||||||
|
|
||||||
|
describe('#PortalAPI', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(articlesAPI).toBeInstanceOf(ApiClient);
|
||||||
|
expect(articlesAPI).toHaveProperty('get');
|
||||||
|
expect(articlesAPI).toHaveProperty('show');
|
||||||
|
expect(articlesAPI).toHaveProperty('create');
|
||||||
|
expect(articlesAPI).toHaveProperty('update');
|
||||||
|
expect(articlesAPI).toHaveProperty('delete');
|
||||||
|
expect(articlesAPI).toHaveProperty('getArticles');
|
||||||
|
});
|
||||||
|
describeWithAPIMock('API calls', context => {
|
||||||
|
it('#getArticles', () => {
|
||||||
|
articlesAPI.getArticles({
|
||||||
|
pageNumber: 1,
|
||||||
|
portalSlug: 'room-rental',
|
||||||
|
locale: 'en-US',
|
||||||
|
status: 'published',
|
||||||
|
author_id: '1',
|
||||||
|
});
|
||||||
|
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describeWithAPIMock('API calls', context => {
|
||||||
|
it('#getArticle', () => {
|
||||||
|
articlesAPI.getArticle({
|
||||||
|
id: 1,
|
||||||
|
portalSlug: 'room-rental',
|
||||||
|
});
|
||||||
|
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/portals/room-rental/articles/1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describeWithAPIMock('API calls', context => {
|
||||||
|
it('#updateArticle', () => {
|
||||||
|
articlesAPI.updateArticle({
|
||||||
|
articleId: 1,
|
||||||
|
portalSlug: 'room-rental',
|
||||||
|
articleObj: { title: 'Update shipping address' },
|
||||||
|
});
|
||||||
|
expect(context.axiosMock.patch).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/portals/room-rental/articles/1',
|
||||||
|
{
|
||||||
|
title: 'Update shipping address',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describeWithAPIMock('API calls', context => {
|
||||||
|
it('#deleteArticle', () => {
|
||||||
|
articlesAPI.deleteArticle({
|
||||||
|
articleId: 1,
|
||||||
|
portalSlug: 'room-rental',
|
||||||
|
});
|
||||||
|
expect(context.axiosMock.delete).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/portals/room-rental/articles/1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -12,6 +12,7 @@ describe('#ContactsAPI', () => {
|
||||||
expect(contactAPI).toHaveProperty('delete');
|
expect(contactAPI).toHaveProperty('delete');
|
||||||
expect(contactAPI).toHaveProperty('getConversations');
|
expect(contactAPI).toHaveProperty('getConversations');
|
||||||
expect(contactAPI).toHaveProperty('filter');
|
expect(contactAPI).toHaveProperty('filter');
|
||||||
|
expect(contactAPI).toHaveProperty('destroyAvatar');
|
||||||
});
|
});
|
||||||
|
|
||||||
describeWithAPIMock('API calls', context => {
|
describeWithAPIMock('API calls', context => {
|
||||||
|
@ -100,6 +101,13 @@ describe('#ContactsAPI', () => {
|
||||||
queryPayload
|
queryPayload
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#destroyAvatar', () => {
|
||||||
|
contactAPI.destroyAvatar(1);
|
||||||
|
expect(context.axiosMock.delete).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/contacts/1/avatar'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
import categoriesAPI from '../../helpCenter/categories';
|
||||||
|
import ApiClient from '../../ApiClient';
|
||||||
|
|
||||||
|
describe('#BulkActionsAPI', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(categoriesAPI).toBeInstanceOf(ApiClient);
|
||||||
|
expect(categoriesAPI).toHaveProperty('get');
|
||||||
|
expect(categoriesAPI).toHaveProperty('create');
|
||||||
|
expect(categoriesAPI).toHaveProperty('update');
|
||||||
|
expect(categoriesAPI).toHaveProperty('delete');
|
||||||
|
});
|
||||||
|
});
|
13
app/javascript/dashboard/api/specs/portals.spec.js
Normal file
13
app/javascript/dashboard/api/specs/portals.spec.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import PortalsAPI from '../helpCenter/portals';
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
const portalAPI = new PortalsAPI();
|
||||||
|
describe('#PortalAPI', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(portalAPI).toBeInstanceOf(ApiClient);
|
||||||
|
expect(portalAPI).toHaveProperty('get');
|
||||||
|
expect(portalAPI).toHaveProperty('show');
|
||||||
|
expect(portalAPI).toHaveProperty('create');
|
||||||
|
expect(portalAPI).toHaveProperty('update');
|
||||||
|
expect(portalAPI).toHaveProperty('delete');
|
||||||
|
});
|
||||||
|
});
|
3
app/javascript/dashboard/assets/images/bubble-logo.svg
Normal file
3
app/javascript/dashboard/assets/images/bubble-logo.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="66" height="66" viewBox="0 0 66 66" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M63 63H32.9976C16.4591 63 2.99996 49.5399 2.99996 32.9973C2.99996 16.4601 16.4591 3 32.9979 3C49.5408 3 63 16.4601 63 32.9973V63Z" fill="white" stroke="white" stroke-width="6"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 289 B |
|
@ -1,11 +1,9 @@
|
||||||
/* Enter and leave animations can use different */
|
|
||||||
/* durations and timing functions. */
|
|
||||||
.slide-fade-enter-active {
|
.slide-fade-enter-active {
|
||||||
transition: all .3s $ease-in-cubic;
|
transition: all 0.3s var(--ease-in-cubic);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-leave-active {
|
.slide-fade-leave-active {
|
||||||
transition: all .3s $ease-out-cubic;
|
transition: all 0.3s var(--ease-out-cubic);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-enter,
|
.slide-fade-enter,
|
||||||
|
@ -24,7 +22,7 @@
|
||||||
|
|
||||||
.conversations-list-enter-active,
|
.conversations-list-enter-active,
|
||||||
.conversations-list-leave-active {
|
.conversations-list-leave-active {
|
||||||
transition: all .25s $ease-out-cubic;
|
transition: all 0.25s var(--ease-out-cubic);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversations-list-enter,
|
.conversations-list-enter,
|
||||||
|
@ -35,11 +33,10 @@
|
||||||
|
|
||||||
.menu-list-enter-active,
|
.menu-list-enter-active,
|
||||||
.menu-list-leave-active {
|
.menu-list-leave-active {
|
||||||
transition: opacity .3s $ease-out-cubic,
|
transition: opacity 0.3s var(--ease-out-cubic),
|
||||||
transform .2s $ease-out-cubic;
|
transform 0.2s var(--ease-out-cubic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.menu-list-leave-to {
|
.menu-list-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -52,23 +49,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-up-enter-active {
|
.slide-up-enter-active {
|
||||||
transition: all .3s $ease-in-cubic;
|
transition: all 0.3s var(--ease-in-cubic);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-up-leave-active {
|
.slide-up-leave-active {
|
||||||
transition: all .3s $ease-out-cubic;
|
transition: all 0.3s var(--ease-out-cubic);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-up-enter,
|
.slide-up-enter,
|
||||||
.slide-up-leave-to {
|
.slide-up-leave-to {
|
||||||
transform: translateY(-$space-medium);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translateY(-$space-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-slide-enter-active,
|
.menu-slide-enter-active,
|
||||||
.menu-slide-leave-active {
|
.menu-slide-leave-active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
|
transition: transform 0.25s var(--ease-in-cubic),
|
||||||
|
opacity 0.15s var(--ease-in-cubic);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-slide-enter,
|
.menu-slide-enter,
|
||||||
|
@ -77,13 +75,12 @@
|
||||||
transform: translateY($space-small);
|
transform: translateY($space-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.toast-fade-enter-active {
|
.toast-fade-enter-active {
|
||||||
transition: all .3s $ease-in-sine;
|
transition: all 0.3s var(--ease-in-sine);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-fade-leave-active {
|
.toast-fade-leave-active {
|
||||||
transition: all .1s $ease-out-sine;
|
transition: all 0.1s var(--ease-out-sine);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-fade-enter,
|
.toast-fade-enter,
|
||||||
|
@ -93,11 +90,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-fade-enter-active {
|
.modal-fade-enter-active {
|
||||||
transition: all .3s $ease-in-sine;
|
transition: all 0.3s var(--ease-in-sine);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-fade-leave-active {
|
.modal-fade-leave-active {
|
||||||
transition: all .1s $ease-out-sine;
|
transition: all 0.1s var(--ease-out-sine);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-fade-enter,
|
.modal-fade-enter,
|
||||||
|
@ -106,15 +103,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.network-notification-fade-enter-active {
|
.network-notification-fade-enter-active {
|
||||||
transition: all .1s $ease-in-sine;
|
transition: all 0.1s var(--ease-in-sine);
|
||||||
}
|
}
|
||||||
|
|
||||||
.network-notification-fade-leave-active {
|
.network-notification-fade-leave-active {
|
||||||
transition: all .1s $ease-out-sine;
|
transition: all 0.1s var(--ease-out-sine);
|
||||||
}
|
}
|
||||||
|
|
||||||
.network-notification-fade-enter,
|
.network-notification-fade-enter,
|
||||||
.network-notification-fade-leave-to {
|
.network-notification-fade-leave-to {
|
||||||
transform: translateY(-$space-small);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translateY(-$space-small);
|
||||||
}
|
}
|
||||||
|
|
|
@ -370,7 +370,7 @@ $input-font-weight: $global-weight-normal;
|
||||||
$input-background: $white;
|
$input-background: $white;
|
||||||
$input-background-focus: $white;
|
$input-background-focus: $white;
|
||||||
$input-background-disabled: $light-gray;
|
$input-background-disabled: $light-gray;
|
||||||
$input-border: 1px solid $color-border;
|
$input-border: 1px solid var(--s-200);
|
||||||
$input-border-focus: 1px solid lighten($primary-color, 15%);
|
$input-border-focus: 1px solid lighten($primary-color, 15%);
|
||||||
$input-shadow: 0;
|
$input-shadow: 0;
|
||||||
$input-shadow-focus: 0;
|
$input-shadow-focus: 0;
|
||||||
|
@ -383,8 +383,8 @@ $form-button-radius: var(--border-radius-normal);
|
||||||
// 20. Label
|
// 20. Label
|
||||||
// ---------
|
// ---------
|
||||||
|
|
||||||
$label-background: $primary-color;
|
$label-background: $white;
|
||||||
$label-color: $white;
|
$label-color: $black;
|
||||||
$label-color-alt: $black;
|
$label-color-alt: $black;
|
||||||
$label-palette: $foundation-palette;
|
$label-palette: $foundation-palette;
|
||||||
$label-font-size: $font-size-mini;
|
$label-font-size: $font-size-mini;
|
||||||
|
|
|
@ -41,24 +41,22 @@ is-closed .app-root {
|
||||||
|
|
||||||
.view-box {
|
.view-box {
|
||||||
@include full-height;
|
@include full-height;
|
||||||
@include margin(0);
|
|
||||||
@include space-between-column;
|
@include space-between-column;
|
||||||
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-panel {
|
.view-panel {
|
||||||
@include margin($zero);
|
|
||||||
@include padding($space-normal);
|
|
||||||
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: $space-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-box {
|
.content-box {
|
||||||
@include padding($space-normal);
|
|
||||||
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
padding: $space-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
|
@ -91,8 +89,7 @@ is-closed .app-root {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
@include padding($space-one);
|
|
||||||
|
|
||||||
max-width: $space-mega;
|
max-width: $space-mega;
|
||||||
|
padding: $space-one;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
.page-sub-title {
|
.page-sub-title {
|
||||||
font-size: $font-size-large;
|
font-size: $font-size-large;
|
||||||
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-title {
|
.block-title {
|
||||||
|
|
|
@ -42,7 +42,6 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.border-right {
|
.border-right {
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
@ -60,3 +59,19 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-between {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-end {
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-align-center {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
|
@ -99,3 +99,7 @@ $ionicons-font-path: '~ionicons/fonts';
|
||||||
|
|
||||||
// Transitions
|
// Transitions
|
||||||
$transition-ease-in: all 0.250s ease-in;
|
$transition-ease-in: all 0.250s ease-in;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--dashboard-app-tabs-height: 3.9rem;
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
@import 'shared/assets/fonts/inter';
|
@import 'shared/assets/fonts/inter';
|
||||||
|
@import 'shared/assets/stylesheets/animations';
|
||||||
@import 'shared/assets/stylesheets/colors';
|
@import 'shared/assets/stylesheets/colors';
|
||||||
@import 'shared/assets/stylesheets/spacing';
|
@import 'shared/assets/stylesheets/spacing';
|
||||||
@import 'shared/assets/stylesheets/font-size';
|
@import 'shared/assets/stylesheets/font-size';
|
||||||
|
@ -16,7 +17,6 @@
|
||||||
@import 'date-picker';
|
@import 'date-picker';
|
||||||
|
|
||||||
@import 'foundation-sites/scss/foundation';
|
@import 'foundation-sites/scss/foundation';
|
||||||
@import '~bourbon/core/bourbon';
|
|
||||||
|
|
||||||
@include foundation-everything($flex: true);
|
@include foundation-everything($flex: true);
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
margin-bottom: var(--space-normal);
|
margin-bottom: var(--space-normal);
|
||||||
|
|
||||||
&.multiselect--disabled {
|
&.multiselect--disabled {
|
||||||
opacity: .8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect--active {
|
.multiselect--active {
|
||||||
|
@ -96,9 +96,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect__tags {
|
.multiselect__tags {
|
||||||
@include margin(0);
|
border: 1px solid var(--s-200);
|
||||||
border: 1px solid $color-border;
|
border-color: var(--s-200);
|
||||||
border-color: $color-border;
|
margin: 0;
|
||||||
min-height: 4.4rem;
|
min-height: 4.4rem;
|
||||||
padding-top: $zero;
|
padding-top: $zero;
|
||||||
}
|
}
|
||||||
|
@ -130,10 +130,10 @@
|
||||||
|
|
||||||
.multiselect__input {
|
.multiselect__input {
|
||||||
@include ghost-input;
|
@include ghost-input;
|
||||||
@include padding($zero);
|
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
height: 4.4rem;
|
height: 4.4rem;
|
||||||
margin-bottom: $zero;
|
margin-bottom: $zero;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect__single {
|
.multiselect__single {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
@import 'shared/assets/fonts/inter';
|
@import 'shared/assets/fonts/inter';
|
||||||
|
@import 'shared/assets/stylesheets/animations';
|
||||||
@import 'shared/assets/stylesheets/colors';
|
@import 'shared/assets/stylesheets/colors';
|
||||||
@import 'shared/assets/stylesheets/spacing';
|
@import 'shared/assets/stylesheets/spacing';
|
||||||
@import 'shared/assets/stylesheets/font-size';
|
@import 'shared/assets/stylesheets/font-size';
|
||||||
|
@ -17,8 +18,6 @@
|
||||||
@import 'foundation-sites/scss/foundation';
|
@import 'foundation-sites/scss/foundation';
|
||||||
|
|
||||||
@include foundation-prototype-spacing;
|
@include foundation-prototype-spacing;
|
||||||
@import '~bourbon/core/bourbon';
|
|
||||||
|
|
||||||
@include foundation-everything($flex: true);
|
@include foundation-everything($flex: true);
|
||||||
|
|
||||||
@import 'typography';
|
@import 'typography';
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
$channel-hover-color: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
.channels {
|
.channels {
|
||||||
margin-top: $space-medium;
|
margin-top: $space-medium;
|
||||||
|
|
||||||
|
@ -7,14 +9,14 @@
|
||||||
|
|
||||||
.channel {
|
.channel {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include padding($space-normal $zero);
|
|
||||||
@include background-white;
|
@include background-white;
|
||||||
@include border-light;
|
@include border-light;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: -1px;
|
margin: -1px;
|
||||||
transition: all 0.200s ease-in;
|
padding: $space-normal $zero;
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
@include border-light;
|
@include border-light;
|
||||||
|
@ -22,16 +24,16 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 1px solid $primary-color;
|
border: 1px solid $primary-color;
|
||||||
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px $channel-hover-color;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: .6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
@include margin($space-normal auto);
|
margin: $space-normal auto;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,8 +45,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
width: 100%;
|
|
||||||
color: $medium-gray;
|
color: $medium-gray;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,33 +4,33 @@
|
||||||
|
|
||||||
// Conversation header - Light BG
|
// Conversation header - Light BG
|
||||||
.settings-header {
|
.settings-header {
|
||||||
@include padding($space-small $space-normal);
|
|
||||||
@include background-white;
|
@include background-white;
|
||||||
@include flex;
|
@include flex;
|
||||||
@include flex-align($x: justify, $y: middle);
|
@include flex-align($x: justify, $y: middle);
|
||||||
border-bottom: 1px solid var(--s-50);
|
border-bottom: 1px solid var(--s-50);
|
||||||
height: $header-height;
|
height: $header-height;
|
||||||
min-height: $header-height;
|
min-height: $header-height;
|
||||||
|
padding: $space-small $space-normal;
|
||||||
|
|
||||||
// Resolve Button
|
// Resolve Button
|
||||||
.button {
|
.button {
|
||||||
@include margin(0);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User thumbnail and text
|
// User thumbnail and text
|
||||||
.page-title {
|
.page-title {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include flex-align($x: center, $y: middle);
|
@include flex-align($x: center, $y: middle);
|
||||||
@include margin($zero);
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-box {
|
.wizard-box {
|
||||||
.item {
|
.item {
|
||||||
@include padding($space-normal $space-normal $space-normal $space-medium);
|
|
||||||
@include background-light;
|
@include background-light;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: $space-normal $space-normal $space-normal $space-medium;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&::before,
|
&::before,
|
||||||
|
@ -91,10 +91,11 @@
|
||||||
font-size: $font-size-default;
|
font-size: $font-size-default;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding-left: $space-medium;
|
padding-left: $space-medium;
|
||||||
|
}
|
||||||
|
|
||||||
.completed {
|
.completed {
|
||||||
color: $success-color;
|
color: $success-color;
|
||||||
}
|
margin-left: $space-smaller;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
|
@ -128,89 +129,27 @@
|
||||||
|
|
||||||
.wizard-body {
|
.wizard-body {
|
||||||
@include background-white;
|
@include background-white;
|
||||||
@include padding($space-medium);
|
|
||||||
@include border-light;
|
@include border-light;
|
||||||
@include full-height();
|
@include full-height();
|
||||||
|
padding: $space-medium;
|
||||||
|
|
||||||
&.height-auto {
|
&.height-auto {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inoboxes-list {
|
|
||||||
.inbox-item {
|
|
||||||
@include margin($space-normal);
|
|
||||||
@include flex;
|
|
||||||
@include flex-shrink;
|
|
||||||
@include padding($space-normal $space-normal);
|
|
||||||
@include border-light-bottom();
|
|
||||||
|
|
||||||
background: $color-white;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-direction: column;
|
|
||||||
float: left;
|
|
||||||
min-height: 10rem;
|
|
||||||
width: 20%;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
@include border-nil;
|
|
||||||
|
|
||||||
margin-bottom: $zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@include background-gray;
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX($space-small);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch {
|
|
||||||
align-self: center;
|
|
||||||
margin-bottom: $zero;
|
|
||||||
margin-right: $space-normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item--details {
|
|
||||||
@include padding($space-normal $zero);
|
|
||||||
|
|
||||||
.item--name {
|
|
||||||
font-size: $font-size-large;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item--sub {
|
|
||||||
font-size: $font-size-small;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
align-self: center;
|
|
||||||
color: $medium-gray;
|
|
||||||
font-size: $font-size-small;
|
|
||||||
opacity: 0.7;
|
|
||||||
transform: translateX(0);
|
|
||||||
transition: opacity 0.1s ease-in 0s, transform 0.2s ease-in 0.03s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings--content {
|
.settings--content {
|
||||||
@include margin($space-small $space-large);
|
margin: $space-small $space-large;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code {
|
.code {
|
||||||
@include padding($space-one);
|
|
||||||
|
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
max-height: $space-mega;
|
max-height: $space-mega;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
padding: $space-one;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
code {
|
code {
|
||||||
|
@ -225,7 +164,7 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@include padding($space-medium);
|
padding: $space-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
> a > img {
|
> a > img {
|
||||||
|
|
|
@ -44,6 +44,52 @@ $default-button-height: 4.0rem;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.hollow {
|
||||||
|
border-color: var(--s-200);
|
||||||
|
color: var(--w-700);
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
border-color: var(--s-200);
|
||||||
|
color: var(--s-700)
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
border-color: var(--s-200);
|
||||||
|
color: var(--g-700)
|
||||||
|
}
|
||||||
|
|
||||||
|
&.alert {
|
||||||
|
border-color: var(--s-200);
|
||||||
|
color: var(--r-700)
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
border-color: var(--s-200);
|
||||||
|
color: var(--y-700)
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--s-75);
|
||||||
|
border-color: var(--s-100);
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
border-color: var(--s-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
border-color: var(--s-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.alert {
|
||||||
|
border-color: var(--s-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
border-color: var(--s-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Smooth style
|
// Smooth style
|
||||||
&.smooth {
|
&.smooth {
|
||||||
@include button-style(var(--w-50), var(--w-100), var(--w-700));
|
@include button-style(var(--w-50), var(--w-100), var(--w-700));
|
||||||
|
@ -67,11 +113,12 @@ $default-button-height: 4.0rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.clear {
|
&.clear {
|
||||||
|
|
||||||
&.warning {
|
&.warning {
|
||||||
color: var(--y-600);
|
color: var(--y-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.button--only-icon:hover {
|
&:hover {
|
||||||
background: var(--w-50);
|
background: var(--w-50);
|
||||||
|
|
||||||
&.secondary {
|
&.secondary {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
@keyframes left-shift-animation {
|
@keyframes left-shift-animation {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
|
@ -13,15 +12,15 @@
|
||||||
.conversation {
|
.conversation {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include flex-shrink;
|
@include flex-shrink;
|
||||||
@include padding(0 0 0 $space-normal);
|
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
border-left: $space-micro solid transparent;
|
border-left: $space-micro solid transparent;
|
||||||
border-top: 1px solid transparent;
|
border-top: 1px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 0 0 0 $space-normal;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
animation: left-shift-animation .25s $swift-ease-out-function;
|
animation: left-shift-animation 0.25s $swift-ease-out-function;
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
border-bottom-color: $color-border-light;
|
border-bottom-color: $color-border-light;
|
||||||
border-left-color: $color-woot;
|
border-left-color: $color-woot;
|
||||||
|
@ -48,13 +47,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.conversation--details {
|
.conversation--details {
|
||||||
@include margin(0 0 0 $space-one);
|
|
||||||
@include border-light-bottom;
|
@include border-light-bottom;
|
||||||
@include border-light-top;
|
@include border-light-top;
|
||||||
@include padding($space-slab 0);
|
|
||||||
border-bottom-color: transparent;
|
border-bottom-color: transparent;
|
||||||
|
margin: 0 0 0 $space-one;
|
||||||
|
padding: $space-slab 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation--user {
|
.conversation--user {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
|
// scss-lint:disable MergeableSelector
|
||||||
|
|
||||||
@mixin bubble-with-types {
|
@mixin bubble-with-types {
|
||||||
@include padding($space-small $space-normal);
|
padding: $space-small $space-normal;
|
||||||
@include margin($zero);
|
margin: 0;
|
||||||
background: $color-woot;
|
background: $color-woot;
|
||||||
border-radius: $space-one;
|
border-radius: $space-one;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
|
@ -37,7 +39,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background-image: linear-gradient(-180deg, transparent 3%, $color-heading 130%);
|
background-image: linear-gradient(
|
||||||
|
-180deg,
|
||||||
|
transparent 3%,
|
||||||
|
$color-heading 130%
|
||||||
|
);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
content: '';
|
content: '';
|
||||||
height: 20%;
|
height: 20%;
|
||||||
|
@ -75,16 +81,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversations-list {
|
.conversations-list {
|
||||||
@include flex-weight(1);
|
|
||||||
@include scroll-on-hover;
|
@include scroll-on-hover;
|
||||||
|
flex: 1 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list__top {
|
.chat-list__top {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include padding($zero $zero $space-micro $zero);
|
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
padding: $zero $zero $space-micro;
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
margin-bottom: $zero;
|
margin-bottom: $zero;
|
||||||
|
@ -92,13 +97,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.status--filter {
|
.status--filter {
|
||||||
@include padding($zero null $zero $space-normal);
|
|
||||||
@include margin($zero);
|
|
||||||
background-color: $color-background-light;
|
background-color: $color-background-light;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
float: right;
|
float: right;
|
||||||
font-size: $font-size-mini;
|
font-size: $font-size-mini;
|
||||||
height: $space-medium;
|
height: $space-medium;
|
||||||
|
margin: 0;
|
||||||
|
padding: $zero $space-medium $zero $space-normal;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,10 +115,10 @@
|
||||||
|
|
||||||
.conversation-panel {
|
.conversation-panel {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include flex-weight(1 1 1px);
|
flex: 1 1 1px;
|
||||||
@include margin($zero);
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-bottom: var(--space-normal);
|
padding-bottom: var(--space-normal);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -122,7 +127,7 @@
|
||||||
.conversation-panel > li {
|
.conversation-panel > li {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include flex-shrink;
|
@include flex-shrink;
|
||||||
@include margin($zero $zero $space-micro);
|
margin: $zero $zero $space-micro;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
|
@ -165,9 +170,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
&.left {
|
&.left {
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
@include border-normal;
|
@include border-normal;
|
||||||
background: $white;
|
background: $white;
|
||||||
|
@ -198,7 +201,6 @@
|
||||||
color: $color-primary-dark;
|
color: $color-primary-dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
+ .right {
|
+ .right {
|
||||||
|
@ -285,7 +287,6 @@
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.center {
|
&.center {
|
||||||
|
@ -293,10 +294,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
@include margin($zero $space-normal);
|
|
||||||
|
|
||||||
--bubble-max-width: 49.6rem;
|
--bubble-max-width: 49.6rem;
|
||||||
max-width: Min(var(--bubble-max-width), 85%);
|
margin: $zero $space-normal;
|
||||||
|
max-width: Min(var(--bubble-max-width), 84%);
|
||||||
|
|
||||||
.sender--name {
|
.sender--name {
|
||||||
font-size: $font-size-mini;
|
font-size: $font-size-mini;
|
||||||
|
@ -320,7 +320,8 @@
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: var(--space-smaller) 0;
|
margin: var(--space-smaller) 0;
|
||||||
padding: var(--space-smaller) var(--space-micro) var(--space-smaller) var(--space-one);
|
padding: var(--space-smaller) var(--space-micro) var(--space-smaller)
|
||||||
|
var(--space-one);
|
||||||
|
|
||||||
.is-text {
|
.is-text {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
@ -371,7 +372,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.left .bubble .text-content {
|
.left .bubble .text-content {
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
|
@ -400,7 +400,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.right .bubble .text-content {
|
.right .bubble .text-content {
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
|
|
|
@ -1,5 +1,22 @@
|
||||||
|
// scss-lint:disable QualifyingElement
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
#{$all-text-inputs},
|
input[type='color'],
|
||||||
|
input[type='date'],
|
||||||
|
input[type='datetime'],
|
||||||
|
input[type='datetime-local'],
|
||||||
|
input[type='email'],
|
||||||
|
input[type='month'],
|
||||||
|
input[type='number'],
|
||||||
|
input[type='password'],
|
||||||
|
input[type='search'],
|
||||||
|
input[type='tel'],
|
||||||
|
input[type='text'],
|
||||||
|
input[type='time'],
|
||||||
|
input[type='url'],
|
||||||
|
input[type='week'],
|
||||||
|
input:not([type]),
|
||||||
|
textarea,
|
||||||
select,
|
select,
|
||||||
.multiselect > .multiselect__tags {
|
.multiselect > .multiselect__tags {
|
||||||
@include thin-border(var(--r-400));
|
@include thin-border(var(--r-400));
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-top-bar {
|
.page-top-bar {
|
||||||
@include padding($space-large $space-large $zero);
|
padding: $space-large $space-large $zero;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-height: 6rem;
|
max-height: 6rem;
|
||||||
|
@ -53,8 +53,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-box {
|
.content-box {
|
||||||
@include padding($zero);
|
|
||||||
height: auto;
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
@ -64,29 +64,29 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@include margin($zero);
|
|
||||||
@include padding($zero);
|
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@include padding($space-large);
|
padding: $space-large;
|
||||||
}
|
}
|
||||||
|
|
||||||
form,
|
form,
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@include padding($space-large);
|
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
padding: $space-large;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@include padding($space-normal);
|
padding: $space-normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@include flex;
|
@include flex;
|
||||||
@include flex-align($x: flex-start, $y: middle);
|
@include flex-align($x: flex-end, $y: middle);
|
||||||
@include padding($space-small $zero);
|
padding: $space-small $zero;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
|
@ -98,10 +98,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-item {
|
.delete-item {
|
||||||
@include padding($space-large);
|
padding: $space-large;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@include margin($zero);
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
.reply-box {
|
.reply-box {
|
||||||
transition: box-shadow .35s $swift-ease-out-function,
|
transition: box-shadow 0.35s $swift-ease-out-function,
|
||||||
height 2s $swift-ease-out-function;
|
height 2s $swift-ease-out-function;
|
||||||
|
|
||||||
|
|
||||||
&.is-focused {
|
&.is-focused {
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-box__top {
|
.reply-box__top {
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
color: $medium-gray;
|
color: $medium-gray;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -20,7 +18,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.attachment {
|
.attachment {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: $space-one;
|
margin-right: $space-one;
|
||||||
|
@ -39,11 +36,10 @@
|
||||||
|
|
||||||
> textarea {
|
> textarea {
|
||||||
@include ghost-input();
|
@include ghost-input();
|
||||||
@include margin(0);
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
// Override min-height : 50px in foundation
|
margin: 0;
|
||||||
//
|
|
||||||
max-height: $space-mega * 2.4;
|
max-height: $space-mega * 2.4;
|
||||||
|
// Override min-height : 50px in foundation
|
||||||
min-height: 4.8rem;
|
min-height: 4.8rem;
|
||||||
padding: var(--space-normal) 0 0;
|
padding: var(--space-normal) 0 0;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
@ -61,5 +57,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
.report-card {
|
.report-card {
|
||||||
@include padding($space-normal $space-small $space-normal $space-two);
|
|
||||||
@include margin($zero);
|
|
||||||
cursor: pointer;
|
|
||||||
@include custom-border-top(3px, transparent);
|
@include custom-border-top(3px, transparent);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: $space-normal $space-small $space-normal $space-two;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@include custom-border-top(3px, $color-woot);
|
@include custom-border-top(3px, $color-woot);
|
||||||
@include background-white;
|
@include background-white;
|
||||||
|
@ -14,12 +15,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
@include margin($zero);
|
align-items: center;
|
||||||
font-size: $font-size-small;
|
|
||||||
font-weight: $font-weight-bold;
|
|
||||||
color: $color-heading;
|
color: $color-heading;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
font-size: $font-size-small;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-icon {
|
.info-icon {
|
||||||
|
@ -52,31 +53,31 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.desc {
|
.desc {
|
||||||
@include margin($zero);
|
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
|
margin: 0;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-bar {
|
.report-bar {
|
||||||
@include margin(-1px $zero);
|
|
||||||
@include background-white;
|
@include background-white;
|
||||||
@include border-light;
|
@include border-light;
|
||||||
@include padding($space-small $space-medium);
|
margin: -1px $zero;
|
||||||
|
padding: $space-small $space-medium;
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
@include flex;
|
@include flex;
|
||||||
flex-direction: column;
|
|
||||||
@include flex-align(center, middle);
|
@include flex-align(center, middle);
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
div {
|
div {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@include margin($space-jumbo);
|
|
||||||
font-size: $font-size-default;
|
|
||||||
color: $color-gray;
|
color: $color-gray;
|
||||||
|
font-size: $font-size-default;
|
||||||
|
margin: $space-jumbo;
|
||||||
}
|
}
|
||||||
|
|
||||||
.business-hours {
|
.business-hours {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue