diff --git a/.circleci/config.yml b/.circleci/config.yml index 974b981ab..87572fdc3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ defaults: &defaults working_directory: ~/build docker: # specify the version you desire here - - image: cimg/ruby:3.0.2-browsers + - image: cimg/ruby:3.0.4-browsers # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images @@ -40,14 +40,13 @@ jobs: - restore_cache: keys: - - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} - - chatwoot-bundle + - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - run: bundle install --frozen --path ~/.bundle - save_cache: paths: - ~/.bundle - key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} + key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} # Only necessary if app uses webpacker or yarn in some other way diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e3eb2fd01..2418bd621 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -5,4 +5,4 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest # Do the set up required for chatwoot app WORKDIR /workspace COPY . /workspace -RUN yarn && gem install bundler && bundle install +RUN yarn && gem install bundler && bundle install diff --git a/.devcontainer/Dockerfile.base b/.devcontainer/Dockerfile.base index de007e202..769be24f8 100644 --- a/.devcontainer/Dockerfile.base +++ b/.devcontainer/Dockerfile.base @@ -1,6 +1,6 @@ -# pre-build stage -ARG VARIANT=3 -FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT} + +ARG VARIANT=ubuntu-20.04 +FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT} # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. 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; \ 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 \ && apt-get -y install --no-install-recommends \ + build-essential \ libssl-dev \ + zlib1g-dev \ + gnupg2 \ tar \ tzdata \ postgresql-client \ + libpq-dev \ yarn \ git \ imagemagick \ tmux \ - zsh + 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 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 \ && chmod +x /usr/local/bin/overmind + +# Install gh +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && sudo apt update \ + && sudo apt install gh + + # Do the set up required for chatwoot app WORKDIR /workspace COPY . /workspace -RUN yarn +# set up ruby COPY Gemfile Gemfile.lock ./ 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 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c0301d87b..a70ba3788 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,17 +23,18 @@ // 5432 postgres // 6379 redis // 1025,8025 mailhog - "forwardPorts": [8025], - //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], + "forwardPorts": [8025, 3000, 3035], "postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn", "portsAttributes": { "3000": { "label": "Rails Server" }, + "3035": { + "label": "Webpack Dev Server" + }, "8025": { "label": "Mailhog UI" } - }, + } } diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 1b5842603..4ffee2d3a 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -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 # uncomment the webpacker env variable sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env +# fix the error with webpacker +echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc + +# codespaces make the ports public +gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME diff --git a/.env.example b/.env.example index cc7e1c2dd..d46acac9f 100644 --- a/.env.example +++ b/.env.example @@ -161,13 +161,15 @@ USE_INBOX_AVATAR_FOR_BOT=true ## NewRelic # https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/ # NEW_RELIC_LICENSE_KEY= +# Set this to true to allow newrelic apm to send logs. +# This is turned off by default. +# NEW_RELIC_APPLICATION_LOGGING_ENABLED= ## Datadog ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables # DD_TRACE_AGENT_URL= - ## IP look up configuration ## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md ## works only on accounts with ip look up feature enabled diff --git a/.eslintrc.js b/.eslintrc.js index f30e21e03..e699f0fd4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,18 +19,32 @@ module.exports = { 'jsx-a11y/label-has-for': 'off', 'jsx-a11y/anchor-is-valid': 'off', 'import/no-unresolved': 'off', - 'vue/max-attributes-per-line': ['error', { - 'singleline': 20, - 'multiline': { - 'max': 1, - 'allowFirstLine': false + 'vue/max-attributes-per-line': [ + 'error', + { + singleline: 20, + 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', 'import/extensions': ['off'], - 'no-console': 'error' + 'no-console': 'error', }, settings: { 'import/resolver': { @@ -41,12 +55,10 @@ module.exports = { }, env: { browser: true, - node: true, jest: true, - jasmine: true + node: true, }, globals: { - __WEBPACK_ENV__: true, bus: true, }, }; diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml new file mode 100644 index 000000000..37f0f3e6e --- /dev/null +++ b/.github/workflows/publish_foss_docker.yml @@ -0,0 +1,62 @@ +# # +# # This action will publish Chatwoot CE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot CE docker images +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile + + - name: set docker tag + run: | + echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV + + - name: replace docker tag if master + if: github.ref_name == 'master' + run: | + echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: docker/Dockerfile + push: true + tags: ${{ env.DOCKER_TAG }} diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml new file mode 100644 index 000000000..39df7aa3d --- /dev/null +++ b/.github/workflows/run_foss_spec.yml @@ -0,0 +1,73 @@ +# # +# # This action will strip the enterprise folder +# # and run the spec. +# # This is set to run against every PR. +# # + +name: Run Chatwoot CE spec +on: + push: + branches: + - develop + - master + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:10.8 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: "" + POSTGRES_DB: postgres + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + # tmpfs makes DB faster by using RAM + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.4 # Not needed with a .ruby-version file + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + + - name: yarn + run: yarn install + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Create database + run: bundle exec rake db:create + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: yarn check-files + run: yarn install --check-files + + # Run rails tests + - name: Run backend tests + run: | + bundle exec rspec --profile=10 --format documentation diff --git a/.ruby-version b/.ruby-version index b50214693..b0f2dcb32 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.2 +3.0.4 diff --git a/Gemfile b/Gemfile index 297a88497..9029ccbcb 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -ruby '3.0.2' +ruby '3.0.4' ##-- base gems for rails --## gem 'rack-cors', require: 'rack/cors' @@ -97,14 +97,14 @@ gem 'brakeman' gem 'ddtrace' gem 'newrelic_rpm' gem 'scout_apm' -gem 'sentry-rails' -gem 'sentry-ruby' -gem 'sentry-sidekiq' +gem 'sentry-rails', '~> 5.3' +gem 'sentry-ruby', '~> 5.3' +gem 'sentry-sidekiq', '~> 5.3' ##-- background job processing --## gem 'sidekiq', '~> 6.4.0' # We want cron jobs -gem 'sidekiq-cron' +gem 'sidekiq-cron', '~> 1.3' ##-- Push notification service --## gem 'fcm' @@ -125,6 +125,12 @@ gem 'procore-sift' gem 'email_reply_trimmer' gem 'html2text' +# to calculate working hours +gem 'working_hours' + +# full text search for articles +gem 'pg_search' + group :production, :staging do # we dont want request timing out in development while using byebug gem 'rack-timeout' @@ -153,11 +159,6 @@ group :test do end group :development, :test do - # TODO: is this needed ? - # errors thrown by devise password gem - gem 'flay' - gem 'rspec' - # for error thrown by devise password gem gem 'active_record_query_trace' gem 'bundle-audit', require: false gem 'byebug', platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index eb6022ac7..cff30308b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/chatwoot/devise-secure_password - revision: de11e8765654b8242d42101ee9c8ffc8126f7975 + revision: d777b04f12652d576b1272b8f39857e3e0b3fc26 specs: devise-secure_password (2.0.1) devise (>= 4.0.0, < 5.0.0) @@ -9,63 +9,63 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.4.7) - actionpack (= 6.1.4.7) - activesupport (= 6.1.4.7) + actioncable (6.1.5.1) + actionpack (= 6.1.5.1) + activesupport (= 6.1.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.4.7) - actionpack (= 6.1.4.7) - activejob (= 6.1.4.7) - activerecord (= 6.1.4.7) - activestorage (= 6.1.4.7) - activesupport (= 6.1.4.7) + actionmailbox (6.1.5.1) + actionpack (= 6.1.5.1) + activejob (= 6.1.5.1) + activerecord (= 6.1.5.1) + activestorage (= 6.1.5.1) + activesupport (= 6.1.5.1) mail (>= 2.7.1) - actionmailer (6.1.4.7) - actionpack (= 6.1.4.7) - actionview (= 6.1.4.7) - activejob (= 6.1.4.7) - activesupport (= 6.1.4.7) + actionmailer (6.1.5.1) + actionpack (= 6.1.5.1) + actionview (= 6.1.5.1) + activejob (= 6.1.5.1) + activesupport (= 6.1.5.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.4.7) - actionview (= 6.1.4.7) - activesupport (= 6.1.4.7) + actionpack (6.1.5.1) + actionview (= 6.1.5.1) + activesupport (= 6.1.5.1) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.4.7) - actionpack (= 6.1.4.7) - activerecord (= 6.1.4.7) - activestorage (= 6.1.4.7) - activesupport (= 6.1.4.7) + actiontext (6.1.5.1) + actionpack (= 6.1.5.1) + activerecord (= 6.1.5.1) + activestorage (= 6.1.5.1) + activesupport (= 6.1.5.1) nokogiri (>= 1.8.5) - actionview (6.1.4.7) - activesupport (= 6.1.4.7) + actionview (6.1.5.1) + activesupport (= 6.1.5.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (6.1.4.7) - activesupport (= 6.1.4.7) + activejob (6.1.5.1) + activesupport (= 6.1.5.1) globalid (>= 0.3.6) - activemodel (6.1.4.7) - activesupport (= 6.1.4.7) - activerecord (6.1.4.7) - activemodel (= 6.1.4.7) - activesupport (= 6.1.4.7) + activemodel (6.1.5.1) + activesupport (= 6.1.5.1) + activerecord (6.1.5.1) + activemodel (= 6.1.5.1) + activesupport (= 6.1.5.1) activerecord-import (1.3.0) activerecord (>= 4.2) - activestorage (6.1.4.7) - actionpack (= 6.1.4.7) - activejob (= 6.1.4.7) - activerecord (= 6.1.4.7) - activesupport (= 6.1.4.7) - marcel (~> 1.0.0) + activestorage (6.1.5.1) + actionpack (= 6.1.5.1) + activejob (= 6.1.5.1) + activerecord (= 6.1.5.1) + activesupport (= 6.1.5.1) + marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.4.7) + activesupport (6.1.5.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -136,7 +136,7 @@ GEM climate_control (1.0.1) coderay (1.1.3) commonmarker (0.23.4) - concurrent-ruby (1.1.9) + concurrent-ruby (1.1.10) connection_pool (2.2.5) crack (0.4.5) rexml @@ -182,8 +182,7 @@ GEM regexp_parser (~> 2.2) email_reply_trimmer (0.1.13) erubi (1.10.0) - erubis (2.7.0) - et-orbi (1.2.6) + et-orbi (1.2.7) tzinfo execjs (2.8.1) facebook-messenger (2.0.1) @@ -204,14 +203,9 @@ GEM faraday (~> 1) ffi (1.15.5) flag_shih_tzu (0.3.23) - flay (2.12.1) - erubis (~> 2.7.0) - path_expander (~> 1.0) - ruby_parser (~> 3.0) - sexp_processor (~> 4.0) foreman (0.87.2) - fugit (1.5.2) - et-orbi (~> 1.1, >= 1.1.8) + fugit (1.5.3) + et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) gapic-common (0.3.4) google-protobuf (~> 3.12, >= 3.12.2) @@ -309,7 +303,7 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) - jmespath (1.6.0) + jmespath (1.6.1) jquery-rails (4.4.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) @@ -349,7 +343,7 @@ GEM listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.14.0) + loofah (2.17.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -376,16 +370,16 @@ GEM net-http-persistent (4.0.1) connection_pool (~> 2.2) netrc (0.11.0) - newrelic_rpm (8.4.0) + newrelic_rpm (8.7.0) nio4r (2.5.8) - nokogiri (1.13.3) + nokogiri (1.13.6) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.3-arm64-darwin) + nokogiri (1.13.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.3-x86_64-darwin) + nokogiri (1.13.6-x86_64-darwin) racc (~> 1.4) - nokogiri (1.13.3-x86_64-linux) + nokogiri (1.13.6-x86_64-linux) racc (~> 1.4) oauth (0.5.8) orm_adapter (0.5.0) @@ -393,8 +387,10 @@ GEM parallel (1.21.0) parser (3.1.1.0) ast (~> 2.4.1) - path_expander (1.1.0) pg (1.3.2) + pg_search (2.3.6) + activerecord (>= 5.2) + activesupport (>= 5.2) procore-sift (0.16.0) rails (> 4.2.0) pry (0.14.1) @@ -403,13 +399,13 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.6.2) + puma (5.6.4) nio4r (~> 2.0) pundit (2.2.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.6.0) - rack (2.2.3) + rack (2.2.3.1) rack-attack (6.6.0) rack (>= 1.0, < 3) rack-cors (1.1.1) @@ -419,31 +415,31 @@ GEM rack-test (1.1.0) rack (>= 1.0, < 3) rack-timeout (0.6.0) - rails (6.1.4.7) - actioncable (= 6.1.4.7) - actionmailbox (= 6.1.4.7) - actionmailer (= 6.1.4.7) - actionpack (= 6.1.4.7) - actiontext (= 6.1.4.7) - actionview (= 6.1.4.7) - activejob (= 6.1.4.7) - activemodel (= 6.1.4.7) - activerecord (= 6.1.4.7) - activestorage (= 6.1.4.7) - activesupport (= 6.1.4.7) + rails (6.1.5.1) + actioncable (= 6.1.5.1) + actionmailbox (= 6.1.5.1) + actionmailer (= 6.1.5.1) + actionpack (= 6.1.5.1) + actiontext (= 6.1.5.1) + actionview (= 6.1.5.1) + activejob (= 6.1.5.1) + activemodel (= 6.1.5.1) + activerecord (= 6.1.5.1) + activestorage (= 6.1.5.1) + activesupport (= 6.1.5.1) bundler (>= 1.15.0) - railties (= 6.1.4.7) + railties (= 6.1.5.1) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.4.2) loofah (~> 2.3) - railties (6.1.4.7) - actionpack (= 6.1.4.7) - activesupport (= 6.1.4.7) + railties (6.1.5.1) + actionpack (= 6.1.5.1) + activesupport (= 6.1.5.1) method_source - rake (>= 0.13) + rake (>= 12.2) thor (~> 1.0) rainbow (3.1.1) rake (13.0.6) @@ -468,10 +464,6 @@ GEM netrc (~> 0.8) retriable (3.1.2) rexml (3.2.5) - rspec (3.11.0) - rspec-core (~> 3.11.0) - rspec-expectations (~> 3.11.0) - rspec-mocks (~> 3.11.0) rspec-core (3.11.0) rspec-support (~> 3.11.0) rspec-expectations (3.11.0) @@ -533,16 +525,16 @@ GEM activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (5.1.0) + sentry-rails (5.3.0) railties (>= 5.0) - sentry-ruby-core (~> 5.1.0) - sentry-ruby (5.1.0) + sentry-ruby-core (~> 5.3.0) + sentry-ruby (5.3.0) concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-ruby-core (= 5.1.0) - sentry-ruby-core (5.1.0) + sentry-ruby-core (= 5.3.0) + sentry-ruby-core (5.3.0) concurrent-ruby - sentry-sidekiq (5.1.0) - sentry-ruby-core (~> 5.1.0) + sentry-sidekiq (5.3.0) + sentry-ruby-core (~> 5.3.0) sidekiq (>= 3.0) sexp_processor (4.16.0) shoulda-matchers (5.1.0) @@ -551,8 +543,8 @@ GEM connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.2.0) - sidekiq-cron (1.2.0) - fugit (~> 1.1) + sidekiq-cron (1.4.0) + fugit (~> 1) sidekiq (>= 4.2.1) signet (0.16.0) addressable (~> 2.8) @@ -636,6 +628,9 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.0) + working_hours (1.4.1) + activesupport (>= 3.2) + tzinfo zeitwerk (2.5.4) PLATFORMS @@ -678,7 +673,6 @@ DEPENDENCIES faker fcm flag_shih_tzu - flay foreman geocoder google-cloud-dialogflow @@ -703,6 +697,7 @@ DEPENDENCIES mock_redis newrelic_rpm pg + pg_search procore-sift pry-rails puma @@ -715,7 +710,6 @@ DEPENDENCIES redis-namespace responders rest-client - rspec rspec-rails (~> 5.0.0) rubocop rubocop-performance @@ -723,12 +717,12 @@ DEPENDENCIES rubocop-rspec scout_apm seed_dump - sentry-rails - sentry-ruby - sentry-sidekiq + sentry-rails (~> 5.3) + sentry-ruby (~> 5.3) + sentry-sidekiq (~> 5.3) shoulda-matchers sidekiq (~> 6.4.0) - sidekiq-cron + sidekiq-cron (~> 1.3) simplecov (= 0.17.1) slack-ruby-client spring @@ -746,9 +740,10 @@ DEPENDENCIES webpacker (~> 5.x) webpush wisper (= 2.0.0) + working_hours RUBY VERSION - ruby 3.0.2p107 + ruby 3.0.4p208 BUNDLED WITH - 2.3.8 + 2.3.15 diff --git a/app/builders/campaigns/campaign_conversation_builder.rb b/app/builders/campaigns/campaign_conversation_builder.rb index 48a3cebd4..b04c6d077 100644 --- a/app/builders/campaigns/campaign_conversation_builder.rb +++ b/app/builders/campaigns/campaign_conversation_builder.rb @@ -1,5 +1,5 @@ class Campaigns::CampaignConversationBuilder - pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes] + pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes] def perform @contact_inbox = ContactInbox.find(@contact_inbox_id) @@ -21,7 +21,8 @@ class Campaigns::CampaignConversationBuilder def message_params ActionController::Parameters.new({ - content: @campaign.message + content: @campaign.message, + campaign_id: @campaign.id }) end @@ -32,7 +33,8 @@ class Campaigns::CampaignConversationBuilder contact_id: @contact_inbox.contact_id, contact_inbox_id: @contact_inbox.id, campaign_id: @campaign.id, - additional_attributes: conversation_additional_attributes + additional_attributes: conversation_additional_attributes, + custom_attributes: custom_attributes || {} } end end diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb index b63d72627..10ce8ee26 100644 --- a/app/builders/contact_builder.rb +++ b/app/builders/contact_builder.rb @@ -15,11 +15,10 @@ class ContactBuilder end def create_contact_inbox(contact) - ::ContactInbox.create!( + ::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!( contact_id: contact.id, inbox_id: inbox.id, - source_id: source_id, - hmac_verified: hmac_verified || false + source_id: source_id ) end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 204b452d6..f19d3c8b7 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -27,9 +27,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end ensure_contact_avatar rescue Koala::Facebook::AuthenticationError - Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}" + @inbox.channel.authorization_error! rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception true end @@ -43,7 +43,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder return if contact.present? @contact = Contact.create!(contact_params.except(:remote_avatar_url)) - @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) + @contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id) end def build_message @@ -128,10 +128,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder result = {} # OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user # We don't need to capture this error as we don't care about contact params in case of echo messages - Sentry.capture_exception(e) unless @outgoing_echo + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo rescue StandardError => e result = {} - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception end process_contact_params_result(result) end diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 69475fb16..3b8ead18c 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -24,7 +24,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder @inbox.channel.authorization_error! raise rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception true end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 9c26ccca1..e9bf0802b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -9,6 +9,7 @@ class Messages::MessageBuilder @user = user @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] + @automation_rule = @params&.dig(:content_attributes, :automation_rule_id) return unless params.instance_of?(ActionController::Parameters) @in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to) @@ -64,6 +65,18 @@ class Messages::MessageBuilder @params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {} end + def automation_rule_id + @automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {} + end + + def campaign_id + @params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {} + end + + def template_params + @params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {} + end + def message_sender return if @params[:sender_type] != 'AgentBot' @@ -82,6 +95,6 @@ class Messages::MessageBuilder items: @items, in_reply_to: @in_reply_to, echo_id: @params[:echo_id] - }.merge(external_created_at) + }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) end end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 2e58825f4..6d3ed0179 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -53,16 +53,7 @@ class Messages::Messenger::MessageBuilder def fetch_story_link(attachment) message = attachment.message - begin - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - result = k.get_object(message.source_id, fields: %w[story from]) || {} - rescue Koala::Facebook::AuthenticationError - @inbox.channel.authorization_error! - raise - rescue StandardError => e - result = {} - Sentry.capture_exception(e) - end + result = get_story_object_from_source_id(message.source_id) story_id = result['story']['mention']['id'] story_sender = result['from']['username'] message.content_attributes[:story_sender] = story_sender @@ -70,4 +61,15 @@ class Messages::Messenger::MessageBuilder message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender) message.save! end + + def get_story_object_from_source_id(source_id) + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + k.get_object(source_id, fields: %w[story from]) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + {} + end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 67b80b7a5..5cd8b4a63 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -4,6 +4,7 @@ class V2::ReportBuilder attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze + AGENT_RESULTS_PER_PAGE = 25 def initialize(account, params) @account = account @@ -45,7 +46,7 @@ class V2::ReportBuilder if params[:type].equal?(:account) conversations else - agent_metrics + agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse end end @@ -79,20 +80,23 @@ class V2::ReportBuilder end def agent_metrics - users = @account.users - users = users.where(id: params[:user_id]) if params[:user_id].present? - users.each_with_object([]) do |user, arr| - @user = user + account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE) + account_users.each_with_object([]) do |account_user, arr| + @user = account_user.user arr << { - user: { id: user.id, name: user.name, thumbnail: user.avatar_url }, + id: @user.id, + name: @user.name, + email: @user.email, + thumbnail: @user.avatar_url, + availability: account_user.availability_status, metric: conversations } end end def conversations - @open_conversations = scope.conversations.open - first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count + @open_conversations = scope.conversations.where(account_id: @account.id).open + first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count metric = { open: @open_conversations.count, unattended: @open_conversations.count - first_response_count diff --git a/app/channels/room_channel.rb b/app/channels/room_channel.rb index 05bff5d86..0680f1458 100644 --- a/app/channels/room_channel.rb +++ b/app/channels/room_channel.rb @@ -45,6 +45,8 @@ class RoomChannel < ApplicationCable::Channel end def current_account + return if current_user.blank? + @current_account ||= if @current_user.is_a? Contact @current_user.account else diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb new file mode 100644 index 000000000..232eecd32 --- /dev/null +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -0,0 +1,48 @@ +class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController + before_action :portal + before_action :fetch_article, except: [:index, :create] + + def index + @articles = @portal.articles + @articles.search(list_params) if params[:payload].present? + end + + def create + @article = @portal.articles.create!(article_params) + 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 + ) + end + + def list_params + params.require(:payload).permit( + :category_slug, :locale, :query + ) + end +end diff --git a/app/controllers/api/v1/accounts/assignable_agents_controller.rb b/app/controllers/api/v1/accounts/assignable_agents_controller.rb new file mode 100644 index 000000000..a712342dd --- /dev/null +++ b/app/controllers/api/v1/accounts/assignable_agents_controller.rb @@ -0,0 +1,24 @@ +class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController + before_action :fetch_inboxes + + def index + agent_ids = @inboxes.map do |inbox| + authorize inbox, :show? + member_ids = inbox.members.pluck(:user_id) + member_ids + end + agent_ids = agent_ids.inject(:&) + agents = Current.account.users.where(id: agent_ids) + @assignable_agents = (agents + Current.account.administrators).uniq + end + + private + + def fetch_inboxes + @inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids]) + end + + def permitted_params + params.permit(inbox_ids: []) + end +end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index dc4f7b6c8..859c8a4bf 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def create @automation_rule = Current.account.automation_rules.new(automation_rules_permit) @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? @@ -17,12 +18,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont @automation_rule end + def attach_file + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:attachment].tempfile, + filename: params[:attachment].original_filename, + content_type: params[:attachment].content_type + ) + render json: { blob_key: file_blob.key, blob_id: file_blob.id } + end + def show; end def update - @automation_rule.update(automation_rules_permit) - process_attachments - @automation_rule + ActiveRecord::Base.transaction do + automation_rule_update + process_attachments + + rescue StandardError => e + Rails.logger.error e + render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity + end end def destroy @@ -37,20 +53,30 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont @automation_rule = new_rule end + def process_attachments + actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } + return if actions.blank? + + actions.each do |action| + blob_id = action['action_params'] + blob = ActiveStorage::Blob.find_by(id: blob_id) + @automation_rule.files.attach(blob) + end + end + private - def process_attachments - return if params[:attachments].blank? - - params[:attachments].each do |uploaded_attachment| - @automation_rule.files.attach(uploaded_attachment) - end + 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 params.permit( :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: [] }] ) end diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 842930874..8a163f011 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e).capture_exception end end @@ -60,7 +60,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController set_instagram_id(access_token, fb_page) fb_page&.reauthorized! rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e).capture_exception end end diff --git a/app/controllers/api/v1/accounts/kbase/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb similarity index 59% rename from app/controllers/api/v1/accounts/kbase/categories_controller.rb rename to app/controllers/api/v1/accounts/categories_controller.rb index a40053dd2..a77f1fb2a 100644 --- a/app/controllers/api/v1/accounts/kbase/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -1,8 +1,9 @@ -class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase::BaseController +class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController + before_action :portal before_action :fetch_category, except: [:index, :create] def index - @categories = @portal.categories + @categories = @portal.categories.search(params) end def create @@ -24,9 +25,13 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase: @category = @portal.categories.find(params[:id]) end + def portal + @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + end + def category_params params.require(:category).permit( - :name, :description, :position + :name, :description, :position, :slug, :locale ) end end diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index a53650e65..f5a3c6a6d 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -7,7 +7,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: build_inbox setup_webhooks if @twilio_channel.sms? rescue StandardError => e - Sentry.capture_exception(e) render_could_not_create_error(e.message) end end diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb index b55b72013..da821a3e5 100644 --- a/app/controllers/api/v1/accounts/conversations/base_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base private def conversation - @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) + @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) authorize @conversation.inbox, :show? end end diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb index 347f028f7..7b5c51d6e 100644 --- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb +++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base RESULTS_PER_PAGE = 25 before_action :check_authorization - before_action :set_csat_survey_responses, only: [:index, :metrics] + before_action :set_csat_survey_responses, only: [:index, :metrics, :download] before_action :set_current_page, only: [:index] before_action :set_current_page_surveys, only: [:index] before_action :set_total_sent_messages_count, only: [:metrics] @@ -19,6 +19,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base @ratings_count = @csat_survey_responses.group(:rating).count end + def download + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv' + render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv' + end + private def set_total_sent_messages_count diff --git a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb new file mode 100644 index 000000000..a8d7ebcb9 --- /dev/null +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -0,0 +1,44 @@ +class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController + before_action :fetch_dashboard_apps, except: [:create] + before_action :fetch_dashboard_app, only: [:show, :update, :destroy] + + def index; end + + def show; end + + def create + @dashboard_app = Current.account.dashboard_apps.create!( + permitted_payload.merge(user_id: Current.user.id) + ) + end + + def update + @dashboard_app.update!(permitted_payload) + end + + def destroy + @dashboard_app.destroy! + head :no_content + end + + private + + def fetch_dashboard_apps + @dashboard_apps = Current.account.dashboard_apps + end + + def fetch_dashboard_app + @dashboard_app = @dashboard_apps.find(permitted_params[:id]) + end + + def permitted_payload + params.require(:dashboard_app).permit( + :title, + content: [:url, :type] + ) + end + + def permitted_params + params.permit(:id) + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 66a71985d..95662e29b 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -12,6 +12,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def show; end + # Deprecated: This API will be removed in 2.7.0 def assignable_agents @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq end @@ -41,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def update - @inbox.update(permitted_params.except(:channel)) - @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] + @inbox.update!(permitted_params.except(:channel)) + update_inbox_working_hours channel_attributes = get_channel_attributes(@inbox.channel_type) # Inbox update doesn't necessarily need channel attributes return if permitted_params(channel_attributes)[:channel].blank? if @inbox.inbox_type == 'Email' - validate_email_channel(channel_attributes) + begin + validate_email_channel(channel_attributes) + rescue StandardError => e + render json: { message: e }, status: :unprocessable_entity and return + end @inbox.channel.reauthorized! end @@ -57,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController update_channel_feature_flags 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 @agent_bot = @inbox.agent_bot end @@ -88,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end - def inbox_name(channel) - return channel.try(:bot_name) if channel.is_a?(Channel::Telegram) - - permitted_params[:name] - end - def create_channel return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) @@ -108,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @inbox.channel.save! 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 = []) params.permit( - :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, + *inbox_attributes, channel: [:type, *channel_attributes] ) end @@ -128,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController }[permitted_params[:channel][:type]] 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) if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) channel_type.constantize::EDITABLE_ATTRS.presence @@ -147,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController [] 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 + +Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/controllers/api/v1/accounts/kbase/base_controller.rb b/app/controllers/api/v1/accounts/kbase/base_controller.rb deleted file mode 100644 index f50140883..000000000 --- a/app/controllers/api/v1/accounts/kbase/base_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseController - before_action :portal - - private - - def portal - @portal ||= Current.account.kbase_portals.find_by(id: params[:portal_id]) - end -end diff --git a/app/controllers/api/v1/accounts/kbase/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb similarity index 50% rename from app/controllers/api/v1/accounts/kbase/portals_controller.rb rename to app/controllers/api/v1/accounts/portals_controller.rb index 804b2d421..75ffc35e9 100644 --- a/app/controllers/api/v1/accounts/kbase/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -1,12 +1,14 @@ -class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::BaseController +class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController before_action :fetch_portal, except: [:index, :create] def index - @portals = Current.account.kbase_portals + @portals = Current.account.portals end + def show; end + def create - @portal = Current.account.kbase_portals.create!(portal_params) + @portal = Current.account.portals.create!(portal_params) end def update @@ -21,12 +23,16 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba private def fetch_portal - @portal = current_account.kbase_portals.find(params[:id]) + @portal = Current.account.portals.find_by(slug: permitted_params[:id]) + end + + def permitted_params + params.permit(:id) end def portal_params params.require(:portal).permit( - :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug + :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived ) end end diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 0add18047..7ea257ed2 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController private def webhook_params - params.require(:webhook).permit(:inbox_id, :url) + params.require(:webhook).permit(:inbox_id, :url, subscriptions: []) end def fetch_webhook diff --git a/app/controllers/api/v1/webhooks_controller.rb b/app/controllers/api/v1/webhooks_controller.rb index c96ae9a96..4f9f6d55b 100644 --- a/app/controllers/api/v1/webhooks_controller.rb +++ b/app/controllers/api/v1/webhooks_controller.rb @@ -10,7 +10,7 @@ class Api::V1::WebhooksController < ApplicationController twitter_consumer.consume head :ok rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e).capture_exception head :ok end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 8df4737db..fc4d33fc3 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -39,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController browser: browser_params, referer: permitted_params[:message][:referer_url], initiated_at: timestamp_params - } + }, + custom_attributes: permitted_params[:custom_attributes].presence || {} } end @@ -52,16 +53,39 @@ class Api::V1::Widget::BaseController < ApplicationController mergee_contact: @contact ).perform else - @contact.update!(email: email, name: contact_name) + @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 - permitted_params[:contact][:email].downcase + permitted_params.dig(:contact, :email)&.downcase end def contact_name - params[:contact][:name] || contact_email.split('@')[0] + params[:contact][:name] || contact_email.split('@')[0] if contact_email.present? + end + + def contact_phone_number + permitted_params.dig(:contact, :phone_number) end def browser_params diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index cc1b16b75..8d054229c 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -7,12 +7,17 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def create ActiveRecord::Base.transaction do - update_contact(contact_email) if @contact.email.blank? && contact_email.present? + process_update_contact @conversation = create_conversation conversation.messages.create(message_params) end end + def process_update_contact + update_contact(contact_email) if @contact.email.blank? && contact_email.present? + update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present? + end + def update_last_seen head :ok && return if conversation.nil? @@ -45,7 +50,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def toggle_status - head :not_found && return if conversation.nil? + return head :not_found if conversation.nil? + + return head :forbidden unless @web_widget.end_conversation? + unless conversation.resolved? conversation.status = :resolved conversation.save @@ -60,6 +68,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def permitted_params - params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id]) + params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], + message: [:content, :referer_url, :timestamp, :echo_id], + custom_attributes: {}) end end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index b2932e34f..bbc4dde7b 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -1,4 +1,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController + include Api::V2::Accounts::ReportsHelper before_action :check_authorization def index @@ -12,27 +13,23 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController end def agents - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = 'attachment; filename=agents_report.csv' - render layout: false, template: 'api/v2/accounts/reports/agents.csv.erb', format: 'csv' + @report_data = generate_agents_report + generate_csv('agents_report', 'api/v2/accounts/reports/agents.csv.erb') end def inboxes - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = 'attachment; filename=inboxes_report.csv' - render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv' + @report_data = generate_inboxes_report + generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes.csv.erb') end def labels - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = 'attachment; filename=labels_report.csv' - render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv' + @report_data = generate_labels_report + generate_csv('labels_report', 'api/v2/accounts/reports/labels.csv.erb') end def teams - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv' - render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' + @report_data = generate_teams_report + generate_csv('teams_report', 'api/v2/accounts/reports/teams.csv.erb') end def conversations @@ -43,46 +40,53 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController private + def generate_csv(filename, template) + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv" + render layout: false, template: template, format: 'csv' + end + def check_authorization raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end - def current_summary_params + def common_params { type: params[:type].to_sym, id: params[:id], - since: range[:current][:since], - until: range[:current][:until], - group_by: params[:group_by] + group_by: params[:group_by], + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) } end + def current_summary_params + common_params.merge({ + since: range[:current][:since], + until: range[:current][:until] + }) + end + def previous_summary_params - { - type: params[:type].to_sym, - id: params[:id], - since: range[:previous][:since], - until: range[:previous][:until], - group_by: params[:group_by] - } + common_params.merge({ + since: range[:previous][:since], + until: range[:previous][:until] + }) end def report_params - { - metric: params[:metric], - type: params[:type].to_sym, - since: params[:since], - until: params[:until], - id: params[:id], - group_by: params[:group_by], - timezone_offset: params[:timezone_offset] - } + common_params.merge({ + metric: params[:metric], + since: params[:since], + until: params[:until], + timezone_offset: params[:timezone_offset] + }) end def conversation_params { type: params[:type].to_sym, - user_id: params[:user_id] + user_id: params[:user_id], + page: params[:page].presence || 1 } end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0a1c672ec..0762c3d91 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,7 @@ class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken include RequestExceptionHandler - include Pundit + include Pundit::Authorization include SwitchLocale skip_before_action :verify_authenticity_token diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index 51061017e..6e9ed04cc 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -9,8 +9,7 @@ module RequestExceptionHandler def handle_with_exception yield - rescue ActiveRecord::RecordNotFound => e - Sentry.capture_exception(e) + rescue ActiveRecord::RecordNotFound render_not_found_error('Resource could not be found') rescue Pundit::NotAuthorizedError render_unauthorized('You are not authorized to do this action') diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 2bb8e9847..36c789b52 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -38,9 +38,13 @@ class DashboardController < ActionController::Base end def app_config - { APP_VERSION: Chatwoot.config[:version], + { + APP_VERSION: Chatwoot.config[:version], VAPID_PUBLIC_KEY: VapidService.public_key, 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: 'v14.0', + IS_ENTERPRISE: ChatwootApp.enterprise? + } end end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index c9f256c6f..4a7eafc9c 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -21,6 +21,10 @@ class Platform::Api::V1::UsersController < PlatformController def update @resource.assign_attributes(user_update_params) + + # We are using devise's reconfirmable flow for changing emails + # But in case of platform APIs we don't want user to go through this extra step + @resource.skip_reconfirmation! if user_update_params[:email].present? @resource.save! end diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 6932386fd..eb794f2a0 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -43,6 +43,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon end def permitted_params - params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {}) + params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {}) end end diff --git a/app/controllers/widget_tests_controller.rb b/app/controllers/widget_tests_controller.rb index fff47d907..6d6742cf4 100644 --- a/app/controllers/widget_tests_controller.rb +++ b/app/controllers/widget_tests_controller.rb @@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base before_action :ensure_widget_position before_action :ensure_widget_type before_action :ensure_widget_style + before_action :ensure_dark_mode def index render @@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base @widget_style = params[:widget_style] || 'standard' end + def ensure_dark_mode + @dark_mode = params[:dark_mode] || 'light' + end + def ensure_widget_position @widget_position = params[:position] || 'left' end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 58013f128..ecbccfb88 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -51,6 +51,7 @@ class ConversationFinder filter_by_team if @team filter_by_labels if params[:labels] filter_by_query if params[:q] + filter_by_reply_status end def set_inboxes @@ -90,6 +91,10 @@ class ConversationFinder @conversations end + def filter_by_reply_status + @conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended' + end + def filter_by_query allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]] @conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%") diff --git a/app/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb new file mode 100644 index 000000000..1c8eaeaf2 --- /dev/null +++ b/app/finders/email_channel_finder.rb @@ -0,0 +1,15 @@ +class EmailChannelFinder + def initialize(email_object) + @email_object = email_object + end + + def perform + channel = nil + recipient_mails = @email_object.to.to_a + @email_object.cc.to_a + recipient_mails.each do |email| + channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase) + break if channel.present? + end + channel + end +end diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 63c72bd8e..e2581e0ad 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -22,7 +22,7 @@ class MessageFinder def current_messages if @params[:before].present? - messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse + messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse else messages.reorder('created_at desc').limit(20).reverse end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8cdf8d987..aa28e4ccc 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -1,4 +1,10 @@ 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) channel_data = permitted_params(attributes)[:channel] @@ -14,13 +20,12 @@ module Api::V1::InboxesHelper Mail.defaults do retriever_method :imap, { address: channel_data[:imap_address], port: channel_data[:imap_port], - user_name: channel_data[:imap_email], + user_name: channel_data[:imap_login], password: channel_data[:imap_password], enable_ssl: channel_data[:imap_enable_ssl] } end - Mail.connection do # rubocop:disable:block - end + check_imap_connection(channel_data) end def validate_smtp(channel_data) @@ -29,8 +34,31 @@ module Api::V1::InboxesHelper smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port]) set_smtp_encryption(channel_data, smtp) + check_smtp_connection(channel_data, smtp) + end - smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login) + 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 + Rails.logger.error e if e.present? + end + + def check_smtp_connection(channel_data, smtp) + smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], + channel_data[:smtp_authentication]&.to_sym || :login) smtp.finish unless smtp&.nil? end @@ -70,4 +98,22 @@ module Api::V1::InboxesHelper context.verify_mode = openssl_verify_mode context 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 diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb new file mode 100644 index 000000000..0604eba2f --- /dev/null +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -0,0 +1,56 @@ +module Api::V2::Accounts::ReportsHelper + def generate_agents_report + Current.account.users.map do |agent| + agent_report = generate_report({ type: :agent, id: agent.id }) + [agent.name] + generate_readable_report_metrics(agent_report) + end + end + + def generate_inboxes_report + Current.account.inboxes.map do |inbox| + inbox_report = generate_report({ type: :inbox, id: inbox.id }) + [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report) + end + end + + def generate_teams_report + Current.account.teams.map do |team| + team_report = generate_report({ type: :team, id: team.id }) + [team.name] + generate_readable_report_metrics(team_report) + end + end + + def generate_labels_report + Current.account.labels.map do |label| + label_report = generate_report({ type: :label, id: label.id }) + [label.title] + generate_readable_report_metrics(label_report) + end + end + + def generate_report(report_params) + V2::ReportBuilder.new( + Current.account, + report_params.merge( + { + since: params[:since], + until: params[:until], + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + } + ) + ).summary + end + + private + + def generate_readable_report_metrics(report_metric) + [ + report_metric[:conversations_count], + time_to_minutes(report_metric[:avg_first_response_time]), + time_to_minutes(report_metric[:avg_resolution_time]) + ] + end + + def time_to_minutes(time_in_seconds) + (time_in_seconds / 60).to_i + end +end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 31e2c1ce8..5fdb34170 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -17,33 +17,39 @@ module ReportHelper end def conversations_count - (get_grouped_values scope.conversations).count + (get_grouped_values scope.conversations.where(account_id: account.id)).count end def incoming_messages_count - (get_grouped_values scope.messages.incoming.unscope(:order)).count + (get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count end def outgoing_messages_count - (get_grouped_values scope.messages.outgoing.unscope(:order)).count + (get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count end def resolutions_count - (get_grouped_values scope.conversations.resolved).count + (get_grouped_values scope.conversations.where(account_id: account.id).resolved).count end def avg_first_response_time - (get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) + return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] + + grouped_reporting_events.average(:value) end def avg_resolution_time - (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id)) + return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] + + grouped_reporting_events.average(:value) end def avg_resolution_time_summary - avg_rt = scope.reporting_events - .where(name: 'conversation_resolved', created_at: range) - .average(:value) + reporting_events = scope.reporting_events + .where(name: 'conversation_resolved', account_id: account.id, created_at: range) + avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) return 0 if avg_rt.blank? @@ -51,9 +57,9 @@ module ReportHelper end def avg_first_response_time_summary - avg_frt = scope.reporting_events - .where(name: 'first_response', created_at: range) - .average(:value) + reporting_events = scope.reporting_events + .where(name: 'first_response', account_id: account.id, created_at: range) + avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) return 0 if avg_frt.blank? diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb new file mode 100644 index 000000000..eee1af283 --- /dev/null +++ b/app/helpers/reporting_event_helper.rb @@ -0,0 +1,50 @@ +module ReportingEventHelper + def business_hours(inbox, from, to) + return 0 unless inbox.working_hours_enabled? + + inbox_working_hours = configure_working_hours(inbox.working_hours) + return 0 if inbox_working_hours.blank? + + # Configure working hours + WorkingHours::Config.working_hours = inbox_working_hours + + # Configure timezone + WorkingHours::Config.time_zone = inbox.timezone + + # Use inbox timezone to change from & to values. + from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time + to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time + from_in_inbox_timezone.working_time_until(to_in_inbox_timezone) + end + + private + + def configure_working_hours(working_hours) + working_hours.each_with_object({}) do |working_hour, object| + object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day? + end + end + + def day(day_of_week) + week_days = { + 0 => :sun, + 1 => :mon, + 2 => :tue, + 3 => :wed, + 4 => :thu, + 5 => :fri, + 6 => :sat + } + week_days[day_of_week] + end + + def working_hour_range(working_hour) + { format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) } + end + + def format_time(hour, minute) + hour = hour < 10 ? "0#{hour}" : hour + minute = minute < 10 ? "0#{minute}" : minute + "#{hour}:#{minute}" + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 2c91de16f..7243e566e 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,8 +1,8 @@ @@ -529,7 +649,7 @@ export default { .conversations-list-wrap { flex-shrink: 0; width: 34rem; - + overflow: hidden; @include breakpoint(large up) { width: 36rem; } diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index f86935959..f4cad844a 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -98,4 +98,7 @@ export default { width: 48rem; } } +.modal-big { + width: 60%; +} diff --git a/app/javascript/dashboard/components/ModalHeader.vue b/app/javascript/dashboard/components/ModalHeader.vue index 9b80220a5..e0ce5f993 100644 --- a/app/javascript/dashboard/components/ModalHeader.vue +++ b/app/javascript/dashboard/components/ModalHeader.vue @@ -7,7 +7,7 @@

