diff --git a/.circleci/config.yml b/.circleci/config.yml index fbb4fea9074e9..69ceb27723bdc 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.4-browsers + - image: cimg/ruby:3.1.3-browsers # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images @@ -38,6 +38,18 @@ jobs: name: Which bundler? command: bundle -v + - run: + name: Swap node versions + command: | + set +e + wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" + nvm install v16 + echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV + echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV + # Run bundler # Load installed gems from cache if possible, bundle install then save cache # Multiple caches are used to increase the chance of a cache hit @@ -90,8 +102,8 @@ jobs: echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." exit 1 fi - 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 + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar + java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json # Database setup - run: yarn install --check-files @@ -193,4 +205,3 @@ workflows: - upload-coverage: requires: - build - diff --git a/.devcontainer/Dockerfile.base b/.devcontainer/Dockerfile.base index 769be24f80f4a..c0beb9eed0b29 100644 --- a/.devcontainer/Dockerfile.base +++ b/.devcontainer/Dockerfile.base @@ -30,7 +30,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ npm # Install rbenv and ruby -ARG RUBY_VERSION="3.0.4" +ARG RUBY_VERSION="3.1.3" RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \ && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ && echo 'eval "$(rbenv init -)"' >> ~/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a70ba3788c8a7..d2dac356bfd95 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,7 @@ // 1025,8025 mailhog "forwardPorts": [8025, 3000, 3035], - "postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn", + "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn", "portsAttributes": { "3000": { "label": "Rails Server" diff --git a/.dockerignore b/.dockerignore index 46b3b7b8a8cfc..85065ec7d8170 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,7 +11,6 @@ storage public/system tmp .codeclimate.yml -public/assets public/packs node_modules vendor/bundle diff --git a/.env.example b/.env.example index 830ee7bce2f26..ac4a2c0e8e841 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,7 @@ REDIS_SENTINELS= REDIS_SENTINEL_MASTER_NAME= # By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels -# Use the following environment variable to customize passwords for sentinels. +# Use the following environment variable to customize passwords for sentinels. # Use empty string if sentinels are configured with out passwords # REDIS_SENTINEL_PASSWORD= @@ -45,24 +45,27 @@ REDIS_SENTINEL_MASTER_NAME= # REDIS_OPENSSL_VERIFY_MODE=none # Postgres Database config variables -# You can leave POSTGRES_DATABASE blank. The default name of +# 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_USERNAME=postgres POSTGRES_PASSWORD= RAILS_ENV=development +# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required. +# POSTGRES_STATEMENT_TIMEOUT=14s RAILS_MAX_THREADS=5 # The email from which all outgoing emails are sent # could user either `email@yourdomain.com` or `BrandName ` -MAILER_SENDER_EMAIL="Chatwoot " +MAILER_SENDER_EMAIL=Chatwoot #SMTP domain key is set up for HELO checking SMTP_DOMAIN=chatwoot.com -# the default value is set "mailhog" and is used by docker-compose for development environments, +# Set the value to "mailhog" if using docker-compose for development environments, # Set the value as "localhost" or your SMTP address in other environments -SMTP_ADDRESS=mailhog +# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix) +SMTP_ADDRESS= SMTP_PORT=1025 SMTP_USERNAME= SMTP_PASSWORD= @@ -130,6 +133,11 @@ TWITTER_ENVIRONMENT= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_OAUTH_CALLBACK_URL= + ### Change this env variable only if you are using a custom build mobile app ## Mobile app env variables IOS_APP_ID=L7YLMN4634.com.chatwoot.app @@ -163,6 +171,9 @@ USE_INBOX_AVATAR_FOR_BOT=true ## Sentry # SENTRY_DSN= +## LogRocket +# LOG_ROCKET_PROJECT_ID=xxxxx/some-project + ## Scout ## https://scoutapm.com/docs/ruby/configuration # SCOUT_KEY=YOURKEY @@ -180,11 +191,7 @@ USE_INBOX_AVATAR_FOR_BOT=true ## 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 -# IP_LOOKUP_SERVICE=geoip2 -# maxmindb api key to use geoip2 service +# MaxMindDB API key to download GeoLite2 City database # IP_LOOKUP_API_KEY= ## Rack Attack configuration @@ -212,3 +219,12 @@ 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= + +#MS OAUTH creds +AZURE_APP_ID= +AZURE_APP_SECRET= + +## Advanced configurations +## Change these values to fine tune performance +# control the concurrency setting of sidekiq +# SIDEKIQ_CONCURRENCY=10 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 790a322fda497..c429098564543 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -19,6 +19,7 @@ concurrency: jobs: action: runs-on: ubuntu-latest + if: ${{ github.repository == 'chatwoot/chatwoot' }} steps: - uses: dessant/lock-threads@v3 with: diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index 6b076cd8a6e1a..034c08e8c9a60 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -44,3 +44,9 @@ jobs: # sudo systemctl restart chatwoot.target # curl http://localhost:3000/api + - name: Upload chatwoot setup log file as an artifact + uses: actions/upload-artifact@v3 + if: always() + with: + name: chatwoot-setup-log-file + path: /var/log/chatwoot-setup.log diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 39df7aa3de745..51688944ced96 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -15,7 +15,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 services: postgres: image: postgres:10.8 @@ -47,9 +47,12 @@ jobs: - 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 + - uses: actions/setup-node@v3 + with: + node-version: 16 + - name: yarn run: yarn install @@ -71,3 +74,10 @@ jobs: - name: Run backend tests run: | bundle exec rspec --profile=10 --format documentation + + - name: Upload rails log folder + uses: actions/upload-artifact@v3 + if: always() + with: + name: rails-log-folder + path: log diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000000..7a7564ecbfd71 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,28 @@ +# This workflow warns and then closes PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '28 3 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/stale@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-issue-close: -1, + days-before-issue-stale: -1 + days-before-pr-close: -1, + days-before-pr-stale: 30, + stale-pr-message: '🐢 Turtley slow progress alert! This pull request has been idle for over 30 days. Can we please speed things up and either merge it or release it back into the wild?' + stale-pr-label: 'stale' diff --git a/.gitignore b/.gitignore index 11a8c50e37d6c..4182a0ac79a89 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,9 @@ test/cypress/videos/* /config/master.key /config/*.enc + +.vscode/settings.json + +# yalc for local testing +.yalc +yalc.lock \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 7774f34f5c6c2..adda426adad92 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,11 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm run eslint -bundle exec rubocop -a -git add + +# lint js and vue files +npx --no-install lint-staged + +# lint only staged ruby files +git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a + +# stage rubocop changes to files +git diff --name-only --cached | xargs git add diff --git a/.rubocop.yml b/.rubocop.yml index 3665ad2e37c1f..8b41dd5fd347d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,9 +16,7 @@ Metrics/ClassLength: - 'app/models/message.rb' - 'app/builders/messages/facebook/message_builder.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb' - - 'app/controllers/api/v1/accounts/conversations_controller.rb' - 'app/listeners/action_cable_listener.rb' - - 'app/models/conversation.rb' RSpec/ExampleLength: Max: 25 Style/Documentation: @@ -87,6 +85,10 @@ Style/ClassAndModuleChildren: - 'config/application.rb' Style/MapToHash: Enabled: false +Style/HashSyntax: + Enabled: true + EnforcedStyle: no_mixed_keys + EnforcedShorthandSyntax: never RSpec/NestedGroups: Enabled: true Max: 4 @@ -160,7 +162,7 @@ RSpec/NamedSubject: Enabled: false # we should bring this down RSpec/MultipleMemoizedHelpers: - Max: 12 + Max: 14 AllCops: NewCops: enable @@ -185,4 +187,3 @@ AllCops: - db/migrate/20200927135222_add_last_activity_at_to_conversation.rb - db/migrate/20210306170117_add_last_activity_at_to_contacts.rb - db/migrate/20220809104508_revert_cascading_indexes.rb - diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4d66828c39e2f..48e714dd6300e 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -68,7 +68,6 @@ Naming/AccessorMethodName: - 'app/controllers/api/v1/accounts_controller.rb' - 'app/controllers/api/v1/callbacks_controller.rb' - 'app/controllers/api/v1/conversations_controller.rb' - - 'app/controllers/passwords_controller.rb' # Offense count: 9 # Configuration parameters: EnforcedStyleForLeadingUnderscores. diff --git a/.ruby-version b/.ruby-version index b0f2dcb32fc28..ff365e06b9577 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.4 +3.1.3 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000..b3cfaee508281 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + "chatwoot", + "dompurify" + ] +} diff --git a/Gemfile b/Gemfile index 0ce0ec4c986ee..4740b768fd46d 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,10 @@ source 'https://rubygems.org' -ruby '3.0.4' +ruby '3.1.3' ##-- base gems for rails --## gem 'rack-cors', require: 'rack/cors' -gem 'rails', '~>6.1' +gem 'rails', '~> 6.1', '>= 6.1.7.3' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -37,6 +37,12 @@ gem 'json_schemer' gem 'rack-attack' # a utility tool for streaming, flexible and safe downloading of remote files gem 'down', '~> 5.0' +# authentication type to fetch and send mail over oauth2.0 +gem 'gmail_xoauth' +# Prevent CSV injection +gem 'csv-safe' +# Support message translation +gem 'google-cloud-translate' ##-- for active storage --## gem 'aws-sdk-s3', require: false @@ -56,7 +62,7 @@ gem 'activerecord-import' gem 'dotenv-rails' gem 'foreman' gem 'puma' -gem 'webpacker', '~> 5.x' +gem 'webpacker', '~> 5.4', '>= 5.4.3' # metrics on heroku gem 'barnes' @@ -90,22 +96,25 @@ gem 'slack-ruby-client' gem 'google-cloud-dialogflow' ##-- apm and error monitoring ---# -gem 'ddtrace' -gem 'elastic-apm' -gem 'newrelic_rpm' -gem 'scout_apm' -gem 'sentry-rails', '~> 5.3' -gem 'sentry-ruby', '~> 5.3' -gem 'sentry-sidekiq', '~> 5.3' +# loaded only when environment variables are set. +# ref application.rb +gem 'ddtrace', require: false +gem 'elastic-apm', require: false +gem 'newrelic_rpm', require: false +gem 'newrelic-sidekiq-metrics', require: false +gem 'scout_apm', require: false +gem 'sentry-rails', require: false +gem 'sentry-ruby', require: false +gem 'sentry-sidekiq', require: false ##-- background job processing --## -gem 'sidekiq', '~> 6.4.0' +gem 'sidekiq', '~> 6.4.2' # We want cron jobs -gem 'sidekiq-cron', '~> 1.3' +gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0' ##-- Push notification service --## gem 'fcm' -gem 'webpush' +gem 'web-push' ##-- geocoding / parse location from ip --## # http://www.rubygeocoder.com/ @@ -135,7 +144,27 @@ gem 'stripe' ## to populate db with sample data gem 'faker' -group :production, :staging do +# Can remove this in rails 7 +gem 'net-imap', require: false +gem 'net-pop', require: false +gem 'net-smtp', require: false + +gem 'lograge', '~> 0.12.0' + +# worked with microsoft refresh token +gem 'omniauth-oauth2' + +gem 'audited', '~> 5.2' + +# need for google auth +gem 'omniauth' +gem 'omniauth-google-oauth2' +gem 'omniauth-rails_csrf_protection', '~> 1.0' + +### Gems required only in specific deployment environments ### +############################################################## + +group :production do # we dont want request timing out in development while using byebug gem 'rack-timeout' end @@ -151,15 +180,21 @@ group :development do # When we want to squash migrations gem 'squasher' + + # profiling + gem 'rack-mini-profiler', require: false + gem 'stackprof' end group :test do # Cypress in rails. - gem 'cypress-on-rails', '~> 1.0' + gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1' # fast cleaning of database gem 'database_cleaner' # mock http calls gem 'webmock' + # test profiling + gem 'test-prof' end group :development, :test do @@ -175,7 +210,7 @@ group :development, :test do gem 'mock_redis' gem 'pry-rails' gem 'rspec_junit_formatter' - gem 'rspec-rails', '~> 5.0.0' + gem 'rspec-rails', '~> 5.0.3' gem 'rubocop', require: false gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false @@ -185,6 +220,4 @@ group :development, :test do gem 'simplecov', '0.17.1', require: false gem 'spring' gem 'spring-watcher-listen' -end - -gem 'lograge', '~> 0.12.0' +end \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index cc4b722cad341..a02eae99179f2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,63 +9,63 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.6.1) - actionpack (= 6.1.6.1) - activesupport (= 6.1.6.1) + actioncable (6.1.7.3) + actionpack (= 6.1.7.3) + activesupport (= 6.1.7.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.6.1) - actionpack (= 6.1.6.1) - activejob (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + actionmailbox (6.1.7.3) + actionpack (= 6.1.7.3) + activejob (= 6.1.7.3) + activerecord (= 6.1.7.3) + activestorage (= 6.1.7.3) + activesupport (= 6.1.7.3) mail (>= 2.7.1) - actionmailer (6.1.6.1) - actionpack (= 6.1.6.1) - actionview (= 6.1.6.1) - activejob (= 6.1.6.1) - activesupport (= 6.1.6.1) + actionmailer (6.1.7.3) + actionpack (= 6.1.7.3) + actionview (= 6.1.7.3) + activejob (= 6.1.7.3) + activesupport (= 6.1.7.3) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.6.1) - actionview (= 6.1.6.1) - activesupport (= 6.1.6.1) + actionpack (6.1.7.3) + actionview (= 6.1.7.3) + activesupport (= 6.1.7.3) 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.6.1) - actionpack (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + actiontext (6.1.7.3) + actionpack (= 6.1.7.3) + activerecord (= 6.1.7.3) + activestorage (= 6.1.7.3) + activesupport (= 6.1.7.3) nokogiri (>= 1.8.5) - actionview (6.1.6.1) - activesupport (= 6.1.6.1) + actionview (6.1.7.3) + activesupport (= 6.1.7.3) 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.6.1) - activesupport (= 6.1.6.1) + activejob (6.1.7.3) + activesupport (= 6.1.7.3) globalid (>= 0.3.6) - activemodel (6.1.6.1) - activesupport (= 6.1.6.1) - activerecord (6.1.6.1) - activemodel (= 6.1.6.1) - activesupport (= 6.1.6.1) + activemodel (6.1.7.3) + activesupport (= 6.1.7.3) + activerecord (6.1.7.3) + activemodel (= 6.1.7.3) + activesupport (= 6.1.7.3) activerecord-import (1.4.0) activerecord (>= 4.2) - activestorage (6.1.6.1) - actionpack (= 6.1.6.1) - activejob (= 6.1.6.1) - activerecord (= 6.1.6.1) - activesupport (= 6.1.6.1) + activestorage (6.1.7.3) + actionpack (= 6.1.7.3) + activejob (= 6.1.7.3) + activerecord (= 6.1.7.3) + activesupport (= 6.1.7.3) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.6.1) + activesupport (6.1.7.3) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -73,8 +73,8 @@ GEM zeitwerk (~> 2.3) acts-as-taggable-on (9.0.1) activerecord (>= 6.0, < 7.1) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) administrate (0.17.0) actionpack (>= 5.0) actionview (>= 5.0) @@ -90,6 +90,8 @@ GEM rake (>= 10.4, < 14.0) ast (2.4.2) attr_extras (6.2.5) + audited (5.2.0) + activerecord (>= 5.0, < 7.1) aws-eventstream (1.2.0) aws-partitions (1.605.0) aws-sdk-core (3.131.2) @@ -135,12 +137,13 @@ GEM byebug (11.1.3) climate_control (1.1.1) coderay (1.1.3) - commonmarker (0.23.6) - concurrent-ruby (1.1.10) + commonmarker (0.23.7) + concurrent-ruby (1.2.2) connection_pool (2.2.5) crack (0.4.5) rexml crass (1.0.6) + csv-safe (3.1.1) cypress-on-rails (1.13.1) rack database_cleaner (2.0.1) @@ -186,7 +189,7 @@ GEM concurrent-ruby (~> 1.0) http (>= 3.0) email_reply_trimmer (0.1.13) - erubi (1.10.0) + erubi (1.12.0) et-orbi (1.2.7) tzinfo execjs (2.8.1) @@ -247,8 +250,10 @@ GEM grpc (~> 1.36) geocoder (1.8.0) gli (2.21.0) - globalid (1.0.0) + globalid (1.1.0) activesupport (>= 5.0) + gmail_xoauth (0.4.2) + oauth (>= 0.3.6) google-apis-core (0.7.0) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) @@ -286,6 +291,19 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) + google-cloud-translate (3.3.0) + google-cloud-core (~> 1.6) + google-cloud-translate-v2 (>= 0.0, < 2.a) + google-cloud-translate-v3 (>= 0.0, < 2.a) + google-cloud-translate-v2 (0.4.0) + faraday (>= 0.17.3, < 2.a) + google-cloud-core (~> 1.6) + googleapis-common-protos (>= 1.3.10, < 2.a) + googleapis-common-protos-types (>= 1.0.5, < 2.a) + googleauth (>= 0.16.2, < 2.a) + google-cloud-translate-v3 (0.5.0) + gapic-common (>= 0.10, < 2.a) + google-cloud-errors (~> 1.0) google-protobuf (3.21.7) google-protobuf (3.21.7-x86_64-darwin) google-protobuf (3.21.7-x86_64-linux) @@ -321,7 +339,7 @@ GEM hana (1.3.7) hashdiff (1.0.1) hashie (5.0.0) - hkdf (0.3.0) + hkdf (1.0.0) html2text (0.2.1) nokogiri (~> 1.6) http (5.1.0) @@ -333,11 +351,11 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.20.0) - mime-types (~> 3.0) + httparty (0.21.0) + mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.11.0) + i18n (1.12.0) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -358,7 +376,7 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) - jwt (2.4.1) + jwt (2.5.0) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -403,11 +421,14 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.18.0) + loofah (2.19.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) - mail (2.7.1) + mail (2.8.1) mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp marcel (1.0.2) maxminddb (0.1.22) memoist (0.16.2) @@ -417,8 +438,8 @@ GEM mime-types-data (3.2022.0105) mini_magick (4.11.0) mini_mime (1.1.2) - mini_portile2 (2.8.0) - minitest (5.16.2) + mini_portile2 (2.8.1) + minitest (5.18.0) mock_redis (0.32.0) ruby2_keywords momentjs-rails (2.29.1.1) @@ -429,19 +450,53 @@ GEM multipart-post (2.2.3) net-http-persistent (4.0.1) connection_pool (~> 2.2) + net-imap (0.3.1) + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.1) + timeout + net-smtp (0.3.3) + net-protocol netrc (0.11.0) - newrelic_rpm (8.9.0) + newrelic-sidekiq-metrics (1.6.1) + newrelic_rpm (~> 8) + sidekiq + newrelic_rpm (8.15.0) nio4r (2.5.8) - nokogiri (1.13.9) + nokogiri (1.14.2) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.9-arm64-darwin) + nokogiri (1.14.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.9-x86_64-darwin) + nokogiri (1.14.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.13.9-x86_64-linux) + nokogiri (1.14.2-x86_64-linux) racc (~> 1.4) oauth (0.5.10) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) + omniauth (2.1.0) + hashie (>= 3.4.6) + rack (>= 2.2.3) + rack-protection + omniauth-google-oauth2 (1.1.1) + jwt (>= 2.0) + oauth2 (~> 2.0.6) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8.0) + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + omniauth-rails_csrf_protection (1.0.1) + actionpack (>= 4.2) + omniauth (~> 2.0) + openssl (3.1.0) orm_adapter (0.5.0) os (1.1.4) parallel (1.22.1) @@ -458,46 +513,50 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.7) + public_suffix (5.0.1) 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.4) + racc (1.6.2) + rack (2.2.6.4) rack-attack (6.6.1) rack (>= 1.0, < 3) rack-cors (1.1.1) rack (>= 2.0.0) + rack-mini-profiler (3.0.0) + rack (>= 1.2.0) + rack-protection (3.0.5) + rack rack-proxy (0.7.2) rack - rack-test (2.0.2) + rack-test (2.1.0) rack (>= 1.3) rack-timeout (0.6.3) - rails (6.1.6.1) - actioncable (= 6.1.6.1) - actionmailbox (= 6.1.6.1) - actionmailer (= 6.1.6.1) - actionpack (= 6.1.6.1) - actiontext (= 6.1.6.1) - actionview (= 6.1.6.1) - activejob (= 6.1.6.1) - activemodel (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + rails (6.1.7.3) + actioncable (= 6.1.7.3) + actionmailbox (= 6.1.7.3) + actionmailer (= 6.1.7.3) + actionpack (= 6.1.7.3) + actiontext (= 6.1.7.3) + actionview (= 6.1.7.3) + activejob (= 6.1.7.3) + activemodel (= 6.1.7.3) + activerecord (= 6.1.7.3) + activestorage (= 6.1.7.3) + activesupport (= 6.1.7.3) bundler (>= 1.15.0) - railties (= 6.1.6.1) + railties (= 6.1.7.3) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.4.3) - loofah (~> 2.3) - railties (6.1.6.1) - actionpack (= 6.1.6.1) - activesupport (= 6.1.6.1) + rails-html-sanitizer (1.5.0) + loofah (~> 2.19, >= 2.19.1) + railties (6.1.7.3) + actionpack (= 6.1.7.3) + activesupport (= 6.1.7.3) method_source rake (>= 12.2) thor (~> 1.0) @@ -627,26 +686,32 @@ GEM gli hashie websocket-driver + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) spring (2.1.1) spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) spring (>= 1.2, < 3.0) - sprockets (4.1.1) + sprockets (4.2.0) concurrent-ruby (~> 1.0) - rack (> 1, < 3) + rack (>= 2.2.4, < 4) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) squasher (0.6.2) + stackprof (0.2.24) statsd-ruby (1.5.0) stripe (6.5.0) telephone_number (1.4.16) + test-prof (1.0.11) thor (1.2.1) tilt (2.0.10) time_diff (0.3.0) activesupport i18n + timeout (0.3.1) trailblazer-option (0.1.2) twilio-ruby (5.68.0) faraday (>= 0.9, < 3.0) @@ -654,7 +719,7 @@ GEM nokogiri (>= 1.6, < 2.0) twitty (0.1.4) oauth - tzinfo (2.0.4) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) tzinfo-data (1.2022.1) tzinfo (>= 1.0.0) @@ -670,6 +735,7 @@ GEM valid_email2 (4.0.3) activemodel (>= 3.2) mail (~> 2.5) + version_gem (1.1.1) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.0) @@ -677,7 +743,11 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webmock (3.14.0) + web-push (3.0.0) + hkdf (~> 1.0) + jwt (~> 2.0) + openssl (~> 3.0) + webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -686,9 +756,6 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webpush (1.1.0) - hkdf (~> 0.2) - jwt (~> 2.0) webrick (1.7.0) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) @@ -697,7 +764,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.0) + zeitwerk (2.6.7) PLATFORMS arm64-darwin-20 @@ -714,6 +781,7 @@ DEPENDENCIES administrate annotate attr_extras + audited (~> 5.2) aws-sdk-s3 azure-storage-blob barnes @@ -725,7 +793,8 @@ DEPENDENCIES byebug climate_control commonmarker - cypress-on-rails (~> 1.0) + csv-safe + cypress-on-rails (~> 1.13, >= 1.13.1) database_cleaner ddtrace devise @@ -742,8 +811,10 @@ DEPENDENCIES flag_shih_tzu foreman geocoder + gmail_xoauth google-cloud-dialogflow google-cloud-storage + google-cloud-translate groupdate haikunator hairtrigger @@ -763,7 +834,15 @@ DEPENDENCIES lograge (~> 0.12.0) maxminddb mock_redis + net-imap + net-pop + net-smtp + newrelic-sidekiq-metrics newrelic_rpm + omniauth + omniauth-google-oauth2 + omniauth-oauth2 + omniauth-rails_csrf_protection (~> 1.0) pg pg_search procore-sift @@ -772,13 +851,14 @@ DEPENDENCIES pundit rack-attack rack-cors + rack-mini-profiler rack-timeout - rails (~> 6.1) + rails (~> 6.1, >= 6.1.7.3) redis redis-namespace responders rest-client - rspec-rails (~> 5.0.0) + rspec-rails (~> 5.0.3) rspec_junit_formatter rubocop rubocop-performance @@ -786,19 +866,21 @@ DEPENDENCIES rubocop-rspec scout_apm seed_dump - sentry-rails (~> 5.3) - sentry-ruby (~> 5.3) - sentry-sidekiq (~> 5.3) + sentry-rails + sentry-ruby + sentry-sidekiq shoulda-matchers - sidekiq (~> 6.4.0) - sidekiq-cron (~> 1.3) + sidekiq (~> 6.4.2) + sidekiq-cron (~> 1.6, >= 1.6.0) simplecov (= 0.17.1) slack-ruby-client spring spring-watcher-listen squasher + stackprof stripe telephone_number + test-prof time_diff twilio-ruby (~> 5.66) twitty @@ -806,14 +888,14 @@ DEPENDENCIES uglifier valid_email2 web-console + web-push webmock - webpacker (~> 5.x) - webpush + webpacker (~> 5.4, >= 5.4.3) wisper (= 2.0.0) working_hours RUBY VERSION - ruby 3.0.4p208 + ruby 3.1.3p185 BUNDLED WITH - 2.3.16 + 2.3.26 diff --git a/Procfile b/Procfile index 01bfd1c1f3c4c..3cfa8ae13f721 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,3 @@ -release: bundle exec rails db:chatwoot_prepare -web: bin/rails server -p $PORT -e $RAILS_ENV -worker: bundle exec sidekiq -C config/sidekiq.yml +release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare +web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV +worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml diff --git a/Procfile.dev b/Procfile.dev index f7c4e6a67c5c7..94371cbae1f59 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,4 @@ backend: bin/rails s -p 3000 frontend: bin/webpack-dev-server -worker: bundle exec sidekiq -C config/sidekiq.yml +# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695 +worker: dotenv bundle exec sidekiq -C config/sidekiq.yml diff --git a/Procfile.test b/Procfile.test index 760852e802ce8..32792c245b649 100644 --- a/Procfile.test +++ b/Procfile.test @@ -1,3 +1,3 @@ backend: RAILS_ENV=test bin/rails s -p 5050 frontend: bin/webpack-dev-server -worker: RAILS_ENV=test bundle exec sidekiq -C config/sidekiq.yml +worker: dotenv RAILS_ENV=test bundle exec sidekiq -C config/sidekiq.yml diff --git a/README.md b/README.md index cf417447a21bc..ac11d649d5a44 100644 --- a/README.md +++ b/README.md @@ -119,4 +119,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri -*Chatwoot* © 2017-2022, Chatwoot Inc - Released under the MIT License. +*Chatwoot* © 2017-2023, Chatwoot Inc - Released under the MIT License. diff --git a/app.json b/app.json index 0552350329ae2..6324672fb8f15 100644 --- a/app.json +++ b/app.json @@ -41,16 +41,24 @@ "formation": { "web": { "quantity": 1, - "size": "FREE" + "size": "basic" }, "worker": { "quantity": 1, - "size": "FREE" + "size": "basic" } }, "stack": "heroku-20", "image": "heroku/ruby", - "addons": [ "heroku-redis", "heroku-postgresql"], + "addons": [ + { + "plan": "heroku-redis:mini" + }, + { + "plan": "heroku-postgresql:mini" + } + ], + "stack": "heroku-20", "buildpacks": [ { "url": "heroku/ruby" diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index f5e0f5476e652..38da59e094577 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -2,3 +2,4 @@ //= link administrate/application.css //= link administrate/application.js //= link dashboardChart.js +//= link secretField.js diff --git a/app/assets/javascripts/secretField.js b/app/assets/javascripts/secretField.js new file mode 100644 index 0000000000000..463109812edb3 --- /dev/null +++ b/app/assets/javascripts/secretField.js @@ -0,0 +1,34 @@ +// eslint-disable-next-line +function toggleSecretField(e) { + e.preventDefault(); + e.stopPropagation(); + + const toggler = e.currentTarget; + const secretField = toggler.parentElement; + const textElement = secretField.querySelector('[data-secret-masked]'); + + if (!textElement) return; + + if (textElement.dataset.secretMasked === 'false') { + textElement.textContent = '•'.repeat(10); + textElement.dataset.secretMasked = 'true'; + toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); + + return; + } + + textElement.textContent = secretField.dataset.secretText; + textElement.dataset.secretMasked = 'false'; + toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-hide'); +} + +// eslint-disable-next-line +function copySecretField(e) { + e.preventDefault(); + e.stopPropagation(); + + const toggler = e.currentTarget; + const secretField = toggler.parentElement; + + navigator.clipboard.writeText(secretField.dataset.secretText); +} diff --git a/app/assets/stylesheets/administrate/application.scss b/app/assets/stylesheets/administrate/application.scss index 79738bbf3fe7e..86c5254ec499f 100644 --- a/app/assets/stylesheets/administrate/application.scss +++ b/app/assets/stylesheets/administrate/application.scss @@ -6,7 +6,6 @@ @import 'utilities/text-color'; @import 'selectize'; -@import 'datetime_picker'; @import 'library/clearfix'; @import 'library/data-label'; diff --git a/app/assets/stylesheets/administrate/components/_cells.scss b/app/assets/stylesheets/administrate/components/_cells.scss index 2f7e27c4ad038..b5a079976c036 100644 --- a/app/assets/stylesheets/administrate/components/_cells.scss +++ b/app/assets/stylesheets/administrate/components/_cells.scss @@ -43,3 +43,20 @@ .cell-label--number { text-align: right; } + +.cell-data__secret-field { + align-items: center; + display: flex; + + span { + flex: 1; + } + + button { + margin-left: 5px; + + svg { + fill: currentColor; + } + } +} diff --git a/app/builders/conversation_builder.rb b/app/builders/conversation_builder.rb new file mode 100644 index 0000000000000..6a995b18895fc --- /dev/null +++ b/app/builders/conversation_builder.rb @@ -0,0 +1,40 @@ +class ConversationBuilder + pattr_initialize [:params!, :contact_inbox!] + + def perform + look_up_exising_conversation || create_new_conversation + end + + private + + def look_up_exising_conversation + return unless @contact_inbox.inbox.lock_to_single_conversation? + + @contact_inbox.conversations.last + end + + def create_new_conversation + ::Conversation.create!(conversation_params) + end + + def conversation_params + additional_attributes = params[:additional_attributes]&.permit! || {} + custom_attributes = params[:custom_attributes]&.permit! || {} + status = params[:status].present? ? { status: params[:status] } : {} + + # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases + # commenting this out to see if there are any errors, if not we can remove this in subsequent releases + # status = { status: 'pending' } if status[:status] == 'bot' + { + account_id: @contact_inbox.inbox.account_id, + inbox_id: @contact_inbox.inbox_id, + contact_id: @contact_inbox.contact_id, + contact_inbox_id: @contact_inbox.id, + additional_attributes: additional_attributes, + custom_attributes: custom_attributes, + snoozed_until: params[:snoozed_until], + assignee_id: params[:assignee_id], + team_id: params[:team_id] + }.merge(status) + end +end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 42d567e54c47b..e0343c0a01f5f 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -116,7 +116,11 @@ def contact_params 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 - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo + if e.message.include?('2018218') + Rails.logger.warn e + else + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo + end rescue StandardError => e result = {} ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 69ed786ce5e96..ba58618591078 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -48,13 +48,25 @@ def process_attachments def process_emails return unless @conversation.inbox&.inbox_type == 'Email' - cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails] - bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails] + cc_emails = [] + cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present? + + bcc_emails = [] + bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present? + + all_email_addresses = cc_emails + bcc_emails + validate_email_addresses(all_email_addresses) @message.content_attributes[:cc_emails] = cc_emails @message.content_attributes[:bcc_emails] = bcc_emails end + def validate_email_addresses(all_emails) + all_emails&.each do |email| + raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP) + end + end + def message_type if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' raise StandardError, 'Incoming messages are only allowed in Api inboxes' diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index d3b5bf6b9ea01..0739829aa3588 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -46,6 +46,7 @@ def file_type_params(attachment) end def update_attachment_file_type(attachment) + return if @message.reload.attachments.blank? return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention' attachment.file_type = file_type(attachment.file&.content_type) @@ -62,6 +63,7 @@ def fetch_story_link(attachment) story_sender = result['from']['username'] message.content_attributes[:story_sender] = story_sender message.content_attributes[:story_id] = story_id + message.content_attributes[:image_type] = 'story_mention' message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender) message.save! end @@ -74,6 +76,7 @@ def get_story_object_from_source_id(source_id) raise rescue Koala::Facebook::ClientError => e # The exception occurs when we are trying fetch the deleted story or blocked story. + @message.attachments.destroy_all @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) Rails.logger.error e {} diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 5cd8b4a634c41..b786ba25c0469 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -74,7 +74,7 @@ def get_grouped_values(object_scope) :created_at, default_value: 0, range: range, - permit: %w[day week month year], + permit: %w[day week month year hour], time_zone: @timezone ) end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index eb409b9ff19c5..9a089f2424f73 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -19,6 +19,7 @@ def update def destroy @agent.current_account_user.destroy! + delete_user_record(@agent) head :ok end @@ -74,4 +75,8 @@ def agents def validate_limit render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents] end + + def delete_user_record(agent) + DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank? + end end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index a7891ffd44af7..f1689d918a9ad 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -1,14 +1,14 @@ 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 :fetch_article, except: [:index, :create, :attach_file] before_action :set_current_page, only: [:index] def index @portal_articles = @portal.articles @all_articles = @portal_articles.search(list_params) @articles_count = @all_articles.count - @articles = @all_articles.page(@current_page) + @articles = @all_articles.order_by_updated_at.page(@current_page) end def create @@ -23,7 +23,8 @@ def edit; end def show; end def update - @article.update!(article_params) + @article.update!(article_params) if params[:article].present? + render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? end def destroy @@ -31,6 +32,17 @@ def destroy head :ok end + def attach_file + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:background_image].tempfile, + filename: params[:background_image].original_filename, + content_type: params[:background_image].content_type + ) + file_blob.save! + render json: { file_url: url_for(file_blob) } + end + private def fetch_article @@ -43,8 +55,9 @@ def portal def article_params params.require(:article).permit( - :title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description, - { tags: [] }] + :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, + :description, + { tags: [] }] ) end diff --git a/app/controllers/api/v1/accounts/canned_responses_controller.rb b/app/controllers/api/v1/accounts/canned_responses_controller.rb index 031ffc415909b..5528610ec615d 100644 --- a/app/controllers/api/v1/accounts/canned_responses_controller.rb +++ b/app/controllers/api/v1/accounts/canned_responses_controller.rb @@ -33,7 +33,10 @@ def canned_response_params def canned_responses if params[:search] - Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%") + Current.account.canned_responses + .where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%") + .order_by_search(params[:search]) + else Current.account.canned_responses end diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index cadfe133f9ca4..5e0a0e55e773d 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts:: def index @conversations = Current.account.conversations.includes( :assignee, :contact, :inbox, :taggings - ).where(inbox_id: inbox_ids, contact_id: @contact.id) + ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20) end private diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index b86b973df9c7f..8afd5b6556f73 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -119,11 +119,12 @@ def set_current_page end def fetch_contacts_with_conversation_count(contacts) - contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations) - .select('contacts.*, COUNT(conversations.id) as conversations_count') - .group('contacts.id') - .includes([{ avatar_attachment: [:blob] }]) - .page(@current_page).per(RESULTS_PER_PAGE) + conversation_count_sub_query = 'SELECT COUNT(*) FROM "conversations" WHERE "conversations"."contact_id" = "contacts"."id"' + contacts_with_conversation_count = filtrate(contacts) + .select("contacts.*, (#{conversation_count_sub_query}) as conversations_count") + .group('contacts.id') + .includes([{ avatar_attachment: [:blob] }]) + .page(@current_page).per(RESULTS_PER_PAGE) return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 77a3a7081c720..319c6763f5f6a 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -18,6 +18,24 @@ def destroy end end + def translate + return head :ok if already_translated_content_available? + + translated_content = Integrations::GoogleTranslate::ProcessorService.new( + message: message, + target_language: permitted_params[:target_language] + ).perform + + if translated_content.present? + translations = {} + translations[permitted_params[:target_language]] = translated_content + translations = message.translations.merge!(translations) if message.translations.present? + message.update!(translations: translations) + end + + render json: { content: translated_content } + end + private def message @@ -29,6 +47,10 @@ def message_finder end def permitted_params - params.permit(:id) + params.permit(:id, :target_language) + end + + def already_translated_content_available? + message.translations.present? && message.translations[permitted_params[:target_language]].present? end end diff --git a/app/controllers/api/v1/accounts/conversations/participants_controller.rb b/app/controllers/api/v1/accounts/conversations/participants_controller.rb new file mode 100644 index 0000000000000..ebd02380f9acb --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/participants_controller.rb @@ -0,0 +1,41 @@ +class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController + def show + @participants = @conversation.conversation_participants + end + + def create + ActiveRecord::Base.transaction do + @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + end + end + + def update + ActiveRecord::Base.transaction do + participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } + end + @participants = @conversation.conversation_participants + render action: 'show' + end + + def destroy + ActiveRecord::Base.transaction do + params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } + end + head :ok + end + + private + + def participants_to_be_added_ids + params[:user_ids] - current_participant_ids + end + + def participants_to_be_removed_ids + current_participant_ids - params[:user_ids] + end + + def current_participant_ids + @current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id) + end +end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 8734a3dd4fb9e..10a676738be3e 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -24,7 +24,7 @@ def search def create ActiveRecord::Base.transaction do - @conversation = ::Conversation.create!(conversation_params) + @conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present? end end @@ -75,10 +75,13 @@ def toggle_typing_status end def update_last_seen - # rubocop:disable Rails/SkipsModelValidations - @conversation.update_column(:agent_last_seen_at, DateTime.now.utc) - @conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee? - # rubocop:enable Rails/SkipsModelValidations + update_last_seen_on_conversation(DateTime.now.utc, assignee?) + end + + def unread + last_incoming_message = @conversation.messages.incoming.last + last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present? + update_last_seen_on_conversation(last_seen_at, true) end def custom_attributes @@ -88,9 +91,18 @@ def custom_attributes private + def update_last_seen_on_conversation(last_seen_at, update_assignee) + # rubocop:disable Rails/SkipsModelValidations + @conversation.update_column(:agent_last_seen_at, last_seen_at) + @conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present? + # rubocop:enable Rails/SkipsModelValidations + end + def set_conversation_status - status = params[:status] == 'bot' ? 'pending' : params[:status] - @conversation.status = status + # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases + # commenting this out to see if there are any errors, if not we can remove this in subsequent releases + # status = params[:status] == 'bot' ? 'pending' : params[:status] + @conversation.status = params[:status] @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] end @@ -130,6 +142,8 @@ def contact_inbox # and deprecate the support of passing only source_id as the param @contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id]) authorize @contact_inbox.inbox, :show? + rescue ActiveRecord::RecordNotUnique + render json: { error: 'source_id should be unique' }, status: :unprocessable_entity end def build_contact_inbox @@ -142,31 +156,11 @@ def build_contact_inbox ).perform end - def conversation_params - additional_attributes = params[:additional_attributes]&.permit! || {} - custom_attributes = params[:custom_attributes]&.permit! || {} - status = params[:status].present? ? { status: params[:status] } : {} - - # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases - status = { status: 'pending' } if status[:status] == 'bot' - { - account_id: Current.account.id, - inbox_id: @contact_inbox.inbox_id, - contact_id: @contact_inbox.contact_id, - contact_inbox_id: @contact_inbox.id, - additional_attributes: additional_attributes, - custom_attributes: custom_attributes, - snoozed_until: params[:snoozed_until], - assignee_id: params[:assignee_id], - team_id: params[:team_id] - }.merge(status) - end - def conversation_finder - @conversation_finder ||= ConversationFinder.new(current_user, params) + @conversation_finder ||= ConversationFinder.new(Current.user, params) end def assignee? - @conversation.assignee_id? && current_user == @conversation.assignee + @conversation.assignee_id? && Current.user == @conversation.assignee end end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 95662e29b9270..20402b5d1bcf1 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -14,7 +14,7 @@ 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 + @assignable_agents = @inbox.assignable_agents end def campaigns @@ -82,8 +82,8 @@ def set_agent_bot end def destroy - @inbox.destroy! - head :ok + ::DeleteObjectJob.perform_later(@inbox) if @inbox.present? + render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end private @@ -113,7 +113,8 @@ def update_channel_feature_flags 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] + :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, + :lock_to_single_conversation] end def permitted_params(channel_attributes = []) diff --git a/app/controllers/api/v1/accounts/integrations/apps_controller.rb b/app/controllers/api/v1/accounts/integrations/apps_controller.rb index 7ec5cc5ec468c..358a15d87d381 100644 --- a/app/controllers/api/v1/accounts/integrations/apps_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/apps_controller.rb @@ -1,5 +1,5 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController - before_action :check_admin_authorization? + before_action :check_admin_authorization?, except: [:index, :show] before_action :fetch_apps, only: [:index] before_action :fetch_app, only: [:show] diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb new file mode 100644 index 0000000000000..c5f795d341063 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -0,0 +1,48 @@ +class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseController + before_action :fetch_conversation, only: [:create_a_meeting] + before_action :fetch_message, only: [:add_participant_to_meeting] + before_action :authorize_request + + def create_a_meeting + render_response(dyte_processor_service.create_a_meeting(Current.user)) + end + + def add_participant_to_meeting + if @message.content_type != 'integrations' + return render json: { + error: I18n.t('errors.dyte.invalid_message_type') + }, status: :unprocessable_entity + end + + render_response( + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + ) + end + + private + + def authorize_request + authorize @conversation.inbox, :show? + end + + def render_response(response) + render json: response, status: response[:error].blank? ? :ok : :unprocessable_entity + end + + def dyte_processor_service + Integrations::Dyte::ProcessorService.new(account: Current.account, conversation: @conversation) + end + + def permitted_params + params.permit(:conversation_id, :message_id) + end + + def fetch_conversation + @conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id]) + end + + def fetch_message + @message = Current.account.messages.find(permitted_params[:message_id]) + @conversation = @message.conversation + end +end diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb index 3e812ed63a762..a13d74995f878 100644 --- a/app/controllers/api/v1/accounts/macros_controller.rb +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -1,6 +1,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController - before_action :check_authorization before_action :fetch_macro, only: [:show, :update, :destroy, :execute] + before_action :check_authorization, only: [:show, :update, :destroy, :execute] def index @macros = Macro.with_visibility(current_user, params) @@ -55,6 +55,8 @@ def execute head :ok end + private + def process_attachments actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } return if actions.blank? @@ -80,4 +82,8 @@ def macros_with_user def fetch_macro @macro = Current.account.macros.find_by(id: params[:id]) end + + def check_authorization + authorize(@macro) if @macro.present? + end end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb new file mode 100644 index 0000000000000..bee47b2139fa3 --- /dev/null +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -0,0 +1,28 @@ +class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController + include MicrosoftConcern + before_action :check_authorization + + def create + email = params[:authorization][:email] + redirect_url = microsoft_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/microsoft/callback", + scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', + prompt: 'consent' + } + ) + if redirect_url + email = email.downcase + ::Redis::Alfred.setex(email, Current.account.id, 5.minutes) + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 62a98a87243c4..b1837acbd25dc 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -1,7 +1,7 @@ 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, :attach_file] before_action :check_authorization before_action :set_current_page, only: [:index] @@ -21,6 +21,7 @@ def show def create @portal = Current.account.portals.build(portal_params) + @portal.custom_domain = parsed_custom_domain @portal.save! process_attached_logo end @@ -28,6 +29,7 @@ def create def update ActiveRecord::Base.transaction do @portal.update!(portal_params) if params[:portal].present? + # @portal.custom_domain = parsed_custom_domain process_attached_logo rescue StandardError => e Rails.logger.error e @@ -46,7 +48,19 @@ def archive end def process_attached_logo - @portal.logo.attach(params[:logo]) + blob_id = params[:blob_id] + blob = ActiveStorage::Blob.find_by(id: blob_id) + @portal.logo.attach(blob) + end + + def attach_file + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:logo].tempfile, + filename: params[:logo].original_filename, + content_type: params[:logo].content_type + ) + render json: { blob_key: file_blob.key, blob_id: file_blob.id } end private @@ -73,4 +87,9 @@ def portal_member_params def set_current_page @current_page = params[:page] || 1 end + + def parsed_custom_domain + domain = URI.parse(@portal.custom_domain) + domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain + end end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb new file mode 100644 index 0000000000000..35979f70f6836 --- /dev/null +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -0,0 +1,28 @@ +class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController + def index + @result = search('all') + end + + def conversations + @result = search('Conversation') + end + + def contacts + @result = search('Contact') + end + + def messages + @result = search('Message') + end + + private + + def search(search_type) + SearchService.new( + current_user: Current.user, + current_account: Current.account, + search_type: search_type, + params: params + ).perform + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 507e00e64612d..af48ccdf39e32 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,5 +1,6 @@ class Api::V1::AccountsController < Api::BaseController include AuthHelper + include CacheKeysHelper skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, only: [:create], raise: false @@ -30,6 +31,10 @@ def create end end + def cache_keys + render json: { cache_keys: get_cache_keys }, status: :ok + end + def show @latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION) render 'api/v1/accounts/show', format: :json @@ -47,6 +52,14 @@ def update_active_at private + def get_cache_keys + { + label: fetch_value_for_key(params[:id], Label.name.underscore), + inbox: fetch_value_for_key(params[:id], Inbox.name.underscore), + team: fetch_value_for_key(params[:id], Team.name.underscore) + } + end + def fetch_account @account = current_user.accounts.find(params[:id]) @current_account_user = @account.account_users.find_by(user_id: current_user.id) diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index 20b8e7ae8ebec..cbf801e82f054 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -18,6 +18,10 @@ def avatar head :ok end + def auto_offline + @user.account_users.find_by!(account_id: auto_offline_params[:account_id]).update!(auto_offline: auto_offline_params[:auto_offline] || false) + end + def availability @user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability]) end @@ -37,6 +41,10 @@ def availability_params params.require(:profile).permit(:account_id, :availability) end + def auto_offline_params + params.require(:profile).permit(:account_id, :auto_offline) + end + def profile_params params.require(:profile).permit( :email, diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 9ec702e6c3479..229cf27e94366 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -50,7 +50,9 @@ def contact_email end def contact_name - params[:contact][:name] || contact_email.split('@')[0] if contact_email.present? + return if @contact.email.present? || @contact.phone_number.present? || @contact.identifier.present? + + permitted_params.dig(:contact, :name) || (contact_email.split('@')[0] if contact_email.present?) end def contact_phone_number diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 9b8c4da8145c7..2c72a0361e493 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -65,6 +65,16 @@ def toggle_status head :ok end + def set_custom_attributes + conversation.update!(custom_attributes: permitted_params[:custom_attributes]) + end + + def destroy_custom_attributes + conversation.custom_attributes = conversation.custom_attributes.excluding(params[:custom_attribute]) + conversation.save! + render json: conversation + end + private def trigger_typing_event(event) diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb new file mode 100644 index 0000000000000..0661b4a3cac2a --- /dev/null +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseController + before_action :set_message + + def add_participant_to_meeting + if @message.content_type != 'integrations' + return render json: { + error: I18n.t('errors.dyte.invalid_message_type') + }, status: :unprocessable_entity + end + + response = dyte_processor_service.add_participant_to_meeting( + @message.content_attributes['data']['meeting_id'], + @conversation.contact + ) + render_response(response) + end + + private + + def render_response(response) + render json: response, status: response[:error].blank? ? :ok : :unprocessable_entity + end + + def dyte_processor_service + Integrations::Dyte::ProcessorService.new(account: @web_widget.inbox.account, conversation: @conversation) + end + + def set_message + @message = @web_widget.inbox.messages.find(permitted_params[:message_id]) + @conversation = @message.conversation + end + + def permitted_params + params.permit(:website_token, :message_id) + end +end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 7ff77be320f69..6287c94eed676 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -17,7 +17,7 @@ def update @message.update!(submitted_email: contact_email) ContactIdentifyAction.new( contact: @contact, - params: { email: contact_email } + params: { email: contact_email, name: contact_name } ).perform else @message.update!(message_update_params[:message]) diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index dedeb17bf0623..a9fc5d4b4cb12 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -32,6 +32,11 @@ def teams generate_csv('teams_report', 'api/v2/accounts/reports/teams') end + def conversation_traffic + @report_data = generate_conversations_heatmap_report + generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic') + end + def conversations return head :unprocessable_entity if params[:type].blank? diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index a7764cbc9065d..23178b5f37cb6 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -2,6 +2,24 @@ class ApiController < ApplicationController skip_before_action :set_current_user, only: [:index] def index - render json: { version: Chatwoot.config[:version], timestamp: Time.now.utc.to_formatted_s(:db) } + render json: { version: Chatwoot.config[:version], + timestamp: Time.now.utc.to_formatted_s(:db), + queue_services: redis_status, + data_services: postgres_status } + end + + private + + def redis_status + r = Redis.new(Redis::Config.app) + return 'ok' if r.ping + rescue Redis::CannotConnectError + 'failing' + end + + def postgres_status + ActiveRecord::Base.connection.active? ? 'ok' : 'failing' + rescue ActiveRecord::ConnectionNotEstablished + 'failing' end end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb new file mode 100644 index 0000000000000..3aa3e4e81ebde --- /dev/null +++ b/app/controllers/concerns/microsoft_concern.rb @@ -0,0 +1,22 @@ +module MicrosoftConcern + extend ActiveSupport::Concern + + def microsoft_client + ::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil), + { + site: 'https://login.microsoftonline.com', + authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token' + }) + end + + private + + def parsed_body + @parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body) + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index 5f308b8abb5ca..744a70da9485d 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -3,25 +3,25 @@ module SwitchLocale private - def switch_locale(&action) + def switch_locale(&) # priority is for locale set in query string (mostly for widget/from js sdk) locale ||= locale_from_params # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= locale_from_env_variable - set_locale(locale, &action) + set_locale(locale, &) end - def switch_locale_using_account_locale(&action) + def switch_locale_using_account_locale(&) locale = locale_from_account(@current_account) - set_locale(locale, &action) + set_locale(locale, &) end - def set_locale(locale, &action) + def set_locale(locale, &) # if locale is empty, use default_locale locale ||= I18n.default_locale # Ensure locale won't bleed into other requests # https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests - I18n.with_locale(locale, &action) + I18n.with_locale(locale, &) end def locale_from_params diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 84677c7700b2c..25a981df615fd 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -16,8 +16,7 @@ def set_global_config @global_config = GlobalConfig.get( 'LOGO', 'LOGO_THUMBNAIL', 'INSTALLATION_NAME', - 'WIDGET_BRAND_URL', - 'TERMS_URL', + 'WIDGET_BRAND_URL', 'TERMS_URL', 'PRIVACY_URL', 'DISPLAY_MANIFEST', 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', @@ -25,12 +24,12 @@ def set_global_config 'API_CHANNEL_NAME', 'API_CHANNEL_THUMBNAIL', 'ANALYTICS_TOKEN', - 'ANALYTICS_HOST', 'DIRECT_UPLOADS_ENABLED', 'HCAPTCHA_SITE_KEY', 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', - 'DEPLOYMENT_ENV' + 'DEPLOYMENT_ENV', + 'CSML_EDITOR_HOST' ).merge(app_config) end @@ -56,7 +55,8 @@ def app_config ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FACEBOOK_API_VERSION: 'v14.0', - IS_ENTERPRISE: ChatwootApp.enterprise? + IS_ENTERPRISE: ChatwootApp.enterprise?, + AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', '') } end end diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb new file mode 100644 index 0000000000000..e1cf76d6bff49 --- /dev/null +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -0,0 +1,75 @@ +class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController + include EmailHelper + + def omniauth_success + get_resource_from_auth_hash + + @resource.present? ? sign_in_user : sign_up_user + end + + private + + def sign_in_user + @resource.skip_confirmation! if confirmable_enabled? + + # once the resource is found and verified + # we can just send them to the login page again with the SSO params + # that will log them in + encoded_email = ERB::Util.url_encode(@resource.email) + redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token) + end + + def sign_up_user + return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? + return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account? + + create_account_for_user + token = @resource.send(:set_reset_password_token) + frontend_url = ENV.fetch('FRONTEND_URL', nil) + redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" + end + + def login_page_url(error: nil, email: nil, sso_auth_token: nil) + frontend_url = ENV.fetch('FRONTEND_URL', nil) + params = { email: email, sso_auth_token: sso_auth_token }.compact + params[:error] = error if error.present? + + "#{frontend_url}/app/login?#{params.to_query}" + end + + def account_signup_allowed? + # set it to true by default, this is the behaviour across the app + GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false' + end + + def resource_class(_mapping = nil) + User + end + + def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName + # find the user with their email instead of UID and token + @resource = resource_class.where( + email: auth_hash['info']['email'] + ).first + end + + def validate_business_account? + # return true if the user is a business account, false if it is a gmail account + auth_hash['info']['email'].exclude?('@gmail.com') + end + + def create_account_for_user + @resource, @account = AccountBuilder.new( + account_name: extract_domain_without_tld(auth_hash['info']['email']), + user_full_name: auth_hash['info']['name'], + email: auth_hash['info']['email'], + locale: I18n.locale, + confirmed: auth_hash['info']['email_verified'] + ).perform + Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) + end + + def default_devise_mapping + 'user' + end +end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb new file mode 100644 index 0000000000000..215103bd4efc4 --- /dev/null +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -0,0 +1,77 @@ +class Microsoft::CallbacksController < ApplicationController + include MicrosoftConcern + + def show + @response = microsoft_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/microsoft/callback" + ) + + inbox = find_or_create_inbox + ::Redis::Alfred.delete(users_data['email'].downcase) + redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) + rescue StandardError => e + ChatwootExceptionTracker.new(e).capture_exception + redirect_to '/' + end + + private + + def oauth_code + params[:code] + end + + def users_data + decoded_token = JWT.decode parsed_body[:id_token], nil, false + decoded_token[0] + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end + + def account_id + ::Redis::Alfred.get(users_data['email'].downcase) + end + + def account + @account ||= Account.find(account_id) + end + + def find_or_create_inbox + channel_email = Channel::Email.find_by(email: users_data['email'], account: account) + channel_email ||= create_microsoft_channel_with_inbox + update_microsoft_channel(channel_email) + channel_email.inbox + end + + # Fallback name, for when name field is missing from users_data + def fallback_name + users_data['email'].split('@').first.parameterize.titleize + end + + def create_microsoft_channel_with_inbox + ActiveRecord::Base.transaction do + channel_email = Channel::Email.create!(email: users_data['email'], account: account) + account.inboxes.create!( + account: account, + channel: channel_email, + name: users_data['name'] || fallback_name + ) + channel_email + end + end + + def update_microsoft_channel(channel_email) + channel_email.update!({ + imap_login: users_data['email'], imap_address: 'outlook.office365.com', + imap_port: '993', imap_enabled: true, + provider: 'microsoft', + provider_config: { + access_token: parsed_body['access_token'], + refresh_token: parsed_body['refresh_token'], + expires_on: (Time.current.utc + 1.hour).to_s + } + }) + end +end diff --git a/app/controllers/microsoft_controller.rb b/app/controllers/microsoft_controller.rb new file mode 100644 index 0000000000000..07e58d4db85f4 --- /dev/null +++ b/app/controllers/microsoft_controller.rb @@ -0,0 +1,17 @@ +class MicrosoftController < ApplicationController + after_action :set_version_header + + def identity_association + microsoft_indentity + end + + private + + def set_version_header + response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length + end + + def microsoft_indentity + @identity_json = ENV.fetch('AZURE_APP_ID', nil) + end +end diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index 78143410fea18..1ea7d49546de6 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -1,14 +1,16 @@ class Platform::Api::V1::AccountsController < PlatformController def create - @resource = Account.new(account_params) - @resource.save! + @resource = Account.create!(account_params) + update_resource_features @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) end def show; end def update - @resource.update!(account_params) + @resource.assign_attributes(account_params) + update_resource_features + @resource.save! end def destroy @@ -23,14 +25,18 @@ def set_resource end def account_params - if permitted_params[:enabled_features] - return permitted_params.except(:enabled_features).merge(selected_feature_flags: permitted_params[:enabled_features].map(&:to_sym)) - end + permitted_params.except(:features) + end + + def update_resource_features + return if permitted_params[:features].blank? - permitted_params + permitted_params[:features].each do |key, value| + value.present? ? @resource.enable_features(key) : @resource.disable_features(key) + end end def permitted_params - params.permit(:name, :locale, enabled_features: [], limits: {}) + params.permit(:name, :locale, :domain, :support_email, :status, features: {}, limits: {}, custom_attributes: {}) end end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index 2c8995f81185c..e0de74da15b8b 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -13,8 +13,7 @@ def create end def login - encoded_email = ERB::Util.url_encode(@resource.email) - render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" } + render json: { url: @resource.generate_sso_link } end def show; end diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 1fde3051ef330..b3ebd4ade5e19 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -7,7 +7,7 @@ def create @contact_inbox = ::ContactInboxWithContactBuilder.new( source_id: source_id, inbox: @inbox_channel.inbox, - contact_attributes: permitted_params.except(:identifier, :identifier_hash) + contact_attributes: permitted_params.except(:identifier_hash) ).perform end diff --git a/app/controllers/public/api/v1/inboxes/messages_controller.rb b/app/controllers/public/api/v1/inboxes/messages_controller.rb index 925c16d384336..4dc780fffc384 100644 --- a/app/controllers/public/api/v1/inboxes/messages_controller.rb +++ b/app/controllers/public/api/v1/inboxes/messages_controller.rb @@ -12,6 +12,8 @@ def create end def update + render json: { error: 'You cannot update the CSAT survey after 14 days' }, status: :unprocessable_entity and return if check_csat_locked + @message.update!(message_update_params) rescue StandardError => e render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error @@ -43,7 +45,7 @@ def message_finder end def message_update_params - params.permit(submitted_values: [:name, :title, :value]) + params.permit(submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }]) end def permitted_params @@ -64,4 +66,8 @@ def message_params message_type: :incoming } end + + def check_csat_locked + (Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 and @message.content_type == 'input_csat' + end end diff --git a/app/controllers/public/api/v1/inboxes_controller.rb b/app/controllers/public/api/v1/inboxes_controller.rb index a57e72e40423a..65fad57b1af49 100644 --- a/app/controllers/public/api/v1/inboxes_controller.rb +++ b/app/controllers/public/api/v1/inboxes_controller.rb @@ -3,9 +3,15 @@ class Public::Api::V1::InboxesController < PublicController before_action :set_contact_inbox before_action :set_conversation + def show + @inbox_channel = ::Channel::Api.find_by!(identifier: params[:id]) + end + private def set_inbox_channel + return if params[:inbox_id].blank? + @inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id]) end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index ae0fb2b6a35f3..af5410fc3d531 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,4 +1,4 @@ -class Public::Api::V1::Portals::ArticlesController < PublicController +class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal before_action :set_category, except: [:index] @@ -8,6 +8,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController def index @articles = @portal.articles @articles = @articles.search(list_params) if list_params.present? + @articles.order(position: :asc) end def show; end @@ -15,20 +16,30 @@ def show; end private def set_article - @article = @category.articles.find(params[:id]) + @article = @category.articles.find(permitted_params[:id]) + @article.increment_view_count @parsed_content = render_article_content(@article.content) end def set_category - @category = @portal.categories.find_by!(slug: params[:category_slug]) if params[:category_slug].present? + return if permitted_params[:category_slug].blank? + + @category = @portal.categories.find_by!( + slug: permitted_params[:category_slug], + locale: permitted_params[:locale] + ) end def portal - @portal ||= Portal.find_by!(slug: params[:slug], archived: false) + @portal ||= Portal.find_by!(slug: permitted_params[:slug], archived: false) end def list_params - params.permit(:query) + params.permit(:query, :locale) + end + + def permitted_params + params.permit(:slug, :category_slug, :locale, :id) end def render_article_content(content) diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb new file mode 100644 index 0000000000000..35dbc3ff96984 --- /dev/null +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -0,0 +1,22 @@ +class Public::Api::V1::Portals::BaseController < PublicController + around_action :set_locale + + private + + def set_locale(&) + switch_locale_with_portal(&) if params[:locale].present? + end + + def switch_locale_with_portal(&) + locale_without_variant = params[:locale].split('_')[0] + is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale]) + is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant) + if is_locale_available + @locale = params[:locale] + elsif is_locale_variant_available + @locale = locale_without_variant + end + + I18n.with_locale(@locale, &) + end +end diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index 6a6ba6377b064..7326f383b5d9f 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,11 +1,11 @@ -class Public::Api::V1::Portals::CategoriesController < PublicController +class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal before_action :set_category, only: [:show] layout 'portal' def index - @categories = @portal.categories + @categories = @portal.categories.order(position: :asc) end def show; end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index da9b23956c826..fe51ed161aa5c 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,4 +1,4 @@ -class Public::Api::V1::PortalsController < PublicController +class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show] before_action :portal before_action :redirect_to_portal_with_locale, only: [:show] diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb new file mode 100644 index 0000000000000..e7b037099f634 --- /dev/null +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -0,0 +1,44 @@ +class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController + def show + @metrics = {} + chatwoot_version + sha + postgres_status + redis_metrics + end + + def chatwoot_version + @metrics['Chatwoot version'] = Chatwoot.config[:version] + end + + def sha + sha = `git rev-parse HEAD` + @metrics['Git SHA'] = sha.presence || 'n/a' + end + + def postgres_status + @metrics['Postgres alive'] = if ActiveRecord::Base.connection.active? + 'true' + else + 'false' + end + end + + def redis_metrics + r = Redis.new(Redis::Config.app) + if r.ping == 'PONG' + redis_server = r.info + @metrics['Redis alive'] = 'true' + @metrics['Redis version'] = redis_server['redis_version'] + @metrics['Redis number of connected clients'] = redis_server['connected_clients'] + @metrics["Redis 'maxclients' setting"] = redis_server['maxclients'] + @metrics['Redis memory used'] = redis_server['used_memory_human'] + @metrics['Redis memory peak'] = redis_server['used_memory_peak_human'] + @metrics['Redis total memory available'] = redis_server['total_system_memory_human'] + @metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory'] + @metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy'] + end + rescue Redis::CannotConnectError + @metrics['Redis alive'] = false + end +end diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index 23c212c2968b1..fec4fb79031d0 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -1,6 +1,18 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController # Overwrite any of the RESTful controller actions to implement custom behavior # For example, you may want to send an email after a foo is updated. + + def create + resource = resource_class.new(resource_params) + authorize_resource(resource) + + if resource.save + redirect_to super_admin_user_path(resource), notice: translate_with_resource('create.success') + else + notice = resource.errors.full_messages.first + redirect_to new_super_admin_user_path, notice: notice + end + end # # def update # super diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb index 23a87b75720dc..697f04ed0b489 100644 --- a/app/controllers/swagger_controller.rb +++ b/app/controllers/swagger_controller.rb @@ -11,8 +11,8 @@ def respond def derived_path params[:path] ||= 'index.html' - path = params[:path] - path << ".#{params[:format]}" unless path.ends_with?(params[:format].to_s) + path = Rack::Utils.clean_path_info(params[:path]) + path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s) path end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 9639b28b2afac..a4e40eb9c6e0f 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -5,6 +5,7 @@ class WidgetsController < ActionController::Base before_action :set_global_config before_action :set_web_widget before_action :ensure_account_is_active + before_action :ensure_location_is_supported before_action :set_token before_action :set_contact before_action :build_contact @@ -18,6 +19,9 @@ def set_global_config def set_web_widget @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) + rescue ActiveRecord::RecordNotFound + Rails.logger.error('web widget does not exist') + render json: { error: 'web widget does not exist' }, status: :not_found end def set_token @@ -51,6 +55,8 @@ def ensure_account_is_active render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active? end + def ensure_location_is_supported; end + def additional_attributes if @web_widget.inbox.account.feature_enabled?('ip_lookup') { created_at_ip: request.remote_ip } @@ -67,3 +73,5 @@ def allow_iframe_requests response.headers.delete('X-Frame-Options') end end + +WidgetsController.prepend_mod_with('WidgetsController') diff --git a/app/dashboards/access_token_dashboard.rb b/app/dashboards/access_token_dashboard.rb index 8d4f7840eb7c5..d3f05a7994e4a 100644 --- a/app/dashboards/access_token_dashboard.rb +++ b/app/dashboards/access_token_dashboard.rb @@ -10,7 +10,7 @@ class AccessTokenDashboard < Administrate::BaseDashboard ATTRIBUTE_TYPES = { owner: Field::Polymorphic, id: Field::Number, - token: Field::String, + token: SecretField, created_at: Field::DateTime, updated_at: Field::DateTime }.freeze diff --git a/app/drops/contact_drop.rb b/app/drops/contact_drop.rb new file mode 100644 index 0000000000000..6611ecdc399ce --- /dev/null +++ b/app/drops/contact_drop.rb @@ -0,0 +1,21 @@ +class ContactDrop < BaseDrop + def name + @obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ') + end + + def email + @obj.try(:email) + end + + def phone_number + @obj.try(:phone_number) + end + + def first_name + @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + end + + def last_name + @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + end +end diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb index f10eee131ba63..7cafea1bbf2df 100644 --- a/app/drops/user_drop.rb +++ b/app/drops/user_drop.rb @@ -1,5 +1,17 @@ class UserDrop < BaseDrop + def name + @obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ') + end + def available_name @obj.try(:available_name) end + + def first_name + @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + end + + def last_name + @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + end end diff --git a/app/fields/secret_field.rb b/app/fields/secret_field.rb new file mode 100644 index 0000000000000..6bc7c535a99c4 --- /dev/null +++ b/app/fields/secret_field.rb @@ -0,0 +1,4 @@ +require 'administrate/field/base' + +class SecretField < Administrate::Field::String +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 58e91136281af..9472dc623108f 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -56,7 +56,6 @@ def set_up 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 @@ -76,12 +75,9 @@ def set_team end def find_all_conversations - if params[:conversation_type] == 'mention' - conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) - @conversations = current_account.conversations.where(id: conversation_ids) - else - @conversations = current_account.conversations.where(inbox_id: @inbox_ids) - end + @conversations = current_account.conversations.where(inbox_id: @inbox_ids) + filter_by_conversation_type if params[:conversation_type] + @conversations end def filter_by_assignee_type @@ -96,8 +92,17 @@ def filter_by_assignee_type @conversations end - def filter_by_reply_status - @conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended' + def filter_by_conversation_type + case @params[:conversation_type] + when 'mention' + conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) + @conversations = @conversations.where(id: conversation_ids) + when 'participating' + @conversations = current_user.participating_conversations.where(account_id: current_account.id) + when 'unattended' + @conversations = @conversations.where(first_reply_created_at: nil) + end + @conversations end def filter_by_query diff --git a/app/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb index 1c8eaeaf29cc7..3fb7edd53b0d2 100644 --- a/app/finders/email_channel_finder.rb +++ b/app/finders/email_channel_finder.rb @@ -1,4 +1,6 @@ class EmailChannelFinder + include EmailHelper + def initialize(email_object) @email_object = email_object end @@ -7,7 +9,8 @@ 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) + normalized_email = normalize_email_with_plus_addressing(email) + channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) break if channel.present? end channel diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index e2581e0ad0a00..56173cd636ad4 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -21,7 +21,9 @@ def messages end def current_messages - if @params[:before].present? + if @params[:after].present? && @params[:before].present? + messages.reorder('created_at asc').where('id >= ? AND id < ?', @params[:after].to_i, @params[:before].to_i).limit(1000) + elsif @params[:before].present? messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse else messages.reorder('created_at desc').limit(20).reverse diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 0604eba2fc10b..b15683afddbd6 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -27,6 +27,27 @@ def generate_labels_report end end + def generate_conversations_heatmap_report + report_params = { + type: :account, + group_by: 'hour', + since: params[:since], + until: params[:until], + metric: 'conversations_count', + business_hours: false + } + data = V2::ReportBuilder.new(Current.account, report_params).build + + # data format is { timestamp: 1231242342, value: 3} + # we need to convert it to { date: "2020-01-01", hour: 12, value: 3} + # + # the generated report is **always** in UTC timezone + data.map do |d| + date = Time.zone.at(d[:timestamp]).to_s + [date, d[:value]] + end + end + def generate_report(report_params) V2::ReportBuilder.new( Current.account, diff --git a/app/helpers/cache_keys_helper.rb b/app/helpers/cache_keys_helper.rb new file mode 100644 index 0000000000000..aab33e44c125e --- /dev/null +++ b/app/helpers/cache_keys_helper.rb @@ -0,0 +1,15 @@ +module CacheKeysHelper + def get_prefixed_cache_key(account_id, key) + "idb-cache-key-account-#{account_id}-#{key}" + end + + def fetch_value_for_key(account_id, key) + prefixed_cache_key = get_prefixed_cache_key(account_id, key) + value_from_cache = Redis::Alfred.get(prefixed_cache_key) + + return value_from_cache if value_from_cache.present? + + # zero epoch time: 1970-01-01 00:00:00 UTC + '0000000000' + end +end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb new file mode 100644 index 0000000000000..46689ba006a90 --- /dev/null +++ b/app/helpers/email_helper.rb @@ -0,0 +1,13 @@ +module EmailHelper + def extract_domain_without_tld(email) + domain = email.split('@').last + domain.split('.').first + end + + # ref: https://www.rfc-editor.org/rfc/rfc5233.html + # This is not a mandatory requirement for email addresses, but it is a common practice. + # john+test@xyc.com is the same as john@xyc.com + def normalize_email_with_plus_addressing(email) + "#{email.split('@').first.split('+').first}@#{email.split('@').last}".downcase + end +end diff --git a/app/helpers/message_format_helper.rb b/app/helpers/message_format_helper.rb index 3dd8d8f239498..1e89c56c1d5f1 100644 --- a/app/helpers/message_format_helper.rb +++ b/app/helpers/message_format_helper.rb @@ -2,7 +2,8 @@ module MessageFormatHelper include RegexHelper def transform_user_mention_content(message_content) - message_content.gsub(MENTION_REGEX, '\1') + # attachment message without content, message_content is nil + message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : '' end def render_message_content(message_content) diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb index eee1af283dd88..e08b5691c7add 100644 --- a/app/helpers/reporting_event_helper.rb +++ b/app/helpers/reporting_event_helper.rb @@ -17,6 +17,15 @@ def business_hours(inbox, from, to) from_in_inbox_timezone.working_time_until(to_in_inbox_timezone) end + def last_non_human_activity(conversation) + # check if a handoff event already exists + handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last + + # if a handoff exists, last non human activity is when the handoff ended, + # otherwise it's when the conversation was created + handoff_event&.event_end_time || conversation.created_at + end + private def configure_working_hours(working_hours) diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 0ff94efa5eab1..ccae9e52cabc5 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,5 +1,10 @@ diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index 1d8ced4455f2e..4d75e363ed731 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -261,14 +261,7 @@ export default { width: 20rem; flex-shrink: 0; overflow-y: hidden; - - @include breakpoint(xlarge down) { - position: absolute; - } - - @include breakpoint(xlarge up) { - position: unset; - } + position: unset; &:hover { overflow-y: hidden; diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue index 2dd23544f41fa..cb5c52c7617fd 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue @@ -112,6 +112,7 @@ $label-badge-size: var(--space-slab); padding: var(--space-smaller) var(--space-smaller); margin: var(--space-smaller) 0; text-align: left; + line-height: 1.2; &:hover { background: var(--s-25); @@ -135,8 +136,6 @@ $label-badge-size: var(--space-slab); .menu-label { flex-grow: 1; - display: inline-flex; - align-items: center; } .inbox-icon { diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue index 644258f506fbd..74323b082029b 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue @@ -87,6 +87,10 @@ import { } from 'dashboard/helper/inbox'; import SecondaryChildNavItem from './SecondaryChildNavItem'; +import { + isOnMentionsView, + isOnUnattendedView, +} from '../../../store/modules/conversations/helpers/actionHelpers'; export default { components: { SecondaryChildNavItem }, @@ -102,32 +106,48 @@ export default { activeInbox: 'getSelectedInbox', accountId: 'getCurrentAccountId', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + globalConfig: 'globalConfig/get', }), hasSubMenu() { return !!this.menuItem.children; }, isMenuItemVisible() { - if (!this.menuItem.featureFlag) { - return true; + if (this.menuItem.globalConfigFlag) { + return !!this.globalConfig[this.menuItem.globalConfigFlag]; } - return this.isFeatureEnabledonAccount( - this.accountId, - this.menuItem.featureFlag - ); + if (this.menuItem.featureFlag) { + return this.isFeatureEnabledonAccount( + this.accountId, + this.menuItem.featureFlag + ); + } + return true; }, - isInboxConversation() { + isAllConversations() { return ( this.$store.state.route.name === 'inbox_conversation' && this.menuItem.toStateName === 'home' ); }, + isMentions() { + return ( + isOnMentionsView({ route: this.$route }) && + this.menuItem.toStateName === 'conversation_mentions' + ); + }, + isUnattended() { + return ( + isOnUnattendedView({ route: this.$route }) && + this.menuItem.toStateName === 'conversation_unattended' + ); + }, isTeamsSettings() { return ( this.$store.state.route.name === 'settings_teams_edit' && this.menuItem.toStateName === 'settings_teams_list' ); }, - isInboxsSettings() { + isInboxSettings() { return ( this.$store.state.route.name === 'settings_inbox_show' && this.menuItem.toStateName === 'settings_inbox_list' @@ -150,14 +170,20 @@ export default { }, computedClass() { - // If active Inbox is present - // donot highlight conversations + // If active inbox is present, do not highlight conversations if (this.activeInbox) return ' '; + if ( + this.isAllConversations || + this.isMentions || + this.isUnattended || + this.isCurrentRoute + ) { + return 'is-active'; + } if (this.hasSubMenu) { if ( - this.isInboxConversation || this.isTeamsSettings || - this.isInboxsSettings || + this.isInboxSettings || this.isIntegrationsSettings || this.isApplicationsSettings ) { @@ -166,10 +192,6 @@ export default { return ' '; } - if (this.isCurrentRoute) { - return 'is-active'; - } - return ''; }, }, @@ -309,11 +331,11 @@ export default { .beta { padding-right: var(--space-smaller) !important; padding-left: var(--space-smaller) !important; - margin-left: var(--space-smaller) !important; + margin: 0 var(--space-smaller) !important; display: inline-block; font-size: var(--font-size-micro); font-weight: var(--font-weight-medium); - line-height: 18px; + line-height: 14px; border: 1px solid transparent; border-radius: 2em; color: var(--g-800); @@ -326,7 +348,7 @@ export default { color: var(--s-600); font-size: var(--font-size-micro); font-weight: var(--font-weight-bold); - margin-left: var(--space-smaller); + margin: 0 var(--space-smaller); padding: var(--space-zero) var(--space-smaller); } diff --git a/app/javascript/dashboard/components/specs/__snapshots__/SidemenuIcon.spec.js.snap b/app/javascript/dashboard/components/specs/__snapshots__/SidemenuIcon.spec.js.snap index 58e820fc2574d..32e293da6a461 100644 --- a/app/javascript/dashboard/components/specs/__snapshots__/SidemenuIcon.spec.js.snap +++ b/app/javascript/dashboard/components/specs/__snapshots__/SidemenuIcon.spec.js.snap @@ -1,10 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SidemenuIcon matches snapshot 1`] = ` - + `; diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js new file mode 100644 index 0000000000000..47cd387e5e8ac --- /dev/null +++ b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js @@ -0,0 +1,69 @@ +import { shallowMount } from '@vue/test-utils'; +import GoogleOAuthButton from './GoogleOAuthButton.vue'; + +function getWrapper(showSeparator, buttonSize) { + return shallowMount(GoogleOAuthButton, { + propsData: { showSeparator: showSeparator, buttonSize: buttonSize }, + methods: { + $t(text) { + return text; + }, + }, + }); +} + +describe('GoogleOAuthButton.vue', () => { + beforeEach(() => { + window.chatwootConfig = { + googleOAuthClientId: 'clientId', + googleOAuthCallbackUrl: 'http://localhost:3000/test-callback', + }; + }); + + afterEach(() => { + window.chatwootConfig = {}; + }); + + it('renders the OR separator if showSeparator is true', () => { + const wrapper = getWrapper(true); + expect(wrapper.find('.separator').exists()).toBe(true); + }); + + it('does not render the OR separator if showSeparator is false', () => { + const wrapper = getWrapper(false); + expect(wrapper.find('.separator').exists()).toBe(false); + }); + + it('generates the correct Google Auth URL', () => { + const wrapper = getWrapper(); + const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl()); + + const params = googleAuthUrl.searchParams; + expect(googleAuthUrl.origin).toBe('https://accounts.google.com'); + expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount'); + expect(params.get('client_id')).toBe('clientId'); + expect(params.get('redirect_uri')).toBe( + 'http://localhost:3000/test-callback' + ); + expect(params.get('response_type')).toBe('code'); + expect(params.get('scope')).toBe('email profile'); + }); + + it('responds to buttonSize prop properly', () => { + let wrapper = getWrapper(true, 'tiny'); + expect(wrapper.find('.button.tiny').exists()).toBe(true); + + wrapper = getWrapper(true, 'small'); + expect(wrapper.find('.button.small').exists()).toBe(true); + + wrapper = getWrapper(true, 'large'); + expect(wrapper.find('.button.large').exists()).toBe(true); + + // should not render either + wrapper = getWrapper(true, 'default'); + expect(wrapper.find('.button.small').exists()).toBe(false); + expect(wrapper.find('.button.tiny').exists()).toBe(false); + expect(wrapper.find('.button.large').exists()).toBe(false); + expect(wrapper.find('.button').exists()).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue new file mode 100644 index 0000000000000..404afe619ac75 --- /dev/null +++ b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/app/javascript/dashboard/components/ui/ContextMenu.vue b/app/javascript/dashboard/components/ui/ContextMenu.vue index b968cda19a5b4..6d8fca703e765 100644 --- a/app/javascript/dashboard/components/ui/ContextMenu.vue +++ b/app/javascript/dashboard/components/ui/ContextMenu.vue @@ -1,7 +1,5 @@ - diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 07a984f041cc9..5ebee6b407f33 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -1,56 +1,15 @@ diff --git a/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue b/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue index 9320278875dc8..35f6de9d691a0 100644 --- a/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue +++ b/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue @@ -6,7 +6,7 @@ :username="user.name" :status="user.availability_status" /> -
+
{{ user.name }}
@@ -38,11 +38,7 @@ export default { text-align: left; .user-name { - margin: 0; - text-transform: capitalize; - } - .user-thumbnail-box { - margin-right: var(--space-small); + margin: 0 var(--space-small); } } diff --git a/app/javascript/dashboard/components/widgets/VideoCallButton.vue b/app/javascript/dashboard/components/widgets/VideoCallButton.vue new file mode 100644 index 0000000000000..12050c8111ae4 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/VideoCallButton.vue @@ -0,0 +1,57 @@ + + diff --git a/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue b/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue index 893bdcf3a75c4..68072c8467d65 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue @@ -10,11 +10,11 @@ import 'videojs-record/dist/css/videojs.record.css'; import videojs from 'video.js'; -import inboxMixin from '../../../../shared/mixins/inboxMixin'; import alertMixin from '../../../../shared/mixins/alertMixin'; import Recorder from 'opus-recorder'; import encoderWorker from 'opus-recorder/dist/encoderWorker.min'; +import waveWorker from 'opus-recorder/dist/waveWorker.min'; import WaveSurfer from 'wavesurfer.js'; import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js'; @@ -23,19 +23,25 @@ import 'videojs-wavesurfer/dist/videojs.wavesurfer.js'; import 'videojs-record/dist/videojs.record.js'; import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js'; import { format, addSeconds } from 'date-fns'; +import { AUDIO_FORMATS } from 'shared/constants/messages'; WaveSurfer.microphone = MicrophonePlugin; export default { name: 'WootAudioRecorder', - mixins: [inboxMixin, alertMixin], + mixins: [alertMixin], + props: { + audioRecordFormat: { + type: String, + default: AUDIO_FORMATS.WEBM, + }, + }, data() { return { player: false, recordingDateStarted: new Date(0), initialTimeDuration: '00:00', recorderOptions: { - debug: true, controls: true, bigPlayButton: false, fluid: false, @@ -70,13 +76,28 @@ export default { record: { audio: true, video: false, - displayMilliseconds: false, - maxLength: 300, - audioEngine: 'opus-recorder', - audioWorkerURL: encoderWorker, - audioChannels: 1, - audioSampleRate: 48000, - audioBitRate: 128, + maxLength: 900, + timeSlice: 1000, + maxFileSize: 15 * 1024 * 1024, + ...(this.audioRecordFormat === AUDIO_FORMATS.WEBM && { + monitorGain: 0, + recordingGain: 1, + numberOfChannels: 1, + encoderSampleRate: 16000, + originalSampleRateOverride: 16000, + streamPages: true, + maxFramesPerPage: 1, + encoderFrameSize: 1, + encoderPath: waveWorker, + }), + ...(this.audioRecordFormat === AUDIO_FORMATS.OGG && { + displayMilliseconds: false, + audioEngine: 'opus-recorder', + audioWorkerURL: encoderWorker, + audioChannels: 1, + audioSampleRate: 48000, + audioBitRate: 128, + }), }, }, }, diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index ee3337cf953d1..abe84c6c9c76f 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -6,49 +6,57 @@ @click="insertMentionNode" /> +
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index a8c26201ec747..6922389b08ed4 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -87,6 +87,10 @@ :title="'Whatsapp Templates'" @click="$emit('selectWhatsappTemplate')" /> +
- + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue index 34a349fff50be..9caa110841435 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationAdvancedFilter.vue @@ -61,6 +61,7 @@ import { mapGetters } from 'vuex'; import { filterAttributeGroups } from './advancedFilterItems'; import filterMixin from 'shared/mixins/filterMixin'; import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'; +import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; export default { components: { @@ -76,6 +77,10 @@ export default { type: Array, default: () => [], }, + initialAppliedFilters: { + type: Array, + default: () => [], + }, }, validations: { appliedFilters: { @@ -101,7 +106,7 @@ export default { data() { return { show: true, - appliedFilters: [], + appliedFilters: this.initialAppliedFilters, filterTypes: this.initialFilterTypes, filterAttributeGroups, filterGroups: [], @@ -119,6 +124,7 @@ export default { this.setFilterAttributes(); this.$store.dispatch('campaigns/get'); if (this.getAppliedConversationFilters.length) { + this.appliedFilters = []; this.appliedFilters = [...this.getAppliedConversationFilters]; } else { this.appliedFilters.push({ @@ -229,10 +235,6 @@ export default { name: statusFilters[status].TEXT, }; }), - { - id: 'all', - name: this.$t('CHAT_LIST.FILTER_ALL'), - }, ]; case 'assignee_id': return this.$store.getters['agents/getAgents']; @@ -287,6 +289,12 @@ export default { JSON.parse(JSON.stringify(this.appliedFilters)) ); this.$emit('applyFilter', this.appliedFilters); + this.$track(CONVERSATION_EVENTS.APPLY_FILTER, { + applied_filters: this.appliedFilters.map(filter => ({ + key: filter.attribute_key, + operator: filter.filter_operator, + })), + }); }, resetFilter(index, currentFilter) { this.appliedFilters[index].filter_operator = this.filterTypes.find( diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue index 801aad164d66f..c4e4a80c406a0 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue @@ -1,7 +1,7 @@ @@ -130,7 +144,6 @@ import { mapGetters } from 'vuex'; import { mixin as clickaway } from 'vue-clickaway'; import alertMixin from 'shared/mixins/alertMixin'; -import EmojiInput from 'shared/components/emoji/EmojiInput'; import CannedResponse from './CannedResponse'; import ResizableTextArea from 'shared/components/ResizableTextArea'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview'; @@ -146,9 +159,14 @@ import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { MAXIMUM_FILE_UPLOAD_SIZE, MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL, + AUDIO_FORMATS, } from 'shared/constants/messages'; import { BUS_EVENTS } from 'shared/constants/busEvents'; - +import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper'; +import { + getMessageVariables, + getUndefinedVariablesInMessage, +} from 'dashboard/helper/messageHelper'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import { buildHotKeys } from 'shared/helpers/KeyboardHelpers'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; @@ -160,6 +178,10 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage'; import { trimContent, debounce } from '@chatwoot/utils'; import wootConstants from 'dashboard/constants'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; +import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; +import rtlMixin from 'shared/mixins/rtlMixin'; + +const EmojiInput = () => import('shared/components/emoji/EmojiInput'); export default { components: { @@ -181,6 +203,7 @@ export default { uiSettingsMixin, alertMixin, messageFormatterMixin, + rtlMixin, ], props: { selectedTweet: { @@ -201,7 +224,6 @@ export default { message: '', isFocused: false, showEmojiPicker: false, - showMentions: false, attachedFiles: [], isRecordingAudio: false, recordingAudioState: '', @@ -209,12 +231,17 @@ export default { isUploading: false, replyType: REPLY_EDITOR_MODES.REPLY, mentionSearchKey: '', - hasUserMention: false, hasSlashCommand: false, bccEmails: '', ccEmails: '', doAutoSaveDraft: () => {}, showWhatsAppTemplatesModal: false, + updateEditorSelectionWith: '', + undefinedVariableMessage: '', + showMentions: false, + showUserMentions: false, + showCannedMenu: false, + showVariablesMenu: false, }; }, computed: { @@ -309,6 +336,12 @@ export default { this.message.length > this.maxLength ); }, + sender() { + return { + name: this.currentUser.name, + thumbnail: this.currentUser.avatar_url, + }; + }, conversationType() { const { additional_attributes: additionalAttributes } = this.currentChat; const type = additionalAttributes ? additionalAttributes.type : ''; @@ -397,10 +430,14 @@ export default { } = this.uiSettings; return conversationDisplayType !== CONDENSED; }, - emojiDialogClassOnExpanedLayout() { - return this.isOnExpandedLayout && !this.popoutReplyBox - ? 'emoji-dialog--expanded' - : ''; + emojiDialogClassOnExpandedLayoutAndRTLView() { + if (this.isOnExpandedLayout || this.popoutReplyBox) { + return 'emoji-dialog--expanded'; + } + if (this.isRTLView) { + return 'emoji-dialog--rtl'; + } + return ''; }, replyToUserLength() { const selectedTweet = this.selectedTweet || {}; @@ -421,7 +458,12 @@ export default { return !this.isOnPrivateNote && this.isAnEmailChannel; }, enableMultipleFileUpload() { - return this.isAnEmailChannel || this.isAWebWidgetInbox || this.isAPIInbox; + return ( + this.isAnEmailChannel || + this.isAWebWidgetInbox || + this.isAPIInbox || + this.isAWhatsAppChannel + ); }, isSignatureEnabledForInbox() { return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature; @@ -450,12 +492,23 @@ export default { return this.currentChat.id; }, conversationIdByRoute() { - const { conversation_id: conversationId } = this.$route.params; - return conversationId; + return this.conversationId; }, editorStateId() { return `draft-${this.conversationIdByRoute}-${this.replyType}`; }, + audioRecordFormat() { + if (this.isAWebWidgetInbox) { + return AUDIO_FORMATS.WEBM; + } + return AUDIO_FORMATS.OGG; + }, + messageVariables() { + const variables = getMessageVariables({ + conversation: this.currentChat, + }); + return variables; + }, }, watch: { currentChat(conversation) { @@ -587,6 +640,7 @@ export default { e.preventDefault(); } else if (keyCode === 'enter' && this.isAValidEvent('enter')) { this.onSendReply(); + e.preventDefault(); } else if ( ['meta+enter', 'ctrl+enter'].includes(keyCode) && this.isAValidEvent('cmd_enter') @@ -596,8 +650,10 @@ export default { }, isAValidEvent(selectedKey) { return ( - !this.hasUserMention && + !this.showUserMentions && + !this.showMentions && !this.showCannedMenu && + !this.showVariablesMenu && this.isFocused && isEditorHotKeyEnabled(this.uiSettings, selectedKey) ); @@ -616,11 +672,14 @@ export default { }); }, toggleUserMention(currentMentionState) { - this.hasUserMention = currentMentionState; + this.showUserMentions = currentMentionState; }, toggleCannedMenu(value) { this.showCannedMenu = value; }, + toggleVariablesMenu(value) { + this.showVariablesMenu = value; + }, openWhatsappTemplateModal() { this.showWhatsAppTemplatesModal = true; }, @@ -650,7 +709,7 @@ export default { }; this.assignedAgent = selfAssign; }, - async onSendReply() { + confirmOnSendReply() { if (this.isReplyButtonDisabled) { return; } @@ -659,18 +718,57 @@ export default { if (this.isSignatureEnabledForInbox && this.messageSignature) { newMessage += '\n\n' + this.messageSignature; } - const messagePayload = this.getMessagePayload(newMessage); - this.clearMessage(); + const isOnWhatsApp = + this.isATwilioWhatsAppChannel || + this.isAWhatsAppCloudChannel || + this.is360DialogWhatsAppChannel; + if (isOnWhatsApp && !this.isPrivate) { + this.sendMessageAsMultipleMessages(newMessage); + } else { + const messagePayload = this.getMessagePayload(newMessage); + this.sendMessage(messagePayload); + } + if (!this.isPrivate) { this.clearEmailField(); } - this.sendMessage(messagePayload); + this.clearMessage(); this.hideEmojiPicker(); this.$emit('update:popoutReplyBox', false); } }, + sendMessageAsMultipleMessages(message) { + const messages = this.getMessagePayloadForWhatsapp(message); + messages.forEach(messagePayload => { + this.sendMessage(messagePayload); + }); + }, + async onSendReply() { + const undefinedVariables = getUndefinedVariablesInMessage({ + message: this.message, + variables: this.messageVariables, + }); + if (undefinedVariables.length > 0) { + const undefinedVariablesCount = + undefinedVariables.length > 1 ? undefinedVariables.length : 1; + this.undefinedVariableMessage = this.$t( + 'CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.MESSAGE', + { + undefinedVariablesCount, + undefinedVariables: undefinedVariables.join(', '), + } + ); + + const ok = await this.$refs.confirmDialog.showConfirmation(); + if (ok) { + this.confirmOnSendReply(); + } + } else { + this.confirmOnSendReply(); + } + }, async sendMessage(messagePayload) { try { await this.$store.dispatch( @@ -693,8 +791,13 @@ export default { this.hideWhatsappTemplatesModal(); }, replaceText(message) { + const updatedMessage = replaceVariablesInMessage({ + message, + variables: this.messageVariables, + }); setTimeout(() => { - this.message = message; + this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE); + this.message = updatedMessage; }, 100); }, setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { @@ -708,8 +811,26 @@ export default { } this.$nextTick(() => this.$refs.messageInput.focus()); }, + clearEditorSelection() { + this.updateEditorSelectionWith = ''; + }, + insertEmoji(emoji, selectionStart, selectionEnd) { + const { message } = this; + const newMessage = + message.slice(0, selectionStart) + + emoji + + message.slice(selectionEnd, message.length); + this.message = newMessage; + }, emojiOnClick(emoji) { - this.message = `${this.message}${emoji} `; + if (this.showRichContentEditor) { + this.updateEditorSelectionWith = emoji; + this.onFocus(); + } + if (!this.showRichContentEditor) { + const { selectionStart, selectionEnd } = this.$refs.messageInput.$el; + this.insertEmoji(emoji, selectionStart, selectionEnd); + } }, clearMessage() { this.message = ''; @@ -777,6 +898,11 @@ export default { toggleTyping(status) { const conversationId = this.currentChat.id; const isPrivate = this.isPrivate; + + if (!conversationId) { + return; + } + this.$store.dispatch('conversationTypingStatus/toggleTyping', { status, conversationId, @@ -862,11 +988,43 @@ export default { (item, index) => itemIndex !== index ); }, + getMessagePayloadForWhatsapp(message) { + const multipleMessagePayload = []; + + if (this.attachedFiles && this.attachedFiles.length) { + let caption = message; + this.attachedFiles.forEach(attachment => { + const attachedFile = this.globalConfig.directUploadsEnabled + ? attachment.blobSignedId + : attachment.resource.file; + const attachmentPayload = { + conversationId: this.currentChat.id, + files: [attachedFile], + private: false, + message: caption, + sender: this.sender, + }; + multipleMessagePayload.push(attachmentPayload); + caption = ''; + }); + } else { + const messagePayload = { + conversationId: this.currentChat.id, + message, + private: false, + sender: this.sender, + }; + multipleMessagePayload.push(messagePayload); + } + + return multipleMessagePayload; + }, getMessagePayload(message) { const messagePayload = { conversationId: this.currentChat.id, message, private: this.isPrivate, + sender: this.sender, }; if (this.inReplyTo) { @@ -957,6 +1115,7 @@ export default { } .reply-box__top { + position: relative; padding: 0 var(--space-normal); border-top: 1px solid var(--color-border); margin-top: -1px; @@ -964,17 +1123,30 @@ export default { .emoji-dialog { top: unset; - bottom: 12px; + bottom: -40px; left: -320px; right: unset; &::before { - right: -16px; - bottom: 10px; + right: var(--space-minus-normal); + bottom: var(--space-small); transform: rotate(270deg); filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08)); } } + +.emoji-dialog--rtl { + left: unset; + right: -320px; + &::before { + left: var(--space-minus-normal); + transform: rotate(90deg); + right: 0; + bottom: var(--space-small); + filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08)); + } +} + .emoji-dialog--expanded { left: unset; bottom: var(--space-jumbo); @@ -984,7 +1156,7 @@ export default { &::before { transform: rotate(0deg); left: var(--space-smaller); - bottom: var(--space-minus-slab); + bottom: var(--space-minus-small); } } .message-signature { @@ -994,4 +1166,9 @@ export default { margin-bottom: 0; } } + +.normal-editor__canned-box { + width: calc(100% - 2 * var(--space-normal)); + left: var(--space-normal); +} diff --git a/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue b/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue index 8cfe1033b4e55..c48af3f1607eb 100644 --- a/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue +++ b/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue @@ -2,7 +2,6 @@