diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 908d97175..0a6c574ab 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -2,8 +2,19 @@ ignore: - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) - # Chatwoot defaults to Active Storage redirect-style URLs, and its recommended - # storage setup uses local/cloud storage with optional direct uploads to the - # storage provider rather than Rails proxy mode. Revisit if we enable - # rails_storage_proxy or other app-served Active Storage proxy routes. + # Rails 7.1 has no patched release for the Active Storage proxy range + # advisories. Chatwoot limits proxy range requests locally. - CVE-2026-33658 + # Rails 7.1 has no patched release for this Active Storage direct-upload + # advisory. Chatwoot filters internal metadata keys locally. + - CVE-2026-33173 + - CVE-2026-33174 + # Rails 7.1 has no patched release for these Rails advisories. These are not + # reachable through Chatwoot's current usage patterns and should be removed + # once we upgrade to Rails 7.2.3.1+. + - CVE-2026-33168 + - CVE-2026-33169 + - CVE-2026-33170 + - CVE-2026-33176 + - CVE-2026-33195 + - CVE-2026-33202 diff --git a/.circleci/config.yml b/.circleci/config.yml index 59702c139..f764cb611 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 18 + parallelism: 20 steps: - checkout - node/install: diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py new file mode 100644 index 000000000..064361b26 --- /dev/null +++ b/.github/scripts/ghsa_linear_sync.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Sync triage GitHub security advisories to Linear issues.""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +import requests + +GITHUB_API = "https://api.github.com" +LINEAR_API = "https://api.linear.app/graphql" + +SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4} +SEVERITY_COLOR = { + "critical": 15548997, + "high": 15105570, + "medium": 15844367, + "low": 3066993, +} +DEFAULT_COLOR = 9807270 + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + sys.exit(f"Missing required env var: {name}") + return value + + +def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]: + url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories" + params: dict[str, Any] | None = {"state": "triage", "per_page": 100} + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + advisories: list[dict[str, Any]] = [] + while url: + r = requests.get(url, headers=headers, params=params, timeout=30) + r.raise_for_status() + advisories.extend(r.json()) + next_link = r.links.get("next") + url = next_link["url"] if next_link else None + params = None + return advisories + + +def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]: + r = requests.post( + LINEAR_API, + headers={"Authorization": api_key}, + json={"query": query, "variables": variables}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def linear_issue_exists(ghsa_id: str, api_key: str) -> bool: + query = ( + "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) " + "{ nodes { id } } }" + ) + resp = linear_call(query, {"q": ghsa_id}, api_key) + return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0 + + +def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None: + query = ( + "mutation($input: IssueCreateInput!) { issueCreate(input: $input) " + "{ success issue { identifier url } } }" + ) + resp = linear_call(query, {"input": input_data}, api_key) + create = resp.get("data", {}).get("issueCreate") or {} + if not create.get("success"): + return None + return create.get("issue") + + +def reporter_login(advisory: dict[str, Any]) -> str: + for credit in advisory.get("credits") or []: + user = (credit or {}).get("user") or {} + if user.get("login"): + return user["login"] + return "unknown" + + +def cvss_score(advisory: dict[str, Any]) -> str: + score = (advisory.get("cvss") or {}).get("score") + return str(score) if score is not None else "n/a" + + +def build_description(adv: dict[str, Any]) -> str: + return ( + f"**GHSA:** {adv['ghsa_id']}\n" + f"**CVE:** {adv.get('cve_id') or 'n/a'}\n" + f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n" + f"**Reporter:** {reporter_login(adv)}\n" + f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n" + f"**Advisory:** {adv['html_url']}\n\n" + f"---\n\n" + f"{adv.get('description') or 'No description provided.'}" + ) + + +def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None: + severity = adv.get("severity") or "unknown" + title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250] + payload = { + "username": "GHSA Sync", + "embeds": [ + { + "title": title, + "url": issue["url"], + "color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR), + "fields": [ + {"name": "Linear", "value": issue["identifier"], "inline": True}, + { + "name": "Severity", + "value": f"{severity} (CVSS {cvss_score(adv)})", + "inline": True, + }, + { + "name": "Advisory", + "value": f"[GitHub]({adv['html_url']})", + "inline": True, + }, + ], + } + ], + } + try: + requests.post(webhook_url, json=payload, timeout=10) + except requests.RequestException: + pass + + +def main() -> int: + repo = required_env("GITHUB_REPOSITORY") + gh_token = required_env("GHSA_READ_TOKEN") + linear_api_key = required_env("LINEAR_API_KEY") + team_id = required_env("LINEAR_TEAM_ID") + project_id = required_env("LINEAR_PROJECT_ID") + label_id = required_env("LINEAR_LABEL_ID") + discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None + + advisories = fetch_triage_advisories(repo, gh_token) + print(f"Fetched {len(advisories)} triage advisories") + + created = skipped = failed = 0 + + for adv in advisories: + ghsa_id = adv.get("ghsa_id") + if not ghsa_id: + failed += 1 + continue + + try: + if linear_issue_exists(ghsa_id, linear_api_key): + skipped += 1 + continue + + severity = adv.get("severity") or "unknown" + issue = linear_create_issue( + { + "title": f"[{ghsa_id}] {adv.get('summary', '')}", + "description": build_description(adv), + "teamId": team_id, + "projectId": project_id, + "labelIds": [label_id], + "priority": SEVERITY_PRIORITY.get(severity, 3), + }, + linear_api_key, + ) + except requests.RequestException: + failed += 1 + continue + + if not issue: + failed += 1 + continue + + created += 1 + if discord_webhook: + post_discord(adv, issue, discord_webhook) + + print(f"Created {created}, skipped {skipped}, failed {failed}") + return 1 if failed > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/deploy_check.yml b/.github/workflows/deploy_check.yml index 9f295a6c8..9f2ae42d8 100644 --- a/.github/workflows/deploy_check.yml +++ b/.github/workflows/deploy_check.yml @@ -11,6 +11,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: deployment_check: name: Check Deployment diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 1d1116d0c..3d992662a 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -8,6 +8,9 @@ on: branches: - develop +permissions: + contents: read + jobs: test: runs-on: ubuntu-22.04 diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml new file mode 100644 index 000000000..a21fbea7f --- /dev/null +++ b/.github/workflows/ghsa-linear-sync.yml @@ -0,0 +1,29 @@ +name: Sync GHSA advisories to Linear + +on: + schedule: + - cron: '0 4 * * *' # daily at 09:30 IST + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install requests==2.32.3 + - name: Sync advisories + env: + GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} + LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: python3 .github/scripts/ghsa_linear_sync.py diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml index 5c45ba635..cef07cc2f 100644 --- a/.github/workflows/logging_percentage_check.yml +++ b/.github/workflows/logging_percentage_check.yml @@ -10,6 +10,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: log_lines_check: runs-on: ubuntu-latest diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index beef5727c..e0c5ed88e 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -14,6 +14,9 @@ on: - cron: "0 0 * * *" workflow_dispatch: +permissions: + contents: read + jobs: nightly: runs-on: ubuntu-24.04 diff --git a/.github/workflows/publish_codespace_image.yml b/.github/workflows/publish_codespace_image.yml index 5da4fda05..c1b0e4e28 100644 --- a/.github/workflows/publish_codespace_image.yml +++ b/.github/workflows/publish_codespace_image.yml @@ -3,6 +3,10 @@ name: Publish Codespace Base Image on: workflow_dispatch: +permissions: + contents: read + packages: write + jobs: publish-code-space-image: runs-on: ubuntu-latest diff --git a/.github/workflows/publish_ee_docker.yml b/.github/workflows/publish_ee_docker.yml index 8e2c22481..982054a18 100644 --- a/.github/workflows/publish_ee_docker.yml +++ b/.github/workflows/publish_ee_docker.yml @@ -18,6 +18,9 @@ on: env: DOCKER_REPO: chatwoot/chatwoot +permissions: + contents: read + jobs: build: strategy: diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml index 3075a7f3d..994e5cef8 100644 --- a/.github/workflows/publish_foss_docker.yml +++ b/.github/workflows/publish_foss_docker.yml @@ -18,6 +18,9 @@ on: env: DOCKER_REPO: chatwoot/chatwoot +permissions: + contents: read + jobs: build: strategy: diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 7869bf89c..909636a75 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -10,6 +10,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test: runs-on: ubuntu-22.04 diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml index b27d90408..96a6c69ac 100644 --- a/.github/workflows/test_docker_build.yml +++ b/.github/workflows/test_docker_build.yml @@ -7,6 +7,9 @@ on: - master workflow_dispatch: +permissions: + contents: read + jobs: test-build: strategy: diff --git a/Gemfile b/Gemfile index a5068e765..dfcdb3e30 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' gem 'email-provider-info' +gem 'gemoji' # compress javascript config.assets.js_compressor gem 'uglifier' ##-- used for single column multiple binary flags in notification settings/feature flagging --## @@ -84,6 +85,7 @@ gem 'barnes' gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth', '>= 1.2.3' +gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization @@ -193,10 +195,10 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.9.1' +gem 'ai-agents', '>= 0.10.0' # TODO: Move this gem as a dependency of ai-agents -gem 'ruby_llm', '>= 1.8.2' +gem 'ruby_llm', '>= 1.14.1' gem 'ruby_llm-schema' gem 'cld3', '~> 3.7' diff --git a/Gemfile.lock b/Gemfile.lock index b77e5880f..21dfd3547 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,8 +108,8 @@ GEM 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) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) administrate (0.20.1) actionpack (>= 6.0, < 8.0) actionview (>= 6.0, < 8.0) @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.9.1) - ruby_llm (~> 1.9.1) + ai-agents (0.10.0) + ruby_llm (~> 1.14) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -212,7 +212,7 @@ GEM logger msgpack datadog-ruby_core_source (3.4.1) - date (3.4.1) + date (3.5.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -307,8 +307,8 @@ GEM faraday-mashify (1.0.0) faraday (~> 2.0) hashie - faraday-multipart (1.0.4) - multipart-post (~> 2) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) faraday-net_http (3.4.2) net-http (~> 0.5) faraday-net_http_persistent (2.1.0) @@ -349,6 +349,7 @@ GEM googleapis-common-protos-types (>= 1.3.1, < 2.a) googleauth (~> 1.0) grpc (~> 1.36) + gemoji (4.1.0) geocoder (1.8.1) gli (2.22.2) ostruct @@ -465,7 +466,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.19.2) + json (2.19.5) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -573,7 +574,7 @@ GEM uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.20) + net-imap (0.4.24) date net-protocol net-pop (0.1.2) @@ -589,14 +590,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.19.1) + nokogiri (1.19.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) + nokogiri (1.19.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -676,14 +677,14 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (6.0.2) + public_suffix (7.0.5) puma (6.4.3) nio4r (~> 2.0) pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.5) + rack (3.2.6) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -698,7 +699,7 @@ GEM rack (>= 3.0.0, < 4) rack-proxy (0.7.7) rack - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.1.0) @@ -727,6 +728,9 @@ 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) + rails-i18n (7.0.10) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) railties (7.1.5.2) actionpack (= 7.1.5.2) activesupport (= 7.1.5.2) @@ -831,17 +835,17 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.9.2) + ruby_llm (1.15.0) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) faraday-multipart (>= 1) faraday-net_http (>= 1) faraday-retry (>= 1) - marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) + marcel (~> 1) + ruby_llm-schema (~> 0) zeitwerk (~> 2) - ruby_llm-schema (0.2.5) + ruby_llm-schema (0.3.0) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -957,7 +961,7 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.3) + timeout (0.6.1) trailblazer-option (0.1.2) twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) @@ -1015,7 +1019,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS arm64-darwin-20 @@ -1035,7 +1039,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.9.1) + ai-agents (>= 0.10.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) @@ -1072,6 +1076,7 @@ DEPENDENCIES fcm flag_shih_tzu foreman + gemoji geocoder gmail_xoauth google-cloud-dialogflow-v2 (>= 0.24.0) @@ -1125,6 +1130,7 @@ DEPENDENCIES rack-mini-profiler (>= 3.2.0) rack-timeout rails (~> 7.1) + rails-i18n (~> 7.0) redis redis-namespace responders (>= 3.1.1) @@ -1138,7 +1144,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai - ruby_llm (>= 1.8.2) + ruby_llm (>= 1.14.1) ruby_llm-schema scout_apm scss_lint diff --git a/VERSION_CW b/VERSION_CW index 813b83b65..c412a4e2e 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.13.0 +4.14.0 diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index 532487a1b..5127ea612 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -44,7 +44,11 @@ class AccountBuilder end def create_account - @account = Account.create!(name: account_name, locale: I18n.locale) + @account = Account.create!( + name: account_name, + locale: I18n.locale, + custom_attributes: { 'onboarding_step' => 'account_details' } + ) Current.account = @account end diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 2fe11cae0..d2715011c 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -29,8 +29,9 @@ class AgentBuilder user = User.from_email(email) return user if user + @name = email.split('@').first if @name.blank? temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" - User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) + User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password) end # Checks if the user needs confirmation. diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb index 6f79d6018..a6da58792 100644 --- a/app/builders/email/base_builder.rb +++ b/app/builders/email/base_builder.rb @@ -41,7 +41,7 @@ class Email::BaseBuilder end def business_name - inbox.business_name || inbox.sanitized_name + inbox.sanitized_business_name end def account_support_email diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index be1f98b43..ecd6f06ea 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder filename: attachment_file.original_filename, content_type: attachment_file.content_type ) + # The Attachment row is saved before the blob is attached, so the + # after_create_commit broadcast bails on `file.attached?`. Re-fire here + # for audio so the bubble updates without waiting on transcription. + attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio end def attachment_params(attachment) diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index d5461eb4e..e09e44d50 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -27,6 +27,8 @@ class NotificationBuilder return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? # skip notifications for blocked conversations except for user mentions return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' + # respect conversation access (inbox/team membership and custom-role permissions) + return unless user_can_access_conversation? user.notifications.create!( notification_type: notification_type, @@ -36,4 +38,17 @@ class NotificationBuilder secondary_actor: secondary_actor || current_user ) end + + def user_can_access_conversation? + conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation) + return true if conversation.blank? + + account_user = AccountUser.find_by(account_id: account.id, user_id: user.id) + return false if account_user.blank? + + ConversationPolicy.new( + { user: user, account: account, account_user: account_user }, + conversation + ).show? + end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index fb986d335..b935bd520 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -1,6 +1,7 @@ class V2::ReportBuilder include DateRangeHelper include ReportHelper + attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze diff --git a/app/builders/v2/reports/agent_summary_builder.rb b/app/builders/v2/reports/agent_summary_builder.rb index 9b4541aaa..58689aaad 100644 --- a/app/builders/v2/reports/agent_summary_builder.rb +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group('assignee_id').count - end - def prepare_report account.account_users.map do |account_user| build_agent_stats(account_user) diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb index d4a9e7c0b..bd8ed5f56 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder private def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end + results = data_source.summary - def load_reporting_events_data - # Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id') - index_key = group_by_key.to_s.split('.').last - - results = reporting_events - .select( - "#{group_by_key} as #{index_key}", - "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count", - "AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time", - "AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time", - "AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time" - ) - .group(group_by_key) - .index_by { |record| record.public_send(index_key) } - - @resolved_count = results.transform_values(&:resolved_count) - @avg_resolution_time = results.transform_values(&:avg_resolution_time) - @avg_first_response_time = results.transform_values(&:avg_first_response_time) - @avg_reply_time = results.transform_values(&:avg_reply_time) - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range) - end - - def fetch_conversations_count - # Override this method + @conversations_count = results.transform_values { |data| data[:conversations_count] } + @resolved_count = results.transform_values { |data| data[:resolved_conversations_count] } + @avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] } + @avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] } + @avg_reply_time = results.transform_values { |data| data[:avg_reply_time] } end def group_by_key @@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: nil, + dimension_type: summary_dimension_type, + dimension_id: nil, + scope: nil, + range: range, + group_by: 'day', + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + + def summary_dimension_type + { + 'account_id' => 'account', + 'user_id' => 'agent', + 'inbox_id' => 'inbox', + 'conversations.team_id' => 'team' + }.fetch(group_by_key.to_s) end end diff --git a/app/builders/v2/reports/bot_metrics_builder.rb b/app/builders/v2/reports/bot_metrics_builder.rb index c46daf978..f151e1a66 100644 --- a/app/builders/v2/reports/bot_metrics_builder.rb +++ b/app/builders/v2/reports/bot_metrics_builder.rb @@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder end def bot_resolutions_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range).distinct.count + # Exclude conversations that also had a handoff in the same range — handoff wins + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) + .distinct.count end def bot_handoffs_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, - created_at: range).distinct.count + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .distinct.count + end + + def bot_handoff_conversation_ids_subquery + account.reporting_events + .where(name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) end def bot_resolution_rate diff --git a/app/builders/v2/reports/conversations/base_report_builder.rb b/app/builders/v2/reports/conversations/base_report_builder.rb index a7961b0d6..ee0155150 100644 --- a/app/builders/v2/reports/conversations/base_report_builder.rb +++ b/app/builders/v2/reports/conversations/base_report_builder.rb @@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder private - AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze - COUNT_METRICS = %w[ - conversations_count - incoming_messages_count - outgoing_messages_count - resolutions_count - bot_resolutions_count - bot_handoffs_count - ].freeze - def builder_class(metric) - case metric - when *AVG_METRICS - V2::Reports::Timeseries::AverageReportBuilder - when *COUNT_METRICS - V2::Reports::Timeseries::CountReportBuilder - end + return unless Reports::ReportMetricRegistry.supported?(metric) + + V2::Reports::Timeseries::ReportBuilder end def log_invalid_metric diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb index 935afeb82..fcfabc599 100644 --- a/app/builders/v2/reports/inbox_summary_builder.rb +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end - - def fetch_conversations_count - account.conversations.where(created_at: range).group(group_by_key).count - end - def prepare_report account.inboxes.map do |inbox| build_inbox_stats(inbox) @@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder def group_by_key :inbox_id end - - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value - end end diff --git a/app/builders/v2/reports/team_summary_builder.rb b/app/builders/v2/reports/team_summary_builder.rb index b98151bc6..3bcf51816 100644 --- a/app/builders/v2/reports/team_summary_builder.rb +++ b/app/builders/v2/reports/team_summary_builder.rb @@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group(:team_id).count - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation) - end - def prepare_report account.teams.map do |team| build_team_stats(team) diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb deleted file mode 100644 index 5df718b6a..000000000 --- a/app/builders/v2/reports/timeseries/average_report_builder.rb +++ /dev/null @@ -1,48 +0,0 @@ -class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_average_time = reporting_events.average(average_value_key) - grouped_event_count = reporting_events.count - grouped_average_time.each_with_object([]) do |element, arr| - event_date, average_time = element - arr << { - value: average_time, - timestamp: event_date.in_time_zone(timezone).to_i, - count: grouped_event_count[event_date] - } - end - end - - def aggregate_value - object_scope.average(average_value_key) - end - - private - - def event_name - metric_to_event_name = { - avg_first_response_time: :first_response, - avg_resolution_time: :conversation_resolved, - reply_time: :reply_time - } - metric_to_event_name[params[:metric].to_sym] - end - - def object_scope - scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) - end - - def reporting_events - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ) - end - - def average_value_key - @average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value - end -end diff --git a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb index 50699417d..86fddba07 100644 --- a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb +++ b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb @@ -1,12 +1,13 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder include TimezoneHelper include DateRangeHelper + DEFAULT_GROUP_BY = 'day'.freeze pattr_initialize :account, :params def scope - case params[:type].to_sym + case dimension_type.to_sym when :account account when :inbox @@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder end end + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: params[:metric], + dimension_type: dimension_type, + dimension_id: params[:id], + scope: scope, + range: range, + group_by: group_by, + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder def timezone @timezone ||= timezone_name_from_offset(params[:timezone_offset]) end + + private + + def dimension_type + (params[:type].presence || 'account').to_s + end end diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb deleted file mode 100644 index bb3b1250c..000000000 --- a/app/builders/v2/reports/timeseries/count_report_builder.rb +++ /dev/null @@ -1,78 +0,0 @@ -class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_count.each_with_object([]) do |element, arr| - event_date, event_count = element - - # The `event_date` is in Date format (without time), such as "Wed, 15 May 2024". - # We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i` - # because it converts the date to 12:00 AM server timezone. - # The desired output should be 12:00 AM in the specified timezone. - arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i } - end - end - - def aggregate_value - object_scope.count - end - - private - - def metric - @metric ||= params[:metric] - end - - def object_scope - send("scope_for_#{metric}") - end - - def scope_for_conversations_count - scope.conversations.where(account_id: account.id, created_at: range) - end - - def scope_for_incoming_messages_count - scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) - end - - def scope_for_outgoing_messages_count - scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) - end - - def scope_for_resolutions_count - scope.reporting_events.where( - name: :conversation_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_resolutions_count - scope.reporting_events.where( - name: :conversation_bot_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_handoffs_count - scope.reporting_events.joins(:conversation).select(:conversation_id).where( - name: :conversation_bot_handoff, - account_id: account.id, - created_at: range - ).distinct - end - - def grouped_count - # IMPORTANT: time_zone parameter affects both data grouping AND output timestamps - # It converts timestamps to the target timezone before grouping, which means - # the same event can fall into different day buckets depending on timezone - # Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day) - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ).count - end -end diff --git a/app/builders/v2/reports/timeseries/report_builder.rb b/app/builders/v2/reports/timeseries/report_builder.rb new file mode 100644 index 000000000..a3e83c78a --- /dev/null +++ b/app/builders/v2/reports/timeseries/report_builder.rb @@ -0,0 +1,9 @@ +class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + data_source.timeseries + end + + def aggregate_value + data_source.aggregate + end +end diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb new file mode 100644 index 000000000..b45c16828 --- /dev/null +++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb @@ -0,0 +1,43 @@ +class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController + before_action :portal + before_action :check_authorization + before_action :set_articles, only: [:update_status, :delete_articles] + + def translate + head :not_implemented + end + + def update_status + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status]) + + ActiveRecord::Base.transaction do + @articles.find_each { |article| article.update!(status: params[:status]) } + end + head :ok + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.message) + end + + def delete_articles + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + + @articles.destroy_all + head :ok + end + + private + + def portal + @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) + end + + def check_authorization + authorize(Article, :create?) + end + + def set_articles + @articles = @portal.articles.where(id: params[:ids]) + end +end +Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') diff --git a/app/controllers/api/v1/accounts/contacts/attachments_controller.rb b/app/controllers/api/v1/accounts/contacts/attachments_controller.rb new file mode 100644 index 000000000..761f00130 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/attachments_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController + RESULTS_PER_PAGE = 100 + + def index + conversations = Conversations::PermissionFilterService.new( + Current.account.conversations.where(contact_id: @contact.id), + Current.user, + Current.account + ).perform + + @attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id)) + .includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }]) + .order(created_at: :desc) + .page(params[:page]) + .per(RESULTS_PER_PAGE) + @attachments_count = @attachments.total_count + end +end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index dd5346bd6..eafda0fe2 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController sort_on :phone_number, type: :string sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction] sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction] - sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] + sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction] sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction] diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 6159f804d..6cc77cd54 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def attachments @attachments_count = @conversation.attachments.count @attachments = @conversation.attachments - .includes(:message) + .includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }]) .order(created_at: :desc) .page(attachment_params[:page]) .per(ATTACHMENT_RESULTS_PER_PAGE) diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 69df99e14..7bd9ae0c3 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -1,6 +1,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController before_action :fetch_custom_attributes_definitions, except: [:create] before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy] + before_action :check_authorization DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze def index; end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index c7d3e2737..757af9b62 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index - @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) + @inboxes = policy_scope(Current.account.inboxes) + .includes(:channel, :portal, :working_hours, { avatar_attachment: :blob }) + .order_by_name end def show; end @@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def fetch_agent_bot - @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] + @agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot] end def create_channel diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 54455943b..6889d30a4 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -18,7 +18,16 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController end def destroy + label_title = @label.title + account_id = Current.account.id + label_deleted_at = Time.current + @label.destroy! + Labels::RemoveAssociationsJob.perform_later( + label_title: label_title, + account_id: account_id, + label_deleted_at: label_deleted_at + ) head :ok end diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 52035ce64..bb5c57ca6 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro def destroy_all if params[:type] == 'read' - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read) else - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all) end head :ok end @@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro end def fetch_notification - @notification = current_user.notifications.find(params[:id]) + @notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id]) end def set_current_page diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 972b244fa..27c4126a6 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params)) @portal.custom_domain = parsed_custom_domain @portal.save! - process_attached_logo + process_attached_logo if params[:blob_id].present? end def update @@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def process_attached_logo - blob_id = params[:blob_id] - blob = ActiveStorage::Blob.find_signed(blob_id) - @portal.logo.attach(blob) + blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s) + @portal.logo.attach(blob) if blob end private @@ -88,7 +87,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController return {} unless permitted_params.key?(:inbox_id) return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? - inbox = Inbox.find(permitted_params[:inbox_id]) + inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) return {} unless inbox.web_widget? { channel_web_widget_id: inbox.channel.id } @@ -99,6 +98,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def parsed_custom_domain + return @portal.custom_domain if @portal.custom_domain.blank? + domain = URI.parse(@portal.custom_domain) domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 7176d6e1b..865b387b9 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.custom_attributes.merge!(custom_attributes_params) @account.settings.merge!(settings_params) + @account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.save! end @@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController private def enqueue_branding_enrichment - return if account_params[:email].blank? + email = account_params[:email].presence || @user&.email + return if email.blank? - Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email]) + Account::BrandingEnrichmentJob.perform_later(@account.id, email) Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30) rescue StandardError => e # Enrichment is optional — never let queue/Redis failures abort signup @@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController end def custom_attributes_params - params.permit(:industry, :company_size, :timezone) + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) end def settings_params diff --git a/app/controllers/api/v1/notification_subscriptions_controller.rb b/app/controllers/api/v1/notification_subscriptions_controller.rb index 1a797a74d..bb20c64ae 100644 --- a/app/controllers/api/v1/notification_subscriptions_controller.rb +++ b/app/controllers/api/v1/notification_subscriptions_controller.rb @@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController end def destroy - notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first + notification_subscription = current_user.notification_subscriptions + .where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first notification_subscription.destroy! if notification_subscription.present? head :ok end diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb index dd874f222..8480b64fb 100644 --- a/app/controllers/api/v1/profile/mfa_controller.rb +++ b/app/controllers/api/v1/profile/mfa_controller.rb @@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController before_action :check_mfa_feature_available before_action :check_mfa_enabled, only: [:destroy, :backup_codes] before_action :check_mfa_disabled, only: [:create, :verify] - before_action :validate_otp, only: [:verify, :backup_codes, :destroy] before_action :validate_password, only: [:destroy] + before_action :validate_otp, only: [:verify, :backup_codes, :destroy] def show; end @@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController def validate_otp authenticated = Mfa::AuthenticationService.new( user: current_user, - otp_code: mfa_params[:otp_code] + otp_code: mfa_params[:otp_code], + backup_code: mfa_params[:backup_code] ).authenticate return if authenticated @@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController end def mfa_params - params.permit(:otp_code, :password) + params.permit(:otp_code, :backup_code, :password) end end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 83b3dc8b1..8b1e8ef48 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_message - @message = @web_widget.inbox.messages.find(permitted_params[:id]) + # `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`, + # which means a visitor with more than one open thread could not edit a message in any + # but their most recent one. Scoping across all of the visitor's conversations keeps the + # happy path correct for that future multi-conversation widget flow. + @message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id]) end end diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 98b3f05d7..40d5947b9 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel] def agent - render_report_with(V2::Reports::AgentSummaryBuilder) + render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent) end def team - render_report_with(V2::Reports::TeamSummaryBuilder) + render_report_with(V2::Reports::TeamSummaryBuilder, type: :team) end def inbox - render_report_with(V2::Reports::InboxSummaryBuilder) + render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox) end def label @@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr } end - def render_report_with(builder_class) - builder = builder_class.new(account: Current.account, params: @builder_params) + def render_report_with(builder_class, type: nil) + builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params + builder = builder_class.new(account: Current.account, params: builder_params) render json: builder.build end diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb index b3f920644..42fe918cc 100644 --- a/app/controllers/concerns/meta_token_verify_concern.rb +++ b/app/controllers/concerns/meta_token_verify_concern.rb @@ -2,6 +2,10 @@ # This concern handles the token verification step. module MetaTokenVerifyConcern + CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze + META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze + META_SIGNATURE_PREFIX = 'sha256='.freeze + def verify service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' if valid_token?(params['hub.verify_token']) @@ -14,6 +18,53 @@ module MetaTokenVerifyConcern private + def verify_meta_signature! + return unless meta_signature_verification_required? + return if valid_meta_signature? + + head :unauthorized + end + + def valid_meta_signature? + signature = request.headers[META_SIGNATURE_HEADER] + return false unless signature&.start_with?(META_SIGNATURE_PREFIX) + + meta_app_secrets.any? do |secret| + next false if secret.blank? + + expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}" + ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature) + end + end + + def meta_request_body + @meta_request_body ||= request.raw_post + end + + def meta_app_secrets + raise 'Overwrite this method in your controller' + end + + def meta_signature_verification_required? + true + end + + def channel_meta_app_secrets(channel) + return [] if channel.blank? + + secrets = [] + secrets << channel.app_secret if channel.respond_to?(:app_secret) + secrets.concat(provider_config_meta_app_secrets(channel)) + secrets.compact_blank.uniq + end + + def provider_config_meta_app_secrets(channel) + return [] unless channel.respond_to?(:provider_config) + + provider_config = channel.provider_config.to_h.with_indifferent_access + CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence } + end + def valid_token?(_token) raise 'Overwrite this method your controller' end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d57ad0e53..b6df015f7 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -80,10 +80,17 @@ class DashboardController < ActionController::Base IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), GIT_SHA: GIT_HASH, - ALLOWED_LOGIN_METHODS: allowed_login_methods + ALLOWED_LOGIN_METHODS: allowed_login_methods, + ACTIVE_PLATFORM_BANNERS: active_platform_banners } end + def active_platform_banners + return [] unless ChatwootApp.chatwoot_cloud? + + PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at]) + end + def allowed_login_methods methods = ['email'] methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false' diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 974fb05e4..bd7bb9b44 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -25,6 +25,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + def render_create_error_not_confirmed + render_error( + :unauthorized, + I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email), + error_code: 'user_not_confirmed' + ) + end + def find_user_for_authentication return nil unless params[:email].present? && params[:password].present? diff --git a/app/controllers/platform/api/v1/agent_bots_controller.rb b/app/controllers/platform/api/v1/agent_bots_controller.rb index dd70a1ba5..594bb856e 100644 --- a/app/controllers/platform/api/v1/agent_bots_controller.rb +++ b/app/controllers/platform/api/v1/agent_bots_controller.rb @@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController before_action :validate_platform_app_permissible, except: [:index, :create] def index - @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all + @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible) end def show; end diff --git a/app/controllers/public/api/v1/inboxes_controller.rb b/app/controllers/public/api/v1/inboxes_controller.rb index 65fad57b1..5679a4a3e 100644 --- a/app/controllers/public/api/v1/inboxes_controller.rb +++ b/app/controllers/public/api/v1/inboxes_controller.rb @@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController def set_conversation return if params[:conversation_id].blank? - @conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + @conversation = if @contact_inbox.hmac_verified? + @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + else + @contact_inbox.conversations.find_by!(display_id: params[:conversation_id]) + end end end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef1..86d1b70ef 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController if errors.any? redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ') else - redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" + redirect_to super_admin_settings_path, flash: success_flash end end @@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY] ) end + + def success_notice + message = "#{@config.titleize} settings updated successfully" + return message unless restart_required_config_saved? + + "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." + end + + def success_flash + restart_required_config_saved? ? { success: success_notice } : { notice: success_notice } + end + + def restart_required_config_saved? + params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end end SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController') diff --git a/app/controllers/super_admin/installation_configs_controller.rb b/app/controllers/super_admin/installation_configs_controller.rb index b1f15b518..e888b91e7 100644 --- a/app/controllers/super_admin/installation_configs_controller.rb +++ b/app/controllers/super_admin/installation_configs_controller.rb @@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl resource_class.editable end + def create + resource = new_resource(resource_params) + authorize_resource(resource) + + if resource.save + redirect_to after_resource_created_path(resource), flash: success_flash(resource) + else + render :new, locals: { + page: Administrate::Page::Form.new(dashboard, resource) + }, status: :unprocessable_entity + end + end + + def update + if requested_resource.update(resource_params) + redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource) + else + render :edit, locals: { + page: Administrate::Page::Form.new(dashboard, requested_resource) + }, status: :unprocessable_entity + end + end + # Override `resource_params` if you want to transform the submitted # data before it's persisted. For example, the following would turn all # empty values into nil values. It uses other APIs such as `resource_class` @@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl .transform_values { |value| value == '' ? nil : value }.merge(locked: false) end + private + + def success_flash(resource) + message = translate_with_resource('update.success') + message = translate_with_resource('create.success') if action_name == 'create' + return { notice: message } unless restart_required_config?(resource) + + { success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." } + end + + def restart_required_config?(resource) + resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end + # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # for more information end diff --git a/app/controllers/super_admin/platform_banners_controller.rb b/app/controllers/super_admin/platform_banners_controller.rb new file mode 100644 index 000000000..e4bf727a0 --- /dev/null +++ b/app/controllers/super_admin/platform_banners_controller.rb @@ -0,0 +1,9 @@ +class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController + before_action :ensure_chatwoot_cloud + + private + + def ensure_chatwoot_cloud + raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud? + end +end diff --git a/app/controllers/super_admin/push_diagnostics_controller.rb b/app/controllers/super_admin/push_diagnostics_controller.rb new file mode 100644 index 000000000..c33cfdc1e --- /dev/null +++ b/app/controllers/super_admin/push_diagnostics_controller.rb @@ -0,0 +1,68 @@ +class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController + def show + @query = params[:user_query].to_s.strip + @user = resolve_user(@query) + @subscriptions = @user ? @user.notification_subscriptions.order(:id) : [] + @results = [] + end + + def create + @user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: @user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test') + end + + run_test_and_render(ids) + end + + def destroy_subscriptions + user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete') + end + + deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size + log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}") + redirect_to super_admin_push_diagnostics_path(user_query: user.id), + notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count) + end + + private + + def run_test_and_render(ids) + @query = @user.id.to_s + @subscriptions = @user.notification_subscriptions.order(:id) + @results = Notification::PushTestService.new( + user: @user, subscription_ids: ids, + title: params[:push_title], body: params[:push_body] + ).perform + + log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}") + render :show + end + + def log_super_admin_action(message) + Rails.logger.info( + "[SuperAdmin] push diagnostics #{message} " \ + "(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})" + ) + end + + def resolve_user(query) + return if query.blank? + + query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query) + end + + def parsed_subscription_ids + Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i) + end +end diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb index af5a4b039..680bf92fc 100644 --- a/app/controllers/swagger_controller.rb +++ b/app/controllers/swagger_controller.rb @@ -1,7 +1,12 @@ class SwaggerController < ApplicationController def respond if Rails.env.development? || Rails.env.test? - render inline: Rails.root.join('swagger', derived_path).read + swagger_root = Rails.root.join('swagger') + file_path = swagger_root.join(derived_path).cleanpath + + return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file? + + render inline: file_path.read else head :not_found end @@ -11,8 +16,8 @@ class SwaggerController < ApplicationController def derived_path params[:path] ||= 'index.html' - path = Rack::Utils.clean_path_info(params[:path]) - path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s) + path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/') + path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s) path end end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 569c2524b..6e5168634 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -1,6 +1,8 @@ class Webhooks::InstagramController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :events + def events Rails.logger.info('Instagram webhook received events') if params['object'].casecmp('instagram').zero? @@ -39,4 +41,38 @@ class Webhooks::InstagramController < ActionController::API token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end + + def meta_app_secrets + [ + *instagram_channel_meta_app_secrets, + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil), + GlobalConfigService.load('FB_APP_SECRET', nil) + ] + end + + def instagram_channel_meta_app_secrets + instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) } + end + + def instagram_channels_from_payload + Array(params.to_unsafe_hash[:entry]).flat_map do |entry| + instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id| + [ + Channel::Instagram.find_by(instagram_id: instagram_id), + Channel::FacebookPage.find_by(instagram_id: instagram_id) + ] + end + end.compact.uniq + end + + def instagram_ids_from_entry(entry) + messages = entry[:messaging].presence || entry[:standby] || [] + messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) } + end + + def instagram_id_from_messaging(messaging) + return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present? + + messaging.dig(:recipient, :id) + end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c..ee71f3c92 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -1,6 +1,8 @@ class Webhooks::WhatsappController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :process_payload + def process_payload if inactive_whatsapp_number? Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}") @@ -20,6 +22,45 @@ class Webhooks::WhatsappController < ActionController::API token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + def meta_app_secrets + [ + *channel_meta_app_secrets(whatsapp_channel), + GlobalConfigService.load('WHATSAPP_APP_SECRET', nil) + ] + end + + def whatsapp_channel + @whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + end + + def meta_signature_verification_required? + return true if whatsapp_channel.blank? + return false unless whatsapp_channel.provider == 'whatsapp_cloud' + return true if channel_meta_app_secrets(whatsapp_channel).present? + + whatsapp_channel.provider_config['source'] == 'embedded_signup' + end + + def whatsapp_business_payload_channel + return unless params[:object] == 'whatsapp_business_account' + + metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) + return if metadata.blank? + + phone_number = normalized_phone_number(metadata[:display_phone_number]) + phone_number_id = metadata[:phone_number_id] + channel = Channel::Whatsapp.find_by(phone_number: phone_number) + + return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id + end + + def normalized_phone_number(phone_number) + return if phone_number.blank? + + phone_number = phone_number.to_s + phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + end + def inactive_whatsapp_number? phone_number = params[:phone_number] return false if phone_number.blank? diff --git a/app/dashboards/platform_banner_dashboard.rb b/app/dashboards/platform_banner_dashboard.rb new file mode 100644 index 000000000..41674406a --- /dev/null +++ b/app/dashboards/platform_banner_dashboard.rb @@ -0,0 +1,20 @@ +require 'administrate/base_dashboard' + +class PlatformBannerDashboard < Administrate::BaseDashboard + ATTRIBUTE_TYPES = { + id: Field::Number, + banner_message: Field::Text.with_options(truncate: 200), + banner_type: Field::Select.with_options(collection: %w[info warning error]), + active: Field::Boolean, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze + SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze + FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze + + def display_resource(platform_banner) + "Banner ##{platform_banner.id} (#{platform_banner.banner_type})" + end +end diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb index 7cafea1bb..cf6f1b6a1 100644 --- a/app/drops/user_drop.rb +++ b/app/drops/user_drop.rb @@ -7,11 +7,15 @@ class UserDrop < BaseDrop @obj.try(:available_name) end + def email + @obj.try(:email) + end + def first_name - @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 end def last_name - @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 end end diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 8854e239a..bf82d61d5 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -48,3 +48,5 @@ class MessageFinder messages.reorder('created_at desc').limit(20).reverse end end + +MessageFinder.prepend_mod_with('MessageFinder') diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 3d6b559c8..8a10fa99c 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -17,15 +17,12 @@ module Api::V1::InboxesHelper def validate_imap(channel_data) return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled] - Mail.defaults do - retriever_method :imap, { address: channel_data[:imap_address], - port: channel_data[:imap_port], - user_name: channel_data[:imap_login], - password: channel_data[:imap_password], - enable_ssl: channel_data[:imap_enable_ssl] } - end + # Validate the user-selected auth mechanism before opening the connection. + authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication]) - check_imap_connection(channel_data) + # Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command, + # not SASL AUTH=LOGIN. + check_imap_connection(channel_data, authentication) end def validate_smtp(channel_data) @@ -37,8 +34,8 @@ module Api::V1::InboxesHelper check_smtp_connection(channel_data, smtp) end - def check_imap_connection(channel_data) - Mail.connection {} # rubocop:disable:block + def check_imap_connection(channel_data, authentication) + imap = open_imap_connection(channel_data, authentication) rescue SocketError => e raise StandardError, I18n.t('errors.inboxes.imap.socket_error') rescue Net::IMAP::NoResponseError => e @@ -53,9 +50,20 @@ module Api::V1::InboxesHelper rescue StandardError => e raise StandardError, e.message ensure + imap.disconnect if imap.present? && !imap.disconnected? Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present? end + def open_imap_connection(channel_data, authentication) + imap = build_imap_connection(channel_data) + Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password]) + imap + end + + def build_imap_connection(channel_data) + Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl]) + end + def check_smtp_connection(channel_data, smtp) smtp.open_timeout = 10 smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index fcc8b463d..67e8d953d 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -7,7 +7,7 @@ module EmailHelper def render_email_html(content) return '' if content.blank? - ChatwootMarkdownRenderer.new(content).render_message.to_s + ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s end # Raise a standard error if any email address is invalid diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 09a84b110..d5b773c87 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -53,13 +53,12 @@ module ReportHelper end def resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range) end def bot_resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) end def bot_handoffs @@ -67,6 +66,10 @@ module ReportHelper created_at: range).distinct end + def bot_handoff_conversation_ids_subquery + bot_handoffs + end + def avg_first_response_time grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index a706e2df5..99aebfd1a 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -3,6 +3,7 @@ import { mapGetters } from 'vuex'; import LoadingState from './components/widgets/LoadingState.vue'; import NetworkNotification from './components/NetworkNotification.vue'; import UpdateBanner from './components/app/UpdateBanner.vue'; +import StatusBanner from './components/app/StatusBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import vueActionCable from './helper/actionCable'; @@ -27,6 +28,7 @@ export default { LoadingState, NetworkNotification, UpdateBanner, + StatusBanner, PaymentPendingBanner, WootSnackbarBox, PendingEmailVerificationBanner, @@ -59,7 +61,6 @@ export default { isRTL: 'accounts/isRTL', currentUser: 'getCurrentUser', authUIFlags: 'getAuthUIFlags', - accountUIFlags: 'accounts/getUIFlags', }), hideOnOnboardingView() { return !isOnOnboardingView(this.$route); @@ -107,8 +108,9 @@ export default { this.$store.dispatch('setActiveAccount', { accountId: this.currentAccountId, }); + const account = this.getAccount(this.currentAccountId); const { locale, latest_chatwoot_version: latestChatwootVersion } = - this.getAccount(this.currentAccountId); + account; const { pubsub_token: pubsubToken } = this.currentUser || {}; // If user locale is set, use it; otherwise use account locale this.setLocale(this.uiSettings?.locale || locale); @@ -131,12 +133,13 @@ export default {