diff --git a/.circleci/config.yml b/.circleci/config.yml index 2519c34297f44..c67063ae6b31d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ 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: cimg/postgres:14.1 + - image: cimg/postgres:15.3 - image: cimg/redis:6.2.6 environment: - RAILS_LOG_TO_STDOUT: false @@ -64,7 +64,6 @@ jobs: - ~/.bundle key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - # Only necessary if app uses webpacker or yarn in some other way - restore_cache: keys: @@ -73,7 +72,7 @@ jobs: - run: name: yarn - command: yarn install --cache-folder ~/.cache/yarn + command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn # Store yarn / webpacker cache - save_cache: @@ -82,7 +81,7 @@ jobs: - ~/.cache/yarn - run: - name: Download cc-test-reporter + name: Download cc-test-reporter command: | mkdir -p ~/tmp curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter @@ -104,9 +103,8 @@ jobs: fi 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 - run: bundle exec rake db:create - run: bundle exec rake db:schema:load @@ -117,7 +115,7 @@ jobs: - run: name: Rubocop command: bundle exec rubocop - + # - run: # name: Brakeman # command: bundle exec brakeman @@ -126,6 +124,21 @@ jobs: name: eslint command: yarn run eslint + # Run frontend tests + - run: + name: Run frontend tests + command: | + mkdir -p ~/tmp/test-results/frontend_specs + ~/tmp/cc-test-reporter before-build + TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings) + yarn test:coverage --profile 10 \ + --out ~/tmp/test-results/yarn.xml \ + -- ${TESTFILES} + - run: + name: Code Climate Test Coverage + command: | + ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" + # Run rails tests - run: name: Run backend tests @@ -145,20 +158,6 @@ jobs: command: | ~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json" - - run: - name: Run frontend tests - command: | - mkdir -p ~/tmp/test-results/frontend_specs - ~/tmp/cc-test-reporter before-build - TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings) - yarn test:coverage --profile 10 \ - --out ~/tmp/test-results/yarn.xml \ - -- ${TESTFILES} - - run: - name: Code Climate Test Coverage - command: | - ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" - - persist_to_workspace: root: coverage paths: @@ -182,7 +181,7 @@ jobs: - attach_workspace: at: ~/build - run: - name: Download cc-test-reporter + name: Download cc-test-reporter command: | mkdir -p ~/tmp curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter @@ -204,4 +203,4 @@ workflows: - build - upload-coverage: requires: - - build + - build diff --git a/.env.example b/.env.example index 65e8c6d6f5af0..335334e31b894 100644 --- a/.env.example +++ b/.env.example @@ -204,6 +204,8 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: ## Rack Attack configuration ## To prevent and throttle abusive requests # ENABLE_RACK_ATTACK=true +# RACK_ATTACK_LIMIT=300 +# ENABLE_RACK_ATTACK_WIDGET_API=true ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints @@ -235,3 +237,11 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 + + +# AI powered features +## OpenAI key +# OPENAI_API_KEY= + +# Sentiment analysis model file path +SENTIMENT_FILE_PATH= diff --git a/.eslintrc.js b/.eslintrc.js index 0fae1bc44a93f..a58c77e98193a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,7 @@ module.exports = { 'plugin:storybook/recommended', ], parserOptions: { - parser: 'babel-eslint', + parser: '@babel/eslint-parser', ecmaVersion: 2020, sourceType: 'module', }, @@ -24,13 +24,16 @@ module.exports = { 'jsx-a11y/label-has-for': 'off', 'jsx-a11y/anchor-is-valid': 'off', 'import/no-unresolved': 'off', + 'vue/html-indent': 'off', + 'vue/multi-word-component-names': 'off', 'vue/max-attributes-per-line': [ 'error', { - singleline: 20, + singleline: { + max: 20, + }, multiline: { max: 1, - allowFirstLine: false, }, }, ], @@ -47,6 +50,7 @@ module.exports = { }, ], 'vue/no-v-html': 'off', + 'vue/component-definition-name-casing': 'off', 'vue/singleline-html-element-content-newline': 'off', 'import/extensions': ['off'], 'no-console': 'error', diff --git a/.github/workflows/lint_pr.yml b/.github/workflows/lint_pr.yml new file mode 100644 index 0000000000000..ec8f6f4abf9bf --- /dev/null +++ b/.github/workflows/lint_pr.yml @@ -0,0 +1,23 @@ +# ref: https://github.com/amannn/action-semantic-pull-request +# ensure PR title is in semantic format + +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml new file mode 100644 index 0000000000000..7b5627d452796 --- /dev/null +++ b/.github/workflows/logging_percentage_check.yml @@ -0,0 +1,55 @@ +name: Log Lines Percentage Check + +on: + pull_request: + branches: + - develop + +jobs: + log_lines_check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Check for log lines and calculate percentage + run: | + # Define the log line pattern + LOG_LINE_PATTERN="Rails\.logger" + + # Get the list of changed files in the pull request + CHANGED_FILES=$(git diff --name-only) + + # Initialize a flag to track if any files have insufficient log lines + INSUFFICIENT_LOGS=0 + + for file in $CHANGED_FILES; do + if [[ $file =~ \.rb$ && ! $file =~ _spec\.rb$ ]]; then + # Count the total number of lines in the file + total_lines=$(wc -l < "$file") + + # Count the number of log lines in the file + log_lines=$(grep -c "$LOG_LINE_PATTERN" "$file") + + # Calculate the percentage of log lines + if [ "$total_lines" -gt 0 ]; then + percentage=$(awk "BEGIN { pc=100*${log_lines}/${total_lines}; i=int(pc); print (pc-i<0.5)?i:i+1 }") + else + percentage=0 + fi + + # Check if the percentage is less than 5% + if [ "$percentage" -lt 5 ]; then + echo "Error: Log lines percentage is less than 5% ($percentage%) in $file. Please add more log lines using Rails.logger statements." + INSUFFICIENT_LOGS=1 + else + echo "Log lines percentage is $percentage% in $file. Code looks good!" + fi + fi + done + + # If any files have insufficient log lines, fail the action + if [ "$INSUFFICIENT_LOGS" -eq 1 ]; then + exit 1 + fi diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index b21ecd8246f7a..7a32f3fd15aaa 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -18,11 +18,12 @@ jobs: runs-on: ubuntu-20.04 services: postgres: - image: postgres:10.8 + image: postgres:15.3 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: "" POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust ports: - 5432:5432 # needed because the postgres container does not provide a healthcheck @@ -52,6 +53,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version: 20 + cache: yarn - name: yarn run: yarn install diff --git a/.github/workflows/run_response_bot_spec.yml b/.github/workflows/run_response_bot_spec.yml new file mode 100644 index 0000000000000..6fd6a7b22a876 --- /dev/null +++ b/.github/workflows/run_response_bot_spec.yml @@ -0,0 +1,84 @@ +# # +# # This workflow will run specs related to response bot +# # This can only be activated in installations Where vector extension is available. +# # + +name: Run Response Bot spec +on: + push: + branches: + - develop + - master + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-20.04 + services: + postgres: + image: ankane/pgvector + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: "" + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + # tmpfs makes DB faster by using RAM + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + + - uses: actions/setup-node@v3 + with: + node-version: 20 + cache: yarn + + - name: yarn + run: yarn install + + - name: Create database + run: bundle exec rake db:create + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: Enable ResponseBotService in installation + run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation" + + # Run Response Bot specs + - name: Run backend tests + run: | + bundle exec rspec \ + spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \ + spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \ + spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \ + --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/size-limit.yml b/.github/workflows/size-limit.yml new file mode 100644 index 0000000000000..01d72339d62fc --- /dev/null +++ b/.github/workflows/size-limit.yml @@ -0,0 +1,41 @@ +name: Run Size Limit Check + +on: + pull_request: + branches: + - develop + +jobs: + test: + runs-on: ubuntu-20.04 + + 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: + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + + - uses: actions/setup-node@v3 + with: + node-version: 20 + cache: 'yarn' + + - name: yarn + run: yarn install + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Run asset compile + run: bundle exec rake assets:precompile + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Size Check + run: yarn run size diff --git a/.gitignore b/.gitignore index 82f4a4df558ad..028a97f09564c 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,5 @@ yalc.lock /yarn-error.log yarn-debug.log* .yarn-integrity + +/storybook-static \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 7cc2069986ba9..6f7af3750c3be 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.5.1 +20.5.1 \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index 9143085511107..484e424ed4b18 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,15 +8,11 @@ Layout/LineLength: Max: 150 Metrics/ClassLength: - Max: 125 + Max: 175 Exclude: - - 'app/models/conversation.rb' - - 'app/models/contact.rb' - - 'app/mailers/conversation_reply_mailer.rb' - '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: @@ -62,6 +58,7 @@ Metrics/BlockLength: Metrics/ModuleLength: Exclude: - lib/seeders/message_seeder.rb + - spec/support/slack_stubs.rb Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' @@ -73,7 +70,7 @@ Rails/ApplicationController: - 'app/controllers/survey/responses_controller.rb' Rails/FindEach: Enabled: true - Include: + Include: - 'app/**/*.rb' Rails/CompactBlank: Enabled: false @@ -87,6 +84,7 @@ Style/ClassAndModuleChildren: EnforcedStyle: compact Exclude: - 'config/application.rb' + - 'config/initializers/monkey_patches/*' Style/MapToHash: Enabled: false Style/HashSyntax: @@ -188,7 +186,6 @@ RSpec/IndexedLet: RSpec/NamedSubject: Enabled: false - # we should bring this down RSpec/MultipleMemoizedHelpers: Max: 14 diff --git a/.storybook/main.js b/.storybook/main.js index b749e262d81ba..cb32634c2ff50 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -23,6 +23,18 @@ module.exports = { }, '@storybook/addon-links', '@storybook/addon-essentials', + { + /** + * Fix Storybook issue with PostCSS@8 + * @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085 + */ + name: '@storybook/addon-postcss', + options: { + postcssLoaderOptions: { + implementation: require('postcss'), + }, + }, + }, ], webpackFinal: config => { const newConfig = { @@ -35,7 +47,7 @@ module.exports = { newConfig.module.rules.push({ test: /\.scss$/, - use: ['style-loader', 'css-loader', 'sass-loader'], + use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'], include: path.resolve(__dirname, '../app/javascript'), }); diff --git a/.storybook/preview.js b/.storybook/preview.js index e553e514a49eb..3f98c2cd87d36 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -4,10 +4,12 @@ import Vuex from 'vuex'; import VueI18n from 'vue-i18n'; import Vuelidate from 'vuelidate'; import Multiselect from 'vue-multiselect'; +import VueDOMPurifyHTML from 'vue-dompurify-html'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon'; import WootUiKit from '../app/javascript/dashboard/components'; import i18n from '../app/javascript/dashboard/i18n'; +import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer'; import '../app/javascript/dashboard/assets/scss/storybook.scss'; @@ -15,6 +17,8 @@ Vue.use(VueI18n); Vue.use(Vuelidate); Vue.use(WootUiKit); Vue.use(Vuex); +Vue.use(VueDOMPurifyHTML, domPurifyConfig); + Vue.component('multiselect', Multiselect); Vue.component('fluent-icon', FluentIcon); diff --git a/Gemfile b/Gemfile index 4ccd70c6346fe..a760de24da014 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ ruby '3.2.2' ##-- base gems for rails --## gem 'rack-cors', require: 'rack/cors' -gem 'rails', '~> 7' +gem 'rails', '~> 7.0.8.0' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -15,7 +15,7 @@ gem 'browser' gem 'hashie' gem 'jbuilder' gem 'kaminari' -gem 'responders' +gem 'responders', '>= 3.1.1' gem 'rest-client' gem 'telephone_number' gem 'time_diff' @@ -34,7 +34,7 @@ gem 'commonmarker' # Validate Data against JSON Schema gem 'json_schemer' # Rack middleware for blocking & throttling abusive requests -gem 'rack-attack' +gem 'rack-attack', '>= 6.7.0' # a utility tool for streaming, flexible and safe downloading of remote files gem 'down' # authentication type to fetch and send mail over oauth2.0 @@ -67,14 +67,16 @@ gem 'webpacker' gem 'barnes' ##--- gems for authentication & authorization ---## -gem 'devise' +gem 'devise', '>= 4.9.3' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth' # authorization gem 'jwt' gem 'pundit' # super admin -gem 'administrate' +gem 'administrate', '>= 0.19.0' +gem 'administrate-field-active_storage' +gem 'administrate-field-belongs_to_search' ##--- gems for pubsub service ---## # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ @@ -90,7 +92,7 @@ gem 'twitty', '~> 0.1.5' # facebook client gem 'koala' # slack client -gem 'slack-ruby-client', '~> 2.0.0' +gem 'slack-ruby-client', '~> 2.2.0' # for dialogflow integrations gem 'google-cloud-dialogflow-v2' gem 'grpc' @@ -107,16 +109,15 @@ 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-rails', '>= 5.12.0', require: false gem 'sentry-ruby', require: false -gem 'sentry-sidekiq', require: false +gem 'sentry-sidekiq', '>= 5.12.0', require: false ##-- background job processing --## -gem 'sidekiq' +gem 'sidekiq', '>= 7.1.3' gem 'sidekiq_alive' - # We want cron jobs -gem 'sidekiq-cron' +gem 'sidekiq-cron', '>= 1.10.1' ##-- Push notification service --## gem 'fcm' @@ -154,18 +155,28 @@ gem 'stripe' gem 'faker' # Include logrange conditionally in intializer using env variable -gem 'lograge', '~> 0.12.0', require: false +gem 'lograge', '~> 0.14.0', require: false # worked with microsoft refresh token gem 'omniauth-oauth2' -gem 'audited', '~> 5.3' +gem 'audited', '~> 5.4', '>= 5.4.0' # need for google auth gem 'omniauth' gem 'omniauth-google-oauth2' gem 'omniauth-rails_csrf_protection', '~> 1.0' +## Gems for reponse bot +# adds cosine similarity to postgres using vector extension +gem 'neighbor' +gem 'pgvector' +# Convert Website HTML to Markdown +gem 'reverse_markdown' + +# Sentiment analysis +gem 'informers' + ### Gems required only in specific deployment environments ### ############################################################## @@ -180,7 +191,7 @@ group :development do gem 'bullet' gem 'letter_opener' gem 'scss_lint', require: false - gem 'web-console' + gem 'web-console', '>= 4.2.1' # used in swagger build gem 'json_refs' @@ -189,7 +200,7 @@ group :development do gem 'squasher' # profiling - gem 'rack-mini-profiler', require: false + gem 'rack-mini-profiler', '>= 3.1.1', require: false gem 'stackprof' end @@ -212,6 +223,7 @@ group :development, :test do gem 'bundle-audit', require: false gem 'byebug', platform: :mri gem 'climate_control' + gem 'debug', '~> 1.8' gem 'factory_bot_rails' gem 'listen' gem 'mock_redis' diff --git a/Gemfile.lock b/Gemfile.lock index f789ee88750d4..896891185796d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,70 +33,70 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.0.4.3) - actionpack (= 7.0.4.3) - activesupport (= 7.0.4.3) + actioncable (7.0.8) + actionpack (= 7.0.8) + activesupport (= 7.0.8) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.4.3) - actionpack (= 7.0.4.3) - activejob (= 7.0.4.3) - activerecord (= 7.0.4.3) - activestorage (= 7.0.4.3) - activesupport (= 7.0.4.3) + actionmailbox (7.0.8) + actionpack (= 7.0.8) + activejob (= 7.0.8) + activerecord (= 7.0.8) + activestorage (= 7.0.8) + activesupport (= 7.0.8) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.4.3) - actionpack (= 7.0.4.3) - actionview (= 7.0.4.3) - activejob (= 7.0.4.3) - activesupport (= 7.0.4.3) + actionmailer (7.0.8) + actionpack (= 7.0.8) + actionview (= 7.0.8) + activejob (= 7.0.8) + activesupport (= 7.0.8) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.4.3) - actionview (= 7.0.4.3) - activesupport (= 7.0.4.3) - rack (~> 2.0, >= 2.2.0) + actionpack (7.0.8) + actionview (= 7.0.8) + activesupport (= 7.0.8) + rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.4.3) - actionpack (= 7.0.4.3) - activerecord (= 7.0.4.3) - activestorage (= 7.0.4.3) - activesupport (= 7.0.4.3) + actiontext (7.0.8) + actionpack (= 7.0.8) + activerecord (= 7.0.8) + activestorage (= 7.0.8) + activesupport (= 7.0.8) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.4.3) - activesupport (= 7.0.4.3) + actionview (7.0.8) + activesupport (= 7.0.8) 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 (7.0.4.3) - activesupport (= 7.0.4.3) + activejob (7.0.8) + activesupport (= 7.0.8) globalid (>= 0.3.6) - activemodel (7.0.4.3) - activesupport (= 7.0.4.3) - activerecord (7.0.4.3) - activemodel (= 7.0.4.3) - activesupport (= 7.0.4.3) + activemodel (7.0.8) + activesupport (= 7.0.8) + activerecord (7.0.8) + activemodel (= 7.0.8) + activesupport (= 7.0.8) activerecord-import (1.4.1) activerecord (>= 4.2) - activestorage (7.0.4.3) - actionpack (= 7.0.4.3) - activejob (= 7.0.4.3) - activerecord (= 7.0.4.3) - activesupport (= 7.0.4.3) + activestorage (7.0.8) + actionpack (= 7.0.8) + activejob (= 7.0.8) + activerecord (= 7.0.8) + activesupport (= 7.0.8) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.4.3) + activesupport (7.0.8) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -105,7 +105,7 @@ GEM activerecord (>= 6.0, < 7.1) addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) - administrate (0.18.0) + administrate (0.19.0) actionpack (>= 5.0) actionview (>= 5.0) activerecord (>= 5.0) @@ -113,13 +113,21 @@ GEM kaminari (>= 1.0) sassc-rails (~> 2.1) selectize-rails (~> 0.6) + administrate-field-active_storage (0.4.2) + administrate (>= 0.2.2) + rails (>= 7.0) + administrate-field-belongs_to_search (0.8.0) + administrate (>= 0.3, < 1.0) + jbuilder (~> 2) + rails (>= 4.2, < 7.1) + selectize-rails (~> 0.6) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) ast (2.4.2) attr_extras (7.1.0) - audited (5.3.3) - activerecord (>= 5.0, < 7.1) + audited (5.4.0) + activerecord (>= 5.0, < 7.2) request_store (~> 1.2) aws-eventstream (1.2.0) aws-partitions (1.760.0) @@ -140,8 +148,9 @@ GEM barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) - bcrypt (3.1.18) + bcrypt (3.1.19) bindex (0.8.1) + blingfire (0.1.8) bootsnap (1.16.0) msgpack (~> 1.2) brakeman (5.4.1) @@ -158,9 +167,9 @@ GEM byebug (11.1.3) climate_control (1.2.0) coderay (1.1.3) - commonmarker (0.23.9) + commonmarker (0.23.10) concurrent-ruby (1.2.2) - connection_pool (2.4.0) + connection_pool (2.4.1) crack (0.4.5) rexml crass (1.0.6) @@ -180,8 +189,11 @@ GEM libddwaf (~> 1.8.2.0.0) msgpack debase-ruby_core_source (3.2.0) + debug (1.8.0) + irb (>= 1.5.0) + reline (>= 0.3.1) declarative (0.0.20) - devise (4.9.2) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -261,9 +273,9 @@ GEM googleauth (~> 1.0) grpc (~> 1.36) geocoder (1.8.1) - gli (2.21.0) - globalid (1.1.0) - activesupport (>= 5.0) + gli (2.21.1) + globalid (1.2.1) + activesupport (>= 6.1) gmail_xoauth (0.4.2) oauth (>= 0.3.6) google-apis-core (0.11.0) @@ -353,16 +365,23 @@ GEM mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.13.0) + i18n (1.14.1) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) + informers (0.2.0) + blingfire (>= 0.1.7) + numo-narray + onnxruntime (>= 0.5.1) + io-console (0.6.0) + irb (1.7.2) + reline (>= 0.3.6) jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) - jquery-rails (4.5.1) + jquery-rails (4.6.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) @@ -416,14 +435,14 @@ GEM ffi-compiler (~> 1.0) rake (~> 13.0) localhost (1.1.10) - lograge (0.12.0) + lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.20.0) + loofah (2.21.4) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) mail (2.8.1) mini_mime (>= 0.1.1) net-imap @@ -437,18 +456,20 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2023.0218.1) mini_magick (4.12.0) - mini_mime (1.1.2) - mini_portile2 (2.8.2) - minitest (5.18.0) + mini_mime (1.1.5) + mini_portile2 (2.8.4) + minitest (5.20.0) mock_redis (0.36.0) ruby2_keywords msgpack (1.7.0) multi_json (1.15.0) multi_xml (0.6.0) multipart-post (2.3.0) + neighbor (0.2.3) + activerecord (>= 5.2) net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.3.4) + net-imap (0.3.7) date net-protocol net-pop (0.1.2) @@ -463,15 +484,16 @@ GEM sidekiq newrelic_rpm (8.16.0) nio4r (2.5.9) - nokogiri (1.14.3) - mini_portile2 (~> 2.8.0) + nokogiri (1.15.4) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.14.3-arm64-darwin) + nokogiri (1.15.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.14.3-x86_64-darwin) + nokogiri (1.15.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.14.3-x86_64-linux) + nokogiri (1.15.4-x86_64-linux) racc (~> 1.4) + numo-narray (0.9.2.1) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) snaky_hash (~> 2.0) @@ -500,6 +522,14 @@ GEM omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) omniauth (~> 2.0) + onnxruntime (0.7.6) + ffi + onnxruntime (0.7.6-arm64-darwin) + ffi + onnxruntime (0.7.6-x86_64-darwin) + ffi + onnxruntime (0.7.6-x86_64-linux) + ffi openssl (3.1.0) orm_adapter (0.5.0) os (1.1.4) @@ -510,6 +540,7 @@ GEM pg_search (2.3.6) activerecord (>= 5.2) activesupport (>= 5.2) + pgvector (0.1.1) procore-sift (1.0.0) activerecord (>= 6.1) pry (0.14.2) @@ -518,18 +549,18 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (5.0.1) - puma (6.2.2) + puma (6.3.1) nio4r (~> 2.0) pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.6.2) - rack (2.2.7) - rack-attack (6.6.1) - rack (>= 1.0, < 3) + racc (1.7.1) + rack (2.2.8) + rack-attack (6.7.0) + rack (>= 1.0, < 4) rack-cors (2.0.1) rack (>= 2.0.0) - rack-mini-profiler (3.1.0) + rack-mini-profiler (3.1.1) rack (>= 1.2.0) rack-protection (3.0.6) rack @@ -538,28 +569,30 @@ GEM rack-test (2.1.0) rack (>= 1.3) rack-timeout (0.6.3) - rails (7.0.4.3) - actioncable (= 7.0.4.3) - actionmailbox (= 7.0.4.3) - actionmailer (= 7.0.4.3) - actionpack (= 7.0.4.3) - actiontext (= 7.0.4.3) - actionview (= 7.0.4.3) - activejob (= 7.0.4.3) - activemodel (= 7.0.4.3) - activerecord (= 7.0.4.3) - activestorage (= 7.0.4.3) - activesupport (= 7.0.4.3) + rails (7.0.8) + actioncable (= 7.0.8) + actionmailbox (= 7.0.8) + actionmailer (= 7.0.8) + actionpack (= 7.0.8) + actiontext (= 7.0.8) + actionview (= 7.0.8) + activejob (= 7.0.8) + activemodel (= 7.0.8) + activerecord (= 7.0.8) + activestorage (= 7.0.8) + activesupport (= 7.0.8) bundler (>= 1.15.0) - railties (= 7.0.4.3) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + railties (= 7.0.8) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) - railties (7.0.4.3) - actionpack (= 7.0.4.3) - activesupport (= 7.0.4.3) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + railties (7.0.8) + actionpack (= 7.0.8) + activesupport (= 7.0.8) method_source rake (>= 12.2) thor (~> 1.0) @@ -571,18 +604,20 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.14.1) + redis-client (0.17.0) connection_pool redis-namespace (1.10.0) redis (>= 4) regexp_parser (2.8.0) + reline (0.3.6) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) request_store (1.5.1) rack (>= 1.4) - responders (3.1.0) + responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) rest-client (2.1.0) @@ -591,6 +626,8 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) retriable (3.1.2) + reverse_markdown (2.1.1) + nokogiri rexml (3.2.5) rspec-core (3.12.2) rspec-support (~> 3.12.0) @@ -666,23 +703,23 @@ GEM activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (5.9.0) + sentry-rails (5.12.0) railties (>= 5.0) - sentry-ruby (~> 5.9.0) - sentry-ruby (5.9.0) + sentry-ruby (~> 5.12.0) + sentry-ruby (5.12.0) concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.9.0) - sentry-ruby (~> 5.9.0) + sentry-sidekiq (5.12.0) + sentry-ruby (~> 5.12.0) sidekiq (>= 3.0) sexp_processor (4.17.0) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.1.0) + sidekiq (7.1.6) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) redis-client (>= 0.14.0) - sidekiq-cron (1.10.0) + sidekiq-cron (1.10.1) fugit (~> 1.8) globalid (>= 1.0.1) sidekiq (>= 6) @@ -700,13 +737,12 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - slack-ruby-client (2.0.0) + slack-ruby-client (2.2.0) faraday (>= 2.0) faraday-mashify faraday-multipart gli hashie - websocket-driver snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) @@ -727,12 +763,12 @@ GEM stripe (8.5.0) telephone_number (1.4.20) test-prof (1.2.1) - thor (1.2.1) - tilt (2.1.0) + thor (1.2.2) + tilt (2.2.0) time_diff (0.3.0) activesupport i18n - timeout (0.3.2) + timeout (0.4.0) trailblazer-option (0.1.2) twilio-ruby (5.77.0) faraday (>= 0.9, < 3.0) @@ -759,7 +795,7 @@ GEM version_gem (1.1.2) warden (1.2.9) rack (>= 2.0.9) - web-console (4.2.0) + web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) @@ -778,14 +814,14 @@ GEM railties (>= 5.2) semantic_range (>= 2.3.0) webrick (1.8.1) - websocket-driver (0.7.5) + websocket-driver (0.7.6) 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.6.8) + zeitwerk (2.6.12) PLATFORMS arm64-darwin-20 @@ -802,10 +838,12 @@ DEPENDENCIES active_record_query_trace activerecord-import acts-as-taggable-on - administrate + administrate (>= 0.19.0) + administrate-field-active_storage + administrate-field-belongs_to_search annotate attr_extras - audited (~> 5.3) + audited (~> 5.4, >= 5.4.0) aws-sdk-s3 azure-storage-blob! barnes @@ -821,7 +859,8 @@ DEPENDENCIES cypress-on-rails database_cleaner ddtrace - devise + debug (~> 1.8) + devise (>= 4.9.3) devise-secure_password! devise_token_auth dotenv-rails @@ -846,6 +885,7 @@ DEPENDENCIES hashie html2text! image_processing + informers jbuilder json_refs json_schemer @@ -857,9 +897,10 @@ DEPENDENCIES liquid listen localhost - lograge (~> 0.12.0) + lograge (~> 0.14.0) maxminddb mock_redis + neighbor newrelic-sidekiq-metrics newrelic_rpm omniauth @@ -868,19 +909,21 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 1.0) pg pg_search + pgvector procore-sift pry-rails puma pundit - rack-attack + rack-attack (>= 6.7.0) rack-cors - rack-mini-profiler + rack-mini-profiler (>= 3.1.1) rack-timeout - rails (~> 7) + rails (~> 7.0.8.0) redis redis-namespace - responders + responders (>= 3.1.1) rest-client + reverse_markdown rspec-rails rspec_junit_formatter rubocop @@ -890,15 +933,15 @@ DEPENDENCIES scout_apm scss_lint seed_dump - sentry-rails + sentry-rails (>= 5.12.0) sentry-ruby - sentry-sidekiq + sentry-sidekiq (>= 5.12.0) shoulda-matchers - sidekiq - sidekiq-cron + sidekiq (>= 7.1.3) + sidekiq-cron (>= 1.10.1) sidekiq_alive simplecov (= 0.17.1) - slack-ruby-client (~> 2.0.0) + slack-ruby-client (~> 2.2.0) spring spring-watcher-listen squasher @@ -912,7 +955,7 @@ DEPENDENCIES tzinfo-data uglifier valid_email2 - web-console + web-console (>= 4.2.1) web-push webmock webpacker @@ -923,4 +966,4 @@ RUBY VERSION ruby 3.2.2p53 BUNDLED WITH - 2.4.10 + 2.4.6 diff --git a/Makefile b/Makefile index 499d0ab6b54e0..16eb807185541 100644 --- a/Makefile +++ b/Makefile @@ -30,9 +30,23 @@ burn: bundle && yarn run: + @if [ -f ./.overmind.sock ]; then \ + echo "Overmind is already running. Use 'make force_run' to start a new instance."; \ + else \ + overmind start -f Procfile.dev; \ + fi + +force_run: + rm -f ./.overmind.sock overmind start -f Procfile.dev +debug: + overmind connect backend + +debug_worker: + overmind connect worker + docker: docker build -t $(APP_NAME) -f ./docker/Dockerfile . -.PHONY: setup db_create db_migrate db_seed db console server burn docker run +.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker diff --git a/VERSION_CW b/VERSION_CW index d76bd2ba3eff8..fd2a01863fdd3 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -2.17.0 +3.1.0 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 276cbf9e2858c..e70b4523ae7ff 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -2.3.0 +2.6.0 diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 38da59e094577..2cc9549bcf688 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,5 +1,6 @@ //= link_tree ../images //= link administrate/application.css //= link administrate/application.js +//= link administrate-field-active_storage/application.css //= link dashboardChart.js //= link secretField.js diff --git a/app/assets/javascripts/dashboardChart.js b/app/assets/javascripts/dashboardChart.js index 6bfe56bda2fc0..46f278b80783c 100644 --- a/app/assets/javascripts/dashboardChart.js +++ b/app/assets/javascripts/dashboardChart.js @@ -11,7 +11,7 @@ function prepareData(data) { function getChartOptions() { var fontFamily = - 'Inter,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; + 'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; return { responsive: true, legend: { labels: { fontFamily } }, diff --git a/app/assets/stylesheets/administrate/application.scss b/app/assets/stylesheets/administrate/application.scss index 86c5254ec499f..b8c5df794bd22 100644 --- a/app/assets/stylesheets/administrate/application.scss +++ b/app/assets/stylesheets/administrate/application.scss @@ -29,3 +29,5 @@ @import 'components/pagination'; @import 'components/search'; @import 'components/reports'; + +@import 'custom_styles'; diff --git a/app/assets/stylesheets/administrate/base/_forms.scss b/app/assets/stylesheets/administrate/base/_forms.scss index bf014a746cc6c..dcf535b58fe69 100644 --- a/app/assets/stylesheets/administrate/base/_forms.scss +++ b/app/assets/stylesheets/administrate/base/_forms.scss @@ -24,14 +24,6 @@ select { font-size: $base-font-size; } -input, -select, -textarea { - display: block; - font-family: $base-font-family; - font-size: 16px; -} - [type="color"], [type="date"], [type="datetime-local"], @@ -51,6 +43,7 @@ textarea { background-color: $white; border: $base-border; border-radius: $base-border-radius; + font-family: $base-font-family; padding: 0.5em; transition: border-color $base-duration $base-timing; width: 100%; diff --git a/app/assets/stylesheets/administrate/components/_attributes.scss b/app/assets/stylesheets/administrate/components/_attributes.scss index 713d9f523a8bd..2b2936650a1dd 100644 --- a/app/assets/stylesheets/administrate/components/_attributes.scss +++ b/app/assets/stylesheets/administrate/components/_attributes.scss @@ -4,8 +4,8 @@ float: left; margin-bottom: $base-spacing; margin-top: 0.25em; - text-align: right; - width: calc(15% - 1rem); + text-align: left; + width: calc(16% - 1rem); } .preserve-whitespace { @@ -17,7 +17,7 @@ float: left; margin-bottom: $base-spacing; margin-left: 2rem; - width: calc(85% - 1rem); + width: calc(84% - 1rem); } .attribute--nested { diff --git a/app/assets/stylesheets/administrate/components/_buttons.scss b/app/assets/stylesheets/administrate/components/_buttons.scss index 3e021e6584f56..7b2f6204515d4 100644 --- a/app/assets/stylesheets/administrate/components/_buttons.scss +++ b/app/assets/stylesheets/administrate/components/_buttons.scss @@ -10,7 +10,7 @@ input[type="submit"], color: $white; cursor: pointer; display: inline-block; - font-size: $font-size-default; + font-size: $font-size-small; -webkit-font-smoothing: antialiased; font-weight: $font-weight-medium; line-height: 1; diff --git a/app/assets/stylesheets/administrate/components/_main-content.scss b/app/assets/stylesheets/administrate/components/_main-content.scss index d03229828ae95..590bb098565c2 100644 --- a/app/assets/stylesheets/administrate/components/_main-content.scss +++ b/app/assets/stylesheets/administrate/components/_main-content.scss @@ -1,13 +1,18 @@ .main-content { font-size: $font-size-default; - left: 23rem; + left: 21rem; position: absolute; right: 0; top: 0; } .main-content__body { + font-size: $font-size-small; padding: $space-two; + + table { + font-size: $font-size-small; + } } .main-content__header { @@ -20,7 +25,7 @@ } .main-content__page-title { - font-size: $font-size-large; + font-size: $font-size-medium; font-weight: $font-weight-medium; margin-right: auto; } diff --git a/app/assets/stylesheets/administrate/components/_navigation.scss b/app/assets/stylesheets/administrate/components/_navigation.scss index 1e7e35d257cd2..4ffc421903a9f 100644 --- a/app/assets/stylesheets/administrate/components/_navigation.scss +++ b/app/assets/stylesheets/administrate/components/_navigation.scss @@ -1,7 +1,12 @@ .logo-brand { margin-bottom: $space-normal; padding: $space-normal $space-smaller $space-small; - text-align: center; + text-align: left; + + img { + margin-bottom: $space-smaller; + max-height: 3rem; + } } .navigation { @@ -19,12 +24,13 @@ padding: $space-normal; position: fixed; top: 0; - width: 23rem; + width: 21rem; z-index: 1023; li { align-items: center; display: flex; + font-size: $font-size-small; a { color: $color-gray; @@ -35,6 +41,10 @@ min-width: $space-medium; } } + + hr { + margin: $space-slab; + } } .navigation__link { @@ -43,7 +53,7 @@ display: block; line-height: 1; margin-bottom: $space-smaller; - padding: $space-one; + padding: $space-small; &:hover { color: $blue; diff --git a/app/assets/stylesheets/administrate/custom_styles.scss b/app/assets/stylesheets/administrate/custom_styles.scss new file mode 100644 index 0000000000000..859a356895d35 --- /dev/null +++ b/app/assets/stylesheets/administrate/custom_styles.scss @@ -0,0 +1,24 @@ +// custom styles for the dashboard + +.feature-cell { + background: $color-extra-light-blue; + border-radius: 10px; + float: left; + margin-left: 8px; + margin-top: 6px; + padding: 4px 12px; + + .icon-container { + margin-right: 4px; + + } + + .value-container { + margin-left: 6px; + } +} + + +.feature-container { + max-width: 100rem; +} diff --git a/app/assets/stylesheets/administrate/library/_variables.scss b/app/assets/stylesheets/administrate/library/_variables.scss index 3fdfcfd8ddd60..08642855603a2 100644 --- a/app/assets/stylesheets/administrate/library/_variables.scss +++ b/app/assets/stylesheets/administrate/library/_variables.scss @@ -1,10 +1,10 @@ // Typography -$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", +$base-font-family: PlusJakarta, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif !default; $heading-font-family: $base-font-family !default; -$base-font-size: 10px !default; +$base-font-size: 14px !default; $base-line-height: 1.5 !default; $heading-line-height: 1.2 !default; diff --git a/app/assets/stylesheets/administrate/utilities/_variables.scss b/app/assets/stylesheets/administrate/utilities/_variables.scss index db8d1a302ed4e..818f96e4c89dd 100644 --- a/app/assets/stylesheets/administrate/utilities/_variables.scss +++ b/app/assets/stylesheets/administrate/utilities/_variables.scss @@ -78,11 +78,6 @@ $conv-header-height: 4rem; $inbox-thumb-size: 4.8rem; -// Spinner -$spinkit-spinner-color: $color-white !default; -$spinkit-spinner-margin: 0 0 0 1.6rem !default; -$spinkit-size: 1.6rem !default; - // Snackbar default $woot-snackbar-bg: #323232; $woot-snackbar-button: #ffeb3b; diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index e0343c0a01f5f..3efb184b96225 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -93,6 +93,9 @@ def message_params message_type: @message_type, content: response.content, source_id: response.identifier, + content_attributes: { + in_reply_to_external_id: response.in_reply_to_external_id + }, sender: @outgoing_echo ? nil : @contact_inbox.contact } end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index aca06cd04d2e5..af312bf2e9355 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -48,17 +48,22 @@ def process_attachments def process_emails return unless @conversation.inbox&.inbox_type == 'Email' - cc_emails = [] - cc_emails = @params[:cc_emails].gsub(/\s+/, '').split(',') if @params[:cc_emails].present? + cc_emails = process_email_string(@params[:cc_emails]) + bcc_emails = process_email_string(@params[:bcc_emails]) + to_emails = process_email_string(@params[:to_emails]) - bcc_emails = [] - bcc_emails = @params[:bcc_emails].gsub(/\s+/, '').split(',') if @params[:bcc_emails].present? - - all_email_addresses = cc_emails + bcc_emails + all_email_addresses = cc_emails + bcc_emails + to_emails validate_email_addresses(all_email_addresses) @message.content_attributes[:cc_emails] = cc_emails @message.content_attributes[:bcc_emails] = bcc_emails + @message.content_attributes[:to_emails] = to_emails + end + + def process_email_string(email_string) + return [] if email_string.blank? + + email_string.gsub(/\s+/, '').split(',') end def validate_email_addresses(all_emails) diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index b786ba25c0469..1442c5799f3ea 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -15,12 +15,15 @@ def initialize(account, params) end def timeseries - send(params[:metric]) + return send(params[:metric]) if metric_valid? + + Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}" + {} end # For backward compatible with old report def build - if %w[avg_first_response_time avg_resolution_time].include?(params[:metric]) + if %w[avg_first_response_time avg_resolution_time reply_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 @@ -33,18 +36,19 @@ def build def summary { - conversations_count: conversations_count.values.sum, - incoming_messages_count: incoming_messages_count.values.sum, - outgoing_messages_count: outgoing_messages_count.values.sum, + conversations_count: conversations.count, + incoming_messages_count: incoming_messages.count, + outgoing_messages_count: outgoing_messages.count, avg_first_response_time: avg_first_response_time_summary, avg_resolution_time: avg_resolution_time_summary, - resolutions_count: resolutions_count.values.sum + resolutions_count: resolutions.count, + reply_time: reply_time_summary } end def conversation_metrics if params[:type].equal?(:account) - conversations + live_conversations else agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse end @@ -52,6 +56,16 @@ def conversation_metrics private + def metric_valid? + %w[conversations_count + incoming_messages_count + outgoing_messages_count + avg_first_response_time + avg_resolution_time reply_time + resolutions_count + reply_time].include?(params[:metric]) + end + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -89,17 +103,16 @@ def agent_metrics email: @user.email, thumbnail: @user.avatar_url, availability: account_user.availability_status, - metric: conversations + metric: live_conversations } end end - def conversations + def live_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 + unattended: @open_conversations.unattended.count } metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) metric diff --git a/app/controllers/api/v1/accounts/actions/contact_merges_controller.rb b/app/controllers/api/v1/accounts/actions/contact_merges_controller.rb index 9daee7b047884..ce38fd9ab08da 100644 --- a/app/controllers/api/v1/accounts/actions/contact_merges_controller.rb +++ b/app/controllers/api/v1/accounts/actions/contact_merges_controller.rb @@ -9,7 +9,6 @@ def create mergee_contact: @mergee_contact ) contact_merge_action.perform - render json: @base_contact end private diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index a1e348723ee47..7762cc476a615 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization - before_action :fetch_article, except: [:index, :create, :attach_file, :reorder] + before_action :fetch_article, except: [:index, :create, :reorder] before_action :set_current_page, only: [:index] def index @@ -36,17 +36,6 @@ 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 - def reorder Article.update_positions(params[:positions_hash]) head :ok diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 3431af9c3cb0f..3d894808dcf12 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -20,16 +20,6 @@ def create @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 update ActiveRecord::Base.transaction do automation_rule_update diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 3575e28e6d114..6e119ca3da8f0 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -14,8 +14,18 @@ def register_facebook_page @facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel) set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) - rescue StandardError => e - ChatwootExceptionTracker.new(e).capture_exception + end + rescue StandardError => e + ChatwootExceptionTracker.new(e).capture_exception + Rails.logger.error "Error in register_facebook_page: #{e.message}" + # Additional log statements + log_additional_info + end + + def log_additional_info + Rails.logger.debug do + "user_access_token: #{params[:user_access_token]} , page_access_token: #{params[:page_access_token]} , + page_id: #{params[:page_id]}, inbox_name: #{params[:inbox_name]}" end end @@ -30,6 +40,8 @@ def set_instagram_id(page_access_token, facebook_channel) instagram_id = response['instagram_business_account']['id'] facebook_channel.update(instagram_id: instagram_id) + rescue StandardError => e + Rails.logger.error "Error in set_instagram_id: #{e.message}" end # get params[:inbox_id], current_account. params[:omniauth_token] @@ -61,6 +73,7 @@ def update_fb_page(fb_page_id, access_token) fb_page&.reauthorized! rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception + Rails.logger.error "Error in update_fb_page: #{e.message}" end end @@ -77,7 +90,7 @@ def long_lived_token(omniauth_token) 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.error e + Rails.logger.error "Error in long_lived_token: #{e.message}" end def mark_already_existing_facebook_pages(data) 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 e6e5b63c3cbd8..d2d51baef171a 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -18,7 +18,11 @@ def authorize_request end def authenticate_twilio - client = Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token]) + client = if permitted_params[:api_key_sid].present? + Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid]) + else + Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token]) + end client.messages.list(limit: 1) end @@ -40,6 +44,7 @@ def build_inbox @twilio_channel = Current.account.twilio_sms.create!( account_sid: permitted_params[:account_sid], auth_token: permitted_params[:auth_token], + api_key_sid: permitted_params[:api_key_sid], messaging_service_sid: permitted_params[:messaging_service_sid].presence, phone_number: phone_number, medium: medium @@ -52,7 +57,7 @@ def build_inbox def permitted_params params.require(:twilio_channel).permit( - :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium + :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid ) end end diff --git a/app/controllers/api/v1/accounts/contact_inboxes_controller.rb b/app/controllers/api/v1/accounts/contact_inboxes_controller.rb new file mode 100644 index 0000000000000..ed7066895ebb0 --- /dev/null +++ b/app/controllers/api/v1/accounts/contact_inboxes_controller.rb @@ -0,0 +1,21 @@ +class Api::V1::Accounts::ContactInboxesController < Api::V1::Accounts::BaseController + before_action :ensure_inbox + + def filter + contact_inbox = @inbox.contact_inboxes.where(inbox_id: permitted_params[:inbox_id], source_id: permitted_params[:source_id]) + return head :not_found if contact_inbox.empty? + + @contact = contact_inbox.first.contact + end + + private + + def ensure_inbox + @inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) + authorize @inbox, :show? + end + + def permitted_params + params.permit(:inbox_id, :source_id) + end +end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 8afd5b6556f73..bec00be96d37c 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -4,6 +4,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController sort_on :name, internal_name: :order_on_name, type: :scope, scope_params: [:direction] sort_on :phone_number, type: :string sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction] + sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction] sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction] sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction] @@ -17,18 +18,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def index @contacts_count = resolved_contacts.count - @contacts = fetch_contacts_with_conversation_count(resolved_contacts) + @contacts = fetch_contacts(resolved_contacts) end def search render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return contacts = resolved_contacts.where( - 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', + 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search + OR contacts.additional_attributes->>\'company_name\' ILIKE :search', search: "%#{params[:q].strip}%" ) @contacts_count = contacts.count - @contacts = fetch_contacts_with_conversation_count(contacts) + @contacts = fetch_contacts(contacts) end def import @@ -42,6 +44,12 @@ def import head :ok end + def export + column_names = params['column_names'] + Account::ContactsExportJob.perform_later(Current.account.id, column_names) + head :ok, message: I18n.t('errors.contacts.export.success') + end + # returns online contacts def active contacts = Current.account.contacts.where(id: ::OnlineStatusTracker @@ -56,7 +64,7 @@ def filter result = ::Contacts::FilterService.new(params.permit!, current_user).perform contacts = result[:contacts] @contacts_count = result[:count] - @contacts = fetch_contacts_with_conversation_count(contacts) + @contacts = fetch_contacts(contacts) end def contactable_inboxes @@ -118,17 +126,14 @@ def set_current_page @current_page = params[:page] || 1 end - def fetch_contacts_with_conversation_count(contacts) - 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) + def fetch_contacts(contacts) + contacts_with_avatar = filtrate(contacts) + .includes([{ avatar_attachment: [:blob] }]) + .page(@current_page).per(RESULTS_PER_PAGE) - return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes + return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes - contacts_with_conversation_count + contacts_with_avatar end def build_contact_inbox diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb index da821a3e5e004..500c7772fb51c 100644 --- a/app/controllers/api/v1/accounts/conversations/base_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController - include EnsureCurrentAccountHelper before_action :conversation private diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index cd854721379d7..eb3187d54a264 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -60,7 +60,7 @@ def transcript end def toggle_status - if params[:status] + if params[:status].present? set_conversation_status @status = @conversation.save! else diff --git a/app/controllers/api/v1/accounts/custom_filters_controller.rb b/app/controllers/api/v1/accounts/custom_filters_controller.rb index 6cd70b07d9b22..f458c018f8c02 100644 --- a/app/controllers/api/v1/accounts/custom_filters_controller.rb +++ b/app/controllers/api/v1/accounts/custom_filters_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController + before_action :check_authorization before_action :fetch_custom_filters, except: [:create] before_action :fetch_custom_filter, only: [:show, :update, :destroy] DEFAULT_FILTER_TYPE = 'conversation'.freeze diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 70c3f2a239986..011faaf280085 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -63,7 +63,7 @@ def set_agent_bot end def destroy - ::DeleteObjectJob.perform_later(@inbox) if @inbox.present? + ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end @@ -124,7 +124,7 @@ 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, - :lock_to_single_conversation, :portal_id] + :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name] end def permitted_params(channel_attributes = []) diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb index b5571b2457da5..eaa18c2de1527 100644 --- a/app/controllers/api/v1/accounts/integrations/slack_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -1,27 +1,27 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController before_action :check_admin_authorization? - before_action :fetch_hook, only: [:update, :destroy] + before_action :fetch_hook, only: [:update, :destroy, :list_all_channels] + + def list_all_channels + @channels = channel_builder.fetch_channels + end def create - ActiveRecord::Base.transaction do - builder = Integrations::Slack::HookBuilder.new( - account: Current.account, - code: params[:code], - inbox_id: params[:inbox_id] - ) - @hook = builder.perform - create_chatwoot_slack_channel - end + hook_builder = Integrations::Slack::HookBuilder.new( + account: Current.account, + code: params[:code], + inbox_id: params[:inbox_id] + ) + @hook = hook_builder.perform end def update - create_chatwoot_slack_channel - render json: @hook + @hook = channel_builder.update(permitted_params[:reference_id]) + render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank? end def destroy @hook.destroy! - head :ok end @@ -31,11 +31,11 @@ def fetch_hook @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'slack') end - def create_chatwoot_slack_channel - channel = params[:channel] || 'customer-conversations' - builder = Integrations::Slack::ChannelBuilder.new( - hook: @hook, channel: channel - ) - builder.perform + def channel_builder + Integrations::Slack::ChannelBuilder.new(hook: @hook) + end + + def permitted_params + params.permit(:reference_id) end end diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb index 604e053f1fdde..5dcdd2023edda 100644 --- a/app/controllers/api/v1/accounts/macros_controller.rb +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -39,16 +39,6 @@ def destroy head :ok 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 execute ::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user) diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index fcc02c6ecb5b2..6d2a181f08dd2 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, :attach_file] + before_action :fetch_portal, except: [:index, :create] before_action :check_authorization before_action :set_current_page, only: [:index] @@ -53,16 +53,6 @@ def process_attached_logo @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 def fetch_portal diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb new file mode 100644 index 0000000000000..4c54938b71e11 --- /dev/null +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -0,0 +1,13 @@ +class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController + def create + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:attachment].tempfile, + filename: params[:attachment].original_filename, + content_type: params[:attachment].content_type + ) + file_blob.save! + + render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id } + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5af8a1fd14eab..ef0e0c7773ef8 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -37,6 +37,7 @@ def create end def cache_keys + expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes render json: { cache_keys: get_cache_keys }, status: :ok end diff --git a/app/controllers/api/v1/integrations/webhooks_controller.rb b/app/controllers/api/v1/integrations/webhooks_controller.rb index 94373c92e537f..283be3d775d62 100644 --- a/app/controllers/api/v1/integrations/webhooks_controller.rb +++ b/app/controllers/api/v1/integrations/webhooks_controller.rb @@ -1,7 +1,15 @@ class Api::V1::Integrations::WebhooksController < ApplicationController def create - builder = Integrations::Slack::IncomingMessageBuilder.new(params) + builder = Integrations::Slack::IncomingMessageBuilder.new(permitted_params) response = builder.perform render json: response end + + private + + # TODO: This is a temporary solution to permit all params for slack unfurling job. + # We should only permit the params that we use. Handle all the params based on events and send it to the respective services. + def permitted_params + params.permit! + end end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 2c72a0361e493..df0ef64ffbf59 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -1,5 +1,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController include Events::Types + before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes] def index @conversation = conversation @@ -27,6 +28,7 @@ def update_last_seen conversation.contact_last_seen_at = DateTime.now.utc conversation.save! + ::Conversations::MarkMessagesAsReadJob.perform_later(conversation) head :ok end @@ -41,8 +43,6 @@ def transcript end def toggle_typing - head :ok && return if conversation.nil? - case permitted_params[:typing_status] when 'on' trigger_typing_event(CONVERSATION_TYPING_ON) @@ -54,8 +54,6 @@ def toggle_typing end def toggle_status - return head :not_found if conversation.nil? - return head :forbidden unless @web_widget.end_conversation? unless conversation.resolved? @@ -81,6 +79,10 @@ def trigger_typing_event(event) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact) end + def render_not_found_if_empty + return head :not_found if conversation.nil? + end + def permitted_params params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], message: [:content, :referer_url, :timestamp, :echo_id], diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 76efb2c427236..306a5f46f28bd 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -17,7 +17,8 @@ def update @message.update!(submitted_email: contact_email) ContactIdentifyAction.new( contact: @contact, - params: { email: contact_email, name: contact_name } + params: { email: contact_email, name: contact_name }, + retain_original_contact_name: true ).perform else @message.update!(message_update_params[:message]) diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index eb781dbfebc2b..5d02e96c824a9 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -8,7 +8,7 @@ def current_account def ensure_current_account account = Account.find(params[:account_id]) - ensure_account_is_active?(account) + render_unauthorized('Account is suspended') and return unless account.active? if current_user account_accessible_for_user?(account) @@ -27,8 +27,4 @@ def account_accessible_for_user?(account) def account_accessible_for_bot?(account) render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) end - - def ensure_account_is_active?(account) - render_unauthorized('Account is suspended') unless account.active? - end end diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index 2f53fdc2b142d..ccab0090a44a7 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -9,11 +9,14 @@ module RequestExceptionHandler def handle_with_exception yield - rescue ActiveRecord::RecordNotFound + rescue ActiveRecord::RecordNotFound => e + log_handled_error(e) render_not_found_error('Resource could not be found') - rescue Pundit::NotAuthorizedError + rescue Pundit::NotAuthorizedError => e + log_handled_error(e) render_unauthorized('You are not authorized to do this action') rescue ActionController::ParameterMissing => e + log_handled_error(e) render_could_not_create_error(e.message) ensure # to address the thread variable leak issues in Puma/Thin webserver @@ -41,6 +44,7 @@ def render_internal_server_error(message) end def render_record_invalid(exception) + log_handled_error(exception) render json: { message: exception.record.errors.full_messages.join(', '), attributes: exception.record.errors.attribute_names @@ -48,6 +52,11 @@ def render_record_invalid(exception) end def render_error_response(exception) + log_handled_error(exception) render json: exception.to_hash, status: exception.http_status end + + def log_handled_error(exception) + logger.info("Handled error: #{exception.inspect}") + end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index ff6b5f57ea194..9e59758ea5df1 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,6 +1,7 @@ class DashboardController < ActionController::Base include SwitchLocale + before_action :set_application_pack before_action :set_global_config around_action :switch_locale before_action :ensure_installation_onboarding, only: [:index] @@ -14,7 +15,7 @@ def index; end def set_global_config @global_config = GlobalConfig.get( - 'LOGO', 'LOGO_THUMBNAIL', + 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', 'INSTALLATION_NAME', 'WIDGET_BRAND_URL', 'TERMS_URL', 'PRIVACY_URL', @@ -60,4 +61,12 @@ def app_config GIT_SHA: GIT_HASH } end + + def set_application_pack + @application_pack = if request.path.include?('/auth') || request.path.include?('/login') + 'v3app' + else + 'application' + end + end end diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index a399bc7490c87..1e20b2a1dbe7f 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -1,4 +1,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController + include Events::Types + before_action :set_conversation, only: [:toggle_typing, :update_last_seen] + def index @conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations end @@ -7,18 +10,38 @@ def create @conversation = create_conversation end + def toggle_typing + case params[:typing_status] + when 'on' + trigger_typing_event(CONVERSATION_TYPING_ON) + when 'off' + trigger_typing_event(CONVERSATION_TYPING_OFF) + end + head :ok + end + + def update_last_seen + @conversation.contact_last_seen_at = DateTime.now.utc + @conversation.save! + ::Conversations::MarkMessagesAsReadJob.perform_later(@conversation) + head :ok + end + private + def set_conversation + @conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:id]) + end + def create_conversation - ::Conversation.create!(conversation_params) + ConversationBuilder.new(params: conversation_params, contact_inbox: @contact_inbox).perform + end + + def trigger_typing_event(event) + Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: @conversation.contact) end def conversation_params - { - account_id: @contact_inbox.contact.account_id, - inbox_id: @contact_inbox.inbox_id, - contact_id: @contact_inbox.contact_id, - contact_inbox_id: @contact_inbox.id - } + params.permit(custom_attributes: {}) end end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index b4739e0d18828..bc5f6c0fd37d5 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -8,13 +8,22 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def index @articles = @portal.articles @articles = @articles.search(list_params) if list_params.present? - @articles.order(position: :asc) + order_by_sort_param + @articles.page(list_params[:page]) if list_params[:page].present? end def show; end private + def order_by_sort_param + @articles = if list_params[:sort].present? && list_params[:sort] == 'views' + @articles.order_by_views + else + @articles.order_by_position + end + end + def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article.increment_view_count @@ -35,7 +44,7 @@ def portal end def list_params - params.permit(:query, :locale) + params.permit(:query, :locale, :sort, :status) end def permitted_params @@ -43,8 +52,6 @@ def permitted_params end def render_article_content(content) - # rubocop:disable Rails/OutputSafety - CommonMarker.render_html(content).html_safe - # rubocop:enable Rails/OutputSafety + ChatwootMarkdownRenderer.new(content).render_article end end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 21b6053960875..d9fec27b108b5 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -1,11 +1,28 @@ class Public::Api::V1::Portals::BaseController < PublicController + before_action :show_plain_layout + before_action :set_color_scheme around_action :set_locale + after_action :allow_iframe_requests private + def show_plain_layout + @is_plain_layout_enabled = params[:show_plain_layout] == 'true' + end + + def set_color_scheme + @theme = if %w[dark light].include?(params[:theme]) + params[:theme] + else + '' + end + end + def set_locale(&) switch_locale_with_portal(&) if params[:locale].present? switch_locale_with_article(&) if params[:article_slug].present? + + yield end def switch_locale_with_portal(&) @@ -32,4 +49,8 @@ def switch_locale_with_article(&) I18n.with_locale(@locale, &) end + + def allow_iframe_requests + response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled + end end diff --git a/app/controllers/slack_uploads_controller.rb b/app/controllers/slack_uploads_controller.rb new file mode 100644 index 0000000000000..127e77649195c --- /dev/null +++ b/app/controllers/slack_uploads_controller.rb @@ -0,0 +1,27 @@ +class SlackUploadsController < ApplicationController + include Rails.application.routes.url_helpers + before_action :set_blob, only: [:show] + + def show + if @blob + redirect_to blob_url + else + redirect_to avatar_url + end + end + + private + + def set_blob + @blob = ActiveStorage::Blob.find_by(key: params[:blob_key]) + end + + def blob_url + url_for(@blob.representation(resize_to_fill: [250, nil])) + end + + def avatar_url + base_url = ENV.fetch('FRONTEND_URL', nil) + "#{base_url}/integrations/slack/#{params[:sender_type]}.png" + end +end diff --git a/app/controllers/super_admin/agent_bots_controller.rb b/app/controllers/super_admin/agent_bots_controller.rb index 8e094e7520af6..29a3021ee499e 100644 --- a/app/controllers/super_admin/agent_bots_controller.rb +++ b/app/controllers/super_admin/agent_bots_controller.rb @@ -41,4 +41,14 @@ class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # for more information + + def destroy_avatar + avatar = requested_resource.avatar + avatar.purge + redirect_back(fallback_location: super_admin_agent_bots_path) + end + + def scoped_resource + resource_class.with_attached_avatar + end end diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index e7b037099f634..a1dc458ae222b 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -5,6 +5,22 @@ def show sha postgres_status redis_metrics + chatwoot_edition + instance_meta + end + + def chatwoot_edition + @metrics['Chatwoot edition'] = if ChatwootApp.enterprise? + 'Enterprise' + elsif ChatwootApp.custom? + 'Custom' + else + 'Community' + end + end + + def instance_meta + @metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed' end def chatwoot_version diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index fec4fb79031d0..ff242030a615f 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -45,6 +45,17 @@ def create # empty values into nil values. It uses other APIs such as `resource_class` # and `dashboard`: # + + def destroy_avatar + avatar = requested_resource.avatar + avatar.purge + redirect_back(fallback_location: super_admin_users_path) + end + + def scoped_resource + resource_class.with_attached_avatar + end + def resource_params permitted_params = super permitted_params.delete(:password) if permitted_params[:password].blank? diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb new file mode 100644 index 0000000000000..cc7afb0fc4907 --- /dev/null +++ b/app/controllers/twilio/delivery_status_controller.rb @@ -0,0 +1,21 @@ +class Twilio::DeliveryStatusController < ApplicationController + def create + ::Twilio::DeliveryStatusService.new(params: permitted_params).perform + + head :no_content + end + + private + + def permitted_params + params.permit( + :AccountSid, + :From, + :MessageSid, + :MessagingServiceSid, + :MessageStatus, + :ErrorCode, + :ErrorMessage + ) + end +end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 50cd3f8f63abc..de6ab42bb9a61 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -18,8 +18,8 @@ class AccountDashboard < Administrate::BaseDashboard end ATTRIBUTE_TYPES = { - id: Field::Number, - name: Field::String, + id: Field::Number.with_options(searchable: true), + name: Field::String.with_options(searchable: true), created_at: Field::DateTime, updated_at: Field::DateTime, users: CountField, @@ -86,7 +86,10 @@ def display_resource(account) "##{account.id} #{account.name}" end - def permitted_attributes + # We do not use the action parameter but we still need to define it + # to prevent an error from being raised (wrong number of arguments) + # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 + def permitted_attributes(action) super + [limits: {}] end end diff --git a/app/dashboards/account_user_dashboard.rb b/app/dashboards/account_user_dashboard.rb index d757f4da5d211..9e8b2c48b9e41 100644 --- a/app/dashboards/account_user_dashboard.rb +++ b/app/dashboards/account_user_dashboard.rb @@ -8,9 +8,9 @@ class AccountUserDashboard < Administrate::BaseDashboard # which determines how the attribute is displayed # on pages throughout the dashboard. ATTRIBUTE_TYPES = { - account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'), - user: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'), - inviter: Field::BelongsTo.with_options(class_name: 'User', searchable: true, searchable_field: 'name'), + account: Field::BelongsToSearch.with_options(class_name: 'Account', searchable: true, searchable_field: [:name, :id], order: 'id DESC'), + user: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'), + inviter: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'), id: Field::Number, role: Field::Select.with_options(collection: AccountUser.roles.keys), created_at: Field::DateTime, diff --git a/app/dashboards/agent_bot_dashboard.rb b/app/dashboards/agent_bot_dashboard.rb index e97a64de84110..baeb6e814ed17 100644 --- a/app/dashboards/agent_bot_dashboard.rb +++ b/app/dashboards/agent_bot_dashboard.rb @@ -10,6 +10,11 @@ class AgentBotDashboard < Administrate::BaseDashboard ATTRIBUTE_TYPES = { access_token: Field::HasOne, avatar_url: AvatarField, + avatar: Field::ActiveStorage.with_options( + destroy_url: proc do |_namespace, _resource, attachment| + [:avatar_super_admin_agent_bot, { attachment_id: attachment.id }] + end + ), id: Field::Number, name: Field::String, account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'), @@ -36,6 +41,7 @@ class AgentBotDashboard < Administrate::BaseDashboard # an array of attributes that will be displayed on the model's show page. SHOW_PAGE_ATTRIBUTES = %i[ id + avatar_url account name description @@ -47,6 +53,7 @@ class AgentBotDashboard < Administrate::BaseDashboard # on the model's form (`new` and `edit`) pages. FORM_ATTRIBUTES = %i[ name + avatar account description outgoing_url diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 1d59525f28950..e00d06a72d203 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -9,8 +9,13 @@ class UserDashboard < Administrate::BaseDashboard # on pages throughout the dashboard. ATTRIBUTE_TYPES = { account_users: Field::HasMany, - id: Field::Number, + id: Field::Number.with_options(searchable: true), avatar_url: AvatarField, + avatar: Field::ActiveStorage.with_options( + destroy_url: proc do |_namespace, _resource, attachment| + [:avatar_super_admin_user, { attachment_id: attachment.id }] + end + ), provider: Field::String, uid: Field::String, password: Field::Password, @@ -23,9 +28,9 @@ class UserDashboard < Administrate::BaseDashboard confirmed_at: Field::DateTime, confirmation_sent_at: Field::DateTime, unconfirmed_email: Field::String, - name: Field::String, + name: Field::String.with_options(searchable: true), display_name: Field::String, - email: Field::String, + email: Field::String.with_options(searchable: true), tokens: Field::String.with_options(searchable: false), created_at: Field::DateTime, updated_at: Field::DateTime, @@ -69,6 +74,7 @@ class UserDashboard < Administrate::BaseDashboard # on the model's form (`new` and `edit`) pages. FORM_ATTRIBUTES = %i[ name + avatar display_name email password diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index cadbb46443d3a..cbbd60caaccaf 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -6,7 +6,8 @@ class ConversationFinder latest: 'latest', sort_on_created_at: 'sort_on_created_at', last_user_message_at: 'last_user_message_at', - sort_on_priority: 'sort_on_priority' + sort_on_priority: 'sort_on_priority', + sort_on_waiting_since: 'sort_on_waiting_since' }.with_indifferent_access # assumptions @@ -102,7 +103,7 @@ def filter_by_conversation_type when 'participating' @conversations = current_user.participating_conversations.where(account_id: current_account.id) when 'unattended' - @conversations = @conversations.where(first_reply_created_at: nil) + @conversations = @conversations.unattended end @conversations end @@ -155,9 +156,9 @@ def current_page end def conversations - @conversations = @conversations.includes(:taggings, :inbox, - { assignee: [{ account_users: [:account] }, { avatar_attachment: [:blob] }] }, - { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox, :messages) + @conversations = @conversations.includes( + :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox + ) sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest'] @conversations.send(sort_by).page(current_page) end diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb new file mode 100644 index 0000000000000..26669e38d292e --- /dev/null +++ b/app/helpers/billing_helper.rb @@ -0,0 +1,21 @@ +module BillingHelper + private + + def default_plan?(account) + installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS') + default_plan = installation_config&.value&.first + + # Return false if not plans are configured, so that no checks are enforced + return false if default_plan.blank? + + account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name'] + end + + def conversations_this_month(account) + account.conversations.where('created_at > ?', 30.days.ago).count + end + + def non_web_inboxes(account) + account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count + end +end diff --git a/app/helpers/message_format_helper.rb b/app/helpers/message_format_helper.rb index 1e89c56c1d5f1..2c50fd609e5d8 100644 --- a/app/helpers/message_format_helper.rb +++ b/app/helpers/message_format_helper.rb @@ -7,8 +7,6 @@ def transform_user_mention_content(message_content) end def render_message_content(message_content) - # rubocop:disable Rails/OutputSafety - CommonMarker.render_html(message_content).html_safe - # rubocop:enable Rails/OutputSafety + ChatwootMarkdownRenderer.new(message_content).render_message end end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb new file mode 100644 index 0000000000000..3551b5ac6f3df --- /dev/null +++ b/app/helpers/portal_helper.rb @@ -0,0 +1,11 @@ +module PortalHelper + def generate_portal_bg_color(portal_color, theme) + base_color = theme == 'dark' ? 'black' : 'white' + "color-mix(in srgb, #{portal_color} 10%, #{base_color})" + end + + def generate_portal_bg(portal_color, theme) + bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg' + "background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" + end +end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 5fdb341702670..2b0f43b3fc0ec 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -17,19 +17,36 @@ def scope end def conversations_count - (get_grouped_values scope.conversations.where(account_id: account.id)).count + (get_grouped_values conversations).count end def incoming_messages_count - (get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count + (get_grouped_values incoming_messages).count end def outgoing_messages_count - (get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count + (get_grouped_values outgoing_messages).count end def resolutions_count - (get_grouped_values scope.conversations.where(account_id: account.id).resolved).count + (get_grouped_values resolutions).count + end + + def conversations + scope.conversations.where(account_id: account.id, created_at: range) + end + + def incoming_messages + scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) + end + + def outgoing_messages + scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) + end + + def resolutions + scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved, + conversations: { status: :resolved }, created_at: range).distinct end def avg_first_response_time @@ -39,6 +56,13 @@ def avg_first_response_time grouped_reporting_events.average(:value) end + def reply_time + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'reply_time', 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] @@ -49,17 +73,35 @@ def avg_resolution_time 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) + avg_rt = if params[:business_hours].present? + reporting_events.average(:value_in_business_hours) + else + reporting_events.average(:value) + end return 0 if avg_rt.blank? avg_rt end + def reply_time_summary + reporting_events = scope.reporting_events + .where(name: 'reply_time', account_id: account.id, created_at: range) + reply_time = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + + return 0 if reply_time.blank? + + reply_time + 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) + avg_frt = if params[:business_hours].present? + reporting_events.average(:value_in_business_hours) + else + reporting_events.average(:value) + end return 0 if avg_frt.blank? diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index ccae9e52cabc5..6fd470fcf9428 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,11 +1,16 @@