diff --git a/.circleci/config.yml b/.circleci/config.yml index 459faf1c8..bc7053130 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,13 +19,19 @@ jobs: steps: - checkout - node/install: - node-version: '20.12' + node-version: '23.7' - node/install-pnpm - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i - run: node --version - run: pnpm --version + - run: + name: Add PostgreSQL repository and update + command: | + sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + sudo apt-get update -y - run: name: Install System Dependencies @@ -34,7 +40,9 @@ jobs: DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ libpq-dev \ redis-server \ - postgresql \ + postgresql-common \ + postgresql-16 \ + postgresql-16-pgvector \ build-essential \ git \ curl \ @@ -74,7 +82,7 @@ jobs: source ~/.rvm/scripts/rvm rvm install "3.3.3" rvm use 3.3.3 --default - gem install bundler + gem install bundler -v 2.5.16 - run: name: Install Application Dependencies diff --git a/.codeclimate.yml b/.codeclimate.yml index 213f7d172..c68251f32 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -51,6 +51,7 @@ exclude_patterns: - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' + - 'app/javascript/dashboard/store/captain/storeFactory.js' - 'app/javascript/dashboard/i18n/index.js' - 'app/javascript/widget/i18n/index.js' - 'app/javascript/survey/i18n/index.js' diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a804bb15c..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 @@ -40,7 +40,7 @@ services: network_mode: service:db db: - image: postgres:latest + image: pgvector/pgvector:pg16 restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data diff --git a/.eslintrc.js b/.eslintrc.js index a2932b2a9..6c867f557 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,8 @@ module.exports = { 'prettier', 'plugin:vue/vue3-recommended', 'plugin:vitest-globals/recommended', + // use recommended-legacy when upgrading the plugin to v4 + 'plugin:@intlify/vue-i18n/recommended', ], overrides: [ { @@ -229,6 +231,18 @@ module.exports = { 'vue/singleline-html-element-content-newline': 'off', 'import/extensions': ['off'], 'no-console': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'warn', + '@intlify/vue-i18n/no-unused-keys': [ + 'warn', + { + extensions: ['.js', '.vue'], + }, + ], + }, + settings: { + 'vue-i18n': { + localeDir: './app/javascript/*/i18n/**.json', + }, }, env: { browser: true, diff --git a/.github/dashboard-screen.png b/.github/dashboard-screen.png deleted file mode 100644 index 847ae1582..000000000 Binary files a/.github/dashboard-screen.png and /dev/null differ diff --git a/.github/screenshots/dashboard-dark.png b/.github/screenshots/dashboard-dark.png new file mode 100644 index 000000000..4d08b52b9 Binary files /dev/null and b/.github/screenshots/dashboard-dark.png differ diff --git a/.github/screenshots/dashboard.png b/.github/screenshots/dashboard.png new file mode 100644 index 000000000..b8b99be49 Binary files /dev/null and b/.github/screenshots/dashboard.png differ diff --git a/.github/screenshots/header-dark.png b/.github/screenshots/header-dark.png new file mode 100644 index 000000000..84931aee3 Binary files /dev/null and b/.github/screenshots/header-dark.png differ diff --git a/.github/screenshots/header.png b/.github/screenshots/header.png new file mode 100644 index 000000000..f10ca0faf Binary files /dev/null and b/.github/screenshots/header.png differ diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 15bb6f5e9..45ff25203 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -10,7 +10,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -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/nightly_installer.yml b/.github/workflows/nightly_installer.yml index d11fe6401..beef5727c 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -2,7 +2,7 @@ # # # # Linux nightly installer action # # This action will try to install and setup -# # chatwoot on an Ubuntu 20.04 machine using +# # chatwoot on an Ubuntu 22.04 machine using # # the linux installer script. # # # # This is set to run daily at midnight. @@ -16,7 +16,7 @@ on: jobs: nightly: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: get installer 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 d48b82a58..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: @@ -14,20 +15,30 @@ on: - v* 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: | @@ -38,26 +49,97 @@ jobs: run: | echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile - - name: set docker tag + - name: Set Docker Tags run: | - echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + 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 - uses: docker/login-action@v1 + 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 - 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 - push: true - tags: ${{ env.DOCKER_TAG }} + 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-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 6a018b314..385feddfc 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -1,9 +1,3 @@ -# # -# # This action will strip the enterprise folder -# # and run the spec. -# # This is set to run against every PR. -# # - name: Run Chatwoot CE spec on: push: @@ -15,10 +9,10 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: postgres: - image: postgres:15.3 + image: pgvector/pgvector:pg15 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: '' @@ -44,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 }} @@ -54,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/run_response_bot_spec.yml b/.github/workflows/run_response_bot_spec.yml deleted file mode 100644 index c788a0400..000000000 --- a/.github/workflows/run_response_bot_spec.yml +++ /dev/null @@ -1,89 +0,0 @@ -# # -# # 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@v4 - 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: pnpm/action-setup@v2 - with: - version: 9.3.0 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: pnpm - run: pnpm 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 \ - spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \ - --profile=10 \ - --format documentation - - - name: Upload rails log folder - uses: actions/upload-artifact@v4 - if: always() - with: - name: rails-log-folder - path: log diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 0758ca7d0..9be79da05 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -7,7 +7,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -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/.gitignore b/.gitignore index 5eb883db0..53deb62a8 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ test/cypress/videos/* #ignore files under .vscode directory .vscode +.cursor # yalc for local testing .yalc @@ -90,3 +91,6 @@ yarn-debug.log* # Vite uses dotenv and suggests to ignore local-only env files. See # https://vitejs.dev/guide/env-and-mode.html#env-files *.local + +# Claude.ai config file +CLAUDE.md diff --git a/.husky/pre-commit b/.husky/pre-commit index 2525dde4e..b3aceacd6 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 that still exist (not deleted) +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true -# # stage rubocop changes to files -# git diff --name-only --cached | xargs git add +# stage rubocop changes to files +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true diff --git a/.scss-lint.yml b/.scss-lint.yml index 1cc029441..2477dfffb 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -283,3 +283,4 @@ exclude: - 'app/javascript/widget/assets/scss/sdk.css' - 'app/assets/stylesheets/administrate/reset/_normalize.scss' - 'app/javascript/shared/assets/stylesheets/*.scss' + - 'app/javascript/dashboard/assets/scss/_woot.scss' diff --git a/Gemfile b/Gemfile index 439b0f63e..1e8605379 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.2' # 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' @@ -175,6 +173,11 @@ gem 'pgvector' # Convert Website HTML to Markdown gem 'reverse_markdown' +gem 'iso-639' +gem 'ruby-openai' + +gem 'shopify_api' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 74cdbeea8..d3ef47631 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) @@ -231,6 +223,7 @@ GEM erubi (1.13.0) et-orbi (1.2.11) tzinfo + event_stream_parser (1.0.0) execjs (2.8.1) facebook-messenger (2.0.1) httparty (~> 0.13, >= 0.13.7) @@ -279,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) @@ -358,8 +352,11 @@ GEM ruby2ruby (~> 2.4) ruby_parser (~> 3.10) hana (1.3.7) + hash_diff (1.1.1) 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) @@ -381,6 +378,8 @@ GEM io-console (0.6.0) irb (1.7.2) reline (>= 0.3.6) + iso-639 (0.3.8) + csv jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) @@ -486,7 +485,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.17) + net-imap (0.4.20) date net-protocol net-pop (0.1.2) @@ -502,14 +501,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.17.1) + nokogiri (1.18.8) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.17.1-arm64-darwin) + nokogiri (1.18.8-arm64-darwin) racc (~> 1.4) - nokogiri (1.17.1-x86_64-darwin) + nokogiri (1.18.8-x86_64-darwin) racc (~> 1.4) - nokogiri (1.17.1-x86_64-linux) + nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -524,6 +523,9 @@ GEM rack (>= 1.2, < 4) snaky_hash (~> 2.0) version_gem (~> 1.1) + oj (3.16.10) + bigdecimal (>= 3.0) + ostruct (>= 0.2) omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -542,6 +544,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) @@ -564,7 +567,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.10) + rack (2.2.13) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -684,6 +687,10 @@ GEM rubocop-rspec (2.21.0) rubocop (~> 1.33) rubocop-capybara (~> 2.17) + ruby-openai (7.3.1) + event_stream_parser (>= 0.3.0, < 2.0.0) + faraday (>= 1) + faraday-multipart (>= 1) ruby-progressbar (1.13.0) ruby-vips (2.1.4) ffi (~> 1.12) @@ -710,6 +717,7 @@ GEM parser scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) + securerandom (0.4.1) seed_dump (3.3.1) activerecord (>= 4) activesupport (>= 4) @@ -724,6 +732,17 @@ GEM sentry-ruby (~> 5.19.0) sidekiq (>= 3.0) sexp_processor (4.17.0) + shopify_api (14.8.0) + activesupport + concurrent-ruby + hash_diff + httparty + jwt + oj + openssl + securerandom + sorbet-runtime + zeitwerk (~> 2.5) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) sidekiq (7.3.1) @@ -746,15 +765,17 @@ 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.2) faraday (>= 2.0) faraday-mashify faraday-multipart gli hashie + logger snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) + sorbet-runtime (0.5.11934) spring (4.1.1) spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) @@ -777,7 +798,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) @@ -797,7 +818,7 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) uniform_notifier (1.16.0) - uri (0.13.0) + uri (1.0.3) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) @@ -892,8 +913,9 @@ DEPENDENCIES haikunator hairtrigger hashie - html2text! + html2text image_processing + iso-639 jbuilder json_refs json_schemer @@ -941,17 +963,19 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec + ruby-openai scout_apm scss_lint seed_dump sentry-rails (>= 5.19.0) sentry-ruby sentry-sidekiq (>= 5.19.0) + shopify_api shoulda-matchers sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) simplecov (= 0.17.1) - slack-ruby-client (~> 2.2.0) + slack-ruby-client (~> 2.5.2) spring spring-watcher-listen squasher diff --git a/README.md b/README.md index e5f73eb97..21316b422 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,11 @@ -## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x) - - -Chat dashboard dark mode -Chat dashboard +Header light mode +Header dark mode ___ # Chatwoot -Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. -

- - Deploy - - - Deploy to DO - -

+The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.

Maintainability @@ -31,41 +20,71 @@ Customer engagement suite, an open-source alternative to Intercom, Zendesk, Sale Artifact HUB

-Chat dashboard dark mode -Chat dashboard -Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile. +

+ + Deploy + + + Deploy to DO + +

-## Features +Chat dashboard dark mode +Chat dashboard -Chatwoot supports the following conversation channels: +--- - - **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support. - - **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page. - - **Instagram**: Connect your Instagram profile and start replying to the direct messages. - - **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned. - - **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard. - - **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot. - - **Line**: Connect your Line account and manage the conversations in Chatwoot. - - **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot. - - **API Channel**: Build custom communication channels using our API channel. - - **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard. +Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels. -And more. +### ✨ Captain – AI Agent for Support -Other features include: +Supercharge your support with Captain, Chatwoot’s AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs). + +### 💬 Omnichannel Support Desk + +Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc. + +### 📚 Help center portal + +Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues. + +### 🗂️ Other features + +#### Collaboration & Productivity + +- Private Notes and @mentions for internal team discussions. +- Labels to organize and categorize conversations. +- Keyboard Shortcuts and a Command Bar for quick navigation. +- Canned Responses to reply faster to frequently asked questions. +- Auto-Assignment to route conversations based on agent availability. +- Multi-lingual Support to serve customers in multiple languages. +- Custom Views and Filters for better inbox organization. +- Business Hours and Auto-Responders to manage response expectations. +- Teams and Automation tools for scaling support workflows. +- Agent Capacity Management to balance workload across the team. + +#### Customer Data & Segmentation +- Contact Management with profiles and interaction history. +- Contact Segments and Notes for targeted communication. +- Campaigns to proactively engage customers. +- Custom Attributes for storing additional customer data. +- Pre-Chat Forms to collect user information before starting conversations. + +#### Integrations +- Slack Integration to manage conversations directly from Slack. +- Dialogflow Integration for chatbot automation. +- Dashboard Apps to embed internal tools within Chatwoot. +- Shopify Integration to view and manage customer orders right within Chatwoot. +- Use Google Translate to translate messages from your customers in realtime. +- Create and manage Linear tickets within Chatwoot. + +#### Reports & Insights +- Live View of ongoing conversations for real-time monitoring. +- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility. +- CSAT Reports to measure customer satisfaction. +- Downloadable Reports for offline analysis and reporting. -- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes. -- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow. -- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox. -- **Private notes**: Use @mentions and private notes to communicate internally about a conversation. -- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions. -- **Conversation Labels**: Use conversation labels to create custom workflows. -- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load. -- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email. -- **Multi-lingual support**: Chatwoot supports 10+ languages. -- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoot’s webhooks and APIs. -- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard. ## Documentation @@ -107,17 +126,15 @@ For other supported options, checkout our [deployment page](https://chatwoot.com Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. - -## Community? Questions? Support ? +## Community If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server. - -## Contributors ✨ +## Contributors Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors): -*Chatwoot* © 2017-2024, Chatwoot Inc - Released under the MIT License. +*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License. diff --git a/SECURITY.md b/SECURITY.md index 0722f9217..ee9eba0b8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke ## Reporting a Vulnerability -We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. +We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. If you have any questions about the process, contact security@chatwoot.com. -Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible. +Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible. -> Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/) +> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new) ## Supported versions | Version | Supported | @@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions. - Brute force attacks - DNSSEC -If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/). +If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new). ## Thanks diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 4a36342fc..944880fa1 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.0.0 +3.2.0 diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 650dbe89f..2633c907d 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -54,7 +54,7 @@ class ContactMergeAction # attributes in base contact are given preference merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes) - @mergee_contact.destroy! + @mergee_contact.reload.destroy! Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)]) @base_contact.update!(merged_attributes) diff --git a/app/assets/stylesheets/administrate/components/_buttons.scss b/app/assets/stylesheets/administrate/components/_buttons.scss index 7b2f62045..1fceddc1f 100644 --- a/app/assets/stylesheets/administrate/components/_buttons.scss +++ b/app/assets/stylesheets/administrate/components/_buttons.scss @@ -1,15 +1,15 @@ -button, -input[type="button"], -input[type="reset"], -input[type="submit"], -.button { +button:not(.reset-base), +input[type='button']:not(.reset-base), +input[type='reset']:not(.reset-base), +input[type='submit']:not(.reset-base), +.button:not(.reset-base) { appearance: none; background-color: $color-woot; border: 0; border-radius: $base-border-radius; color: $white; cursor: pointer; - display: inline-block; + display: inline-flex; font-size: $font-size-small; -webkit-font-smoothing: antialiased; font-weight: $font-weight-medium; diff --git a/app/assets/stylesheets/administrate/custom_styles.scss b/app/assets/stylesheets/administrate/custom_styles.scss index 5e6d803d8..00f1a058c 100644 --- a/app/assets/stylesheets/administrate/custom_styles.scss +++ b/app/assets/stylesheets/administrate/custom_styles.scss @@ -10,7 +10,6 @@ .icon-container { margin-right: 2px; - } .value-container { diff --git a/app/builders/campaigns/campaign_conversation_builder.rb b/app/builders/campaigns/campaign_conversation_builder.rb index 3b3f262c9..0e9b90105 100644 --- a/app/builders/campaigns/campaign_conversation_builder.rb +++ b/app/builders/campaigns/campaign_conversation_builder.rb @@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder @contact_inbox.lock! # We won't send campaigns if a conversation is already present - raise 'Conversation alread present' if @contact_inbox.reload.conversations.present? + raise 'Conversation already present' if @contact_inbox.reload.conversations.present? @conversation = ::Conversation.create!(conversation_params) Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 8fcd2b158..ffa45db2e 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -64,5 +64,40 @@ class ContactInboxBuilder inbox_id: @inbox.id, source_id: @source_id ) + rescue ActiveRecord::RecordNotUnique + Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}") + update_old_contact_inbox + retry + end + + def update_old_contact_inbox + # The race condition occurs when there’s a contact inbox with the + # same source ID but linked to a different contact. This can happen + # if the agent updates the contact’s email or phone number, or + # if the contact is merged with another. + # + # We update the old contact inbox source_id to a random value to + # avoid disrupting the current flow. However, the root cause of + # this issue is a flaw in the contact inbox model design. + # Contact inbox is essentially tracking a session and is not + # needed for non-live chat channels. + raise ActiveRecord::RecordNotUnique unless allowed_channels? + + contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id) + return if contact_inbox.blank? + + contact_inbox.update!(source_id: new_source_id) + end + + def new_source_id + if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio? + "whatsapp:#{@source_id}#{rand(100)}" + else + "#{rand(10)}#{@source_id}" + end + end + + def allowed_channels? + @inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? end end diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2f30093db..2c0e6087e 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder contact = find_contact_by_identifier(contact_attributes[:identifier]) contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) + contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel? + contact end + def instagram_channel? + inbox.channel_type == 'Channel::Instagram' + end + + # There might be existing contact_inboxes created through Channel::FacebookPage + # with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes + # while still reusing contacts if found in Facebook channels so that we can create + # new conversations with the same contact. + def find_contact_by_instagram_source_id(instagram_id) + return if instagram_id.blank? + + existing_contact_inbox = ContactInbox.joins(:inbox) + .where(source_id: instagram_id) + .where( + 'inboxes.channel_type = ? AND inboxes.account_id = ?', + 'Channel::FacebookPage', + account.id + ).first + + existing_contact_inbox&.contact + end + def find_contact_by_identifier(identifier) return if identifier.blank? diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb new file mode 100644 index 000000000..767115bc6 --- /dev/null +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -0,0 +1,178 @@ +class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder + attr_reader :messaging + + def initialize(messaging, inbox, outgoing_echo: false) + super() + @messaging = messaging + @inbox = inbox + @outgoing_echo = outgoing_echo + end + + def perform + return if @inbox.channel.reauthorization_required? + + ActiveRecord::Base.transaction do + build_message + end + rescue StandardError => e + handle_error(e) + end + + private + + def attachments + @messaging[:message][:attachments] || {} + end + + def message_type + @outgoing_echo ? :outgoing : :incoming + end + + def message_identifier + message[:mid] + end + + def message_source_id + @outgoing_echo ? recipient_id : sender_id + end + + def message_is_unsupported? + message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true + end + + def sender_id + @messaging[:sender][:id] + end + + def recipient_id + @messaging[:recipient][:id] + end + + def message + @messaging[:message] + end + + def contact + @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact + end + + def conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + find_conversation_scope.order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_conversation_scope + Conversation.where(conversation_params) + end + + def find_or_build_for_multiple_conversations + last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first + return build_conversation if last_conversation.nil? + + last_conversation + end + + def message_content + @messaging[:message][:text] + end + + def story_reply_attributes + message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? + end + + def message_reply_attributes + message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? + end + + def build_message + # Duplicate webhook events may be sent for the same message + # when a user is connected to the Instagram account through both Messenger and Instagram login. + # There is chance for echo events to be sent for the same message. + # Therefore, we need to check if the message already exists before creating it. + return if message_already_exists? + + return if message_content.blank? && all_unsupported_files? + + @message = conversation.messages.create!(message_params) + save_story_id + + attachments.each do |attachment| + process_attachment(attachment) + end + end + + def save_story_id + return if story_reply_attributes.blank? + + @message.save_story_info(story_reply_attributes) + end + + def build_conversation + @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) + Conversation.create!(conversation_params.merge( + contact_inbox_id: @contact_inbox.id, + additional_attributes: additional_conversation_attributes + )) + end + + def additional_conversation_attributes + {} + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id + } + end + + def message_params + params = { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: message_type, + source_id: message_identifier, + content: message_content, + sender: @outgoing_echo ? nil : contact, + content_attributes: { + in_reply_to_external_id: message_reply_attributes + } + } + + params[:content_attributes][:is_unsupported] = true if message_is_unsupported? + params + end + + def message_already_exists? + cw_message = conversation.messages.where( + source_id: @messaging[:message][:mid] + ).first + + cw_message.present? + end + + def all_unsupported_files? + return if attachments.empty? + + attachments_type = attachments.pluck(:type).uniq.first + unsupported_file_type?(attachments_type) + end + + def handle_error(error) + ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception + true + end + + # Abstract methods to be implemented by subclasses + def get_story_object_from_source_id(source_id) + raise NotImplementedError + end +end diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 7f1b9cab2..4e7150894 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -1,200 +1,42 @@ -# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` -# Assumptions -# 1. Incase of an outgoing message which is echo, source_id will NOT be nil, -# based on this we are showing "not sent from chatwoot" message in frontend -# Hence there is no need to set user_id in message for outgoing echo messages. - -class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder - attr_reader :messaging - +class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder def initialize(messaging, inbox, outgoing_echo: false) - super() - @messaging = messaging - @inbox = inbox - @outgoing_echo = outgoing_echo - end - - def perform - return if @inbox.channel.reauthorization_required? - - ActiveRecord::Base.transaction do - build_message - end - rescue Koala::Facebook::AuthenticationError => e - Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}") - Rails.logger.error e - @inbox.channel.authorization_error! - raise - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception - true + super(messaging, inbox, outgoing_echo: outgoing_echo) end private - def attachments - @messaging[:message][:attachments] || {} + def get_story_object_from_source_id(source_id) + url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" + + response = HTTParty.get(url) + + return JSON.parse(response.body).with_indifferent_access if response.success? + + # Create message first if it doesn't exist + @message ||= conversation.messages.create!(message_params) + handle_error_response(response) + nil end - def message_type - @outgoing_echo ? :outgoing : :incoming - end + def handle_error_response(response) + parsed_response = JSON.parse(response.body) + error_code = parsed_response.dig('error', 'code') - def message_identifier - message[:mid] - end + # https://developers.facebook.com/docs/messenger-platform/error-codes + # Access token has expired or become invalid. + channel.authorization_error! if error_code == 190 - def message_source_id - @outgoing_echo ? recipient_id : sender_id - end - - def message_is_unsupported? - message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true - end - - def sender_id - @messaging[:sender][:id] - end - - def recipient_id - @messaging[:recipient][:id] - end - - def message - @messaging[:message] - end - - def contact - @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact - end - - def conversation - @conversation ||= set_conversation_based_on_inbox_config - end - - def instagram_direct_message_conversation - Conversation.where(conversation_params) - .where("additional_attributes ->> 'type' = 'instagram_direct_message'") - end - - def set_conversation_based_on_inbox_config - if @inbox.lock_to_single_conversation - instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation - else - find_or_build_for_multiple_conversations + # There was a problem scraping data from the provided link. + # https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 + if error_code == 1_609_005 + @message.attachments.destroy_all + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) end + + Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}") end - def find_or_build_for_multiple_conversations - last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first - - return build_conversation if last_conversation.nil? - - last_conversation + def base_uri + "https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" end - - def message_content - @messaging[:message][:text] - end - - def story_reply_attributes - message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? - end - - def message_reply_attributes - message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? - end - - def build_message - return if @outgoing_echo && already_sent_from_chatwoot? - return if message_content.blank? && all_unsupported_files? - - @message = conversation.messages.create!(message_params) - save_story_id - - attachments.each do |attachment| - process_attachment(attachment) - end - end - - def save_story_id - return if story_reply_attributes.blank? - - @message.save_story_info(story_reply_attributes) - end - - def build_conversation - @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) - - Conversation.create!(conversation_params.merge( - contact_inbox_id: @contact_inbox.id, - additional_attributes: { type: 'instagram_direct_message' } - )) - end - - def conversation_params - { - account_id: @inbox.account_id, - inbox_id: @inbox.id, - contact_id: contact.id - } - end - - def message_params - params = { - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - message_type: message_type, - source_id: message_identifier, - content: message_content, - sender: @outgoing_echo ? nil : contact, - content_attributes: { - in_reply_to_external_id: message_reply_attributes - } - } - - params[:content_attributes][:is_unsupported] = true if message_is_unsupported? - params - end - - def already_sent_from_chatwoot? - cw_message = conversation.messages.where( - source_id: @messaging[:message][:mid] - ).first - - cw_message.present? - end - - def all_unsupported_files? - return if attachments.empty? - - attachments_type = attachments.pluck(:type).uniq.first - unsupported_file_type?(attachments_type) - end - - ### Sample response - # { - # "object": "instagram", - # "entry": [ - # { - # "id": "",// ig id of the business - # "time": 1569262486134, - # "messaging": [ - # { - # "sender": { - # "id": "" - # }, - # "recipient": { - # "id": "" - # }, - # "timestamp": 1569262485349, - # "message": { - # "mid": "", - # "text": "" - # } - # } - # ] - # } - # ], - # } end diff --git a/app/builders/messages/instagram/messenger/message_builder.rb b/app/builders/messages/instagram/messenger/message_builder.rb new file mode 100644 index 000000000..1263dee90 --- /dev/null +++ b/app/builders/messages/instagram/messenger/message_builder.rb @@ -0,0 +1,33 @@ +class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder + def initialize(messaging, inbox, outgoing_echo: false) + super(messaging, inbox, outgoing_echo: outgoing_echo) + end + + private + + def get_story_object_from_source_id(source_id) + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + k.get_object(source_id, fields: %w[story from]) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue Koala::Facebook::ClientError => e + # The exception occurs when we are trying fetch the deleted story or blocked story. + @message.attachments.destroy_all + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) + Rails.logger.error e + {} + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + {} + end + + def find_conversation_scope + Conversation.where(conversation_params) + .where("additional_attributes ->> 'type' = 'instagram_direct_message'") + end + + def additional_conversation_attributes + { type: 'instagram_direct_message' } + end +end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index f01a236c9..4e7f2849d 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder message.save! end - def get_story_object_from_source_id(source_id) - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - k.get_object(source_id, fields: %w[story from]) || {} - rescue Koala::Facebook::AuthenticationError - @inbox.channel.authorization_error! - raise - rescue Koala::Facebook::ClientError => e - # The exception occurs when we are trying fetch the deleted story or blocked story. - @message.attachments.destroy_all - @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) - Rails.logger.error e - {} - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + # This is a placeholder method to be overridden by child classes + def get_story_object_from_source_id(_source_id) {} end diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index 3d8ce9674..d5461eb4e 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -25,6 +25,8 @@ class NotificationBuilder def build_notification # Create conversation_creation notification only if user is subscribed to it return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? + # skip notifications for blocked conversations except for user mentions + return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' user.notifications.create!( notification_type: notification_type, diff --git a/app/builders/v2/reports/agent_summary_builder.rb b/app/builders/v2/reports/agent_summary_builder.rb index 382c67bf5..9b4541aaa 100644 --- a/app/builders/v2/reports/agent_summary_builder.rb +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -2,52 +2,38 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder pattr_initialize [:account!, :params!] def build - set_grouped_conversations_count - set_grouped_avg_reply_time - set_grouped_avg_first_response_time - set_grouped_avg_resolution_time + load_data prepare_report end private - def set_grouped_conversations_count - @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count + attr_reader :conversations_count, :resolved_count, + :avg_resolution_time, :avg_first_response_time, :avg_reply_time + + def fetch_conversations_count + account.conversations.where(created_at: range).group('assignee_id').count end - def set_grouped_avg_resolution_time - @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved')) + def prepare_report + account.account_users.map do |account_user| + build_agent_stats(account_user) + end end - def set_grouped_avg_first_response_time - @grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response')) - end - - def set_grouped_avg_reply_time - @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time')) + def build_agent_stats(account_user) + user_id = account_user.user_id + { + id: user_id, + conversations_count: conversations_count[user_id] || 0, + resolved_conversations_count: resolved_count[user_id] || 0, + avg_resolution_time: avg_resolution_time[user_id], + avg_first_response_time: avg_first_response_time[user_id], + avg_reply_time: avg_reply_time[user_id] + } end def group_by_key :user_id end - - def reporting_events - @reporting_events ||= Current.account.reporting_events.where(created_at: range) - end - - def prepare_report - account.account_users.each_with_object([]) do |account_user, arr| - arr << { - id: account_user.user_id, - conversations_count: @grouped_conversations_count[account_user.user_id], - avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id], - avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id], - avg_reply_time: @grouped_avg_reply_time[account_user.user_id] - } - end - end - - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value - end end diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb index 33fe5e400..4de65926d 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -1,17 +1,50 @@ class V2::Reports::BaseSummaryBuilder include DateRangeHelper + def build + load_data + prepare_report + end + private + def load_data + @conversations_count = fetch_conversations_count + @resolved_count = fetch_resolved_count + @avg_resolution_time = fetch_average_time('conversation_resolved') + @avg_first_response_time = fetch_average_time('first_response') + @avg_reply_time = fetch_average_time('reply_time') + end + + def reporting_events + @reporting_events ||= account.reporting_events.where(created_at: range) + end + + def fetch_conversations_count + # Override this method + end + + def fetch_average_time(event_name) + get_grouped_average(reporting_events.where(name: event_name)) + end + + def fetch_resolved_count + reporting_events.where(name: 'conversation_resolved').group(group_by_key).count + end + def group_by_key # Override this method end + def prepare_report + # Override this method + end + def get_grouped_average(events) events.group(group_by_key).average(average_value_key) end def average_value_key - params[:business_hours].present? ? :value_in_business_hours : :value + ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value end end diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb new file mode 100644 index 000000000..e27385856 --- /dev/null +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -0,0 +1,50 @@ +class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder + pattr_initialize [:account!, :params!] + + def build + load_data + prepare_report + end + + private + + attr_reader :conversations_count, :resolved_count, + :avg_resolution_time, :avg_first_response_time, :avg_reply_time + + def load_data + @conversations_count = fetch_conversations_count + @resolved_count = fetch_resolved_count + @avg_resolution_time = fetch_average_time('conversation_resolved') + @avg_first_response_time = fetch_average_time('first_response') + @avg_reply_time = fetch_average_time('reply_time') + end + + def fetch_conversations_count + account.conversations.where(created_at: range).group(group_by_key).count + end + + def prepare_report + account.inboxes.map do |inbox| + build_inbox_stats(inbox) + end + end + + def build_inbox_stats(inbox) + { + id: inbox.id, + conversations_count: conversations_count[inbox.id] || 0, + resolved_conversations_count: resolved_count[inbox.id] || 0, + avg_resolution_time: avg_resolution_time[inbox.id], + avg_first_response_time: avg_first_response_time[inbox.id], + avg_reply_time: avg_reply_time[inbox.id] + } + end + + def group_by_key + :inbox_id + end + + def average_value_key + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value + end +end diff --git a/app/builders/v2/reports/team_summary_builder.rb b/app/builders/v2/reports/team_summary_builder.rb index 3d7e765b3..b98151bc6 100644 --- a/app/builders/v2/reports/team_summary_builder.rb +++ b/app/builders/v2/reports/team_summary_builder.rb @@ -1,49 +1,37 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder pattr_initialize [:account!, :params!] - def build - set_grouped_conversations_count - set_grouped_avg_reply_time - set_grouped_avg_first_response_time - set_grouped_avg_resolution_time - prepare_report - end - private - def set_grouped_conversations_count - @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count - end + attr_reader :conversations_count, :resolved_count, + :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def set_grouped_avg_resolution_time - @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved')) - end - - def set_grouped_avg_first_response_time - @grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response')) - end - - def set_grouped_avg_reply_time - @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time')) + def fetch_conversations_count + account.conversations.where(created_at: range).group(:team_id).count end def reporting_events - @reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation) + @reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation) + end + + def prepare_report + account.teams.map do |team| + build_team_stats(team) + end + end + + def build_team_stats(team) + { + id: team.id, + conversations_count: conversations_count[team.id] || 0, + resolved_conversations_count: resolved_count[team.id] || 0, + avg_resolution_time: avg_resolution_time[team.id], + avg_first_response_time: avg_first_response_time[team.id], + avg_reply_time: avg_reply_time[team.id] + } end def group_by_key 'conversations.team_id' end - - def prepare_report - account.teams.each_with_object([]) do |team, arr| - arr << { - id: team.id, - conversations_count: @grouped_conversations_count[team.id], - avg_resolution_time: @grouped_avg_resolution_time[team.id], - avg_first_response_time: @grouped_avg_first_response_time[team.id], - avg_reply_time: @grouped_avg_reply_time[team.id] - } - end - end end 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/channels/room_channel.rb b/app/channels/room_channel.rb index 0680f1458..629078229 100644 --- a/app/channels/room_channel.rb +++ b/app/channels/room_channel.rb @@ -2,10 +2,9 @@ class RoomChannel < ApplicationCable::Channel def subscribed # TODO: should we only do ensure stream if current account is present? # for now going ahead with guard clauses in update_subscription and broadcast_presence - - ensure_stream current_user current_account + ensure_stream update_subscription broadcast_presence end @@ -22,12 +21,12 @@ class RoomChannel < ApplicationCable::Channel data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) } data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User - ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data }) + ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data }) end def ensure_stream - @pubsub_token = params[:pubsub_token] - stream_from @pubsub_token + stream_from pubsub_token + stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User) end def update_subscription @@ -36,11 +35,15 @@ class RoomChannel < ApplicationCable::Channel ::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id) end + def pubsub_token + @pubsub_token ||= params[:pubsub_token] + end + def current_user @current_user ||= if params[:user_id].blank? - ContactInbox.find_by!(pubsub_token: @pubsub_token).contact + ContactInbox.find_by!(pubsub_token: pubsub_token).contact else - User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id]) + User.find_by!(pubsub_token: pubsub_token, id: params[:user_id]) end end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 43bce17bc..1422beea1 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController end def permitted_params - params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content]) + params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) end def process_avatar_from_url diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 01aa42620..438944f04 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def allowed_agent_params - [:name, :email, :name, :role, :availability, :auto_offline] + [:name, :email, :role, :availability, :auto_offline] end def agent_params diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index 5e0a0e55e..fda19b8c2 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -1,17 +1,21 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController def index - @conversations = Current.account.conversations.includes( + # Start with all conversations for this contact + conversations = Current.account.conversations.includes( :assignee, :contact, :inbox, :taggings - ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20) - end + ).where(contact_id: @contact.id) - private + # Apply permission-based filtering using the existing service + conversations = Conversations::PermissionFilterService.new( + conversations, + Current.user, + Current.account + ).perform - def inbox_ids - if Current.user.administrator? || Current.user.agent? - Current.user.assigned_inboxes.pluck(:id) - else - [] - end + # Only allow conversations from inboxes the user has access to + inbox_ids = Current.user.assigned_inboxes.pluck(:id) + conversations = conversations.where(inbox_id: inbox_ids) + + @conversations = conversations.order(last_activity_at: :desc).limit(20) end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 0d8e0ed93..b4d5e3fc1 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -68,6 +68,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @contacts = fetch_contacts(contacts) rescue CustomExceptions::CustomFilter::InvalidAttribute, CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidQueryOperator, CustomExceptions::CustomFilter::InvalidValue => e render_could_not_create_error(e.message) end @@ -162,9 +163,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @contact.custom_attributes end + def contact_additional_attributes + return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes] + + @contact.additional_attributes + end + def contact_update_params - # we want the merged custom attributes not the original one - permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes }) + permitted_params.except(:custom_attributes, :avatar_url) + .merge({ custom_attributes: contact_custom_attributes }) + .merge({ additional_attributes: contact_additional_attributes }) end def set_include_contact_inboxes diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 63226f342..67381a715 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController + before_action :ensure_api_inbox, only: :update + def index @messages = message_finder.perform end @@ -11,6 +13,11 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: render_could_not_create_error(e.message) end + def update + Messages::StatusUpdateService.new(message, permitted_params[:status], permitted_params[:external_error]).perform + @message = message + end + def destroy ActiveRecord::Base.transaction do message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true }) @@ -21,7 +28,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: def retry return if message.blank? - message.update!(status: :sent, content_attributes: {}) + service = Messages::StatusUpdateService.new(message, 'sent') + service.perform + message.update!(content_attributes: {}) ::SendReplyJob.perform_later(message.id) rescue StandardError => e render_could_not_create_error(e.message) @@ -56,10 +65,16 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end def permitted_params - params.permit(:id, :target_language) + params.permit(:id, :target_language, :status, :external_error) end def already_translated_content_available? message.translations.present? && message.translations[permitted_params[:target_language]].present? end + + # API inbox check + def ensure_api_inbox + # Only API inboxes can update messages + render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api? + end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 2aedf1928..8753918fc 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -6,6 +6,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :inbox, :contact, :contact_inbox, only: [:create] + ATTACHMENT_RESULTS_PER_PAGE = 100 + def index result = conversation_finder.perform @conversations = result[:conversations] @@ -24,7 +26,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def attachments + @attachments_count = @conversation.attachments.count @attachments = @conversation.attachments + .includes(:message) + .order(created_at: :desc) + .page(attachment_params[:page]) + .per(ATTACHMENT_RESULTS_PER_PAGE) end def show; end @@ -41,11 +48,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def filter - result = ::Conversations::FilterService.new(params.permit!, current_user).perform + result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform @conversations = result[:conversations] @conversations_count = result[:count] rescue CustomExceptions::CustomFilter::InvalidAttribute, CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidQueryOperator, CustomExceptions::CustomFilter::InvalidValue => e render_could_not_create_error(e.message) end @@ -123,6 +131,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro params.permit(:priority) end + def attachment_params + params.permit(:page) + end + def update_last_seen_on_conversation(last_seen_at, update_assignee) # rubocop:disable Rails/SkipsModelValidations @conversation.update_column(:agent_last_seen_at, last_seen_at) 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/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb new file mode 100644 index 000000000..eace4411a --- /dev/null +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController + include InstagramConcern + include Instagram::IntegrationHelper + before_action :check_authorization + + def create + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization + redirect_url = instagram_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/instagram/callback", + scope: REQUIRED_SCOPES.join(','), + enable_fb_login: '0', + force_authentication: '1', + response_type: 'code', + state: generate_instagram_token(Current.account.id) + } + ) + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/api/v1/accounts/integrations/captain_controller.rb b/app/controllers/api/v1/accounts/integrations/captain_controller.rb deleted file mode 100644 index 6813d14b5..000000000 --- a/app/controllers/api/v1/accounts/integrations/captain_controller.rb +++ /dev/null @@ -1,78 +0,0 @@ -class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController - before_action :hook - - def proxy - request_url = build_request_url(request_path) - response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers) - render plain: response.body, status: response.code - end - - def copilot - request_url = build_request_url(build_request_path("/assistants/#{hook.settings['assistant_id']}/copilot")) - params = { - previous_messages: copilot_params[:previous_messages], - conversation_history: conversation_history, - message: copilot_params[:message] - } - response = HTTParty.send(:post, request_url, body: params.to_json, headers: headers) - render plain: response.body, status: response.code - end - - private - - def headers - { - 'X-User-Email' => hook.settings['account_email'], - 'X-User-Token' => hook.settings['access_token'], - 'Content-Type' => 'application/json', - 'Accept' => '*/*' - } - end - - def build_request_path(route) - "api/accounts/#{hook.settings['account_id']}#{route}" - end - - def request_path - request_route = with_leading_hash_on_route(params[:route]) - - return 'api/sessions/profile' if request_route == '/sessions/profile' - - build_request_path(request_route) - end - - def build_request_url(request_path) - base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value - URI.join(base_url, request_path).to_s - end - - def hook - @hook ||= Current.account.hooks.find_by!(app_id: 'captain') - end - - def request_method - method = permitted_params[:method].downcase - raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method) - - method - end - - def with_leading_hash_on_route(request_route) - return '' if request_route.blank? - - request_route.start_with?('/') ? request_route : "/#{request_route}" - end - - def conversation_history - conversation = Current.account.conversations.find_by!(display_id: copilot_params[:conversation_id]) - conversation.to_llm_text - end - - def copilot_params - params.permit(:previous_messages, :conversation_id, :message) - end - - def permitted_params - params.permit(:method, :route, body: {}) - end -end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 814373c7e..4e5348e88 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,11 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end def teams teams = linear_processor_service.teams @@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def permitted_params params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) end + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') + end end diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb new file mode 100644 index 000000000..7fe31889b --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb @@ -0,0 +1,111 @@ +class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController + include Shopify::IntegrationHelper + before_action :setup_shopify_context, only: [:orders] + before_action :fetch_hook, except: [:auth] + before_action :validate_contact, only: [:orders] + + def auth + shop_domain = params[:shop_domain] + return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank? + + state = generate_shopify_token(Current.account.id) + + auth_url = "https://#{shop_domain}/admin/oauth/authorize?" + auth_url += URI.encode_www_form( + client_id: client_id, + scope: REQUIRED_SCOPES.join(','), + redirect_uri: redirect_uri, + state: state + ) + + render json: { redirect_url: auth_url } + end + + def orders + customers = fetch_customers + return render json: { orders: [] } if customers.empty? + + orders = fetch_orders(customers.first['id']) + render json: { orders: orders } + rescue ShopifyAPI::Errors::HttpResponseError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def destroy + @hook.destroy! + head :ok + rescue StandardError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def redirect_uri + "#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback" + end + + def contact + @contact ||= Current.account.contacts.find_by(id: params[:contact_id]) + end + + def fetch_hook + @hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify') + end + + def fetch_customers + query = [] + query << "email:#{contact.email}" if contact.email.present? + query << "phone:#{contact.phone_number}" if contact.phone_number.present? + + shopify_client.get( + path: 'customers/search.json', + query: { + query: query.join(' OR '), + fields: 'id,email,phone' + } + ).body['customers'] || [] + end + + def fetch_orders(customer_id) + orders = shopify_client.get( + path: 'orders.json', + query: { + customer_id: customer_id, + status: 'any', + fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status' + } + ).body['orders'] || [] + + orders.map do |order| + order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}") + end + end + + def setup_shopify_context + return if client_id.blank? || client_secret.blank? + + ShopifyAPI::Context.setup( + api_key: client_id, + api_secret_key: client_secret, + api_version: '2025-01'.freeze, + scope: REQUIRED_SCOPES.join(','), + is_embedded: true, + is_private: false + ) + end + + def shopify_session + ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token) + end + + def shopify_client + @shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session) + end + + def validate_contact + return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?) + + render json: { error: 'Contact information missing' }, + status: :unprocessable_entity + end +end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index fe9a03ef5..6cfed161f 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portals = Current.account.portals end - def add_members - agents = Current.account.agents.where(id: portal_member_params[:member_ids]) - @portal.members << agents - end - def show @all_articles = @portal.articles @articles = @all_articles.search(locale: params[:locale]) @@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController { channel_web_widget_id: inbox.channel.id } end - def portal_member_params - params.require(:portal).permit(:account_id, member_ids: []) - end - def set_current_page @current_page = params[:page] || 1 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/api/v1/widget/campaigns_controller.rb b/app/controllers/api/v1/widget/campaigns_controller.rb index cb9b96a38..10a73aa62 100644 --- a/app/controllers/api/v1/widget/campaigns_controller.rb +++ b/app/controllers/api/v1/widget/campaigns_controller.rb @@ -2,6 +2,10 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController skip_before_action :set_contact def index - @campaigns = @web_widget.inbox.campaigns.where(enabled: true) + @campaigns = @web_widget + .inbox + .campaigns + .where(enabled: true, account_id: @web_widget.inbox.account_id) + .includes(:sender) end end diff --git a/app/controllers/api/v1/widget/inbox_members_controller.rb b/app/controllers/api/v1/widget/inbox_members_controller.rb index c4bc377ea..22934388b 100644 --- a/app/controllers/api/v1/widget/inbox_members_controller.rb +++ b/app/controllers/api/v1/widget/inbox_members_controller.rb @@ -2,6 +2,6 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController skip_before_action :set_contact def index - @inbox_members = @web_widget.inbox.inbox_members.includes(:user) + @inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob }) end end diff --git a/app/controllers/api/v2/accounts/live_reports_controller.rb b/app/controllers/api/v2/accounts/live_reports_controller.rb new file mode 100644 index 000000000..1c703764b --- /dev/null +++ b/app/controllers/api/v2/accounts/live_reports_controller.rb @@ -0,0 +1,64 @@ +class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController + before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics] + before_action :set_group_scope, only: [:grouped_conversation_metrics] + + before_action :check_authorization + + def conversation_metrics + render json: { + open: @conversations.open.count, + unattended: @conversations.open.unattended.count, + unassigned: @conversations.open.unassigned.count, + pending: @conversations.pending.count + } + end + + def grouped_conversation_metrics + count_by_group = @conversations.open.group(@group_scope).count + unattended_by_group = @conversations.open.unattended.group(@group_scope).count + unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count + + group_metrics = count_by_group.map do |group_id, count| + metric = { + open: count, + unattended: unattended_by_group[group_id] || 0, + unassigned: unassigned_by_group[group_id] || 0 + } + metric[@group_scope] = group_id + metric + end + + render json: group_metrics + end + + private + + def check_authorization + authorize :report, :view? + end + + def set_group_scope + render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[ + team_id + assignee_id + ].include?(permitted_params[:group_by]) + + @group_scope = permitted_params[:group_by] + end + + def team + return unless permitted_params[:team_id] + + @team ||= Current.account.teams.find(permitted_params[:team_id]) + end + + def load_conversations + scope = Current.account.conversations + scope = scope.where(team_id: team.id) if team.present? + @conversations = scope + end + + def permitted_params + params.permit(:team_id, :group_by) + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index af3655e59..6e2d0ff4c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController end def check_authorization - return if Current.account_user.administrator? - - raise Pundit::NotAuthorizedError + authorize :report, :view? end def common_params @@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics end end - -Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController') diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 0cbd6dd8e..989952cfd 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -1,6 +1,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :prepare_builder_params, only: [:agent, :team] + before_action :prepare_builder_params, only: [:agent, :team, :inbox] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -10,6 +10,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::TeamSummaryBuilder) end + def inbox + render_report_with(V2::Reports::InboxSummaryBuilder) + end + private def check_authorization @@ -26,8 +30,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr def render_report_with(builder_class) builder = builder_class.new(account: Current.account, params: @builder_params) - data = builder.build - render json: data + render json: builder.build end def permitted_params diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb new file mode 100644 index 000000000..04de9e63c --- /dev/null +++ b/app/controllers/concerns/instagram_concern.rb @@ -0,0 +1,74 @@ +module InstagramConcern + extend ActiveSupport::Concern + + def instagram_client + ::OAuth2::Client.new( + client_id, + client_secret, + { + site: 'https://api.instagram.com', + authorize_url: 'https://api.instagram.com/oauth/authorize', + token_url: 'https://api.instagram.com/oauth/access_token', + auth_scheme: :request_body, + token_method: :post + } + ) + end + + private + + def client_id + GlobalConfigService.load('INSTAGRAM_APP_ID', nil) + end + + def client_secret + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def exchange_for_long_lived_token(short_lived_token) + endpoint = 'https://graph.instagram.com/access_token' + params = { + grant_type: 'ig_exchange_token', + client_secret: client_secret, + access_token: short_lived_token, + client_id: client_id + } + + make_api_request(endpoint, params, 'Failed to exchange token') + end + + def fetch_instagram_user_details(access_token) + endpoint = 'https://graph.instagram.com/v22.0/me' + params = { + fields: 'id,username,user_id,name,profile_picture_url,account_type', + access_token: access_token + } + + make_api_request(endpoint, params, 'Failed to fetch Instagram user details') + end + + def make_api_request(endpoint, params, error_prefix) + response = HTTParty.get( + endpoint, + query: params, + headers: { 'Accept' => 'application/json' } + ) + + unless response.success? + Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}" + raise "#{error_prefix}: #{response.body}" + end + + begin + JSON.parse(response.body) + rescue JSON::ParserError => e + ChatwootExceptionTracker.new(e).capture_exception + Rails.logger.error "Invalid JSON response: #{response.body}" + raise e + end + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index 3013ff3cc..a8ea8ae05 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -5,10 +5,11 @@ module SwitchLocale def switch_locale(&) # priority is for locale set in query string (mostly for widget/from js sdk) - locale ||= locale_from_params + locale ||= params[:locale] + locale ||= locale_from_custom_domain # if locale is not set in account, let's use DEFAULT_LOCALE env variable - locale ||= locale_from_env_variable + locale ||= ENV.fetch('DEFAULT_LOCALE', nil) set_locale(locale, &) end @@ -32,26 +33,30 @@ module SwitchLocale end def set_locale(locale, &) - # if locale is empty, use default_locale - locale ||= I18n.default_locale + safe_locale = validate_and_get_locale(locale) # Ensure locale won't bleed into other requests # https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests - I18n.with_locale(locale, &) + I18n.with_locale(safe_locale, &) end - def locale_from_params - I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil + def validate_and_get_locale(locale) + return I18n.default_locale.to_s if locale.blank? + + available_locales = I18n.available_locales.map(&:to_s) + locale_without_variant = locale.split('_')[0] + + if available_locales.include?(locale) + locale + elsif available_locales.include?(locale_without_variant) + locale_without_variant + else + I18n.default_locale.to_s + end end def locale_from_account(account) return unless account - I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil - end - - def locale_from_env_variable - return unless ENV.fetch('DEFAULT_LOCALE', nil) - - I18n.available_locales.map(&:to_s).include?(ENV.fetch('DEFAULT_LOCALE')) ? ENV.fetch('DEFAULT_LOCALE') : nil + account.locale end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 6ddaab73b..a2cb466f1 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' + 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end @@ -61,6 +65,7 @@ class DashboardController < ActionController::Base VAPID_PUBLIC_KEY: VapidService.public_key, ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), + INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index e1cf76d6b..2b3ea9067 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa def validate_business_account? # return true if the user is a business account, false if it is a gmail account - auth_hash['info']['email'].exclude?('@gmail.com') + auth_hash['info']['email'].downcase.exclude?('@gmail.com') end def create_account_for_user diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb new file mode 100644 index 000000000..4dc8ece1c --- /dev/null +++ b/app/controllers/instagram/callbacks_controller.rb @@ -0,0 +1,163 @@ +class Instagram::CallbacksController < ApplicationController + include InstagramConcern + include Instagram::IntegrationHelper + + def show + # Check if Instagram redirected with an error (user canceled authorization) + # See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + if params[:error].present? + handle_authorization_error + return + end + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + # Process the authorization code and create inbox + def process_successful_authorization + @response = instagram_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/#{provider_name}/callback", + grant_type: 'authorization_code' + ) + + @long_lived_token_response = exchange_for_long_lived_token(@response.token) + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + end + + # Handle all errors that might occur during authorization + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response + def handle_error(error) + Rails.logger.error("Instagram Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + error_info = extract_error_info(error) + redirect_to_error_page(error_info) + end + + # Extract error details from the exception + def extract_error_info(error) + if error.is_a?(OAuth2::Error) + begin + # Instagram returns JSON error response which we parse to extract error details + JSON.parse(error.message) + rescue JSON::ParseError + # Fall back to a generic OAuth error if JSON parsing fails + { 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message } + end + else + # For other unexpected errors + { 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message } + end + end + + # Handles the case when a user denies permissions or cancels the authorization flow + # Error parameters are documented at: + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + def handle_authorization_error + error_info = { + 'error_type' => params[:error] || 'authorization_error', + 'code' => 400, + 'error_message' => params[:error_description] || 'Authorization was denied' + } + + Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}") + redirect_to_error_page(error_info) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_info) + redirect_to app_new_instagram_inbox_url( + account_id: account_id, + error_type: error_info['error_type'], + code: error_info['code'], + error_message: error_info['error_message'] + ) + end + + def find_or_create_inbox + user_details = fetch_instagram_user_details(@long_lived_token_response['access_token']) + channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s) + channel_exists = channel_instagram.present? + + if channel_instagram + update_channel(channel_instagram, user_details) + else + channel_instagram = create_channel_with_inbox(user_details) + end + + # reauthorize channel, this code path only triggers when instagram auth is successful + # reauthorized will also update cache keys for the associated inbox + channel_instagram.reauthorized! + + [channel_instagram.inbox, channel_exists] + end + + def find_channel_by_instagram_id(instagram_id) + Channel::Instagram.find_by(instagram_id: instagram_id, account: account) + end + + def update_channel(channel_instagram, user_details) + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + channel_instagram.update!( + access_token: @long_lived_token_response['access_token'], + expires_at: expires_at + ) + + # Update inbox name if username changed + channel_instagram.inbox.update!(name: user_details['username']) + channel_instagram + end + + def create_channel_with_inbox(user_details) + ActiveRecord::Base.transaction do + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + channel_instagram = Channel::Instagram.create!( + access_token: @long_lived_token_response['access_token'], + instagram_id: user_details['user_id'].to_s, + account: account, + expires_at: expires_at + ) + + account.inboxes.create!( + account: account, + channel: channel_instagram, + name: user_details['username'] + ) + + channel_instagram + end + end + + def account_id + return unless params[:state] + + verify_instagram_token(params[:state]) + end + + def oauth_code + params[:code] + end + + def account + @account ||= Account.find(account_id) + end + + def provider_name + 'instagram' + end +end diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb new file mode 100644 index 000000000..2eea49333 --- /dev/null +++ b/app/controllers/linear/callbacks_controller.rb @@ -0,0 +1,73 @@ +class Linear::CallbacksController < ApplicationController + include Linear::IntegrationHelper + + def show + @response = oauth_client.auth_code.get_token( + params[:code], + redirect_uri: "#{base_url}/linear/callback" + ) + + handle_response + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + private + + def oauth_client + app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil) + + OAuth2::Client.new( + app_id, + app_secret, + { + site: 'https://api.linear.app', + token_url: '/oauth/token', + authorize_url: '/oauth/authorize' + } + ) + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'linear', + settings: { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + scope: parsed_body['scope'] + } + ) + # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 + hook.save! + redirect_to linear_redirect_uri + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + def account + @account ||= Account.find(account_id) + end + + def account_id + return unless params[:state] + + verify_linear_token(params[:state]) + end + + def linear_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + 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 08c62bada..d07dbcb9d 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -6,16 +6,25 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B layout 'portal' def index - @articles = @portal.articles.published + @articles = @portal.articles.published.includes(:category, :author) + @articles_count = @articles.count search_articles order_by_sort_param - @articles.page(list_params[:page]) if list_params[:page].present? + limit_results end def show; end private + def limit_results + return if list_params[:per_page].blank? + + per_page = [list_params[:per_page].to_i, 100].min + per_page = 25 if per_page < 1 + @articles = @articles.page(list_params[:page]).per(per_page) + end + def search_articles @articles = @articles.search(list_params) if list_params.present? end @@ -44,7 +53,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B end def list_params - params.permit(:query, :locale, :sort, :status) + params.permit(:query, :locale, :sort, :status, :page, :per_page) end def permitted_params diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index f6c10f7c4..66b052b1e 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -1,4 +1,6 @@ class Public::Api::V1::Portals::BaseController < PublicController + include SwitchLocale + before_action :show_plain_layout before_action :set_color_scheme before_action :set_global_config @@ -27,14 +29,7 @@ class Public::Api::V1::Portals::BaseController < PublicController end def switch_locale_with_portal(&) - locale_without_variant = params[:locale].split('_')[0] - is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale]) - is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant) - if is_locale_available - @locale = params[:locale] - elsif is_locale_variant_available - @locale = locale_without_variant - end + @locale = validate_and_get_locale(params[:locale]) I18n.with_locale(@locale, &) end @@ -44,12 +39,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - @locale = if article.category.present? - article.category.locale - else - article.portal.default_locale - end - + article_locale = if article.category.present? + article.category.locale + else + article.portal.default_locale + end + @locale = validate_and_get_locale(article_locale) I18n.with_locale(@locale, &) end diff --git a/app/controllers/shopify/callbacks_controller.rb b/app/controllers/shopify/callbacks_controller.rb new file mode 100644 index 000000000..7fb8b5a47 --- /dev/null +++ b/app/controllers/shopify/callbacks_controller.rb @@ -0,0 +1,72 @@ +class Shopify::CallbacksController < ApplicationController + include Shopify::IntegrationHelper + + def show + verify_account! + + @response = oauth_client.auth_code.get_token( + params[:code], + redirect_uri: '/shopify/callback' + ) + + handle_response + rescue StandardError => e + Rails.logger.error("Shopify callback error: #{e.message}") + redirect_to "#{redirect_uri}?error=true" + end + + private + + def verify_account! + @account_id = verify_shopify_token(params[:state]) + raise StandardError, 'Invalid state parameter' if account.blank? + end + + def handle_response + account.hooks.create!( + app_id: 'shopify', + access_token: parsed_body['access_token'], + status: 'enabled', + reference_id: params[:shop], + settings: { + scope: parsed_body['scope'] + } + ) + + redirect_to shopify_integration_url + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end + + def oauth_client + OAuth2::Client.new( + client_id, + client_secret, + { + site: "https://#{params[:shop]}", + authorize_url: '/admin/oauth/authorize', + token_url: '/admin/oauth/access_token' + } + ) + end + + def account + @account ||= Account.find(@account_id) + end + + def account_id + @account_id ||= params[:state].split('_').first + end + + def shopify_integration_url + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify" + end + + def redirect_uri + return shopify_integration_url if account + + ENV.fetch('FRONTEND_URL', nil) + end +end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 5de25f677..27ce587f7 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -66,3 +66,5 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # rubocop:enable Rails/I18nLocaleTexts end end + +SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController') diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index b8f3bd9a9..550b6c893 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -35,10 +35,16 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController @allowed_configs = case @config when 'facebook' %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT] + when 'shopify' + %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET] when 'microsoft' %w[AZURE_APP_ID AZURE_APP_SECRET] when 'email' ['MAILER_INBOUND_EMAIL_DOMAIN'] + when 'linear' + %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] + when 'instagram' + %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] else %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index 7723e5dd2..ff5db952d 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -1,6 +1,6 @@ class Twilio::CallbackController < ApplicationController def create - ::Twilio::IncomingMessageService.new(params: permitted_params).perform + Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash) head :no_content end diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb index cc7afb0fc..1c756a1c2 100644 --- a/app/controllers/twilio/delivery_status_controller.rb +++ b/app/controllers/twilio/delivery_status_controller.rb @@ -1,6 +1,6 @@ class Twilio::DeliveryStatusController < ApplicationController def create - ::Twilio::DeliveryStatusService.new(params: permitted_params).perform + Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash) head :no_content end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index b658915ed..3d46334ca 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API private def valid_token?(token) - token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') + # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and + # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) + token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || + token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index 8f408d2b0..c4c376e5c 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -2,6 +2,12 @@ class Webhooks::WhatsappController < ActionController::API include MetaTokenVerifyConcern def process_payload + if inactive_whatsapp_number? + Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}") + render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity + return + end + Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) head :ok end @@ -13,4 +19,15 @@ class Webhooks::WhatsappController < ActionController::API whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present? token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + + def inactive_whatsapp_number? + phone_number = params[:phone_number] + return false if phone_number.blank? + + inactive_numbers = GlobalConfig.get_value('INACTIVE_WHATSAPP_NUMBERS').to_s + return false if inactive_numbers.blank? + + inactive_numbers_array = inactive_numbers.split(',').map(&:strip) + inactive_numbers_array.include?(phone_number) + end end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index b566551f4..9be674f11 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -9,10 +9,17 @@ class AccountDashboard < Administrate::BaseDashboard # on pages throughout the dashboard. enterprise_attribute_types = if ChatwootApp.enterprise? - { - limits: Enterprise::AccountLimitsField, - all_features: Enterprise::AccountFeaturesField + attributes = { + limits: AccountLimitsField } + + # Only show manually managed features in Chatwoot Cloud deployment + attributes[:manually_managed_features] = ManuallyManagedFeaturesField if ChatwootApp.chatwoot_cloud? + + # Add all_features last so it appears after manually_managed_features + attributes[:all_features] = AccountFeaturesField + + attributes else {} end @@ -46,7 +53,14 @@ class AccountDashboard < Administrate::BaseDashboard # SHOW_PAGE_ATTRIBUTES # an array of attributes that will be displayed on the model's show page. - enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : [] + enterprise_show_page_attributes = if ChatwootApp.enterprise? + attrs = %i[custom_attributes limits] + attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? + attrs << :all_features + attrs + else + [] + end SHOW_PAGE_ATTRIBUTES = (%i[ id name @@ -61,7 +75,14 @@ class AccountDashboard < Administrate::BaseDashboard # FORM_ATTRIBUTES # an array of attributes that will be displayed # on the model's form (`new` and `edit`) pages. - enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : [] + enterprise_form_attributes = if ChatwootApp.enterprise? + attrs = %i[limits] + attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? + attrs << :all_features + attrs + else + [] + end FORM_ATTRIBUTES = (%i[ name locale @@ -78,7 +99,12 @@ class AccountDashboard < Administrate::BaseDashboard # COLLECTION_FILTERS = { # open: ->(resources) { resources.where(open: true) } # }.freeze - COLLECTION_FILTERS = {}.freeze + COLLECTION_FILTERS = { + active: ->(resources) { resources.where(status: :active) }, + suspended: ->(resources) { resources.where(status: :suspended) }, + recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }, + marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") } + }.freeze # Overwrite this method to customize how accounts are displayed # across all pages of the admin dashboard. @@ -91,6 +117,11 @@ class AccountDashboard < Administrate::BaseDashboard # 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: {}] + attrs = super + [limits: {}] + + # Add manually_managed_features to permitted attributes only for Chatwoot Cloud + attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? + + attrs end end diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 6b2129eed..8abdefd1a 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard # COLLECTION_FILTERS = { # open: ->(resources) { resources.where(open: true) } # }.freeze - COLLECTION_FILTERS = {}.freeze + COLLECTION_FILTERS = { + super_admin: ->(resources) { resources.where(type: 'SuperAdmin') }, + confirmed: ->(resources) { resources.where.not(confirmed_at: nil) }, + unconfirmed: ->(resources) { resources.where(confirmed_at: nil) }, + recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) } + }.freeze # Overwrite this method to customize how users are displayed # across all pages of the admin dashboard. diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index b582bc5dd..7416b7861 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -22,3 +22,5 @@ class AsyncDispatcher < BaseDispatcher ] end end + +AsyncDispatcher.prepend_mod_with('AsyncDispatcher') diff --git a/app/fields/enterprise/account_features_field.rb b/app/fields/enterprise/account_features_field.rb deleted file mode 100644 index 7a9de5a59..000000000 --- a/app/fields/enterprise/account_features_field.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'administrate/field/base' - -class Enterprise::AccountFeaturesField < Administrate::Field::Base - def to_s - data - end -end diff --git a/app/fields/enterprise/account_limits_field.rb b/app/fields/enterprise/account_limits_field.rb deleted file mode 100644 index 5833952ad..000000000 --- a/app/fields/enterprise/account_limits_field.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'administrate/field/base' - -class Enterprise::AccountLimitsField < Administrate::Field::Base - def to_s - data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json - end -end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 44592c201..1694199ba 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -32,6 +32,7 @@ class ConversationFinder def initialize(current_user, params) @current_user = current_user @current_account = current_user.account + @is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator? @params = params end @@ -85,8 +86,19 @@ class ConversationFinder @team = current_account.teams.find(params[:team_id]) if params[:team_id] end + def find_conversation_by_inbox + @conversations = current_account.conversations + @conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin + end + def find_all_conversations - @conversations = current_account.conversations.where(inbox_id: @inbox_ids) + find_conversation_by_inbox + # Apply permission-based filtering + @conversations = Conversations::PermissionFilterService.new( + @conversations, + current_user, + current_account + ).perform filter_by_conversation_type if params[:conversation_type] @conversations 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/billing_helper.rb b/app/helpers/billing_helper.rb index 26669e38d..e2ada7e86 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -18,4 +18,8 @@ module BillingHelper def non_web_inboxes(account) account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count end + + def agents(account) + account.users.count + end end diff --git a/app/helpers/filter_helper.rb b/app/helpers/filters/filter_helper.rb similarity index 83% rename from app/helpers/filter_helper.rb rename to app/helpers/filters/filter_helper.rb index bce2de5ea..fe03dae28 100644 --- a/app/helpers/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -1,4 +1,4 @@ -module FilterHelper +module Filters::FilterHelper def build_condition_query(model_filters, query_hash, current_index) current_filter = model_filters[query_hash['attribute_key']] @@ -81,4 +81,26 @@ module FilterHelper def default_filter(query_hash, filter_operator_value) "#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}" end + + def validate_single_condition(condition) + return if condition['query_operator'].nil? + return if condition['query_operator'].empty? + + operator = condition['query_operator'].upcase + raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator) + end + + def conversation_status_values(values) + return Conversation.statuses.values if values.include?('all') + + values.map { |x| Conversation.statuses[x.to_sym] } + end + + def conversation_priority_values(values) + values.map { |x| Conversation.priorities[x.to_sym] } + end + + def message_type_values(values) + values.map { |x| Message.message_types[x.to_sym] } + end end diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb new file mode 100644 index 000000000..8ba57bf95 --- /dev/null +++ b/app/helpers/instagram/integration_helper.rb @@ -0,0 +1,49 @@ +module Instagram::IntegrationHelper + REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze + + # Generates a signed JWT token for Instagram integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_instagram_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Instagram token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Instagram JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_instagram_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") + nil + end +end diff --git a/app/helpers/linear/integration_helper.rb b/app/helpers/linear/integration_helper.rb new file mode 100644 index 000000000..67df836ce --- /dev/null +++ b/app/helpers/linear/integration_helper.rb @@ -0,0 +1,47 @@ +module Linear::IntegrationHelper + # Generates a signed JWT token for Linear integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_linear_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Linear token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Linear JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_linear_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Linear token: #{e.message}") + nil + 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/shopify/integration_helper.rb b/app/helpers/shopify/integration_helper.rb new file mode 100644 index 000000000..6aad93211 --- /dev/null +++ b/app/helpers/shopify/integration_helper.rb @@ -0,0 +1,58 @@ +module Shopify::IntegrationHelper + REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze + + # Generates a signed JWT token for Shopify integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_shopify_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Shopify token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Shopify JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_shopify_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_id + @client_id ||= GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil) + end + + def client_secret + @client_secret ||= GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode( + token, + secret, + true, + { + algorithm: 'HS256', + verify_expiration: true + } + ).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Shopify token: #{e.message}") + nil + end +end diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb index 759aec134..9f02a72da 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 ChatwootApp.chatwoot_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/App.vue b/app/javascript/dashboard/App.vue index c32cb98d2..e51958e9e 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad import LoadingState from './components/widgets/LoadingState.vue'; import NetworkNotification from './components/NetworkNotification.vue'; import UpdateBanner from './components/app/UpdateBanner.vue'; -import UpgradeBanner from './components/app/UpgradeBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import vueActionCable from './helper/actionCable'; @@ -14,6 +13,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue'; import { setColorTheme } from './helper/themeHelper'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import { useAccount } from 'dashboard/composables/useAccount'; +import { useFontSize } from 'dashboard/composables/useFontSize'; import { registerSubscription, verifyServiceWorkerExistence, @@ -30,15 +30,21 @@ export default { UpdateBanner, PaymentPendingBanner, WootSnackbarBox, - UpgradeBanner, PendingEmailVerificationBanner, }, setup() { const router = useRouter(); const store = useStore(); const { accountId } = useAccount(); + // Use the font size composable (it automatically sets up the watcher) + const { currentFontSize } = useFontSize(); - return { router, store, currentAccountId: accountId }; + return { + router, + store, + currentAccountId: accountId, + currentFontSize, + }; }, data() { return { @@ -138,7 +144,6 @@ export default { diff --git a/app/javascript/dashboard/api/agentBots.js b/app/javascript/dashboard/api/agentBots.js index 4de6fcee0..6e59f38d3 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -1,9 +1,26 @@ +/* global axios */ import ApiClient from './ApiClient'; class AgentBotsAPI extends ApiClient { constructor() { super('agent_bots', { accountScoped: true }); } + + create(data) { + return axios.post(this.url, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + deleteAgentBotAvatar(botId) { + return axios.delete(`${this.url}/${botId}/avatar`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js new file mode 100644 index 000000000..157eba74e --- /dev/null +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainAssistant extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + }, + }); + } + + playground({ assistantId, messageContent, messageHistory }) { + return axios.post(`${this.url}/${assistantId}/playground`, { + message_content: messageContent, + message_history: messageHistory, + }); + } +} + +export default new CaptainAssistant(); diff --git a/app/javascript/dashboard/api/captain/bulkActions.js b/app/javascript/dashboard/api/captain/bulkActions.js new file mode 100644 index 000000000..fd69a1108 --- /dev/null +++ b/app/javascript/dashboard/api/captain/bulkActions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainBulkActionsAPI extends ApiClient { + constructor() { + super('captain/bulk_actions', { accountScoped: true }); + } +} + +export default new CaptainBulkActionsAPI(); diff --git a/app/javascript/dashboard/api/captain/document.js b/app/javascript/dashboard/api/captain/document.js new file mode 100644 index 000000000..dc22b0c32 --- /dev/null +++ b/app/javascript/dashboard/api/captain/document.js @@ -0,0 +1,20 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainDocument extends ApiClient { + constructor() { + super('captain/documents', { accountScoped: true }); + } + + get({ page = 1, searchKey, assistantId } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + assistant_id: assistantId, + }, + }); + } +} + +export default new CaptainDocument(); diff --git a/app/javascript/dashboard/api/captain/inboxes.js b/app/javascript/dashboard/api/captain/inboxes.js new file mode 100644 index 000000000..e0a1efdfe --- /dev/null +++ b/app/javascript/dashboard/api/captain/inboxes.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainInboxes extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId } = {}) { + return axios.get(`${this.url}/${assistantId}/inboxes`); + } + + create(params = {}) { + const { assistantId, inboxId } = params; + return axios.post(`${this.url}/${assistantId}/inboxes`, { + inbox: { inbox_id: inboxId }, + }); + } + + delete(params = {}) { + const { assistantId, inboxId } = params; + return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`); + } +} + +export default new CaptainInboxes(); diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js new file mode 100644 index 000000000..e3c42757a --- /dev/null +++ b/app/javascript/dashboard/api/captain/response.js @@ -0,0 +1,22 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainResponses extends ApiClient { + constructor() { + super('captain/assistant_responses', { accountScoped: true }); + } + + get({ page = 1, searchKey, assistantId, documentId, status } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + assistant_id: assistantId, + document_id: documentId, + status, + }, + }); + } +} + +export default new CaptainResponses(); diff --git a/app/javascript/dashboard/api/channel/instagramClient.js b/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 000000000..51ae26448 --- /dev/null +++ b/app/javascript/dashboard/api/channel/instagramClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class InstagramChannel extends ApiClient { + constructor() { + super('instagram', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new InstagramChannel(); diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index bb95335ad..3f12dc007 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient { getLimits() { return axios.get(`${this.url}limits`); } + + toggleDeletion(action) { + return axios.post(`${this.url}toggle_deletion`, { + action_type: action, + }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 4fb1bd0ee..9c65b0b67 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('update'); expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); + expect(accountAPI).toHaveProperty('toggleDeletion'); }); describe('API calls', () => { @@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => { '/enterprise/api/v1/subscription' ); }); + + it('#toggleDeletion with delete action', () => { + accountAPI.toggleDeletion('delete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'delete' } + ); + }); + + it('#toggleDeletion with undelete action', () => { + accountAPI.toggleDeletion('undelete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'undelete' } + ); + }); }); }); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index ebc6176e7..39546096f 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -133,6 +133,14 @@ class ConversationApi extends ApiClient { getAllAttachments(conversationId) { return axios.get(`${this.url}/${conversationId}/attachments`); } + + requestCopilot(conversationId, body) { + return axios.post(`${this.url}/${conversationId}/copilot`, body); + } + + getInboxAssistant(conversationId) { + return axios.get(`${this.url}/${conversationId}/inbox_assistant`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js index b78a2ea7e..d4ffcbca3 100644 --- a/app/javascript/dashboard/api/integrations.js +++ b/app/javascript/dashboard/api/integrations.js @@ -33,12 +33,10 @@ class IntegrationsAPI extends ApiClient { return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`); } - requestCaptain(body) { - return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body); - } - - requestCaptainCopilot(body) { - return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body); + connectShopify({ shopDomain }) { + return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, { + shop_domain: shopDomain, + }); } } diff --git a/app/javascript/dashboard/api/integrations/shopify.js b/app/javascript/dashboard/api/integrations/shopify.js new file mode 100644 index 000000000..0b6ce8ec1 --- /dev/null +++ b/app/javascript/dashboard/api/integrations/shopify.js @@ -0,0 +1,17 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class ShopifyAPI extends ApiClient { + constructor() { + super('integrations/shopify', { accountScoped: true }); + } + + getOrders(contactId) { + return axios.get(`${this.url}/orders`, { + params: { contact_id: contactId }, + }); + } +} + +export default new ShopifyAPI(); diff --git a/app/javascript/dashboard/api/liveReports.js b/app/javascript/dashboard/api/liveReports.js new file mode 100644 index 000000000..1435da258 --- /dev/null +++ b/app/javascript/dashboard/api/liveReports.js @@ -0,0 +1,20 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class LiveReportsAPI extends ApiClient { + constructor() { + super('live_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getConversationMetric(params = {}) { + return axios.get(`${this.url}/conversation_metrics`, { params }); + } + + getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) { + return axios.get(`${this.url}/grouped_conversation_metrics`, { + params: { group_by: groupBy }, + }); + } +} + +export default new LiveReportsAPI(); 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/_date-picker.scss b/app/javascript/dashboard/assets/scss/_date-picker.scss index 2fa577673..b22381ea0 100644 --- a/app/javascript/dashboard/assets/scss/_date-picker.scss +++ b/app/javascript/dashboard/assets/scss/_date-picker.scss @@ -10,7 +10,7 @@ &.no-margin { .mx-input { - @apply mb-0; + margin-bottom: 0 !important; } } diff --git a/app/javascript/dashboard/assets/scss/_helper-classes.scss b/app/javascript/dashboard/assets/scss/_helper-classes.scss index 48ee1918b..229606254 100644 --- a/app/javascript/dashboard/assets/scss/_helper-classes.scss +++ b/app/javascript/dashboard/assets/scss/_helper-classes.scss @@ -4,7 +4,6 @@ @apply inline-block h-6 py-0 px-6 relative align-middle w-6; &.message { - @include normal-shadow; @apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0; &::before { diff --git a/app/javascript/dashboard/assets/scss/_mixins.scss b/app/javascript/dashboard/assets/scss/_mixins.scss index 9ca87c859..fc742d24f 100644 --- a/app/javascript/dashboard/assets/scss/_mixins.scss +++ b/app/javascript/dashboard/assets/scss/_mixins.scss @@ -1,79 +1,7 @@ @import 'dashboard/assets/scss/variables'; -@import 'widget/assets/scss/mixins'; $spinner-before-border-color: rgba(255, 255, 255, 0.7); -//borders -@mixin border-nil() { - border-color: transparent; - border: 0; -} - -@mixin thin-border($color) { - border: 1px solid $color; -} - -@mixin custom-border-bottom($size, $color) { - border-bottom: $size solid $color; -} - -@mixin custom-border-top($size, $color) { - border-top: $size solid $color; -} - -@mixin border-normal() { - @apply border border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-left() { - @apply border-l border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-top() { - @apply border-t border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-right() { - @apply border-r border-slate-50 dark:border-slate-700; -} - -@mixin border-normal-bottom() { - @apply border-b border-slate-50 dark:border-slate-700; -} - -@mixin border-light() { - @apply border border-slate-25 dark:border-slate-700; -} - -@mixin border-light-left() { - @apply border-l border-slate-25 dark:border-slate-700; -} - -@mixin border-light-top() { - @apply border-t border-slate-25 dark:border-slate-700; -} - -@mixin border-light-right() { - @apply border-r border-slate-25 dark:border-slate-700; -} - -@mixin border-light-bottom() { - @apply border-b border-slate-25 dark:border-slate-700; -} - -// background -@mixin background-gray() { - background: $color-background; -} - -@mixin background-light() { - @apply bg-slate-50 dark:bg-slate-800; -} - -@mixin background-white() { - @apply bg-white dark:bg-slate-900; -} - // input form @mixin ghost-input() { box-shadow: none; @@ -87,65 +15,6 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7); } } -// flex-layout -@mixin space-between() { - display: flex; - justify-content: space-between; -} - -@mixin space-between-column() { - @include space-between; - flex-direction: column; -} - -@mixin space-between-row() { - @include space-between; - flex-direction: row; -} - -@mixin flex-shrink() { - flex: 0 0 auto; - max-width: 100%; -} - -@mixin flex-weight($value) { - // Grab flex-grow for older browsers. - $flex-grow: nth($value, 1); - - // 2009 - @include prefixer(box-flex, $flex-grow, webkit moz spec); - - // 2011 (IE 10), 2012 - @include prefixer(flex, $value, webkit moz ms spec); -} - -// full height -@mixin full-height() { - height: 100%; -} - -@mixin round-corner() { - border-radius: 1000px; -} - -@mixin scroll-on-hover() { - overflow: hidden; - - &:hover { - overflow-y: auto; - } -} - - -@mixin horizontal-scroll() { - overflow-y: auto; -} - -@mixin elegant-card() { - @include normal-shadow; - border-radius: $space-small; -} - @mixin color-spinner() { @keyframes spinner { to { @@ -230,17 +99,3 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7); border-left: $size solid transparent; } } - -@mixin text-ellipsis { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -@mixin three-column-grid($column-one-width: 16rem, - $column-three-width: 16rem) { - width: 100%; - height: 100%; - display: grid; - grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr); -} diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss new file mode 100644 index 000000000..f23c01d42 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -0,0 +1,234 @@ +// scss-lint:disable PropertySortOrder +@layer base { + // NEXT COLORS START + :root { + // slate + --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; + + --blue-1: 251 253 255; + --blue-2: 245 249 255; + --blue-3: 233 243 255; + --blue-4: 218 236 255; + --blue-5: 201 226 255; + --blue-6: 181 213 255; + --blue-7: 155 195 252; + --blue-8: 117 171 247; + --blue-9: 39 129 246; + --blue-10: 16 115 233; + --blue-11: 8 109 224; + --blue-12: 11 50 101; + + --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 + --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; + + --blue-1: 10 17 28; + --blue-2: 15 24 38; + --blue-3: 15 39 72; + --blue-4: 10 49 99; + --blue-5: 18 61 117; + --blue-6: 29 84 134; + --blue-7: 40 89 156; + --blue-8: 48 106 186; + --blue-9: 39 129 246; + --blue-10: 21 116 231; + --blue-11: 126 182 255; + --blue-12: 205 227 255; + + --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; + } +} +// NEXT COLORS END diff --git a/app/javascript/dashboard/assets/scss/_rtl.scss b/app/javascript/dashboard/assets/scss/_rtl.scss index 36679fc60..4f5f4b843 100644 --- a/app/javascript/dashboard/assets/scss/_rtl.scss +++ b/app/javascript/dashboard/assets/scss/_rtl.scss @@ -81,28 +81,6 @@ margin-left: var(--space-small); } } - - // Conversation sidebar close button - .close-button--rtl { - transform: rotate(180deg); - } - - // Resolve actions button - .resolve-actions { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } } // Conversation list @@ -177,71 +155,6 @@ } } - // Help center - .article-container .row--article-block { - td:last-child { - direction: initial; - } - } - - .portal-popover__container .portal { - .actions-container { - margin-left: unset; - margin-right: var(--space-one); - } - } - - .edit-article--container { - .header-right--wrap { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } - - .header-left--wrap { - .back-button { - direction: initial; - } - } - - .article--buttons { - .dropdown-pane { - left: 0; - position: absolute; - right: unset; - } - } - - .sidebar-button { - transform: rotate(180deg); - } - } - - .article-settings--container { - border-left: 0; - border-right: 1px solid var(--color-border-light); - flex-direction: row-reverse; - margin-left: 0; - margin-right: var(--space-normal); - padding-left: 0; - padding-right: var(--space-normal); - } - - .category-list--container .header-left--wrap { - direction: initial; - justify-content: flex-end; - } - // Toggle switch .toggle-button { &.small { @@ -264,11 +177,6 @@ } } - // Widget builder - .widget-builder-container .widget-preview { - direction: initial; - } - // Modal .modal-container { text-align: right; @@ -282,7 +190,6 @@ } // Other changes - .colorpicker--chrome { direction: initial; } @@ -291,14 +198,6 @@ direction: initial; } - .contact--details .contact--bio { - direction: ltr; - } - - .merge-contacts .child-contact-wrap { - direction: ltr; - } - .contact--form .input-group { direction: initial; } diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index fdd467f5f..622eb4085 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -5,6 +5,9 @@ @import 'shared/assets/fonts/InterDisplay/inter-display'; @import 'shared/assets/fonts/inter'; +// Next Colors +@import 'next-colors'; + @import 'shared/assets/stylesheets/animations'; @import 'shared/assets/stylesheets/colors'; @import 'shared/assets/stylesheets/spacing'; @@ -26,7 +29,6 @@ @import 'rtl'; @import 'widgets/base'; -@import 'widgets/buttons'; @import 'widgets/conversation-view'; @import 'widgets/tabs'; @import 'widgets/woot-tables'; @@ -54,186 +56,19 @@ @apply text-n-blue-text; } +.custom-dashed-border { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +.dark .custom-dashed-border { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E"); +} + // scss-lint:disable PropertySortOrder @layer base { - /* NEXT COLORS START */ - :root { - /* slate */ - --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; - - --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; - - --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.1; - } - - body.dark { - /* slate */ - --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; - - --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; - --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; - } - /* NEXT COLORS END */ - :root { --color-amber-25: 254 253 251; --color-amber-50: 255 249 237; diff --git a/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss index e08099ae6..9ab51d12a 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss @@ -1,7 +1,7 @@ .dropdown-pane { - @apply border rounded-lg hidden relative invisible shadow-lg border-slate-25 dark:border-slate-700 box-content p-2 w-fit z-[9999]; + @apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999]; &.dropdown-pane--open { - @apply bg-white absolute dark:bg-slate-800 block visible; + @apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible; } } diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index 2f9c35c51..ffcfca545 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -1,13 +1,13 @@ @mixin label-multiselect-hover { &::after { - @apply text-woot-600 dark:text-woot-600; + @apply text-n-brand; } &:hover { - @apply bg-slate-50 dark:bg-slate-700; + @apply bg-n-slate-3; &::after { - @apply text-woot-500 dark:text-woot-500; + @apply text-n-blue-text; } } } @@ -18,20 +18,16 @@ } &.multiselect--disabled { - @apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed; + @apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto; .multiselect__select { - @apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md; - } - - .multiselect__tags { - @apply border-0; + @apply cursor-not-allowed bg-transparent rounded-lg; } } .multiselect--active { > .multiselect__tags { - @apply border-woot-500 dark:border-woot-500; + @apply outline-n-blue-border; } } @@ -44,7 +40,7 @@ } .multiselect__content-wrapper { - @apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100; + @apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg; } .multiselect__content { @@ -61,15 +57,19 @@ @apply mb-0; } + &::after { + @apply bottom-0 flex items-center justify-center text-center; + } + &.multiselect__option--highlight { - @apply bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100; + @apply bg-n-alpha-black2 text-n-slate-12; } &.multiselect__option--highlight:hover { - @apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100; + @apply bg-n-brand/10 text-n-slate-12; &::after { - @apply bg-woot-50 dark:bg-woot-600 text-slate-600 dark:text-slate-200; + @apply bg-transparent text-center text-n-slate-12; } } @@ -78,17 +78,17 @@ } &.multiselect__option--selected { - @apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100; + @apply bg-n-brand/20 text-n-slate-12; + + &::after { + @apply bg-transparent; + } &.multiselect__option--highlight:hover { - @apply bg-woot-75 dark:bg-woot-600; - - &::after { - @apply bg-transparent; - } + @apply bg-transparent; &::after:hover { - @apply text-slate-800 dark:text-slate-100; + @apply text-n-slate-12 bg-transparent; } } } @@ -96,23 +96,27 @@ } .multiselect__tags { - @apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0; + @apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0; input { - @apply border-0 border-none; + @apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10; } } + .multiselect__spinner { + background-color: transparent; + } + .multiselect__tags-wrap { @apply inline-block leading-none mt-1; } .multiselect__placeholder { - @apply text-slate-400 dark:text-slate-400 font-normal pt-3; + @apply text-n-slate-10 font-normal pt-3; } .multiselect__tag { - @apply bg-slate-50 dark:bg-slate-800 mt-1 text-slate-800 dark:text-slate-100 pr-6 pl-2.5 py-1.5; + @apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5; } .multiselect__tag-icon { @@ -125,7 +129,7 @@ } .multiselect__single { - @apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis; + @apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis; } } @@ -143,11 +147,11 @@ } > .multiselect__tags { - @apply border-transparent; + @apply outline-transparent; } &.multiselect--active > .multiselect__tags { - @apply border-woot-500 dark:border-woot-500; + @apply outline-n-blue-border; } } } @@ -179,7 +183,7 @@ .multiselect__tags, .multiselect__input, .multiselect { - @apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem]; + @apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem]; } .multiselect__input { @@ -187,7 +191,7 @@ } .multiselect__single { - @apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5; + @apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5; } .multiselect__placeholder { @@ -208,6 +212,24 @@ } } +.multiselect--disabled { + background-color: rgba(var(--black-alpha-2)) !important; + + .multiselect__tags { + @apply hover:outline-n-weak; + } +} + +.multiselect--active { + .multiselect__select::before { + @apply top-[62%]; + } +} + +.multiselect__select::before { + top: 60% !important; +} + .multiselect-wrap--medium { .multiselect__tags, .multiselect__input { @@ -217,16 +239,16 @@ .multiselect__tags, .multiselect__input, .multiselect { - @apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 text-sm h-12 min-h-[3rem]; + @apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem]; } .multiselect__input { @apply h-[2.875rem] min-h-[2.875rem]; - margin-bottom: 0px !important; + margin-bottom: 0 !important; } .multiselect__single { - @apply items-center flex m-0 text-sm py-1 px-0.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900; + @apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12; } .multiselect__placeholder { 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/assets/scss/widgets/_base.scss b/app/javascript/dashboard/assets/scss/widgets/_base.scss index d35c1cfc9..9c4ccf126 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_base.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_base.scss @@ -1,22 +1,22 @@ // scss-lint:disable QualifyingElement // Base typography +// ------------------------- h1, h2, h3, h4, h5, h6 { - @apply font-medium text-slate-800 dark:text-slate-50; + @apply font-medium text-n-slate-12; } p { text-rendering: optimizeLegibility; - @apply mb-2 leading-[1.65] text-sm; a { - @apply text-woot-500 dark:text-woot-500 cursor-pointer; + @apply text-n-brand dark:text-n-brand cursor-pointer; } } @@ -40,65 +40,89 @@ dl:not(.reset-base) { @apply mb-0; } -// Form elements -label { - @apply text-slate-800 dark:text-slate-200 block m-0 leading-7 text-sm font-medium; +// Button base +button { + font-family: inherit; + @apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50; +} - &.error { - input { - @apply mb-1; - } - } +// Form elements +// ------------------------- +label { + @apply text-n-slate-12 block m-0 leading-7 text-sm font-medium; } .input-wrap, .help-text { - @apply text-slate-800 dark:text-slate-100 text-sm font-medium; - - .help-text { - @apply font-normal text-slate-600 dark:text-slate-400; - } + @apply text-n-slate-11 text-sm font-medium; } // Focus outline removal .button, -textarea, -input:focus { +textarea { outline: none; } -// Inputs -input[type='text']:not(.reset-base), -input[type='number']:not(.reset-base), -input[type='password']:not(.reset-base), -input[type='date']:not(.reset-base), -input[type='email']:not(.reset-base), -input[type='url']:not(.reset-base) { - @apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600; +// Field base styles (Input, TextArea, Select) +@layer components { + .field-base { + @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; + } - &[disabled] { - @apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed; + .field-disabled { + @apply opacity-50 outline-n-weak dark:outline-n-weak cursor-not-allowed; + } + + .field-error { + @apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8; } } -input[type='file'] { - @apply bg-white dark:bg-slate-800 leading-[1.15] mb-4; +$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)"; + +#{$form-input-selector} { + @apply field-base h-10; + + &[disabled] { + @apply field-disabled; + } + + &.error { + @apply field-error mb-1; + } +} + +input[type='file']:not(.multiselect__input) { + @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm; } // Select select { background-image: url("data:image/svg+xml;utf8,"); - background-position: right -1rem center; background-size: 9px 6px; - @apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600; + + @apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3; + + &[disabled] { + @apply field-disabled; + } } // Textarea textarea { - @apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-16 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600; + @apply field-base h-16; &[disabled] { - @apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed; + @apply field-disabled; + } +} + +// Add mb-1 when .help-text exists within the same label container +label:has(.help-text) { + input, + textarea, + select { + margin-bottom: 0.25rem !important; } } @@ -109,18 +133,41 @@ textarea { } } +// FormKit support +.formkit-outer[data-invalid='true'] { + #{$form-input-selector}, + textarea, + select { + @apply field-error; + } + + .formkit-message { + @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full; + } +} + .error { - input, + #{$form-input-selector}, input:not([type]), textarea, select, .multiselect > .multiselect__tags, .multiselect:not(.no-margin) { - @apply border border-solid border-red-400 dark:border-red-400 mb-1; + @apply field-error; + } + + // Only add mb-1 when .message exists within the same .error container + // And exclude no-margin from the margin-bottom + &:has(.message) { + input:not(.no-margin), + textarea, + select { + margin-bottom: 0.25rem !important; + } } .message { - @apply text-red-400 dark:text-red-400 block text-sm mb-2.5 w-full; + @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full; } } @@ -130,7 +177,7 @@ textarea { } .error { - @apply border-red-400 dark:border-red-400; + @apply text-n-ruby-9 dark:text-n-ruby-9; } } @@ -141,11 +188,16 @@ code { @apply text-xs border-0; &.hljs { - @apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5; + @apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12 rounded-lg p-5; .hljs-number, .hljs-string { - @apply text-red-800 dark:text-red-400; + @apply text-n-ruby-9 dark:text-n-ruby-9; + } + + .hljs-name, + .hljs-tag { + @apply text-n-slate-11; } } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss deleted file mode 100644 index 640e1395f..000000000 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ /dev/null @@ -1,266 +0,0 @@ -// scss-lint:disable SpaceAfterPropertyColon -// scss-lint:disable MergeableSelector -button { - font-family: inherit; - transition: - background-color 0.25s ease-out, - color 0.25s ease-out; - @apply inline-block items-center mb-0 text-center align-middle cursor-pointer text-sm mt-0 mx-0 py-1 px-2.5 border border-solid border-transparent dark:border-transparent rounded-[0.3125rem]; - - &:disabled, - &.disabled { - @apply opacity-40 cursor-not-allowed; - } -} - -.button-group { - @apply mb-0 flex flex-nowrap items-stretch; - - .button { - flex: 0 0 auto; - @apply m-0 text-sm rounded-none first:rounded-tl-[0.3125rem] first:rounded-bl-[0.3125rem] last:rounded-tr-[0.3125rem] last:rounded-br-[0.3125rem] rtl:space-x-reverse; - } - - .button--only-icon { - @apply w-10 justify-center pl-0 pr-0; - } -} - -.back-button { - @apply m-0; -} - -.button { - @apply items-center bg-woot-500 dark:bg-woot-500 px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium; - - .button__content { - @apply w-full whitespace-nowrap overflow-hidden text-ellipsis; - - img, - svg { - @apply inline-block; - } - } - - &:hover { - @apply bg-woot-600 dark:bg-woot-600; - } - - &:disabled, - &.disabled { - @apply opacity-40 cursor-not-allowed; - } - - &.success { - @apply bg-[#44ce4b] dark:bg-[#44ce4b] text-white dark:text-white; - } - - &.secondary { - @apply bg-slate-700 dark:bg-slate-600 text-white dark:text-white; - } - - &.primary { - @apply bg-woot-500 dark:bg-woot-500 text-white dark:text-white; - } - - &.clear { - @apply text-woot-500 dark:text-woot-500 bg-transparent dark:bg-transparent; - } - - &.alert { - @apply bg-red-500 dark:bg-red-500 text-white dark:text-white; - - &.clear { - @apply bg-transparent dark:bg-transparent; - } - } - - &.warning { - @apply bg-[#ffc532] dark:bg-[#ffc532] text-white dark:text-white; - - &.clear { - @apply bg-transparent dark:bg-transparent; - } - } - - &.tiny { - @apply h-6 text-[10px]; - } - - &.small { - @apply h-8 text-xs; - } - - .spinner { - @apply px-2 py-0; - } - - // @TODDO - Remove after moving all buttons to woot-button - .icon + .button__content { - @apply w-auto; - } - - &.expanded { - @apply flex justify-center text-center; - } - - &.round { - @apply rounded-full; - } - - // @TODO Use with link - - &.compact { - @apply pb-0 pt-0; - } - - &.hollow { - @apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900; - - &.secondary { - @apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700; - } - - &.success { - @apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800; - } - - &.alert { - @apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800; - } - - &.warning { - @apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800; - } - - &:hover { - @apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400; - - &.secondary { - @apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100; - } - - &.success { - @apply border-slate-100 dark:border-slate-700 text-green-800 dark:text-green-100; - } - - &.alert { - @apply border-slate-100 dark:border-slate-700 text-red-700 dark:text-red-100; - } - - &.warning { - @apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500; - } - } - } - - // Smooth style - &.smooth { - @apply bg-woot-50 dark:bg-woot-800 text-woot-700 dark:text-woot-100 hover:text-woot-700 dark:hover:text-woot-700 hover:bg-woot-100 dark:hover:bg-woot-900; - - &.secondary { - @apply bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800; - } - - &.success { - @apply bg-green-50 dark:bg-green-700 text-green-700 dark:text-green-100 hover:bg-green-100 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100; - } - - &.alert { - @apply bg-red-50 dark:bg-red-700 dark:bg-opacity-50 text-red-700 dark:text-red-100 hover:bg-red-100 dark:hover:bg-red-800 dark:hover:bg-opacity-30; - } - - &.warning { - @apply bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-200; - } - } - - &.clear { - @apply text-woot-500 dark:text-woot-500; - - &.secondary { - @apply text-slate-700 dark:text-slate-100; - } - - &.success { - @apply text-green-700 dark:text-green-100; - } - - &.alert { - @apply text-red-700 dark:text-red-100; - } - - &.warning { - @apply text-yellow-700 dark:text-yellow-600; - } - - &:hover { - @apply hover:bg-woot-50 dark:hover:bg-woot-900/50 hover:text-woot-500 dark:hover:text-woot-100; - - &.secondary { - @apply hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-slate-800 dark:hover:text-slate-100; - } - - &.success { - @apply hover:bg-green-50 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100; - } - - &.alert { - @apply hover:bg-red-50 dark:hover:bg-red-800 hover:text-red-700 dark:hover:text-red-100; - } - - &.warning { - @apply hover:bg-yellow-100 dark:hover:bg-yellow-800 hover:text-yellow-700 dark:hover:text-yellow-600; - } - } - - &:active { - &.secondary { - @apply active:bg-slate-100 dark:active:bg-slate-900; - } - } - - &:focus { - &.secondary { - @apply focus:bg-slate-50 dark:focus:bg-slate-700; - } - } - } - - // Sizes - &.tiny { - @apply h-6; - } - - &.small { - @apply h-8 pb-1 pt-1; - } - - &.large { - @apply h-12; - } - - &.button--only-icon { - @apply justify-center pl-0 pr-0 w-10; - - &.tiny { - @apply w-6; - } - - &.small { - @apply w-8; - } - - &.large { - @apply w-12; - } - } - - &.link { - @apply h-auto m-0 p-0; - - &:hover { - @apply underline; - } - } -} diff --git a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss index b9999fac9..72a2e6be8 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss @@ -3,7 +3,15 @@ } .tabs--container--with-border { - @apply border-b border-slate-50 dark:border-slate-800/50; + @apply border-b border-n-weak; +} + +.tabs--container--compact.tab--chat-type { + .tabs-title { + a { + @apply py-2 text-sm; + } + } } .tabs { @@ -34,7 +42,7 @@ @apply flex-shrink-0 my-0 mx-2; .badge { - @apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0; + @apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0; } &:first-child { @@ -48,22 +56,22 @@ &:hover, &:focus { a { - @apply text-slate-800 dark:text-slate-100; + @apply text-n-slate-12; } } a { - @apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative; + @apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative; transition: border-color 0.15s $swift-ease-out-function; } &.is-active { a { - @apply border-b border-woot-500 text-woot-500 dark:text-woot-500; + @apply border-b border-n-brand text-n-blue-text; } .badge { - @apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40; + @apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text; } } } diff --git a/app/javascript/dashboard/components-next/Accordion/Accordion.vue b/app/javascript/dashboard/components-next/Accordion/Accordion.vue new file mode 100644 index 000000000..f75a2ef25 --- /dev/null +++ b/app/javascript/dashboard/components-next/Accordion/Accordion.vue @@ -0,0 +1,39 @@ + + + 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/CampaignLayout.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue index 3169dbc3e..184e8bd10 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue @@ -22,7 +22,7 @@ const handleButtonClick = () => {