diff --git a/.circleci/config.yml b/.circleci/config.yml index 5c9b27f03..076903576 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,8 +12,8 @@ defaults: &defaults # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ - - image: circleci/postgres:alpine - - image: circleci/redis:alpine + - image: cimg/postgres:14.1 + - image: cimg/redis:6.2.6 environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - RAILS_LOG_TO_STDOUT: false @@ -40,14 +40,13 @@ jobs: - restore_cache: keys: - - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} - - chatwoot-bundle + - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - run: bundle install --frozen --path ~/.bundle - save_cache: paths: - ~/.bundle - key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} + key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} # Only necessary if app uses webpacker or yarn in some other way @@ -110,7 +109,7 @@ jobs: - run: name: Run backend tests command: | - bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 + bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 --format documentation ~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json - persist_to_workspace: root: ~/tmp diff --git a/.codeclimate.yml b/.codeclimate.yml index c9910f9ed..916b98510 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -50,3 +50,6 @@ exclude_patterns: - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' + - 'app/javascript/dashboard/i18n/index.js' + - 'app/javascript/widget/i18n/index.js' + - 'app/javascript/survey/i18n/index.js' diff --git a/.env.example b/.env.example index 36ca66be1..d46acac9f 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,11 @@ REDIS_SENTINELS= # You can find list of master using "SENTINEL masters" command REDIS_SENTINEL_MASTER_NAME= +# Redis premium breakage in heroku fix +# enable the following configuration +# ref: https://github.com/chatwoot/chatwoot/issues/2420 +# REDIS_OPENSSL_VERIFY_MODE=none + # Postgres Database config variables POSTGRES_HOST=postgres POSTGRES_USERNAME=postgres @@ -156,13 +161,15 @@ USE_INBOX_AVATAR_FOR_BOT=true ## NewRelic # https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/ # NEW_RELIC_LICENSE_KEY= +# Set this to true to allow newrelic apm to send logs. +# This is turned off by default. +# NEW_RELIC_APPLICATION_LOGGING_ENABLED= ## Datadog ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables # DD_TRACE_AGENT_URL= - ## IP look up configuration ## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md ## works only on accounts with ip look up feature enabled diff --git a/.eslintrc.js b/.eslintrc.js index a52594492..f30e21e03 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,8 +29,8 @@ module.exports = { 'vue/html-self-closing': 'off', "vue/no-v-html": 'off', 'vue/singleline-html-element-content-newline': 'off', - 'import/extensions': ['off'] - + 'import/extensions': ['off'], + 'no-console': 'error' }, settings: { 'import/resolver': { diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml new file mode 100644 index 000000000..37f0f3e6e --- /dev/null +++ b/.github/workflows/publish_foss_docker.yml @@ -0,0 +1,62 @@ +# # +# # This action will publish Chatwoot CE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot CE docker images +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile + + - name: set docker tag + run: | + echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV + + - name: replace docker tag if master + if: github.ref_name == 'master' + run: | + echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: docker/Dockerfile + push: true + tags: ${{ env.DOCKER_TAG }} diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml new file mode 100644 index 000000000..395e063e7 --- /dev/null +++ b/.github/workflows/run_foss_spec.yml @@ -0,0 +1,73 @@ +# # +# # This action will strip the enterprise folder +# # and run the spec. +# # This is set to run against every PR. +# # + +name: Run Chatwoot CE spec +on: + push: + branches: + - develop + - master + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:10.8 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: "" + POSTGRES_DB: postgres + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + # tmpfs makes DB faster by using RAM + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.2 # Not needed with a .ruby-version file + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + + - name: yarn + run: yarn install + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Create database + run: bundle exec rake db:create + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: yarn check-files + run: yarn install --check-files + + # Run rails tests + - name: Run backend tests + run: | + bundle exec rspec --profile=10 --format documentation diff --git a/.rubocop.yml b/.rubocop.yml index e0c98cdc4..898f1ff24 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,8 @@ Metrics/ClassLength: - 'app/models/message.rb' - 'app/builders/messages/facebook/message_builder.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb' + - 'app/listeners/action_cable_listener.rb' + - 'app/models/conversation.rb' RSpec/ExampleLength: Max: 25 Style/Documentation: diff --git a/Gemfile b/Gemfile index 9566a837b..c6e7a339a 100644 --- a/Gemfile +++ b/Gemfile @@ -42,7 +42,7 @@ gem 'down', '~> 5.0' gem 'aws-sdk-s3', require: false gem 'azure-storage-blob', require: false gem 'google-cloud-storage', require: false -gem 'image_processing' +gem 'image_processing', '~> 1.12.2' ##-- gems for database --# gem 'groupdate' @@ -97,14 +97,14 @@ gem 'brakeman' gem 'ddtrace' gem 'newrelic_rpm' gem 'scout_apm' -gem 'sentry-rails' -gem 'sentry-ruby' -gem 'sentry-sidekiq' +gem 'sentry-rails', '~> 5.3' +gem 'sentry-ruby', '~> 5.3' +gem 'sentry-sidekiq', '~> 5.3' ##-- background job processing --## gem 'sidekiq', '~> 6.4.0' # We want cron jobs -gem 'sidekiq-cron' +gem 'sidekiq-cron', '~> 1.3' ##-- Push notification service --## gem 'fcm' @@ -128,6 +128,9 @@ gem 'procore-sift' gem 'email_reply_trimmer' gem 'html2text' +# to calculate working hours +gem 'working_hours' + group :production, :staging do # we dont want request timing out in development while using byebug gem 'rack-timeout' diff --git a/Gemfile.lock b/Gemfile.lock index aaed1f989..b8e6181a6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,63 +9,63 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.4.7) - actionpack (= 6.1.4.7) - activesupport (= 6.1.4.7) + actioncable (6.1.5.1) + actionpack (= 6.1.5.1) + activesupport (= 6.1.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.4.7) - actionpack (= 6.1.4.7) - activejob (= 6.1.4.7) - activerecord (= 6.1.4.7) - activestorage (= 6.1.4.7) - activesupport (= 6.1.4.7) + actionmailbox (6.1.5.1) + actionpack (= 6.1.5.1) + activejob (= 6.1.5.1) + activerecord (= 6.1.5.1) + activestorage (= 6.1.5.1) + activesupport (= 6.1.5.1) mail (>= 2.7.1) - actionmailer (6.1.4.7) - actionpack (= 6.1.4.7) - actionview (= 6.1.4.7) - activejob (= 6.1.4.7) - activesupport (= 6.1.4.7) + actionmailer (6.1.5.1) + actionpack (= 6.1.5.1) + actionview (= 6.1.5.1) + activejob (= 6.1.5.1) + activesupport (= 6.1.5.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.4.7) - actionview (= 6.1.4.7) - activesupport (= 6.1.4.7) + actionpack (6.1.5.1) + actionview (= 6.1.5.1) + activesupport (= 6.1.5.1) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.4.7) - actionpack (= 6.1.4.7) - activerecord (= 6.1.4.7) - activestorage (= 6.1.4.7) - activesupport (= 6.1.4.7) + actiontext (6.1.5.1) + actionpack (= 6.1.5.1) + activerecord (= 6.1.5.1) + activestorage (= 6.1.5.1) + activesupport (= 6.1.5.1) nokogiri (>= 1.8.5) - actionview (6.1.4.7) - activesupport (= 6.1.4.7) + actionview (6.1.5.1) + activesupport (= 6.1.5.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (6.1.4.7) - activesupport (= 6.1.4.7) + activejob (6.1.5.1) + activesupport (= 6.1.5.1) globalid (>= 0.3.6) - activemodel (6.1.4.7) - activesupport (= 6.1.4.7) - activerecord (6.1.4.7) - activemodel (= 6.1.4.7) - activesupport (= 6.1.4.7) + activemodel (6.1.5.1) + activesupport (= 6.1.5.1) + activerecord (6.1.5.1) + activemodel (= 6.1.5.1) + activesupport (= 6.1.5.1) activerecord-import (1.3.0) activerecord (>= 4.2) - activestorage (6.1.4.7) - actionpack (= 6.1.4.7) - activejob (= 6.1.4.7) - activerecord (= 6.1.4.7) - activesupport (= 6.1.4.7) - marcel (~> 1.0.0) + activestorage (6.1.5.1) + actionpack (= 6.1.5.1) + activejob (= 6.1.5.1) + activerecord (= 6.1.5.1) + activesupport (= 6.1.5.1) + marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.4.7) + activesupport (6.1.5.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -136,7 +136,7 @@ GEM climate_control (1.0.1) coderay (1.1.3) commonmarker (0.23.4) - concurrent-ruby (1.1.9) + concurrent-ruby (1.1.10) connection_pool (2.2.5) crack (0.4.5) rexml @@ -183,7 +183,7 @@ GEM email_reply_trimmer (0.1.13) erubi (1.10.0) erubis (2.7.0) - et-orbi (1.2.6) + et-orbi (1.2.7) tzinfo execjs (2.8.1) facebook-messenger (2.0.1) @@ -210,8 +210,8 @@ GEM ruby_parser (~> 3.0) sexp_processor (~> 4.0) foreman (0.87.2) - fugit (1.5.2) - et-orbi (~> 1.1, >= 1.1.8) + fugit (1.5.3) + et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) gapic-common (0.3.4) google-protobuf (~> 3.12, >= 3.12.2) @@ -349,7 +349,7 @@ GEM listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.14.0) + loofah (2.17.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -376,16 +376,16 @@ GEM net-http-persistent (4.0.1) connection_pool (~> 2.2) netrc (0.11.0) - newrelic_rpm (8.4.0) + newrelic_rpm (8.7.0) nio4r (2.5.8) - nokogiri (1.13.3) + nokogiri (1.13.6) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.3-arm64-darwin) + nokogiri (1.13.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.3-x86_64-darwin) + nokogiri (1.13.6-x86_64-darwin) racc (~> 1.4) - nokogiri (1.13.3-x86_64-linux) + nokogiri (1.13.6-x86_64-linux) racc (~> 1.4) oauth (0.5.8) orm_adapter (0.5.0) @@ -403,7 +403,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.6.2) + puma (5.6.4) nio4r (~> 2.0) pundit (2.2.0) activesupport (>= 3.0.0) @@ -419,31 +419,31 @@ GEM rack-test (1.1.0) rack (>= 1.0, < 3) rack-timeout (0.6.0) - rails (6.1.4.7) - actioncable (= 6.1.4.7) - actionmailbox (= 6.1.4.7) - actionmailer (= 6.1.4.7) - actionpack (= 6.1.4.7) - actiontext (= 6.1.4.7) - actionview (= 6.1.4.7) - activejob (= 6.1.4.7) - activemodel (= 6.1.4.7) - activerecord (= 6.1.4.7) - activestorage (= 6.1.4.7) - activesupport (= 6.1.4.7) + rails (6.1.5.1) + actioncable (= 6.1.5.1) + actionmailbox (= 6.1.5.1) + actionmailer (= 6.1.5.1) + actionpack (= 6.1.5.1) + actiontext (= 6.1.5.1) + actionview (= 6.1.5.1) + activejob (= 6.1.5.1) + activemodel (= 6.1.5.1) + activerecord (= 6.1.5.1) + activestorage (= 6.1.5.1) + activesupport (= 6.1.5.1) bundler (>= 1.15.0) - railties (= 6.1.4.7) + railties (= 6.1.5.1) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.4.2) loofah (~> 2.3) - railties (6.1.4.7) - actionpack (= 6.1.4.7) - activesupport (= 6.1.4.7) + railties (6.1.5.1) + actionpack (= 6.1.5.1) + activesupport (= 6.1.5.1) method_source - rake (>= 0.13) + rake (>= 12.2) thor (~> 1.0) rainbow (3.1.1) rake (13.0.6) @@ -533,16 +533,16 @@ GEM activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (5.1.0) + sentry-rails (5.3.0) railties (>= 5.0) - sentry-ruby-core (~> 5.1.0) - sentry-ruby (5.1.0) + sentry-ruby-core (~> 5.3.0) + sentry-ruby (5.3.0) concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-ruby-core (= 5.1.0) - sentry-ruby-core (5.1.0) + sentry-ruby-core (= 5.3.0) + sentry-ruby-core (5.3.0) concurrent-ruby - sentry-sidekiq (5.1.0) - sentry-ruby-core (~> 5.1.0) + sentry-sidekiq (5.3.0) + sentry-ruby-core (~> 5.3.0) sidekiq (>= 3.0) sexp_processor (4.16.0) shoulda-matchers (5.1.0) @@ -551,8 +551,8 @@ GEM connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.2.0) - sidekiq-cron (1.2.0) - fugit (~> 1.1) + sidekiq-cron (1.4.0) + fugit (~> 1) sidekiq (>= 4.2.1) signet (0.16.0) addressable (~> 2.8) @@ -637,6 +637,9 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.0) + working_hours (1.4.1) + activesupport (>= 3.2) + tzinfo zeitwerk (2.5.4) PLATFORMS @@ -689,7 +692,7 @@ DEPENDENCIES hairtrigger hashie html2text - image_processing + image_processing (~> 1.12.2) jbuilder json_refs json_schemer @@ -724,12 +727,12 @@ DEPENDENCIES rubocop-rspec scout_apm seed_dump - sentry-rails - sentry-ruby - sentry-sidekiq + sentry-rails (~> 5.3) + sentry-ruby (~> 5.3) + sentry-sidekiq (~> 5.3) shoulda-matchers sidekiq (~> 6.4.0) - sidekiq-cron + sidekiq-cron (~> 1.3) simplecov (= 0.17.1) slack-ruby-client spring @@ -748,9 +751,10 @@ DEPENDENCIES webpacker (~> 5.x) webpush wisper (= 2.0.0) + working_hours RUBY VERSION ruby 3.0.2p107 BUNDLED WITH - 2.3.8 + 2.3.10 diff --git a/app.json b/app.json index 0d908761c..64edc4a81 100644 --- a/app.json +++ b/app.json @@ -32,6 +32,10 @@ "INSTALLATION_ENV": { "description": "Installation method used for Chatwoot.", "value": "heroku" + }, + "REDIS_OPENSSL_VERIFY_MODE":{ + "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", + "value": "none" } }, "formation": { diff --git a/app/builders/campaigns/campaign_conversation_builder.rb b/app/builders/campaigns/campaign_conversation_builder.rb index 48a3cebd4..b04c6d077 100644 --- a/app/builders/campaigns/campaign_conversation_builder.rb +++ b/app/builders/campaigns/campaign_conversation_builder.rb @@ -1,5 +1,5 @@ class Campaigns::CampaignConversationBuilder - pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes] + pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes] def perform @contact_inbox = ContactInbox.find(@contact_inbox_id) @@ -21,7 +21,8 @@ class Campaigns::CampaignConversationBuilder def message_params ActionController::Parameters.new({ - content: @campaign.message + content: @campaign.message, + campaign_id: @campaign.id }) end @@ -32,7 +33,8 @@ class Campaigns::CampaignConversationBuilder contact_id: @contact_inbox.contact_id, contact_inbox_id: @contact_inbox.id, campaign_id: @campaign.id, - additional_attributes: conversation_additional_attributes + additional_attributes: conversation_additional_attributes, + custom_attributes: custom_attributes || {} } end end diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb index 14f26aa82..10ce8ee26 100644 --- a/app/builders/contact_builder.rb +++ b/app/builders/contact_builder.rb @@ -15,11 +15,10 @@ class ContactBuilder end def create_contact_inbox(contact) - ::ContactInbox.create!( + ::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!( contact_id: contact.id, inbox_id: inbox.id, - source_id: source_id, - hmac_verified: hmac_verified || false + source_id: source_id ) end @@ -70,7 +69,7 @@ class ContactBuilder update_contact_avatar(contact) contact_inbox rescue StandardError => e - Rails.logger.info e + Rails.logger.error e raise e end end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 5410aa3c4..3b3248ed1 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -27,9 +27,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end ensure_contact_avatar rescue Koala::Facebook::AuthenticationError - Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}" + Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}" rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception true end @@ -43,7 +43,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder return if contact.present? @contact = Contact.create!(contact_params.except(:remote_avatar_url)) - @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) + @contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id) end def build_message @@ -128,10 +128,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder result = {} # OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user # We don't need to capture this error as we don't care about contact params in case of echo messages - Sentry.capture_exception(e) unless @outgoing_echo + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo rescue StandardError => e result = {} - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception end process_contact_params_result(result) end diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 69475fb16..3b8ead18c 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -24,7 +24,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder @inbox.channel.authorization_error! raise rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception true end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 9c26ccca1..5c8cadbcd 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -9,6 +9,7 @@ class Messages::MessageBuilder @user = user @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] + @automation_rule = @params&.dig(:content_attributes, :automation_rule_id) return unless params.instance_of?(ActionController::Parameters) @in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to) @@ -64,6 +65,14 @@ class Messages::MessageBuilder @params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {} end + def automation_rule_id + @automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {} + end + + def campaign_id + @params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {} + end + def message_sender return if @params[:sender_type] != 'AgentBot' @@ -82,6 +91,6 @@ class Messages::MessageBuilder items: @items, in_reply_to: @in_reply_to, echo_id: @params[:echo_id] - }.merge(external_created_at) + }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id) end end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 2e58825f4..6d3ed0179 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -53,16 +53,7 @@ class Messages::Messenger::MessageBuilder def fetch_story_link(attachment) message = attachment.message - begin - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - result = k.get_object(message.source_id, fields: %w[story from]) || {} - rescue Koala::Facebook::AuthenticationError - @inbox.channel.authorization_error! - raise - rescue StandardError => e - result = {} - Sentry.capture_exception(e) - end + result = get_story_object_from_source_id(message.source_id) story_id = result['story']['mention']['id'] story_sender = result['from']['username'] message.content_attributes[:story_sender] = story_sender @@ -70,4 +61,15 @@ class Messages::Messenger::MessageBuilder message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender) message.save! end + + def get_story_object_from_source_id(source_id) + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + k.get_object(source_id, fields: %w[story from]) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + {} + end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 1f17dbb08..5cd8b4a63 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -1,8 +1,10 @@ class V2::ReportBuilder include DateRangeHelper + include ReportHelper attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze + AGENT_RESULTS_PER_PAGE = 25 def initialize(account, params) @account = account @@ -18,8 +20,14 @@ class V2::ReportBuilder # For backward compatible with old report def build - timeseries.each_with_object([]) do |p, arr| - arr << { value: p[1], timestamp: p[0].to_time.to_i } + if %w[avg_first_response_time avg_resolution_time].include?(params[:metric]) + timeseries.each_with_object([]) do |p, arr| + arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] } + end + else + timeseries.each_with_object([]) do |p, arr| + arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i } + end end end @@ -34,23 +42,16 @@ class V2::ReportBuilder } end - private - - def scope - case params[:type] - when :account - account - when :inbox - inbox - when :agent - user - when :label - label - when :team - team + def conversation_metrics + if params[:type].equal?(:account) + conversations + else + agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse end end + private + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -68,7 +69,7 @@ class V2::ReportBuilder end def get_grouped_values(object_scope) - object_scope.group_by_period( + @grouped_values = object_scope.group_by_period( params[:group_by] || DEFAULT_GROUP_BY, :created_at, default_value: 0, @@ -78,47 +79,29 @@ class V2::ReportBuilder ) end - def conversations_count - (get_grouped_values scope.conversations).count + def agent_metrics + account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE) + account_users.each_with_object([]) do |account_user, arr| + @user = account_user.user + arr << { + id: @user.id, + name: @user.name, + email: @user.email, + thumbnail: @user.avatar_url, + availability: account_user.availability_status, + metric: conversations + } + end end - def incoming_messages_count - (get_grouped_values scope.messages.incoming.unscope(:order)).count - end - - def outgoing_messages_count - (get_grouped_values scope.messages.outgoing.unscope(:order)).count - end - - def resolutions_count - (get_grouped_values scope.conversations.resolved).count - end - - def avg_first_response_time - (get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) - end - - def avg_resolution_time - (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) - end - - def avg_resolution_time_summary - avg_rt = scope.reporting_events - .where(name: 'conversation_resolved', created_at: range) - .average(:value) - - return 0 if avg_rt.blank? - - avg_rt - end - - def avg_first_response_time_summary - avg_frt = scope.reporting_events - .where(name: 'first_response', created_at: range) - .average(:value) - - return 0 if avg_frt.blank? - - avg_frt + def conversations + @open_conversations = scope.conversations.where(account_id: @account.id).open + first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count + metric = { + open: @open_conversations.count, + unattended: @open_conversations.count - first_response_count + } + metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) + metric end end diff --git a/app/channels/room_channel.rb b/app/channels/room_channel.rb index b3c597770..0680f1458 100644 --- a/app/channels/room_channel.rb +++ b/app/channels/room_channel.rb @@ -1,5 +1,8 @@ class RoomChannel < ApplicationCable::Channel def subscribed + # TODO: should we only do ensure stream if current account is present? + # for now going ahead with guard clauses in update_subscription and broadcast_presence + ensure_stream current_user current_account @@ -15,6 +18,8 @@ class RoomChannel < ApplicationCable::Channel private def broadcast_presence + return if @current_account.blank? + data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) } data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data }) @@ -26,6 +31,8 @@ class RoomChannel < ApplicationCable::Channel end def update_subscription + return if @current_account.blank? + ::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id) end @@ -38,6 +45,8 @@ class RoomChannel < ApplicationCable::Channel end def current_account + return if current_user.blank? + @current_account ||= if @current_user.is_a? Contact @current_user.account else diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 7348ef255..9f4ef2cd9 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController end def destroy - @agent_bot.destroy + @agent_bot.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index a666d1a67..09b648a6f 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def destroy - @agent.current_account_user.destroy + @agent.current_account_user.destroy! head :ok end @@ -68,7 +68,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def agents - @agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] }) + @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) end def validate_limit diff --git a/app/controllers/api/v1/accounts/assignable_agents_controller.rb b/app/controllers/api/v1/accounts/assignable_agents_controller.rb new file mode 100644 index 000000000..a712342dd --- /dev/null +++ b/app/controllers/api/v1/accounts/assignable_agents_controller.rb @@ -0,0 +1,24 @@ +class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController + before_action :fetch_inboxes + + def index + agent_ids = @inboxes.map do |inbox| + authorize inbox, :show? + member_ids = inbox.members.pluck(:user_id) + member_ids + end + agent_ids = agent_ids.inject(:&) + agents = Current.account.users.where(id: agent_ids) + @assignable_agents = (agents + Current.account.administrators).uniq + end + + private + + def fetch_inboxes + @inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids]) + end + + def permitted_params + params.permit(inbox_ids: []) + end +end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 2dc71bcec..5e649b6e0 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -7,13 +7,39 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont end def create - @automation_rule = Current.account.automation_rules.create(automation_rules_permit) + @automation_rule = Current.account.automation_rules.new(automation_rules_permit) + @automation_rule.actions = params[:actions] + + render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? + + @automation_rule.save! + process_attachments + @automation_rule + end + + def attach_file + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:attachment].tempfile, + filename: params[:attachment].original_filename, + content_type: params[:attachment].content_type + ) + render json: { blob_key: file_blob.key, blob_id: file_blob.id } end def show; end def update - @automation_rule.update(automation_rules_permit) + ActiveRecord::Base.transaction do + @automation_rule.update!(automation_rules_permit) + @automation_rule.actions = params[:actions] if params[:actions] + @automation_rule.save! + process_attachments + + rescue StandardError => e + Rails.logger.error e + render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity + end end def destroy @@ -28,6 +54,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont @automation_rule = new_rule end + def process_attachments + actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' } + return if actions.blank? + + actions.each do |action| + blob_id = action['action_params'] + blob = ActiveStorage::Blob.find_by(id: blob_id) + @automation_rule.files.attach(blob) + end + end + private def automation_rules_permit diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb index ddb0f44f4..e30effc59 100644 --- a/app/controllers/api/v1/accounts/base_controller.rb +++ b/app/controllers/api/v1/accounts/base_controller.rb @@ -1,32 +1,6 @@ class Api::V1::Accounts::BaseController < Api::BaseController include SwitchLocale + include EnsureCurrentAccountHelper before_action :current_account around_action :switch_locale_using_account_locale - - private - - def current_account - @current_account ||= ensure_current_account - Current.account = @current_account - end - - def ensure_current_account - account = Account.find(params[:account_id]) - if current_user - account_accessible_for_user?(account) - elsif @resource.is_a?(AgentBot) - account_accessible_for_bot?(account) - end - account - end - - def account_accessible_for_user?(account) - @current_account_user = account.account_users.find_by(user_id: current_user.id) - Current.account_user = @current_account_user - render_unauthorized('You are not authorized to access this account') unless @current_account_user - end - - def account_accessible_for_bot?(account) - render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) - end end diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 7c2469c05..8a163f011 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e).capture_exception end end @@ -60,7 +60,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController set_instagram_id(access_token, fb_page) fb_page&.reauthorized! rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e).capture_exception end end @@ -77,7 +77,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', '')) koala.exchange_access_token_info(omniauth_token)['access_token'] rescue StandardError => e - Rails.logger.info e + Rails.logger.error e end def mark_already_existing_facebook_pages(data) diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index 18d0998c8..6d2fb7729 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController end def destroy - @campaign.destroy + @campaign.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/canned_responses_controller.rb b/app/controllers/api/v1/accounts/canned_responses_controller.rb index bbfa9c4b7..031ffc415 100644 --- a/app/controllers/api/v1/accounts/canned_responses_controller.rb +++ b/app/controllers/api/v1/accounts/canned_responses_controller.rb @@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont end def destroy - @canned_response.destroy + @canned_response.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/kbase/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb similarity index 68% rename from app/controllers/api/v1/accounts/kbase/categories_controller.rb rename to app/controllers/api/v1/accounts/categories_controller.rb index e114ee5e4..246eeb2a2 100644 --- a/app/controllers/api/v1/accounts/kbase/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -1,4 +1,5 @@ -class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase::BaseController +class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController + before_action :portal before_action :fetch_category, except: [:index, :create] def index @@ -14,7 +15,7 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase: end def destroy - @category.destroy + @category.destroy! head :ok end @@ -24,6 +25,10 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase: @category = @portal.categories.find(params[:id]) end + def portal + @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + end + def category_params params.require(:category).permit( :name, :description, :position diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index a53650e65..f5a3c6a6d 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -7,7 +7,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: build_inbox setup_webhooks if @twilio_channel.sms? rescue StandardError => e - Sentry.capture_exception(e) render_could_not_create_error(e.message) end end diff --git a/app/controllers/api/v1/accounts/contacts/notes_controller.rb b/app/controllers/api/v1/accounts/contacts/notes_controller.rb index fb9f3c5c3..7bc9dd121 100644 --- a/app/controllers/api/v1/accounts/contacts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/notes_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts end def destroy - @note.destroy + @note.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb index f521719ae..da821a3e5 100644 --- a/app/controllers/api/v1/accounts/conversations/base_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb @@ -1,10 +1,11 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController + include EnsureCurrentAccountHelper before_action :conversation private def conversation - @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) + @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) authorize @conversation.inbox, :show? end end diff --git a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb new file mode 100644 index 000000000..f4ac05d6e --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb @@ -0,0 +1,17 @@ +class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController + include EnsureCurrentAccountHelper + before_action :current_account + before_action :conversation + + def create + return if @conversation.nil? || @current_account.nil? + + super + end + + private + + def conversation + @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) + end +end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index ffd00461b..77a3a7081 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: def destroy ActiveRecord::Base.transaction do - message.update!(content: I18n.t('conversations.messages.deleted'), deleted: true) + message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true }) message.attachments.destroy_all end end diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb index 347f028f7..7b5c51d6e 100644 --- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb +++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base RESULTS_PER_PAGE = 25 before_action :check_authorization - before_action :set_csat_survey_responses, only: [:index, :metrics] + before_action :set_csat_survey_responses, only: [:index, :metrics, :download] before_action :set_current_page, only: [:index] before_action :set_current_page_surveys, only: [:index] before_action :set_total_sent_messages_count, only: [:metrics] @@ -19,6 +19,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base @ratings_count = @csat_survey_responses.group(:rating).count end + def download + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv' + render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv' + end + private def set_total_sent_messages_count diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 419540438..3840644ce 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account end def destroy - @custom_attribute_definition.destroy + @custom_attribute_definition.destroy! head :no_content end diff --git a/app/controllers/api/v1/accounts/custom_filters_controller.rb b/app/controllers/api/v1/accounts/custom_filters_controller.rb index e6c7b6857..188f0e623 100644 --- a/app/controllers/api/v1/accounts/custom_filters_controller.rb +++ b/app/controllers/api/v1/accounts/custom_filters_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro end def destroy - @custom_filter.destroy + @custom_filter.destroy! head :no_content end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index d60e4c3e8..4bc85546a 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -12,6 +12,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def show; end + # Deprecated: This API will be removed in 2.7.0 def assignable_agents @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq end @@ -48,7 +49,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # Inbox update doesn't necessarily need channel attributes return if permitted_params(channel_attributes)[:channel].blank? - validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email' + if @inbox.inbox_type == 'Email' + validate_email_channel(channel_attributes) + @inbox.channel.reauthorized! + end + @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) update_channel_feature_flags end @@ -69,7 +74,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def destroy - @inbox.destroy + @inbox.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb index 18a16a30d..dd2af4ef2 100644 --- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base end def destroy - @hook.destroy + @hook.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb index 537ddd688..b5571b245 100644 --- a/app/controllers/api/v1/accounts/integrations/slack_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base end def destroy - @hook.destroy + @hook.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/kbase/base_controller.rb b/app/controllers/api/v1/accounts/kbase/base_controller.rb deleted file mode 100644 index f50140883..000000000 --- a/app/controllers/api/v1/accounts/kbase/base_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseController - before_action :portal - - private - - def portal - @portal ||= Current.account.kbase_portals.find_by(id: params[:portal_id]) - end -end diff --git a/app/controllers/api/v1/accounts/kbase/portals_controller.rb b/app/controllers/api/v1/accounts/kbase/portals_controller.rb deleted file mode 100644 index e0788b587..000000000 --- a/app/controllers/api/v1/accounts/kbase/portals_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::BaseController - before_action :fetch_portal, except: [:index, :create] - - def index - @portals = Current.account.kbase_portals - end - - def create - @portal = Current.account.kbase_portals.create!(portal_params) - end - - def update - @portal.update!(portal_params) - end - - def destroy - @portal.destroy - head :ok - end - - private - - def fetch_portal - @portal = current_account.kbase_portals.find(params[:id]) - end - - def portal_params - params.require(:portal).permit( - :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug - ) - end -end diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 547b9e6d6..54455943b 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController end def destroy - @label.destroy + @label.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb new file mode 100644 index 000000000..75ffc35e9 --- /dev/null +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -0,0 +1,38 @@ +class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController + before_action :fetch_portal, except: [:index, :create] + + def index + @portals = Current.account.portals + end + + def show; end + + def create + @portal = Current.account.portals.create!(portal_params) + end + + def update + @portal.update!(portal_params) + end + + def destroy + @portal.destroy! + head :ok + end + + private + + def fetch_portal + @portal = Current.account.portals.find_by(slug: permitted_params[:id]) + end + + def permitted_params + params.permit(:id) + end + + def portal_params + params.require(:portal).permit( + :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived + ) + end +end diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index adfeed62e..e8688dcfb 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController end def destroy - @team.destroy + @team.destroy! head :ok end diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 58f9b21a0..7ea257ed2 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -16,14 +16,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController end def destroy - @webhook.destroy + @webhook.destroy! head :ok end private def webhook_params - params.require(:webhook).permit(:inbox_id, :url) + params.require(:webhook).permit(:inbox_id, :url, subscriptions: []) end def fetch_webhook diff --git a/app/controllers/api/v1/notification_subscriptions_controller.rb b/app/controllers/api/v1/notification_subscriptions_controller.rb index 5f1cf30e4..a01c2ca03 100644 --- a/app/controllers/api/v1/notification_subscriptions_controller.rb +++ b/app/controllers/api/v1/notification_subscriptions_controller.rb @@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController def destroy notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first - notification_subscription.destroy + notification_subscription.destroy! head :ok end diff --git a/app/controllers/api/v1/webhooks_controller.rb b/app/controllers/api/v1/webhooks_controller.rb index c96ae9a96..4f9f6d55b 100644 --- a/app/controllers/api/v1/webhooks_controller.rb +++ b/app/controllers/api/v1/webhooks_controller.rb @@ -10,7 +10,7 @@ class Api::V1::WebhooksController < ApplicationController twitter_consumer.consume head :ok rescue StandardError => e - Sentry.capture_exception(e) + ChatwootExceptionTracker.new(e).capture_exception head :ok end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index ad555fc22..646d70acf 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -1,5 +1,6 @@ class Api::V1::Widget::BaseController < ApplicationController include SwitchLocale + include WebsiteTokenHelper before_action :set_web_widget before_action :set_contact @@ -19,23 +20,6 @@ class Api::V1::Widget::BaseController < ApplicationController @conversation ||= conversations.last end - def auth_token_params - @auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token - end - - def set_web_widget - @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) - @current_account = @web_widget.account - end - - def set_contact - @contact_inbox = @web_widget.inbox.contact_inboxes.find_by( - source_id: auth_token_params[:source_id] - ) - @contact = @contact_inbox&.contact - raise ActiveRecord::RecordNotFound unless @contact - end - def create_conversation ::Conversation.create!(conversation_params) end @@ -55,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController browser: browser_params, referer: permitted_params[:message][:referer_url], initiated_at: timestamp_params - } + }, + custom_attributes: permitted_params[:custom_attributes].presence || {} } end @@ -68,20 +53,33 @@ class Api::V1::Widget::BaseController < ApplicationController mergee_contact: @contact ).perform else - @contact.update!(email: email, name: contact_name, phone_number: contact_phone_number) + @contact.update!(email: email) + end + end + + def update_contact_phone_number(phone_number) + contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number) + if contact_with_phone_number + @contact = ::ContactMergeAction.new( + account: @current_account, + base_contact: contact_with_phone_number, + mergee_contact: @contact + ).perform + else + @contact.update!(phone_number: phone_number) end end def contact_email - permitted_params[:contact][:email].downcase + permitted_params.dig(:contact, :email)&.downcase end def contact_name - params[:contact][:name] || contact_email.split('@')[0] + params[:contact][:name] || contact_email.split('@')[0] if contact_email.present? end def contact_phone_number - params[:contact][:phone_number] + permitted_params.dig(:contact, :phone_number) end def browser_params @@ -98,10 +96,6 @@ class Api::V1::Widget::BaseController < ApplicationController { timestamp: permitted_params[:message][:timestamp] } end - def permitted_params - params.permit(:website_token) - end - def message_params { account_id: conversation.account_id, diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 8cfb65be6..cfe107ac6 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -7,12 +7,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def create ActiveRecord::Base.transaction do - update_contact(contact_email) if @contact.email.blank? && contact_email.present? + process_update_contact @conversation = create_conversation conversation.messages.create(message_params) end end + def process_update_contact + update_contact(contact_email) if @contact.email.blank? && contact_email.present? + update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present? + @contact.update!(name: contact_name) if contact_name.present? + end + def update_last_seen head :ok && return if conversation.nil? @@ -44,6 +50,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController head :ok end + def toggle_status + return head :not_found if conversation.nil? + + return head :forbidden unless @web_widget.end_conversation? + + unless conversation.resolved? + conversation.status = :resolved + conversation.save + end + head :ok + end + private def trigger_typing_event(event) @@ -52,6 +70,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def permitted_params params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], - message: [:content, :referer_url, :timestamp, :echo_id]) + message: [:content, :referer_url, :timestamp, :echo_id], + custom_attributes: {}) end end diff --git a/app/controllers/api/v1/widget/direct_uploads_controller.rb b/app/controllers/api/v1/widget/direct_uploads_controller.rb new file mode 100644 index 000000000..a6abdb3e1 --- /dev/null +++ b/app/controllers/api/v1/widget/direct_uploads_controller.rb @@ -0,0 +1,11 @@ +class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController + include WebsiteTokenHelper + before_action :set_web_widget + before_action :set_contact + + def create + return if @contact.nil? || @current_account.nil? + + super + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index efa09a43c..c2117583a 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -35,41 +35,55 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' end + def conversations + return head :unprocessable_entity if params[:type].blank? + + render json: conversation_metrics + end + private def check_authorization raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end - def current_summary_params + def common_params { type: params[:type].to_sym, id: params[:id], - since: range[:current][:since], - until: range[:current][:until], - group_by: params[:group_by] + group_by: params[:group_by], + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) } end + def current_summary_params + common_params.merge({ + since: range[:current][:since], + until: range[:current][:until] + }) + end + def previous_summary_params - { - type: params[:type].to_sym, - id: params[:id], - since: range[:previous][:since], - until: range[:previous][:until], - group_by: params[:group_by] - } + common_params.merge({ + since: range[:previous][:since], + until: range[:previous][:until] + }) end def report_params + common_params.merge({ + metric: params[:metric], + since: params[:since], + until: params[:until], + timezone_offset: params[:timezone_offset] + }) + end + + def conversation_params { - metric: params[:metric], type: params[:type].to_sym, - since: params[:since], - until: params[:until], - id: params[:id], - group_by: params[:group_by], - timezone_offset: params[:timezone_offset] + user_id: params[:user_id], + page: params[:page].presence || 1 } end @@ -91,4 +105,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary summary end + + def conversation_metrics + V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics + end end diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb new file mode 100644 index 000000000..dccc64350 --- /dev/null +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -0,0 +1,28 @@ +module EnsureCurrentAccountHelper + private + + def current_account + @current_account ||= ensure_current_account + Current.account = @current_account + end + + def ensure_current_account + account = Account.find(params[:account_id]) + if current_user + account_accessible_for_user?(account) + elsif @resource.is_a?(AgentBot) + account_accessible_for_bot?(account) + end + account + end + + def account_accessible_for_user?(account) + @current_account_user = account.account_users.find_by(user_id: current_user.id) + Current.account_user = @current_account_user + render_unauthorized('You are not authorized to access this account') unless @current_account_user + end + + def account_accessible_for_bot?(account) + render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) + end +end diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index 51061017e..6e9ed04cc 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -9,8 +9,7 @@ module RequestExceptionHandler def handle_with_exception yield - rescue ActiveRecord::RecordNotFound => e - Sentry.capture_exception(e) + rescue ActiveRecord::RecordNotFound render_not_found_error('Resource could not be found') rescue Pundit::NotAuthorizedError render_unauthorized('You are not authorized to do this action') diff --git a/app/controllers/concerns/website_token_helper.rb b/app/controllers/concerns/website_token_helper.rb new file mode 100644 index 000000000..0158a4107 --- /dev/null +++ b/app/controllers/concerns/website_token_helper.rb @@ -0,0 +1,24 @@ +module WebsiteTokenHelper + def auth_token_params + @auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token + end + + def set_web_widget + @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) + @current_account = @web_widget.account + end + + def set_contact + @contact_inbox = @web_widget.inbox.contact_inboxes.find_by( + source_id: auth_token_params[:source_id] + ) + @contact = @contact_inbox&.contact + raise ActiveRecord::RecordNotFound unless @contact + + Current.contact = @contact + end + + def permitted_params + params.permit(:website_token) + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 2bb8e9847..107aec56e 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -38,9 +38,12 @@ class DashboardController < ActionController::Base end def app_config - { APP_VERSION: Chatwoot.config[:version], + { + APP_VERSION: Chatwoot.config[:version], VAPID_PUBLIC_KEY: VapidService.public_key, ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), - FB_APP_ID: GlobalConfigService.load('FB_APP_ID', '') } + FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), + FACEBOOK_API_VERSION: 'v13.0' + } end end diff --git a/app/controllers/platform/api/v1/account_users_controller.rb b/app/controllers/platform/api/v1/account_users_controller.rb index 8f651cfd9..b8a8f701a 100644 --- a/app/controllers/platform/api/v1/account_users_controller.rb +++ b/app/controllers/platform/api/v1/account_users_controller.rb @@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController end def destroy - @resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy + @resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy! head :ok end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index 960dee0e3..4a7eafc9c 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -7,9 +7,9 @@ class Platform::Api::V1::UsersController < PlatformController def create @resource = (User.find_by(email: user_params[:email]) || User.new(user_params)) + @resource.skip_confirmation! @resource.save! - @resource.confirm - @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) + @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource) end def login @@ -21,6 +21,10 @@ class Platform::Api::V1::UsersController < PlatformController def update @resource.assign_attributes(user_update_params) + + # We are using devise's reconfirmable flow for changing emails + # But in case of platform APIs we don't want user to go through this extra step + @resource.skip_reconfirmation! if user_update_params[:email].present? @resource.save! end diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 6932386fd..eb794f2a0 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -43,6 +43,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon end def permitted_params - params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {}) + params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {}) end end diff --git a/app/controllers/twitter/callbacks_controller.rb b/app/controllers/twitter/callbacks_controller.rb index d484b0871..32fffbceb 100644 --- a/app/controllers/twitter/callbacks_controller.rb +++ b/app/controllers/twitter/callbacks_controller.rb @@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) end rescue StandardError => e - Rails.logger.info e + Rails.logger.error e redirect_to twitter_app_redirect_url end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 2338ca7d1..e6fe93566 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -17,7 +17,7 @@ class Webhooks::InstagramController < ApplicationController ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) render json: :ok else - Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}") + Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}") head :unprocessable_entity end end diff --git a/app/controllers/widget_tests_controller.rb b/app/controllers/widget_tests_controller.rb index fff47d907..6d6742cf4 100644 --- a/app/controllers/widget_tests_controller.rb +++ b/app/controllers/widget_tests_controller.rb @@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base before_action :ensure_widget_position before_action :ensure_widget_type before_action :ensure_widget_style + before_action :ensure_dark_mode def index render @@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base @widget_style = params[:widget_style] || 'standard' end + def ensure_dark_mode + @dark_mode = params[:dark_mode] || 'light' + end + def ensure_widget_position @widget_position = params[:position] || 'left' end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 58013f128..ecbccfb88 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -51,6 +51,7 @@ class ConversationFinder filter_by_team if @team filter_by_labels if params[:labels] filter_by_query if params[:q] + filter_by_reply_status end def set_inboxes @@ -90,6 +91,10 @@ class ConversationFinder @conversations end + def filter_by_reply_status + @conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended' + end + def filter_by_query allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]] @conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%") diff --git a/app/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb new file mode 100644 index 000000000..1c8eaeaf2 --- /dev/null +++ b/app/finders/email_channel_finder.rb @@ -0,0 +1,15 @@ +class EmailChannelFinder + def initialize(email_object) + @email_object = email_object + end + + def perform + channel = nil + recipient_mails = @email_object.to.to_a + @email_object.cc.to_a + recipient_mails.each do |email| + channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase) + break if channel.present? + end + channel + end +end diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 63c72bd8e..e2581e0ad 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -22,7 +22,7 @@ class MessageFinder def current_messages if @params[:before].present? - messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse + messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse else messages.reorder('created_at desc').limit(20).reverse end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8cdf8d987..56fb79908 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -14,7 +14,7 @@ module Api::V1::InboxesHelper Mail.defaults do retriever_method :imap, { address: channel_data[:imap_address], port: channel_data[:imap_port], - user_name: channel_data[:imap_email], + user_name: channel_data[:imap_login], password: channel_data[:imap_password], enable_ssl: channel_data[:imap_enable_ssl] } end @@ -29,8 +29,12 @@ module Api::V1::InboxesHelper smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port]) set_smtp_encryption(channel_data, smtp) + check_smtp_connection(channel_data, smtp) + end - smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login) + def check_smtp_connection(channel_data, smtp) + smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], + channel_data[:smtp_authentication]&.to_sym || :login) smtp.finish unless smtp&.nil? end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb new file mode 100644 index 000000000..5fdb34170 --- /dev/null +++ b/app/helpers/report_helper.rb @@ -0,0 +1,68 @@ +module ReportHelper + private + + def scope + case params[:type] + when :account + account + when :inbox + inbox + when :agent + user + when :label + label + when :team + team + end + end + + def conversations_count + (get_grouped_values scope.conversations.where(account_id: account.id)).count + end + + def incoming_messages_count + (get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count + end + + def outgoing_messages_count + (get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count + end + + def resolutions_count + (get_grouped_values scope.conversations.where(account_id: account.id).resolved).count + end + + def avg_first_response_time + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) + return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] + + grouped_reporting_events.average(:value) + end + + def avg_resolution_time + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id)) + return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] + + grouped_reporting_events.average(:value) + end + + def avg_resolution_time_summary + reporting_events = scope.reporting_events + .where(name: 'conversation_resolved', account_id: account.id, created_at: range) + avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + + return 0 if avg_rt.blank? + + avg_rt + end + + def avg_first_response_time_summary + reporting_events = scope.reporting_events + .where(name: 'first_response', account_id: account.id, created_at: range) + avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + + return 0 if avg_frt.blank? + + avg_frt + end +end diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb new file mode 100644 index 000000000..eee1af283 --- /dev/null +++ b/app/helpers/reporting_event_helper.rb @@ -0,0 +1,50 @@ +module ReportingEventHelper + def business_hours(inbox, from, to) + return 0 unless inbox.working_hours_enabled? + + inbox_working_hours = configure_working_hours(inbox.working_hours) + return 0 if inbox_working_hours.blank? + + # Configure working hours + WorkingHours::Config.working_hours = inbox_working_hours + + # Configure timezone + WorkingHours::Config.time_zone = inbox.timezone + + # Use inbox timezone to change from & to values. + from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time + to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time + from_in_inbox_timezone.working_time_until(to_in_inbox_timezone) + end + + private + + def configure_working_hours(working_hours) + working_hours.each_with_object({}) do |working_hour, object| + object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day? + end + end + + def day(day_of_week) + week_days = { + 0 => :sun, + 1 => :mon, + 2 => :tue, + 3 => :wed, + 4 => :thu, + 5 => :fri, + 6 => :sat + } + week_days[day_of_week] + end + + def working_hour_range(working_hour) + { format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) } + end + + def format_time(hour, minute) + hour = hour < 10 ? "0#{hour}" : hour + minute = minute < 10 ? "0#{minute}" : minute + "#{hour}:#{minute}" + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 502d8bda1..1e262f1ff 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,5 +1,6 @@ @@ -46,5 +54,9 @@ export default { .title--section { padding-right: var(--space-large); } + + .note { + font-weight: var(--font-weight-bold); + } } diff --git a/app/javascript/dashboard/components/app/UpdateBanner.vue b/app/javascript/dashboard/components/app/UpdateBanner.vue new file mode 100644 index 000000000..2e4b00031 --- /dev/null +++ b/app/javascript/dashboard/components/app/UpdateBanner.vue @@ -0,0 +1,74 @@ + + diff --git a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue index cc65c68b0..1a60b3a43 100644 --- a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue +++ b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue @@ -57,3 +57,13 @@ export default { }, }; + diff --git a/app/javascript/dashboard/components/buttons/ToggleButton.vue b/app/javascript/dashboard/components/buttons/ToggleButton.vue deleted file mode 100644 index 37328aba5..000000000 --- a/app/javascript/dashboard/components/buttons/ToggleButton.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 1ad4a9be3..1c70e6755 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -53,7 +53,7 @@ export default { computed: { ...mapGetters({ getCurrentUserAvailability: 'getCurrentUserAvailability', - getCurrentAccountId: 'getCurrentAccountId', + currentAccountId: 'getCurrentAccountId', }), availabilityDisplayLabel() { const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( @@ -63,9 +63,6 @@ export default { availabilityIndex ]; }, - currentAccountId() { - return this.getCurrentAccountId; - }, currentUserAvailability() { return this.getCurrentUserAvailability; }, diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index cdd338710..5017b3d4b 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -8,6 +8,7 @@ :active-menu-item="activePrimaryMenu.key" @toggle-accounts="toggleAccountModal" @key-shortcut-modal="toggleKeyShortcutModal" + @open-notification-panel="openNotificationPanel" /> @@ -176,6 +178,9 @@ export default { showAddLabelPopup() { this.$emit('show-add-label-popup'); }, + openNotificationPanel() { + this.$emit('open-notification-panel'); + }, }, }; @@ -184,6 +189,8 @@ export default { .woot-sidebar { background: var(--white); display: flex; + min-height: 0; + height: 100%; } diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js index a9c703c44..967ee44ed 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js @@ -3,7 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper'; const reports = accountId => ({ parentNav: 'reports', routes: [ - 'settings_account_reports', + 'account_overview_reports', + 'conversation_reports', 'csat_reports', 'agent_reports', 'label_reports', @@ -16,7 +17,14 @@ const reports = accountId => ({ label: 'REPORTS_OVERVIEW', hasSubMenu: false, toState: frontendURL(`accounts/${accountId}/reports/overview`), - toStateName: 'settings_account_reports', + toStateName: 'account_overview_reports', + }, + { + icon: 'chat', + label: 'REPORTS_CONVERSATION', + hasSubMenu: false, + toState: frontendURL(`accounts/${accountId}/reports/conversation`), + toStateName: 'conversation_reports', }, { icon: 'emoji', diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue b/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue index 1438ac7e8..71382d5e6 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue @@ -1,14 +1,34 @@ diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue b/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue index 82d858b30..375cd6ffa 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue @@ -1,19 +1,21 @@ @@ -37,4 +48,32 @@ export default { .notifications-link { margin-bottom: var(--space-small); } + +.badge { + position: absolute; + right: var(--space-minus-smaller); + top: var(--space-minus-smaller); +} +.notifications-link--button { + display: flex; + position: relative; + border-radius: var(--border-radius-large); + border: 1px solid transparent; + color: var(--s-600); + margin: var(--space-small) 0; + + &:hover { + background: var(--w-50); + color: var(--s-600); + } + + &:focus { + border-color: var(--w-500); + } + + &.is-active { + background: var(--w-50); + color: var(--w-500); + } +} diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue index 0f568ab75..956daa5eb 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue @@ -16,7 +16,7 @@ />