diff --git a/.circleci/config.yml b/.circleci/config.yml index db7f87d5c..bc7053130 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,7 +19,7 @@ jobs: steps: - checkout - node/install: - node-version: '20.12' + node-version: '23.7' - node/install-pnpm - node/install-packages: pkg-manager: pnpm diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 8b0704bfa..c5530ac17 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,30 +5,30 @@ version: '3' services: - base: + base: build: context: .. dockerfile: .devcontainer/Dockerfile.base args: - VARIANT: "ubuntu-22.04" - NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.3.3" + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '23.7.0' + RUBY_VERSION: '3.3.3' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. - USER_UID: "1000" - USER_GID: "1000" + USER_UID: '1000' + USER_GID: '1000' image: base:latest - + app: build: context: .. dockerfile: .devcontainer/Dockerfile args: - VARIANT: "ubuntu-22.04" - NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.3.3" + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '23.7.0' + RUBY_VERSION: '3.3.3' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. - USER_UID: "1000" - USER_GID: "1000" + USER_UID: '1000' + USER_GID: '1000' volumes: - ..:/workspace:cached diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 15bb6f5e9..5af4857e0 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -23,12 +23,10 @@ jobs: bundler-cache: true - uses: pnpm/action-setup@v4 - with: - version: 9.3.0 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 23 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/publish_ee_docker.yml b/.github/workflows/publish_ee_docker.yml new file mode 100644 index 000000000..8e2c22481 --- /dev/null +++ b/.github/workflows/publish_ee_docker.yml @@ -0,0 +1,140 @@ +# # +# # This action will publish Chatwoot EE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot EE docker images + +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +env: + DOCKER_REPO: chatwoot/chatwoot + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools inspect $TAG diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml index 732b22a28..3075a7f3d 100644 --- a/.github/workflows/publish_foss_docker.yml +++ b/.github/workflows/publish_foss_docker.yml @@ -5,6 +5,7 @@ # # name: Publish Chatwoot CE docker images + on: push: branches: @@ -12,23 +13,32 @@ on: - master tags: - v* -# pull_request: workflow_dispatch: +env: + DOCKER_REPO: chatwoot/chatwoot + jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} env: - GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches + GIT_REF: ${{ github.head_ref || github.ref_name }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Strip enterprise code run: | @@ -39,29 +49,97 @@ jobs: run: | echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile - - name: set docker tag + - name: Set Docker Tags run: | - # Replace forward slashes with hyphens in the ref name SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') - echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV + fi - - name: replace docker tag if master - if: github.ref_name == 'master' - run: | - echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v2 + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 with: context: . file: docker/Dockerfile - platforms: linux/amd64, linux/arm64 + platforms: ${{ matrix.platform }} push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} - tags: ${{ env.DOCKER_TAG }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools inspect $TAG diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 0af172849..5a9d35d0b 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -38,7 +38,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 9 ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -48,7 +47,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 23 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 0758ca7d0..724f69ec3 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -19,13 +19,11 @@ jobs: with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - uses: pnpm/action-setup@v2 - with: - version: 9.3.0 + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 23 cache: 'pnpm' - name: pnpm @@ -39,7 +37,7 @@ jobs: - name: setup env run: | cp .env.example .env - + - name: Run asset compile run: bundle exec rake assets:precompile env: @@ -47,5 +45,3 @@ jobs: - name: Size Check run: pnpm run size - - diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml new file mode 100644 index 000000000..460c4ba1f --- /dev/null +++ b/.github/workflows/test_docker_build.yml @@ -0,0 +1,40 @@ +name: Test Docker Build + +on: + pull_request: + branches: + - develop + - master + workflow_dispatch: + +jobs: + test-build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: false + load: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.husky/pre-commit b/.husky/pre-commit index 2525dde4e..adda426ad 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,11 +1,11 @@ -# #!/bin/sh -# . "$(dirname "$0")/_/husky.sh" +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" -# # lint js and vue files -# npx --no-install lint-staged +# lint js and vue files +npx --no-install lint-staged -# # lint only staged ruby files -# git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a +# lint only staged ruby files +git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a -# # stage rubocop changes to files -# git diff --name-only --cached | xargs git add +# stage rubocop changes to files +git diff --name-only --cached | xargs git add diff --git a/Gemfile b/Gemfile index 45e194e13..e8639a5bd 100644 --- a/Gemfile +++ b/Gemfile @@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5' # facebook client gem 'koala' # slack client -gem 'slack-ruby-client', '~> 2.2.0' +gem 'slack-ruby-client', '~> 2.5.1' # for dialogflow integrations gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'grpc' @@ -138,9 +138,7 @@ gem 'procore-sift' # parse email gem 'email_reply_trimmer' -# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10. -# and this gem hasn't been updated for a while. -gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot' +gem 'html2text' # to calculate working hours gem 'working_hours' diff --git a/Gemfile.lock b/Gemfile.lock index 45c54bd46..abb007178 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,14 +22,6 @@ GIT devise (>= 4.0.0, < 5.0.0) railties (>= 5.0.0, < 8.0.0) -GIT - remote: https://github.com/chatwoot/html2text_ruby - revision: cdbdbbbf898d846d0136d69d688a003c6b26074b - branch: chatwoot - specs: - html2text (0.3.1) - nokogiri (>= 1.13.6) - GEM remote: https://rubygems.org/ specs: @@ -186,7 +178,7 @@ GEM database_cleaner-core (2.0.1) datadog-ci (0.8.3) msgpack - date (3.3.4) + date (3.4.1) ddtrace (1.23.2) datadog-ci (~> 0.8.1) debase-ruby_core_source (= 3.3.1) @@ -280,7 +272,8 @@ GEM googleauth (~> 1.0) grpc (~> 1.36) geocoder (1.8.1) - gli (2.21.1) + gli (2.22.2) + ostruct globalid (1.2.1) activesupport (>= 6.1) gmail_xoauth (0.4.3) @@ -361,6 +354,8 @@ GEM hana (1.3.7) hashdiff (1.1.0) hashie (5.0.0) + html2text (0.4.0) + nokogiri (>= 1.0, < 2.0) http (5.1.1) addressable (~> 2.8) http-cookie (~> 1.0) @@ -487,7 +482,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.17) + net-imap (0.4.19) date net-protocol net-pop (0.1.2) @@ -503,14 +498,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.17.1) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.17.1-arm64-darwin) + nokogiri (1.18.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.17.1-x86_64-darwin) + nokogiri (1.18.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.17.1-x86_64-linux) + nokogiri (1.18.3-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -543,6 +538,7 @@ GEM openssl (3.2.0) orm_adapter (0.5.0) os (1.1.4) + ostruct (0.6.1) parallel (1.23.0) parser (3.2.2.1) ast (~> 2.4.1) @@ -565,7 +561,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.10) + rack (2.2.11) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -751,12 +747,13 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - slack-ruby-client (2.2.0) + slack-ruby-client (2.5.1) faraday (>= 2.0) faraday-mashify faraday-multipart gli hashie + logger snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) @@ -782,7 +779,7 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.1) + timeout (0.4.3) trailblazer-option (0.1.2) twilio-ruby (5.77.0) faraday (>= 0.9, < 3.0) @@ -897,7 +894,7 @@ DEPENDENCIES haikunator hairtrigger hashie - html2text! + html2text image_processing jbuilder json_refs @@ -957,7 +954,7 @@ DEPENDENCIES sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) simplecov (= 0.17.1) - slack-ruby-client (~> 2.2.0) + slack-ruby-client (~> 2.5.1) spring spring-watcher-listen squasher diff --git a/README.md b/README.md index e5f73eb97..f09bd5698 100644 --- a/README.md +++ b/README.md @@ -120,4 +120,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri -*Chatwoot* © 2017-2024, Chatwoot Inc - Released under the MIT License. +*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License. diff --git a/VERSION_CWCTL b/VERSION_CWCTL index fd2a01863..944880fa1 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.1.0 +3.2.0 diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb index 3e30557e1..5df718b6a 100644 --- a/app/builders/v2/reports/timeseries/average_report_builder.rb +++ b/app/builders/v2/reports/timeseries/average_report_builder.rb @@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B end def object_scope - scope.reporting_events.where(name: event_name, created_at: range) + scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) end def reporting_events diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index 5e0a0e55e..de0ac4db9 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts:: def index @conversations = Current.account.conversations.includes( :assignee, :contact, :inbox, :taggings - ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20) + ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20) end private diff --git a/app/controllers/api/v1/accounts/inbox_members_controller.rb b/app/controllers/api/v1/accounts/inbox_members_controller.rb index 0cb1a0c85..bf8822c27 100644 --- a/app/controllers/api/v1/accounts/inbox_members_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_members_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl def create authorize @inbox, :create? ActiveRecord::Base.transaction do - agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) } + @inbox.add_members(agents_to_be_added_ids) end fetch_updated_agents end @@ -24,7 +24,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl def destroy authorize @inbox, :destroy? ActiveRecord::Base.transaction do - params[:user_ids].map { |user_id| @inbox.remove_member(user_id) } + @inbox.remove_members(params[:user_ids]) end head :ok end @@ -41,8 +41,8 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl # the missing ones are the agents which are to be deleted from the inbox # the new ones are the agents which are to be added to the inbox ActiveRecord::Base.transaction do - agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) } - agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) } + @inbox.add_members(agents_to_be_added_ids) + @inbox.remove_members(agents_to_be_removed_ids) end end diff --git a/app/controllers/api/v1/accounts/team_members_controller.rb b/app/controllers/api/v1/accounts/team_members_controller.rb index 0243a915a..ffb115a34 100644 --- a/app/controllers/api/v1/accounts/team_members_controller.rb +++ b/app/controllers/api/v1/accounts/team_members_controller.rb @@ -9,14 +9,14 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll def create ActiveRecord::Base.transaction do - @team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) } + @team_members = @team.add_members(members_to_be_added_ids) end end def update ActiveRecord::Base.transaction do - members_to_be_added_ids.each { |user_id| @team.add_member(user_id) } - members_to_be_removed_ids.each { |user_id| @team.remove_member(user_id) } + @team.add_members(members_to_be_added_ids) + @team.remove_members(members_to_be_removed_ids) end @team_members = @team.members render action: 'create' @@ -24,7 +24,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll def destroy ActiveRecord::Base.transaction do - params[:user_ids].map { |user_id| @team.remove_member(user_id) } + @team.remove_members(params[:user_ids]) end head :ok end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 6ddaab73b..0f915ab8a 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -7,13 +7,17 @@ class DashboardController < ActionController::Base around_action :switch_locale before_action :ensure_installation_onboarding, only: [:index] before_action :render_hc_if_custom_domain, only: [:index] - + before_action :ensure_html_format layout 'vueapp' def index; end private + def ensure_html_format + head :not_acceptable unless request.format.html? + end + def set_global_config @global_config = GlobalConfig.get( 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', @@ -32,7 +36,7 @@ class DashboardController < ActionController::Base 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', 'DEPLOYMENT_ENV', - 'CSML_EDITOR_HOST' + 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end diff --git a/app/fields/enterprise/account_limits_field.rb b/app/fields/enterprise/account_limits_field.rb index 5833952ad..b014435c6 100644 --- a/app/fields/enterprise/account_limits_field.rb +++ b/app/fields/enterprise/account_limits_field.rb @@ -2,6 +2,6 @@ require 'administrate/field/base' class Enterprise::AccountLimitsField < Administrate::Field::Base def to_s - data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json + data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json end end diff --git a/app/helpers/api/v2/accounts/heatmap_helper.rb b/app/helpers/api/v2/accounts/heatmap_helper.rb index 58dade28d..a4977f43a 100644 --- a/app/helpers/api/v2/accounts/heatmap_helper.rb +++ b/app/helpers/api/v2/accounts/heatmap_helper.rb @@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper end def since_timestamp(date) - (date - 6.days).to_i.to_s + number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days + (date - number_of_days).to_i.to_s end def until_timestamp(date) diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 4d5a6f115..22c51b6ef 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -1,58 +1,70 @@ module Api::V2::Accounts::ReportsHelper def generate_agents_report + reports = V2::Reports::AgentSummaryBuilder.new( + account: Current.account, + params: build_params(type: :agent) + ).build + Current.account.users.map do |agent| - agent_report = report_builder({ type: :agent, id: agent.id }).summary - [agent.name] + generate_readable_report_metrics(agent_report) + report = reports.find { |r| r[:id] == agent.id } + [agent.name] + generate_readable_report_metrics(report) end end def generate_inboxes_report + reports = V2::Reports::InboxSummaryBuilder.new( + account: Current.account, + params: build_params(type: :inbox) + ).build + Current.account.inboxes.map do |inbox| - inbox_report = generate_report({ type: :inbox, id: inbox.id }) - [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report) + report = reports.find { |r| r[:id] == inbox.id } + [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report) end end def generate_teams_report + reports = V2::Reports::TeamSummaryBuilder.new( + account: Current.account, + params: build_params(type: :team) + ).build + Current.account.teams.map do |team| - team_report = report_builder({ type: :team, id: team.id }).summary - [team.name] + generate_readable_report_metrics(team_report) + report = reports.find { |r| r[:id] == team.id } + [team.name] + generate_readable_report_metrics(report) end end def generate_labels_report Current.account.labels.map do |label| - label_report = generate_report({ type: :label, id: label.id }) + label_report = report_builder({ type: :label, id: label.id }).short_summary [label.title] + generate_readable_report_metrics(label_report) end end - def report_builder(report_params) - V2::ReportBuilder.new( - Current.account, - report_params.merge( - { - since: params[:since], - until: params[:until], - business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) - } - ) + private + + def build_params(base_params) + base_params.merge( + { + since: params[:since], + until: params[:until], + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + } ) end - def generate_report(report_params) - report_builder(report_params).short_summary + def report_builder(report_params) + V2::ReportBuilder.new(Current.account, build_params(report_params)) end - private - - def generate_readable_report_metrics(report_metric) + def generate_readable_report_metrics(report) [ - report_metric[:conversations_count], - Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format, - Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format, - Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format, - report_metric[:resolutions_count] + report[:conversations_count], + Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format, + Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format, + Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format, + report[:resolved_conversations_count] ] end end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 342bb62aa..2b1ab7b21 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -5,8 +5,7 @@ module PortalHelper end def generate_portal_bg(portal_color, theme) - bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' - "url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" + generate_portal_bg_color(portal_color, theme) end def generate_gradient_to_bottom(theme) diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb index 759aec134..c24e65e68 100644 --- a/app/helpers/super_admin/account_features_helper.rb +++ b/app/helpers/super_admin/account_features_helper.rb @@ -6,4 +6,47 @@ module SuperAdmin::AccountFeaturesHelper def self.account_premium_features account_features.filter { |feature| feature['premium'] }.pluck('name') end + + # Returns a hash mapping feature names to their display names + def self.feature_display_names + account_features.each_with_object({}) do |feature, hash| + hash[feature['name']] = feature['display_name'] + end + end + + def self.filter_internal_features(features) + return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' + + internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name') + features.except(*internal_features) + end + + def self.filter_deprecated_features(features) + deprecated_features = account_features.select { |f| f['deprecated'] }.pluck('name') + features.except(*deprecated_features) + end + + def self.sort_and_transform_features(features, display_names) + features.sort_by { |key, _| display_names[key] || key } + .to_h + .transform_keys { |key| [key, display_names[key]] } + end + + def self.partition_features(features) + filtered = filter_internal_features(features) + filtered = filter_deprecated_features(filtered) + display_names = feature_display_names + + regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) } + + [ + sort_and_transform_features(regular, display_names), + sort_and_transform_features(premium, display_names) + ] + end + + def self.filtered_features(features) + regular, premium = partition_features(features) + regular.merge(premium) + end end diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 52fa7f444..c87dfc82b 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient { }); } - getConversationTrafficCSV() { + getConversationTrafficCSV({ daysBefore = 6 } = {}) { return axios.get(`${this.url}/conversation_traffic`, { - params: { timezone_offset: getTimeOffset() }, + params: { timezone_offset: getTimeOffset(), days_before: daysBefore }, }); } diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js index 7dc98dcf2..7abb584c0 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -14,26 +14,29 @@ class SearchAPI extends ApiClient { }); } - contacts({ q }) { + contacts({ q, page = 1 }) { return axios.get(`${this.url}/contacts`, { params: { q, + page: page, }, }); } - conversations({ q }) { + conversations({ q, page = 1 }) { return axios.get(`${this.url}/conversations`, { params: { q, + page: page, }, }); } - messages({ q }) { + messages({ q, page = 1 }) { return axios.get(`${this.url}/messages`, { params: { q, + page: page, }, }); } diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js new file mode 100644 index 000000000..f772ef86f --- /dev/null +++ b/app/javascript/dashboard/api/summaryReports.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SummaryReportsAPI extends ApiClient { + constructor() { + super('summary_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getTeamReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/team`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getAgentReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/agent`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getInboxReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/inbox`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } +} + +export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/assets/scss/super_admin/index.scss b/app/javascript/dashboard/assets/scss/super_admin/index.scss index 91f7835d0..ddac5f7c8 100644 --- a/app/javascript/dashboard/assets/scss/super_admin/index.scss +++ b/app/javascript/dashboard/assets/scss/super_admin/index.scss @@ -6,3 +6,209 @@ body { font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; } + +@layer base { + // FIXME: Use a common color file for all packs + // scss-lint:disable PropertySortOrder + :root { + --slate-1: 252 252 253; + --slate-2: 249 249 251; + --slate-3: 240 240 243; + --slate-4: 232 232 236; + --slate-5: 224 225 230; + --slate-6: 217 217 224; + --slate-7: 205 206 214; + --slate-8: 185 187 198; + --slate-9: 139 141 152; + --slate-10: 128 131 141; + --slate-11: 96 100 108; + --slate-12: 28 32 36; + + --iris-1: 253 253 255; + --iris-2: 248 248 255; + --iris-3: 240 241 254; + --iris-4: 230 231 255; + --iris-5: 218 220 255; + --iris-6: 203 205 255; + --iris-7: 184 186 248; + --iris-8: 155 158 240; + --iris-9: 91 91 214; + --iris-10: 81 81 205; + --iris-11: 87 83 198; + --iris-12: 39 41 98; + + --ruby-1: 255 252 253; + --ruby-2: 255 247 248; + --ruby-3: 254 234 237; + --ruby-4: 255 220 225; + --ruby-5: 255 206 214; + --ruby-6: 248 191 200; + --ruby-7: 239 172 184; + --ruby-8: 229 146 163; + --ruby-9: 229 70 102; + --ruby-10: 220 59 93; + --ruby-11: 202 36 77; + --ruby-12: 100 23 43; + + --amber-1: 254 253 251; + --amber-2: 254 251 233; + --amber-3: 255 247 194; + --amber-4: 255 238 156; + --amber-5: 251 229 119; + --amber-6: 243 214 115; + --amber-7: 233 193 98; + --amber-8: 226 163 54; + --amber-9: 255 197 61; + --amber-10: 255 186 24; + --amber-11: 171 100 0; + --amber-12: 79 52 34; + + --teal-1: 250 254 253; + --teal-2: 243 251 249; + --teal-3: 224 248 243; + --teal-4: 204 243 234; + --teal-5: 184 234 224; + --teal-6: 161 222 210; + --teal-7: 131 205 193; + --teal-8: 83 185 171; + --teal-9: 18 165 148; + --teal-10: 13 155 138; + --teal-11: 0 133 115; + --teal-12: 13 61 56; + + --gray-1: 252 252 252; + --gray-2: 249 249 249; + --gray-3: 240 240 240; + --gray-4: 232 232 232; + --gray-5: 224 224 224; + --gray-6: 217 217 217; + --gray-7: 206 206 206; + --gray-8: 187 187 187; + --gray-9: 141 141 141; + --gray-10: 131 131 131; + --gray-11: 100 100 100; + --gray-12: 32 32 32; + + --background-color: 253 253 253; + --text-blue: 8 109 224; + --border-container: 236 236 236; + --border-strong: 235 235 235; + --border-weak: 234 234 234; + --solid-1: 255 255 255; + --solid-2: 255 255 255; + --solid-3: 255 255 255; + --solid-active: 255 255 255; + --solid-amber: 252 232 193; + --solid-blue: 218 236 255; + --solid-iris: 230 231 255; + + --alpha-1: 67, 67, 67, 0.06; + --alpha-2: 201, 202, 207, 0.15; + --alpha-3: 255, 255, 255, 0.96; + --black-alpha-1: 0, 0, 0, 0.12; + --black-alpha-2: 0, 0, 0, 0.04; + --border-blue: 39, 129, 246, 0.5; + --white-alpha: 255, 255, 255, 0.8; + } + + .dark { + --slate-1: 17 17 19; + --slate-2: 24 25 27; + --slate-3: 33 34 37; + --slate-4: 39 42 45; + --slate-5: 46 49 53; + --slate-6: 54 58 63; + --slate-7: 67 72 78; + --slate-8: 90 97 105; + --slate-9: 105 110 119; + --slate-10: 119 123 132; + --slate-11: 176 180 186; + --slate-12: 237 238 240; + + --iris-1: 19 19 30; + --iris-2: 23 22 37; + --iris-3: 32 34 72; + --iris-4: 38 42 101; + --iris-5: 48 51 116; + --iris-6: 61 62 130; + --iris-7: 74 74 149; + --iris-8: 89 88 177; + --iris-9: 91 91 214; + --iris-10: 84 114 228; + --iris-11: 158 177 255; + --iris-12: 224 223 254; + + --ruby-1: 25 17 19; + --ruby-2: 30 21 23; + --ruby-3: 58 20 30; + --ruby-4: 78 19 37; + --ruby-5: 94 26 46; + --ruby-6: 111 37 57; + --ruby-7: 136 52 71; + --ruby-8: 179 68 90; + --ruby-9: 229 70 102; + --ruby-10: 236 90 114; + --ruby-11: 255 148 157; + --ruby-12: 254 210 225; + + --amber-1: 22 18 12; + --amber-2: 29 24 15; + --amber-3: 48 32 8; + --amber-4: 63 39 0; + --amber-5: 77 48 0; + --amber-6: 92 61 5; + --amber-7: 113 79 25; + --amber-8: 143 100 36; + --amber-9: 255 197 61; + --amber-10: 255 214 10; + --amber-11: 255 202 22; + --amber-12: 255 231 179; + + --teal-1: 13 21 20; + --teal-2: 17 28 27; + --teal-3: 13 45 42; + --teal-4: 2 59 55; + --teal-5: 8 72 67; + --teal-6: 20 87 80; + --teal-7: 28 105 97; + --teal-8: 32 126 115; + --teal-9: 18 165 148; + --teal-10: 14 179 158; + --teal-11: 11 216 182; + --teal-12: 173 240 221; + + --gray-1: 17 17 17; + --gray-2: 25 25 25; + --gray-3: 34 34 34; + --gray-4: 42 42 42; + --gray-5: 49 49 49; + --gray-6: 58 58 58; + --gray-7: 72 72 72; + --gray-8: 96 96 96; + --gray-9: 110 110 110; + --gray-10: 123 123 123; + --gray-11: 180 180 180; + --gray-12: 238 238 238; + + --background-color: 18 18 19; + --border-strong: 52 52 52; + --border-weak: 38 38 42; + --solid-1: 23 23 26; + --solid-2: 29 30 36; + --solid-3: 44 45 54; + --solid-active: 53 57 66; + --solid-amber: 42 37 30; + --solid-blue: 16 49 91; + --solid-iris: 38 42 101; + --text-blue: 126 182 255; + + --alpha-1: 36, 36, 36, 0.8; + --alpha-2: 139, 147, 182, 0.15; + --alpha-3: 36, 38, 45, 0.9; + --black-alpha-1: 0, 0, 0, 0.3; + --black-alpha-2: 0, 0, 0, 0.2; + --border-blue: 39, 129, 246, 0.5; + --border-container: 236, 236, 236, 0; + --white-alpha: 255, 255, 255, 0.1; + } +} diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue index 229553253..304e55347 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue @@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue index 2c4b9848c..ea76c6130 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue @@ -21,11 +21,11 @@ const addCampaign = async campaignDetails => { type: CAMPAIGN_TYPES.ONGOING, }); - useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE')); + useAlert(t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.SUCCESS_MESSAGE')); } catch (error) { const errorMessage = error?.response?.message || - t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE'); + t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE'); useAlert(errorMessage); } }; diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue index ad007af1c..57c4ba7c8 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue @@ -8,17 +8,17 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; const props = defineProps({ - buttonLabel: { - type: String, - default: '', - }, selectedContact: { type: Object, default: () => ({}), }, + isUpdating: { + type: Boolean, + default: false, + }, }); -const emit = defineEmits(['goToContactsList']); +const emit = defineEmits(['goToContactsList', 'toggleBlock']); const { t } = useI18n(); const slots = useSlots(); @@ -45,9 +45,17 @@ const breadcrumbItems = computed(() => { return items; }); +const isContactBlocked = computed(() => { + return props.selectedContact?.blocked; +}); + const handleBreadcrumbClick = () => { emit('goToContactsList'); }; + +const toggleBlock = () => { + emit('toggleBlock', isContactBlocked.value); +};