diff --git a/.circleci/config.yml b/.circleci/config.yml index bc7053130..99795db91 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -73,15 +73,15 @@ jobs: libvips - run: - name: Install RVM and Ruby 3.3.3 + name: Install RVM and Ruby 3.4.4 command: | sudo apt-get install -y gpg gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB \curl -sSL https://get.rvm.io | bash -s stable echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV source ~/.rvm/scripts/rvm - rvm install "3.3.3" - rvm use 3.3.3 --default + rvm install "3.4.4" + rvm use 3.4.4 --default gem install bundler -v 2.5.16 - run: 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 c5530ac17..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.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' - image: base:latest - app: build: context: .. @@ -25,7 +12,7 @@ services: args: VARIANT: 'ubuntu-22.04' NODE_VERSION: '23.7.0' - RUBY_VERSION: '3.3.3' + 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' 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/.rubocop.yml b/.rubocop.yml index 1cdfbc713..12e756af6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,7 +1,10 @@ -require: +plugins: - rubocop-performance - rubocop-rails - rubocop-rspec + - rubocop-factory_bot + +require: - ./rubocop/use_from_email.rb - ./rubocop/custom_cop_location.rb @@ -13,44 +16,61 @@ Metrics/ClassLength: Exclude: - 'app/models/message.rb' - 'app/models/conversation.rb' + Metrics/MethodLength: Max: 19 + Exclude: + - 'enterprise/lib/captain/agent.rb' + RSpec/ExampleLength: Max: 25 + Style/Documentation: Enabled: false + Style/ExponentialNotation: Enabled: false + Style/FrozenStringLiteralComment: Enabled: false + Style/SymbolArray: Enabled: false + Style/OpenStructUse: Enabled: false + Style/OptionalBooleanParameter: Exclude: - 'app/services/email_templates/db_resolver_service.rb' - 'app/dispatchers/dispatcher.rb' + Style/GlobalVars: Exclude: - 'config/initializers/01_redis.rb' - 'config/initializers/rack_attack.rb' - 'lib/redis/alfred.rb' - 'lib/global_config.rb' + Style/ClassVars: Exclude: - 'app/services/email_templates/db_resolver_service.rb' + Lint/MissingSuper: Exclude: - 'app/drops/base_drop.rb' + Lint/SymbolConversion: Enabled: false + Lint/EmptyBlock: Exclude: - 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder' + Lint/OrAssignmentToConstant: Exclude: - 'lib/redis/config.rb' + Metrics/BlockLength: Max: 30 Exclude: @@ -58,10 +78,16 @@ Metrics/BlockLength: - '**/routes.rb' - 'config/environments/*' - db/schema.rb + Metrics/ModuleLength: Exclude: - lib/seeders/message_seeder.rb - spec/support/slack_stubs.rb + +Rails/HelperInstanceVariable: + Exclude: + - enterprise/app/helpers/captain/chat_helper.rb + Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' @@ -71,74 +97,101 @@ Rails/ApplicationController: - 'app/controllers/platform_controller.rb' - 'app/controllers/public_controller.rb' - 'app/controllers/survey/responses_controller.rb' + Rails/FindEach: Enabled: true Include: - 'app/**/*.rb' + Rails/CompactBlank: Enabled: false + Rails/EnvironmentVariableAccess: Enabled: false + Rails/TimeZoneAssignment: Enabled: false + Rails/RedundantPresenceValidationOnBelongsTo: Enabled: false + +Rails/InverseOf: + Exclude: + - enterprise/app/models/captain/assistant.rb + +Rails/UniqueValidationWithoutIndex: + Exclude: + - app/models/canned_response.rb + - app/models/telegram_bot.rb + - enterprise/app/models/captain_inbox.rb + - 'app/models/channel/twitter_profile.rb' + - 'app/models/webhook.rb' + - 'app/models/contact.rb' + Style/ClassAndModuleChildren: EnforcedStyle: compact Exclude: - 'config/application.rb' - 'config/initializers/monkey_patches/*' + Style/MapToHash: Enabled: false + Style/HashSyntax: Enabled: true EnforcedStyle: no_mixed_keys EnforcedShorthandSyntax: never + RSpec/NestedGroups: Enabled: true Max: 4 + RSpec/MessageSpies: Enabled: false + RSpec/StubbedMock: Enabled: false -RSpec/FactoryBot/SyntaxMethods: - Enabled: false + Naming/VariableNumber: Enabled: false + Naming/MemoizedInstanceVariableName: Exclude: - 'app/models/message.rb' + Style/GuardClause: Exclude: - 'app/builders/account_builder.rb' - 'app/models/attachment.rb' - 'app/models/message.rb' + Metrics/AbcSize: Max: 26 Exclude: - 'app/controllers/concerns/auth_helper.rb' -Rails/UniqueValidationWithoutIndex: - Exclude: - - 'app/models/channel/twitter_profile.rb' - - 'app/models/webhook.rb' - - 'app/models/contact.rb' + - 'app/models/integrations/hook.rb' - 'app/models/canned_response.rb' - 'app/models/telegram_bot.rb' + Rails/RenderInline: Exclude: - 'app/controllers/swagger_controller.rb' + Rails/ThreeStateBooleanColumn: Exclude: - 'db/migrate/20230503101201_create_sla_policies.rb' + RSpec/IndexedLet: Enabled: false + RSpec/NamedSubject: Enabled: false # we should bring this down RSpec/MultipleExpectations: Max: 7 + RSpec/MultipleMemoizedHelpers: Max: 14 @@ -166,3 +219,121 @@ AllCops: - 'tmp/**/*' - 'storage/**/*' - 'db/migrate/20230426130150_init_schema.rb' + +FactoryBot/SyntaxMethods: + Enabled: false + +# Disable new rules causing errors +Layout/LeadingCommentSpace: + Enabled: false + +Style/ReturnNilInPredicateMethodDefinition: + Enabled: false + +Style/RedundantParentheses: + Enabled: false + +Performance/StringIdentifierArgument: + Enabled: false + +Layout/EmptyLinesAroundExceptionHandlingKeywords: + Enabled: false + +Lint/LiteralAsCondition: + Enabled: false + +Style/RedundantReturn: + Enabled: false + +Layout/SpaceAroundOperators: + Enabled: false + +Rails/EnvLocal: + Enabled: false + +Rails/WhereRange: + Enabled: false + +Lint/UselessConstantScoping: + Enabled: false + +Style/MultipleComparison: + Enabled: false + +Bundler/OrderedGems: + Enabled: false + +RSpec/ExampleWording: + Enabled: false + +RSpec/ReceiveMessages: + Enabled: false + +FactoryBot/AssociationStyle: + Enabled: false + +Rails/EnumSyntax: + Enabled: false + +Lint/RedundantTypeConversion: + Enabled: false + +# Additional rules to disable +Rails/RedundantActiveRecordAllMethod: + Enabled: false + +Layout/TrailingEmptyLines: + Enabled: false + +Style/SafeNavigationChainLength: + Enabled: false + +Lint/SafeNavigationConsistency: + Enabled: false + +Lint/CopDirectiveSyntax: + Enabled: false + +# Final set of rules to disable +FactoryBot/ExcessiveCreateList: + Enabled: false + +RSpec/MissingExpectationTargetMethod: + Enabled: false + +Performance/InefficientHashSearch: + Enabled: false + +Style/RedundantSelfAssignmentBranch: + Enabled: false + +Style/YAMLFileRead: + Enabled: false + +Layout/ExtraSpacing: + Enabled: false + +Style/RedundantFilterChain: + Enabled: false + +Performance/MapMethodChain: + Enabled: false + +Rails/RootPathnameMethods: + Enabled: false + +Style/SuperArguments: + Enabled: false + +# Final remaining rules to disable +Rails/Delegate: + Enabled: false + +Style/CaseLikeIf: + Enabled: false + +FactoryBot/RedundantFactoryOption: + Enabled: false + +FactoryBot/FactoryAssociationWithStrategy: + Enabled: false \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index 619b53766..f9892605c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.3 +3.4.4 diff --git a/Gemfile b/Gemfile index 1e8605379..b4752c745 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,10 @@ source 'https://rubygems.org' -ruby '3.3.3' +ruby '3.4.4' ##-- base gems for rails --## gem 'rack-cors', '2.0.0', require: 'rack/cors' -gem 'rails', '~> 7.0.8.4' +gem 'rails', '~> 7.1' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -33,6 +33,8 @@ gem 'liquid' gem 'commonmarker' # Validate Data against JSON Schema gem 'json_schemer' +# used in swagger build +gem 'json_refs' # Rack middleware for blocking & throttling abusive requests gem 'rack-attack', '>= 6.7.0' # a utility tool for streaming, flexible and safe downloading of remote files @@ -196,9 +198,6 @@ group :development do gem 'scss_lint', require: false gem 'web-console', '>= 4.2.1' - # used in swagger build - gem 'json_refs' - # When we want to squash migrations gem 'squasher' @@ -237,6 +236,7 @@ group :development, :test do gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false + gem 'rubocop-factory_bot', require: false gem 'seed_dump' gem 'shoulda-matchers' gem 'simplecov', '0.17.1', require: false diff --git a/Gemfile.lock b/Gemfile.lock index da19dbb72..d9908f5e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,76 +25,89 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8.7) - actionpack (= 7.0.8.7) - activesupport (= 7.0.8.7) + actioncable (7.1.5.1) + actionpack (= 7.1.5.1) + activesupport (= 7.1.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.7) - actionpack (= 7.0.8.7) - activejob (= 7.0.8.7) - activerecord (= 7.0.8.7) - activestorage (= 7.0.8.7) - activesupport (= 7.0.8.7) + zeitwerk (~> 2.6) + actionmailbox (7.1.5.1) + actionpack (= 7.1.5.1) + activejob (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.7) - actionpack (= 7.0.8.7) - actionview (= 7.0.8.7) - activejob (= 7.0.8.7) - activesupport (= 7.0.8.7) + actionmailer (7.1.5.1) + actionpack (= 7.1.5.1) + actionview (= 7.1.5.1) + activejob (= 7.1.5.1) + activesupport (= 7.1.5.1) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp - rails-dom-testing (~> 2.0) - actionpack (7.0.8.7) - actionview (= 7.0.8.7) - activesupport (= 7.0.8.7) - rack (~> 2.0, >= 2.2.4) + rails-dom-testing (~> 2.2) + actionpack (7.1.5.1) + actionview (= 7.1.5.1) + activesupport (= 7.1.5.1) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.7) - actionpack (= 7.0.8.7) - activerecord (= 7.0.8.7) - activestorage (= 7.0.8.7) - activesupport (= 7.0.8.7) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.5.1) + actionpack (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.7) - activesupport (= 7.0.8.7) + actionview (7.1.5.1) + activesupport (= 7.1.5.1) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) active_record_query_trace (1.8) - activejob (7.0.8.7) - activesupport (= 7.0.8.7) + activejob (7.1.5.1) + activesupport (= 7.1.5.1) globalid (>= 0.3.6) - activemodel (7.0.8.7) - activesupport (= 7.0.8.7) - activerecord (7.0.8.7) - activemodel (= 7.0.8.7) - activesupport (= 7.0.8.7) - activerecord-import (1.4.1) + activemodel (7.1.5.1) + activesupport (= 7.1.5.1) + activerecord (7.1.5.1) + activemodel (= 7.1.5.1) + activesupport (= 7.1.5.1) + timeout (>= 0.4.0) + activerecord-import (2.1.0) activerecord (>= 4.2) - activestorage (7.0.8.7) - actionpack (= 7.0.8.7) - activejob (= 7.0.8.7) - activerecord (= 7.0.8.7) - activesupport (= 7.0.8.7) + activestorage (7.1.5.1) + actionpack (= 7.1.5.1) + activejob (= 7.1.5.1) + activerecord (= 7.1.5.1) + activesupport (= 7.1.5.1) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (7.0.8.7) + activesupport (7.1.5.1) + base64 + benchmark (>= 0.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) - acts-as-taggable-on (9.0.1) - activerecord (>= 6.0, < 7.1) + acts-as-taggable-on (12.0.0) + activerecord (>= 7.1, < 8.1) + zeitwerk (>= 2.4, < 3.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) administrate (0.20.1) @@ -116,7 +129,7 @@ GEM annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) - ast (2.4.2) + ast (2.4.3) attr_extras (7.1.0) audited (5.4.1) activerecord (>= 5.0, < 7.7) @@ -142,14 +155,15 @@ GEM statsd-ruby (~> 1.1) base64 (0.2.0) bcrypt (3.1.20) - bigdecimal (3.1.8) + benchmark (0.4.0) + bigdecimal (3.1.9) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) brakeman (5.4.1) browser (5.3.1) builder (3.3.0) - bullet (7.0.7) + bullet (8.0.7) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) @@ -161,8 +175,8 @@ GEM climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) crack (1.0.0) bigdecimal rexml @@ -176,16 +190,10 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - datadog-ci (0.8.3) - msgpack date (3.4.1) - ddtrace (1.23.2) - datadog-ci (~> 0.8.1) - debase-ruby_core_source (= 3.3.1) - libdatadog (~> 7.0.0.1.0) - libddwaf (~> 1.14.0.0.0) + ddtrace (0.48.0) + ffi (~> 1.0) msgpack - debase-ruby_core_source (3.3.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -196,10 +204,10 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise_token_auth (1.2.3) + devise_token_auth (1.2.5) bcrypt (~> 3.0) devise (> 3.5.2, < 5) - rails (>= 4.2.0, < 7.2) + rails (>= 4.2.0, < 8.1) diff-lcs (1.5.1) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) @@ -212,6 +220,7 @@ GEM railties (>= 6.1) down (5.4.0) addressable (~> 2.8) + drb (2.2.3) dry-cli (1.1.0) ecma-re-validator (0.4.0) regexp_parser (~> 2.2) @@ -254,7 +263,10 @@ GEM fcm (1.0.8) faraday (>= 1.0.0, < 3.0) googleauth (~> 1) - ffi (1.16.3) + ffi (1.17.2) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake @@ -315,16 +327,13 @@ GEM google-cloud-translate-v3 (0.10.0) gapic-common (>= 0.20.0, < 2.a) google-cloud-errors (~> 1.0) - google-protobuf (3.25.5) - google-protobuf (3.25.5-arm64-darwin) - google-protobuf (3.25.5-x86_64-darwin) - google-protobuf (3.25.5-x86_64-linux) + google-protobuf (3.25.7) googleapis-common-protos (1.6.0) google-protobuf (>= 3.18, < 5.a) googleapis-common-protos-types (~> 1.7) grpc (~> 1.41) - googleapis-common-protos-types (1.14.0) - google-protobuf (~> 3.18) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) googleauth (1.11.2) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.1) @@ -334,17 +343,17 @@ GEM signet (>= 0.16, < 2.a) groupdate (6.2.1) activesupport (>= 5.2) - grpc (1.62.0) - google-protobuf (~> 3.25) + grpc (1.72.0) + google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) - grpc (1.62.0-arm64-darwin) - google-protobuf (~> 3.25) + grpc (1.72.0-arm64-darwin) + google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) - grpc (1.62.0-x86_64-darwin) - google-protobuf (~> 3.25) + grpc (1.72.0-x86_64-darwin) + google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) - grpc (1.62.0-x86_64-linux) - google-protobuf (~> 3.25) + grpc (1.72.0-x86_64-linux) + google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) haikunator (1.1.1) hairtrigger (1.0.0) @@ -370,7 +379,7 @@ GEM mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -388,7 +397,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.6.3) + json (2.12.0) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -423,21 +432,13 @@ GEM faraday-multipart json (>= 1.8) rexml + language_server-protocol (3.17.0.5) launchy (2.5.2) addressable (~> 2.8) letter_opener (1.8.1) launchy (>= 2.2, < 3) - libdatadog (7.0.0.1.0) - libdatadog (7.0.0.1.0-x86_64-linux) - libddwaf (1.14.0.0.0) - ffi (~> 1.0) - libddwaf (1.14.0.0.0-arm64-darwin) - ffi (~> 1.0) - libddwaf (1.14.0.0.0-x86_64-darwin) - ffi (~> 1.0) - libddwaf (1.14.0.0.0-x86_64-linux) - ffi (~> 1.0) line-bot-api (1.28.0) + lint_roller (1.1.0) liquid (5.4.0) listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -445,7 +446,7 @@ GEM llhttp-ffi (0.4.0) ffi-compiler (~> 1.0) rake (~> 13.0) - logger (1.6.0) + logger (1.7.0) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -471,10 +472,10 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.4) + minitest (5.25.5) mock_redis (0.36.0) ruby2_keywords - msgpack (1.7.0) + msgpack (1.8.0) multi_json (1.15.0) multi_xml (0.6.0) multipart-post (2.3.0) @@ -545,14 +546,16 @@ GEM orm_adapter (0.5.0) os (1.1.4) ostruct (0.6.1) - parallel (1.23.0) - parser (3.2.2.1) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) + racc pg (1.5.3) pg_search (2.3.6) activerecord (>= 5.2) activesupport (>= 5.2) pgvector (0.1.1) + prism (1.4.0) procore-sift (1.0.0) activerecord (>= 6.1) pry (0.14.2) @@ -567,7 +570,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.14) + rack (2.2.15) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -581,23 +584,28 @@ GEM rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.7) rack + rack-session (1.0.2) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) rack-timeout (0.6.3) - rails (7.0.8.7) - actioncable (= 7.0.8.7) - actionmailbox (= 7.0.8.7) - actionmailer (= 7.0.8.7) - actionpack (= 7.0.8.7) - actiontext (= 7.0.8.7) - actionview (= 7.0.8.7) - activejob (= 7.0.8.7) - activemodel (= 7.0.8.7) - activerecord (= 7.0.8.7) - activestorage (= 7.0.8.7) - activesupport (= 7.0.8.7) + rackup (1.0.1) + rack (< 3) + webrick + rails (7.1.5.1) + actioncable (= 7.1.5.1) + actionmailbox (= 7.1.5.1) + actionmailer (= 7.1.5.1) + actionpack (= 7.1.5.1) + actiontext (= 7.1.5.1) + actionview (= 7.1.5.1) + activejob (= 7.1.5.1) + activemodel (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) bundler (>= 1.15.0) - railties (= 7.0.8.7) + railties (= 7.1.5.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -605,13 +613,14 @@ GEM rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.0.8.7) - actionpack (= 7.0.8.7) - activesupport (= 7.0.8.7) - method_source + railties (7.1.5.1) + actionpack (= 7.1.5.1) + activesupport (= 7.1.5.1) + irb + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) rb-fsevent (0.11.2) @@ -623,7 +632,7 @@ GEM connection_pool redis-namespace (1.10.0) redis (>= 4) - regexp_parser (2.8.0) + regexp_parser (2.10.0) reline (0.3.6) io-console (~> 0.5) representable (3.2.0) @@ -643,7 +652,7 @@ GEM retriable (3.1.2) reverse_markdown (2.1.1) nokogiri - rexml (3.3.9) + rexml (3.4.1) rspec-core (3.13.0) rspec-support (~> 3.13.0) rspec-expectations (3.13.2) @@ -663,30 +672,36 @@ GEM rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.50.2) + rubocop (1.75.6) json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.1) - parser (>= 3.2.1.0) - rubocop-capybara (2.18.0) - rubocop (~> 1.41) - rubocop-performance (1.17.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-rails (2.19.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.44.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-factory_bot (2.27.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.32.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-rspec (2.21.0) - rubocop (~> 1.33) - rubocop-capybara (~> 2.17) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rspec (3.6.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) ruby-openai (7.3.1) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) @@ -816,8 +831,10 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) - unicode-display_width (2.4.2) - uniform_notifier (1.16.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uniform_notifier (1.17.0) uri (1.0.3) uri_template (0.7.0) valid_email2 (5.2.6) @@ -845,7 +862,9 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.6) + webrick (1.9.1) + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.0) @@ -951,7 +970,7 @@ DEPENDENCIES rack-cors (= 2.0.0) rack-mini-profiler (>= 3.2.0) rack-timeout - rails (~> 7.0.8.4) + rails (~> 7.1) redis redis-namespace responders (>= 3.1.1) @@ -960,6 +979,7 @@ DEPENDENCIES rspec-rails (>= 6.1.5) rspec_junit_formatter rubocop + rubocop-factory_bot rubocop-performance rubocop-rails rubocop-rspec @@ -997,7 +1017,7 @@ DEPENDENCIES working_hours RUBY VERSION - ruby 3.3.3p89 + ruby 3.4.4p34 BUNDLED WITH 2.5.16 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/account_builder.rb b/app/builders/account_builder.rb index 6f7980586..532487a1b 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -32,14 +32,7 @@ class AccountBuilder end def validate_email - raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked? - - address = ValidEmail2::Address.new(@email) - if address.valid? && !address.disposable? - true - else - raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? }) - end + Account::SignUpEmailValidationService.new(@email).perform end def validate_user @@ -81,21 +74,4 @@ class AccountBuilder @user.confirm if @confirmed @user.save! end - - def domain_blocked? - domain = @email.split('@').last - - blocked_domains.each do |blocked_domain| - return true if domain.match?(blocked_domain) - end - - false - end - - def blocked_domains - domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '') - return [] if domains.blank? - - domains.split("\n").map(&:strip) - end end diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index ffa45db2e..788ae39d1 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -59,11 +59,13 @@ class ContactInboxBuilder end def create_contact_inbox - ::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!( + attrs = { contact_id: @contact.id, inbox_id: @inbox.id, source_id: @source_id - ) + } + + ::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false) rescue ActiveRecord::RecordNotUnique Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}") update_old_contact_inbox diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 767115bc6..8b40ba3c9 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -152,11 +152,13 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil end def message_already_exists? - cw_message = conversation.messages.where( - source_id: @messaging[:message][:mid] - ).first + find_message_by_source_id(@messaging[:message][:mid]).present? + end - cw_message.present? + def find_message_by_source_id(source_id) + return unless source_id + + @message = Message.find_by(source_id: source_id) end def all_unsupported_files? 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/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 9148e4386..da2be2312 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def article_params params.require(:article).permit( - :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, + :title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status, :locale, meta: [:title, :description, { tags: [] }] diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index fda19b8c2..20d66fb4d 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -12,10 +12,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts:: Current.account ).perform - # 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 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/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 4e5348e88..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 @@ -94,7 +113,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def permitted_params - params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) + params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id, + label_ids: []) end def fetch_hook 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/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/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index 2b3ea9067..db312e94f 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -21,7 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa def sign_up_user return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? - return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account? + return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user token = @resource.send(:set_reset_password_token) @@ -53,9 +53,11 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa ).first end - def validate_business_account? - # return true if the user is a business account, false if it is a gmail account - auth_hash['info']['email'].downcase.exclude?('@gmail.com') + def validate_signup_email_is_business_domain? + # return true if the user is a business account, false if it is a blocked domain account + Account::SignUpEmailValidationService.new(auth_hash['info']['email']).perform + rescue CustomExceptions::Account::InvalidEmail + false end def create_account_for_user diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 4cb02d266..9aa73956a 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -71,6 +71,7 @@ class OauthCallbackController < ApplicationController 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, diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index 453e475b0..7b9284cd9 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -1,9 +1,9 @@ class Platform::Api::V1::UsersController < PlatformController # ref: https://stackoverflow.com/a/45190318/939299 # set resource is called for other actions already in platform controller - # we want to add login to that chain as well - before_action(only: [:login]) { set_resource } - before_action(only: [:login]) { validate_platform_app_permissible } + # we want to add login and token to that chain as well + before_action(only: [:login, :token]) { set_resource } + before_action(only: [:login, :token]) { validate_platform_app_permissible } def show; end @@ -18,6 +18,8 @@ class Platform::Api::V1::UsersController < PlatformController render json: { url: @resource.generate_sso_link } end + def token; end + def update @resource.assign_attributes(user_update_params) 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/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 550b6c893..204bfc95b 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController end def allowed_configs - @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 + mapping = { + 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], + 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], + 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], + 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], + 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], + 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], + 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] + } + + @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) end end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 1694199ba..d43ed31e7 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -88,7 +88,10 @@ class ConversationFinder def find_conversation_by_inbox @conversations = current_account.conversations - @conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin + + return unless params[:inbox_id] + + @conversations = @conversations.where(inbox_id: @inbox_ids) end def find_all_conversations 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/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/ContactDeleteSection.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue new file mode 100644 index 000000000..47b779b61 --- /dev/null +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue @@ -0,0 +1,65 @@ + + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index a3b0bf37c..0932a79c7 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; +import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -16,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 }, }); @@ -91,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue index d9c0deb1b..f43a50883 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue @@ -47,11 +47,7 @@ defineExpose({ dialogRef }); ref="dialogRef" type="alert" :title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')" - :description=" - t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', { - contactName: props.selectedContact.name, - }) - " + :description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')" :confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')" @confirm="handleDialogConfirm" /> diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue index 42f526f95..30717c036 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue @@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; defineProps({ - showSearch: { - type: Boolean, - default: true, - }, - searchValue: { - type: String, - default: '', - }, - headerTitle: { - type: String, - required: true, - }, - buttonLabel: { - type: String, - default: '', - }, - activeSort: { - type: String, - default: 'last_activity_at', - }, - activeOrdering: { - type: String, - default: '', - }, - isSegmentsView: { - type: Boolean, - default: false, - }, - hasActiveFilters: { - type: Boolean, - default: false, - }, - isLabelView: { - type: Boolean, - default: false, - }, + showSearch: { type: Boolean, default: true }, + searchValue: { type: String, default: '' }, + headerTitle: { type: String, required: true }, + buttonLabel: { type: String, default: '' }, + activeSort: { type: String, default: 'last_activity_at' }, + activeOrdering: { type: String, default: '' }, + isSegmentsView: { type: Boolean, default: false }, + hasActiveFilters: { type: Boolean, default: false }, + isLabelView: { type: Boolean, default: false }, + isActiveView: { type: Boolean, default: false }, }); const emit = defineEmits([ @@ -85,7 +59,7 @@ const emit = defineEmits([
-
+