{{ headerContent }}

- + diff --git a/app/javascript/dashboard/components/NetworkNotification.vue b/app/javascript/dashboard/components/NetworkNotification.vue index 115a674a2..6f29dbb74 100644 --- a/app/javascript/dashboard/components/NetworkNotification.vue +++ b/app/javascript/dashboard/components/NetworkNotification.vue @@ -20,8 +20,7 @@ color-scheme="warning" icon="dismiss-circle" @click="closeNotification" - > - + /> diff --git a/app/javascript/dashboard/components/SettingsSection.vue b/app/javascript/dashboard/components/SettingsSection.vue index 26caff02c..b3348ae04 100644 --- a/app/javascript/dashboard/components/SettingsSection.vue +++ b/app/javascript/dashboard/components/SettingsSection.vue @@ -7,9 +7,13 @@

{{ subTitle }}

+

+ {{ $t('INBOX_MGMT.NOTE') }} + {{ note }} +

- +
@@ -25,6 +29,10 @@ export default { type: String, required: true, }, + note: { + type: String, + default: '', + }, }, }; @@ -46,5 +54,9 @@ export default { .title--section { padding-right: var(--space-large); } + + .note { + font-weight: var(--font-weight-bold); + } } diff --git a/app/javascript/dashboard/components/buttons/Button.vue b/app/javascript/dashboard/components/buttons/Button.vue index 69dbc3658..733b84639 100644 --- a/app/javascript/dashboard/components/buttons/Button.vue +++ b/app/javascript/dashboard/components/buttons/Button.vue @@ -7,7 +7,7 @@ :icon="icon" /> - + diff --git a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue index cc65c68b0..1a60b3a43 100644 --- a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue +++ b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue @@ -57,3 +57,13 @@ export default { }, }; + diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 1ad4a9be3..1c70e6755 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -53,7 +53,7 @@ export default { computed: { ...mapGetters({ getCurrentUserAvailability: 'getCurrentUserAvailability', - getCurrentAccountId: 'getCurrentAccountId', + currentAccountId: 'getCurrentAccountId', }), availabilityDisplayLabel() { const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( @@ -63,9 +63,6 @@ export default { availabilityIndex ]; }, - currentAccountId() { - return this.getCurrentAccountId; - }, currentUserAvailability() { return this.getCurrentUserAvailability; }, diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index c7fda74f1..5017b3d4b 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -19,6 +19,7 @@ :menu-config="activeSecondaryMenu" :current-role="currentRole" @add-label="showAddLabelPopup" + @toggle-accounts="toggleAccountModal" /> diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js index a9c703c44..967ee44ed 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js @@ -3,7 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper'; const reports = accountId => ({ parentNav: 'reports', routes: [ - 'settings_account_reports', + 'account_overview_reports', + 'conversation_reports', 'csat_reports', 'agent_reports', 'label_reports', @@ -16,7 +17,14 @@ const reports = accountId => ({ label: 'REPORTS_OVERVIEW', hasSubMenu: false, toState: frontendURL(`accounts/${accountId}/reports/overview`), - toStateName: 'settings_account_reports', + toStateName: 'account_overview_reports', + }, + { + icon: 'chat', + label: 'REPORTS_CONVERSATION', + hasSubMenu: false, + toState: frontendURL(`accounts/${accountId}/reports/conversation`), + toStateName: 'conversation_reports', }, { icon: 'emoji', diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue b/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue index 1438ac7e8..71382d5e6 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue @@ -1,14 +1,34 @@ diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index 1f98305a1..4b19a3ec3 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -1,6 +1,6 @@ @@ -112,10 +111,10 @@ export default { } &.warning { - background: var(--y-800); - color: var(--s-600); + background: var(--y-600); + color: var(--y-500); a { - color: var(--s-600); + color: var(--y-500); } } diff --git a/app/javascript/dashboard/components/ui/Label.vue b/app/javascript/dashboard/components/ui/Label.vue index 03930ab46..f44c920d4 100644 --- a/app/javascript/dashboard/components/ui/Label.vue +++ b/app/javascript/dashboard/components/ui/Label.vue @@ -1,13 +1,18 @@ + + diff --git a/app/javascript/dashboard/components/widgets/AutomationFileInput.vue b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue new file mode 100644 index 000000000..595faa8fb --- /dev/null +++ b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/DashboardApp/Frame.vue b/app/javascript/dashboard/components/widgets/DashboardApp/Frame.vue new file mode 100644 index 000000000..0c2c0e8ed --- /dev/null +++ b/app/javascript/dashboard/components/widgets/DashboardApp/Frame.vue @@ -0,0 +1,64 @@ +