diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3fd4f1a31..9e8c36fdb 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,5 +4,15 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest # Do the set up required for chatwoot app WORKDIR /workspace + +# Copy dependency files first for better caching +COPY package.json pnpm-lock.yaml ./ +COPY Gemfile Gemfile.lock ./ + +# Install dependencies (will be cached if files don't change) +RUN pnpm install --frozen-lockfile && \ + gem install bundler && \ + bundle install --jobs=$(nproc) + +# Copy source code after dependencies are installed COPY . /workspace -RUN yarn && gem install bundler && bundle install diff --git a/.devcontainer/Dockerfile.base b/.devcontainer/Dockerfile.base index fe31dc42e..dc7d4eb8c 100644 --- a/.devcontainer/Dockerfile.base +++ b/.devcontainer/Dockerfile.base @@ -1,12 +1,16 @@ - -ARG VARIANT +ARG VARIANT="ubuntu-22.04" FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} +ENV DEBIAN_FRONTEND=noninteractive + ARG NODE_VERSION ARG RUBY_VERSION ARG USER_UID ARG USER_GID +ARG PNPM_VERSION="10.2.0" +ENV PNPM_VERSION ${PNPM_VERSION} +ENV RUBY_CONFIGURE_OPTS=--disable-install-doc # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ @@ -15,61 +19,80 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ && chmod -R $USER_UID:$USER_GID /home/vscode; \ fi -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends \ - build-essential \ - libssl-dev \ - zlib1g-dev \ - gnupg2 \ - tar \ - tzdata \ - postgresql-client \ - libpq-dev \ - yarn \ - git \ - imagemagick \ - tmux \ - zsh \ - git-flow \ - npm \ - libyaml-dev +RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \ + && curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ + && apt-get update \ + && apt-get -y install --no-install-recommends \ + build-essential \ + libssl-dev \ + zlib1g-dev \ + gnupg \ + tar \ + tzdata \ + postgresql-client \ + libpq-dev \ + git \ + imagemagick \ + libyaml-dev \ + curl \ + ca-certificates \ + tmux \ + nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -# Install rbenv and ruby -RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \ +# Install rbenv and ruby for root user first +RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \ && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ && echo 'eval "$(rbenv init -)"' >> ~/.bashrc ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH" -RUN git clone https://github.com/rbenv/ruby-build.git && \ +RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \ PREFIX=/usr/local ./ruby-build/install.sh RUN rbenv install $RUBY_VERSION && \ rbenv global $RUBY_VERSION && \ rbenv versions -# Install overmind +# Set up rbenv for vscode user +RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \ + && su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \ + && su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \ + && su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \ + && su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION" + +# Install overmind and gh in single layer RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ && gunzip overmind.gz \ - && sudo mv overmind /usr/local/bin \ - && chmod +x /usr/local/bin/overmind - - -# Install gh -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && sudo apt update \ - && sudo apt install gh + && mv overmind /usr/local/bin \ + && chmod +x /usr/local/bin/overmind \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Do the set up required for chatwoot app WORKDIR /workspace -COPY . /workspace +RUN chown vscode:vscode /workspace -# set up ruby -COPY Gemfile Gemfile.lock ./ -RUN gem install bundler && bundle install +# set up node js, pnpm and claude code in single layer +RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ + && npm cache clean --force -# set up node js -RUN npm install n -g && \ - n $NODE_VERSION -RUN npm install --global yarn -RUN yarn +# Switch to vscode user +USER vscode +ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH" + +# Copy dependency files first for better caching +COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./ + +# Install dependencies as vscode user +RUN eval "$(rbenv init -)" \ + && gem install bundler -N \ + && bundle install --jobs=$(nproc) \ + && pnpm install --frozen-lockfile + +# Copy source code after dependencies are installed +COPY --chown=vscode:vscode . /workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d2dac356b..2e237bbcb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,17 +4,26 @@ "dockerComposeFile": "docker-compose.yml", "settings": { - "terminal.integrated.shell.linux": "/bin/zsh" + "terminal.integrated.shell.linux": "/bin/zsh", + "extensions.showRecommendationsOnlyOnDemand": true, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "search.exclude": { + "**/node_modules": true, + "**/tmp": true, + "**/log": true, + "**/coverage": true, + "**/public/packs": true + } }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ - "rebornix.Ruby", + "Shopify.ruby-lsp", "misogi.ruby-rubocop", - "wingrunr21.vscode-ruby", "davidpallinder.rails-test-runner", - "eamodio.gitlens", "github.copilot", "mrmlnc.vscode-duplicate" ], @@ -23,15 +32,15 @@ // 5432 postgres // 6379 redis // 1025,8025 mailhog - "forwardPorts": [8025, 3000, 3035], + "forwardPorts": [8025, 3000, 3036], - "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn", + "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install", "portsAttributes": { "3000": { "label": "Rails Server" }, - "3035": { - "label": "Webpack Dev Server" + "3036": { + "label": "Vite Dev Server" }, "8025": { "label": "Mailhog UI" diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml new file mode 100644 index 000000000..6932b5f10 --- /dev/null +++ b/.devcontainer/docker-compose.base.yml @@ -0,0 +1,18 @@ +# Docker Compose file for building the base image in GitHub Actions +# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base + +version: '3' + +services: + base: + build: + context: .. + dockerfile: .devcontainer/Dockerfile.base + args: + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '23.7.0' + RUBY_VERSION: '3.4.4' + # 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' + image: ghcr.io/chatwoot/chatwoot_codespace:latest diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 21a9fe909..a9185ea09 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,19 +5,6 @@ version: '3' services: - base: - build: - context: .. - dockerfile: .devcontainer/Dockerfile.base - args: - VARIANT: 'ubuntu-22.04' - NODE_VERSION: '23.7.0' - RUBY_VERSION: '3.4.4' - # 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' - image: base:latest - app: build: context: .. diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 4ffee2d3a..36db5cfd9 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -2,12 +2,15 @@ cp .env.example .env sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env -sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env -sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env -# uncomment the webpacker env variable -sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env -# fix the error with webpacker -echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc +sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env + +# Setup Claude Code API key if available +if [ -n "$CLAUDE_CODE_API_KEY" ]; then + mkdir -p ~/.claude + echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json + echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh + chmod +x ~/.claude/anthropic_key.sh +fi # codespaces make the ports public -gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME +gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME diff --git a/.github/workflows/publish_codespace_image.yml b/.github/workflows/publish_codespace_image.yml index 647608473..5da4fda05 100644 --- a/.github/workflows/publish_codespace_image.yml +++ b/.github/workflows/publish_codespace_image.yml @@ -19,6 +19,5 @@ jobs: - name: Build the Codespace Base Image run: | - docker-compose -f .devcontainer/docker-compose.yml build base - docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest + docker compose -f .devcontainer/docker-compose.base.yml build base docker push ghcr.io/chatwoot/chatwoot_codespace:latest diff --git a/Makefile b/Makefile index 1c5ce297c..552ebe659 100644 --- a/Makefile +++ b/Makefile @@ -41,8 +41,15 @@ run: force_run: rm -f ./.overmind.sock + rm -f tmp/pids/*.pid overmind start -f Procfile.dev +force_run_tunnel: + lsof -ti:3000 | xargs kill -9 2>/dev/null || true + rm -f ./.overmind.sock + rm -f tmp/pids/*.pid + overmind start -f Procfile.tunnel + debug: overmind connect backend @@ -52,4 +59,4 @@ debug_worker: docker: docker build -t $(APP_NAME) -f ./docker/Dockerfile . -.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker +.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker diff --git a/Procfile.tunnel b/Procfile.tunnel new file mode 100644 index 000000000..bf3541dd0 --- /dev/null +++ b/Procfile.tunnel @@ -0,0 +1,4 @@ +backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000 +# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695 +worker: dotenv bundle exec sidekiq -C config/sidekiq.yml +vite: bin/vite build --watch diff --git a/app/assets/javascripts/secretField.js b/app/assets/javascripts/secretField.js index 463109812..da2327eff 100644 --- a/app/assets/javascripts/secretField.js +++ b/app/assets/javascripts/secretField.js @@ -10,7 +10,8 @@ function toggleSecretField(e) { if (!textElement) return; if (textElement.dataset.secretMasked === 'false') { - textElement.textContent = '•'.repeat(10); + const maskedLength = secretField.dataset.secretText?.length || 10; + textElement.textContent = '•'.repeat(maskedLength); textElement.dataset.secretMasked = 'true'; toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); @@ -32,3 +33,13 @@ function copySecretField(e) { navigator.clipboard.writeText(secretField.dataset.secretText); } + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.cell-data__secret-field').forEach(field => { + const span = field.querySelector('[data-secret-masked]'); + if (span && span.dataset.secretMasked === 'true') { + const len = field.dataset.secretText?.length || 10; + span.textContent = '•'.repeat(len); + } + }); +}); diff --git a/app/assets/stylesheets/administrate/components/_cells.scss b/app/assets/stylesheets/administrate/components/_cells.scss index b5a079976..ae2d603cd 100644 --- a/app/assets/stylesheets/administrate/components/_cells.scss +++ b/app/assets/stylesheets/administrate/components/_cells.scss @@ -46,17 +46,25 @@ .cell-data__secret-field { align-items: center; + color: $hint-grey; display: flex; span { - flex: 1; + flex: 0 0 auto; } - button { - margin-left: 5px; + [data-secret-toggler], + [data-secret-copier] { + background: transparent; + border: 0; + color: inherit; + margin-left: 0.5rem; + padding: 0; svg { fill: currentColor; + height: 1.25rem; + width: 1.25rem; } } } diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb new file mode 100644 index 000000000..caa5a04d8 --- /dev/null +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -0,0 +1,103 @@ +class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder + attr_reader :account, :params + + # rubocop:disable Lint/MissingSuper + # the parent class has no initialize + def initialize(account:, params:) + @account = account + @params = params + + timezone_offset = (params[:timezone_offset] || 0).to_f + @timezone = ActiveSupport::TimeZone[timezone_offset]&.name + end + # rubocop:enable Lint/MissingSuper + + def build + labels = account.labels.to_a + return [] if labels.empty? + + report_data = collect_report_data + labels.map { |label| build_label_report(label, report_data) } + end + + private + + def collect_report_data + conversation_filter = build_conversation_filter + use_business_hours = use_business_hours? + + { + conversation_counts: fetch_conversation_counts(conversation_filter), + resolved_counts: fetch_resolved_counts(conversation_filter), + resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), + first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), + reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours) + } + end + + def build_label_report(label, report_data) + { + id: label.id, + name: label.title, + conversations_count: report_data[:conversation_counts][label.title] || 0, + avg_resolution_time: report_data[:resolution_metrics][label.title] || 0, + avg_first_response_time: report_data[:first_response_metrics][label.title] || 0, + avg_reply_time: report_data[:reply_metrics][label.title] || 0, + resolved_conversations_count: report_data[:resolved_counts][label.title] || 0 + } + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end + + def build_conversation_filter + conversation_filter = { account_id: account.id } + conversation_filter[:created_at] = range if range.present? + + conversation_filter + end + + def fetch_conversation_counts(conversation_filter) + fetch_counts(conversation_filter) + end + + def fetch_resolved_counts(conversation_filter) + # since the base query is ActsAsTaggableOn, + # the status :resolved won't automatically be converted to integer status + fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved])) + end + + def fetch_counts(conversation_filter) + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + taggable_type: 'Conversation', + context: 'labels', + conversations: conversation_filter + ) + .select('tags.name, COUNT(taggings.*) AS count') + .group('tags.name') + .each_with_object({}) { |record, hash| hash[record.name] = record.count } + end + + def fetch_metrics(conversation_filter, event_name, use_business_hours) + ReportingEvent + .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id') + .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + conversations: conversation_filter, + name: event_name, + taggings: { taggable_type: 'Conversation', context: 'labels' } + ) + .group('tags.name') + .order('tags.name') + .select( + 'tags.name', + use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value' + ) + .each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f } + 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 1422beea1..64c35d33d 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -29,6 +29,11 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController head :ok end + def reset_access_token + @agent_bot.access_token.regenerate_token + @agent_bot.reload + end + private def agent_bot diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index b4d5e3fc1..4fbe50902 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] - before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] + before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index @contacts_count = resolved_contacts.count @@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contacts = Current.account.contacts.where(id: ::OnlineStatusTracker .get_available_contact_ids(Current.account.id)) @contacts_count = contacts.count - @contacts = contacts.page(@current_page) + @contacts = fetch_contacts(contacts) end def show; end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 8753918fc..e27869d82 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro @conversation.save! end + def destroy + authorize @conversation, :destroy? + ::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip) + head :ok + end + private def permitted_update_params diff --git a/app/controllers/api/v1/accounts/google/authorizations_controller.rb b/app/controllers/api/v1/accounts/google/authorizations_controller.rb index 1140a214b..87a7cfa3f 100644 --- a/app/controllers/api/v1/accounts/google/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/google/authorizations_controller.rb @@ -1,32 +1,23 @@ -class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include GoogleConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = google_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/google/callback", - scope: 'email profile https://mail.google.com/', + scope: scope, response_type: 'code', prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it access_type: 'offline', # the default is 'online' + state: state, client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) } ) if redirect_url - cache_key = "google::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index eace4411a..053c29731 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -1,7 +1,6 @@ -class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController 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 @@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts 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/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index c66f06909..bfdfff058 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,5 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController - before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] def destroy @@ -31,6 +31,12 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_created, + issue_data: { id: issue[:data][:identifier] }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end @@ -42,17 +48,30 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_linked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end def unlink_issue link_id = permitted_params[:link_id] + issue_id = permitted_params[:issue_id] issue = linear_processor_service.unlink_issue(link_id) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_unlinked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index df563094a..a300b5f59 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -1,28 +1,19 @@ -class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include MicrosoftConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = microsoft_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/microsoft/callback", - scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', + scope: scope, + state: state, prompt: 'consent' } ) if redirect_url - cache_key = "microsoft::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb new file mode 100644 index 000000000..feb218b59 --- /dev/null +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -0,0 +1,23 @@ +class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController + before_action :check_authorization + + protected + + def scope + '' + end + + def state + Current.account.to_sgid(expires_in: 15.minutes).to_s + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 35979f70f..13e3a6a6c 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController @result = search('Message') end + def articles + @result = search('Article') + end + private def search(search_type) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index c610dc846..773126755 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting) + params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) end def check_signup_enabled diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index ae1a1fe30..141253d0d 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -38,6 +38,11 @@ class Api::V1::ProfilesController < Api::BaseController head :ok end + def reset_access_token + @user.access_token.regenerate_token + @user.reload + end + private def set_user diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 989952cfd..f31a53c7e 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, :inbox] + before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::InboxSummaryBuilder) end + def label + render_report_with(V2::Reports::LabelSummaryBuilder) + end + private def check_authorization diff --git a/app/controllers/concerns/google_concern.rb b/app/controllers/concerns/google_concern.rb index 474b14aec..13de7ced3 100644 --- a/app/controllers/concerns/google_concern.rb +++ b/app/controllers/concerns/google_concern.rb @@ -14,7 +14,7 @@ module GoogleConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'email profile https://mail.google.com/' end end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index 507b9f8a3..c3e0994bd 100644 --- a/app/controllers/concerns/microsoft_concern.rb +++ b/app/controllers/concerns/microsoft_concern.rb @@ -15,7 +15,7 @@ module MicrosoftConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email' end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a2cb466f1..6a4ce2461 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -15,7 +15,7 @@ class DashboardController < ActionController::Base private def ensure_html_format - head :not_acceptable unless request.format.html? + render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json? end def set_global_config diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 4cb02d266..be0fa5008 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -6,7 +6,6 @@ class OauthCallbackController < ApplicationController ) handle_response - ::Redis::Alfred.delete(cache_key) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception redirect_to '/' @@ -64,13 +63,10 @@ class OauthCallbackController < ApplicationController raise NotImplementedError end - def cache_key - "#{provider_name}::#{users_data['email'].downcase}" - end - def create_channel_with_inbox ActiveRecord::Base.transaction do channel_email = Channel::Email.create!(email: users_data['email'], account: account) + account.inboxes.create!( account: account, channel: channel_email, @@ -85,12 +81,17 @@ class OauthCallbackController < ApplicationController decoded_token[0] end - def account_id - ::Redis::Alfred.get(cache_key) + def account_from_signed_id + raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? + + account = GlobalID::Locator.locate_signed(params[:state]) + raise 'Invalid or expired state' if account.nil? + + account end def account - @account ||= Account.find(account_id) + @account ||= account_from_signed_id end # Fallback name, for when name field is missing from users_data diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index d07dbcb9d..32a147d34 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,7 +1,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal - before_action :set_category, except: [:index, :show] + before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_article, only: [:show] layout 'portal' @@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def show; end + def tracking_pixel + @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) + return head :not_found unless @article + + @article.increment_view_count if @article.published? + + # Serve the 1x1 tracking pixel with 24-hour private cache + # Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user + expires_in 24.hours, public: false + response.headers['Content-Type'] = 'image/png' + + pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png') + send_file pixel_path, type: 'image/png', disposition: 'inline' + end + private def limit_results @@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @article.increment_view_count if @article.published? @parsed_content = render_article_content(@article.content) end diff --git a/app/controllers/super_admin/account_users_controller.rb b/app/controllers/super_admin/account_users_controller.rb index b210dea19..d665b5684 100644 --- a/app/controllers/super_admin/account_users_controller.rb +++ b/app/controllers/super_admin/account_users_controller.rb @@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController # Overwrite any of the RESTful controller actions to implement custom behavior # For example, you may want to send an email after a foo is updated. # + + # Since account/user page - account user role attribute links to the show page + # Handle with a redirect to the user show page + def show + redirect_to super_admin_user_path(requested_resource.user) + end + def create resource = resource_class.new(resource_params) authorize_resource(resource) diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 22c51b6ef..23694d08d 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper end def generate_labels_report - Current.account.labels.map do |label| - label_report = report_builder({ type: :label, id: label.id }).short_summary - [label.title] + generate_readable_report_metrics(label_report) + reports = V2::Reports::LabelSummaryBuilder.new( + account: Current.account, + params: build_params({}) + ).build + + reports.map do |report| + [report[:name]] + generate_readable_report_metrics(report) end end diff --git a/app/javascript/dashboard/api/agentBots.js b/app/javascript/dashboard/api/agentBots.js index 6e59f38d3..de887f415 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -21,6 +21,10 @@ class AgentBotsAPI extends ApiClient { deleteAgentBotAvatar(botId) { return axios.delete(`${this.url}/${botId}/avatar`); } + + resetAccessToken(botId) { + return axios.post(`${this.url}/${botId}/reset_access_token`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index dde817866..a1b15ee79 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -38,13 +38,7 @@ export default { } return false; }, - profileUpdate({ - password, - password_confirmation, - displayName, - avatar, - ...profileAttributes - }) { + profileUpdate({ displayName, avatar, ...profileAttributes }) { const formData = new FormData(); Object.keys(profileAttributes).forEach(key => { const hasValue = profileAttributes[key] === undefined; @@ -53,16 +47,22 @@ export default { } }); formData.append('profile[display_name]', displayName || ''); - if (password && password_confirmation) { - formData.append('profile[password]', password); - formData.append('profile[password_confirmation]', password_confirmation); - } if (avatar) { formData.append('profile[avatar]', avatar); } return axios.put(endPoints('profileUpdate').url, formData); }, + profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) { + return axios.put(endPoints('profileUpdate').url, { + profile: { + current_password: currentPassword, + password, + password_confirmation: passwordConfirmation, + }, + }); + }, + updateUISettings({ uiSettings }) { return axios.put(endPoints('profileUpdate').url, { profile: { ui_settings: uiSettings }, @@ -102,4 +102,8 @@ export default { const urlData = endPoints('resendConfirmation'); return axios.post(urlData.url); }, + resetAccessToken() { + const urlData = endPoints('resetAccessToken'); + return axios.post(urlData.url); + }, }; diff --git a/app/javascript/dashboard/api/captain/copilotMessages.js b/app/javascript/dashboard/api/captain/copilotMessages.js new file mode 100644 index 000000000..49e05398a --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotMessages.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CopilotMessages extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } + + get(threadId) { + return axios.get(`${this.url}/${threadId}/copilot_messages`); + } + + create({ threadId, ...rest }) { + return axios.post(`${this.url}/${threadId}/copilot_messages`, rest); + } +} + +export default new CopilotMessages(); diff --git a/app/javascript/dashboard/api/captain/copilotThreads.js b/app/javascript/dashboard/api/captain/copilotThreads.js new file mode 100644 index 000000000..7fdce3b91 --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotThreads.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CopilotThreads extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } +} + +export default new CopilotThreads(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 2eee3f484..025df2122 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -61,6 +61,11 @@ class ContactAPI extends ApiClient { return axios.get(requestURL); } + active(page = 1, sortAttr = 'name') { + let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`; + return axios.get(requestURL); + } + // eslint-disable-next-line default-param-last filter(page = 1, sortAttr = 'name', queryPayload) { let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 31337b7fc..ecd3f0170 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -51,6 +51,10 @@ const endPoints = { resendConfirmation: { url: '/api/v1/profile/resend_confirmation', }, + + resetAccessToken: { + url: '/api/v1/profile/reset_access_token', + }, }; export default page => { diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 39546096f..0f539bfa9 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -134,13 +134,13 @@ class ConversationApi extends ApiClient { 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`); } + + delete(conversationId) { + return axios.delete(`${this.url}/${conversationId}`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/integrations/linear.js b/app/javascript/dashboard/api/integrations/linear.js index 2ac0940aa..bb327b7e8 100644 --- a/app/javascript/dashboard/api/integrations/linear.js +++ b/app/javascript/dashboard/api/integrations/linear.js @@ -33,9 +33,11 @@ class LinearAPI extends ApiClient { ); } - unlinkIssue(linkId) { + unlinkIssue(linkId, issueIdentifier, conversationId) { return axios.post(`${this.url}/unlink_issue`, { link_id: linkId, + issue_id: issueIdentifier, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js index 7abb584c0..d533c2f28 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -40,6 +40,15 @@ class SearchAPI extends ApiClient { }, }); } + + articles({ q, page = 1 }) { + return axios.get(`${this.url}/articles`, { + params: { + q, + page: page, + }, + }); + } } export default new SearchAPI(); diff --git a/app/javascript/dashboard/api/specs/agentBots.spec.js b/app/javascript/dashboard/api/specs/agentBots.spec.js index c89dbfdf5..bf57804c0 100644 --- a/app/javascript/dashboard/api/specs/agentBots.spec.js +++ b/app/javascript/dashboard/api/specs/agentBots.spec.js @@ -9,5 +9,6 @@ describe('#AgentBotsAPI', () => { expect(AgentBotsAPI).toHaveProperty('create'); expect(AgentBotsAPI).toHaveProperty('update'); expect(AgentBotsAPI).toHaveProperty('delete'); + expect(AgentBotsAPI).toHaveProperty('resetAccessToken'); }); }); diff --git a/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/app/javascript/dashboard/api/specs/integrations/linear.spec.js index e4bf679a6..3f33e3ed9 100644 --- a/app/javascript/dashboard/api/specs/integrations/linear.spec.js +++ b/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -91,6 +91,19 @@ describe('#linearAPI', () => { issueData ); }); + + it('creates a valid request with conversation_id', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + conversation_id: 123, + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); }); describe('link_issue', () => { @@ -120,6 +133,18 @@ describe('#linearAPI', () => { } ); }); + + it('creates a valid request with title', () => { + LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 'ENG-123', + conversation_id: 1, + title: 'Sample Issue', + } + ); + }); }); describe('getLinkedIssue', () => { @@ -164,12 +189,26 @@ describe('#linearAPI', () => { window.axios = originalAxios; }); - it('creates a valid request', () => { - LinearAPIClient.unlinkIssue(1); + it('creates a valid request with link_id only', () => { + LinearAPIClient.unlinkIssue('link123'); expect(axiosMock.post).toHaveBeenCalledWith( '/api/v1/integrations/linear/unlink_issue', { - link_id: 1, + link_id: 'link123', + issue_id: undefined, + conversation_id: undefined, + } + ); + }); + + it('creates a valid request with all parameters', () => { + LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: 'ENG-456', + conversation_id: 789, } ); }); diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js index f772ef86f..fad26bf6f 100644 --- a/app/javascript/dashboard/api/summaryReports.js +++ b/app/javascript/dashboard/api/summaryReports.js @@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient { }, }); } + + getLabelReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/label`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } } export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index ffcfca545..1280eb069 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -47,7 +47,7 @@ @apply max-w-full; .multiselect__option { - @apply text-sm font-normal; + @apply text-sm font-normal flex justify-between items-center; span { @apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit; @@ -58,7 +58,7 @@ } &::after { - @apply bottom-0 flex items-center justify-center text-center; + @apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight; } &.multiselect__option--highlight { @@ -74,7 +74,7 @@ } &.multiselect__option--highlight::after { - @apply bg-transparent; + @apply bg-transparent text-n-slate-12; } &.multiselect__option--selected { diff --git a/app/javascript/dashboard/assets/scss/widgets/_base.scss b/app/javascript/dashboard/assets/scss/widgets/_base.scss index 9c4ccf126..afaaf91c8 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_base.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_base.scss @@ -101,7 +101,7 @@ select { background-image: url("data:image/svg+xml;utf8,"); background-size: 9px 6px; - @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; + @apply field-base h-10 bg-origin-content 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; diff --git a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss index 72a2e6be8..72773de7f 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss @@ -3,7 +3,7 @@ } .tabs--container--with-border { - @apply border-b border-n-weak; + @apply border-b border-b-n-weak; } .tabs--container--compact.tab--chat-type { diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index b2b0dbfa0..0932a79c7 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -17,6 +17,7 @@ const props = defineProps({ additionalAttributes: { type: Object, default: () => ({}) }, phoneNumber: { type: String, default: '' }, thumbnail: { type: String, default: '' }, + availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, }); @@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index ad9696f7d..62286e847 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -94,7 +94,7 @@ const newReportRoutes = () => [ { name: 'Reports Label', label: t('SIDEBAR.REPORTS_LABEL'), - to: accountScopedRoute('label_reports'), + to: accountScopedRoute('label_reports_index'), }, { name: 'Reports Inbox', @@ -242,6 +242,12 @@ const menuItems = computed(() => { ), activeOn: ['contacts_dashboard_index', 'contacts_edit'], }, + { + name: 'Active', + label: t('SIDEBAR.ACTIVE'), + to: accountScopedRoute('contacts_dashboard_active'), + activeOn: ['contacts_dashboard_active'], + }, { name: 'Segments', icon: 'i-lucide-group', @@ -489,7 +495,7 @@ const menuItems = computed(() => { + { ); }); +const allCount = computed(() => props.conversationStats?.allCount || 0); +const formattedAllCount = computed(() => formatNumber(allCount.value)); + const toggleConversationLayout = () => { const { LAYOUT_TYPES } = wootConstants; const { @@ -80,20 +71,27 @@ const toggleConversationLayout = () => { diff --git a/app/javascript/dashboard/components/Snackbar.vue b/app/javascript/dashboard/components/Snackbar.vue index 3a23a15ef..0404c4ba8 100644 --- a/app/javascript/dashboard/components/Snackbar.vue +++ b/app/javascript/dashboard/components/Snackbar.vue @@ -20,7 +20,7 @@ export default {