diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 908d97175c92d..c81c54db88c55 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -2,8 +2,30 @@ 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. + # Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor. + # Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable. + - GHSA-jp94-3292-c3xv + # 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 + # No Rails 7.1 patch for this Active Storage advisory; mitigated locally. + # Remove once on Rails 7.2.3.1+. + - CVE-2026-66066 + # RubyLLM's name-normalization ReDoS is reported on Ruby 3.1; we require Ruby 3.4.4. + # Built-in tool names are fixed; custom tools use bounded slugs and override normalization. + # Patched 2.0 releases need an ai-agents migration (currently constrained to RubyLLM 1.x). + # Remove when a supported patched RubyLLM/ai-agents combination is adopted. + - CVE-2026-67991 diff --git a/.circleci/config.yml b/.circleci/config.yml index 59702c1393f4c..c533e140287cd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,7 +77,8 @@ jobs: - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i @@ -118,7 +119,8 @@ jobs: - checkout - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i @@ -149,7 +151,8 @@ jobs: - checkout - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i diff --git a/.env.example b/.env.example index bc7380a292b07..724c0d1ad3e91 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,16 @@ REDIS_SENTINEL_MASTER_NAME= # ref: https://github.com/chatwoot/chatwoot/issues/2420 # REDIS_OPENSSL_VERIFY_MODE=none +# OpenSearch / Elasticsearch-compatible search config +# Set OPENSEARCH_URL to enable advanced search. Elastic Cloud API keys can be configured with either key name below. +# API key values must be provided without the ApiKey authorization scheme prefix. +# OPENSEARCH_URL= +# OPENSEARCH_API_KEY= +# ELASTICSEARCH_API_KEY= +# OPENSEARCH_AWS_ACCESS_KEY_ID= +# OPENSEARCH_AWS_SECRET_ACCESS_KEY= +# OPENSEARCH_AWS_REGION=us-east-1 + # Postgres Database config variables # You can leave POSTGRES_DATABASE blank. The default name of # the database in the production environment is chatwoot_production @@ -98,6 +108,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer # Mail Incoming # This is the domain set for the reply emails when conversation continuity is enabled MAILER_INBOUND_EMAIL_DOMAIN= +# Maximum time in seconds to process a single IMAP email +# EMAIL_PROCESSING_TIMEOUT_SECONDS=60 # Set this to the appropriate ingress channel with regards to incoming emails # Possible values are : # relay for Exim, Postfix, Qmail @@ -130,6 +142,11 @@ ACTION_MAILBOX_SES_SNS_TOPIC= # Storage ACTIVE_STORAGE_SERVICE=local +# Restrict libvips to its trusted, well-tested image loaders when Active Storage +# generates variants. Hardens image processing against untrusted uploads. +# Requires libvips >= 8.13 (ignored on older versions). +VIPS_BLOCK_UNTRUSTED=1 + # Amazon S3 # documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage S3_BUCKET_NAME= @@ -149,6 +166,8 @@ LOG_SIZE=500 # Facebook # documentation: https://www.chatwoot.com/docs/facebook-setup +# Connected Facebook pages are subscribed by Chatwoot to Messenger webhook +# fields, including postbacks used by Get Started and button flows. FB_VERIFY_TOKEN= FB_APP_SECRET= FB_APP_ID= @@ -166,6 +185,7 @@ TWITTER_ENVIRONMENT= #slack integration SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +SLACK_SIGNING_SECRET= # Google OAuth GOOGLE_OAUTH_CLIENT_ID= @@ -228,10 +248,30 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: ## To prevent and throttle abusive requests # ENABLE_RACK_ATTACK=true # RACK_ATTACK_LIMIT=300 +## Parent switch for all widget throttles; per-endpoint switches below only apply when this is on # ENABLE_RACK_ATTACK_WIDGET_API=true +## Widget conversation creates, per minute, keyed on (IP, website_token) +# ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS=true +# RATE_LIMIT_WIDGET_CONVERSATIONS=30 +## Widget message creates, per minute, keyed on (IP, website_token) +# ENABLE_RACK_ATTACK_WIDGET_MESSAGES=true +# RATE_LIMIT_WIDGET_MESSAGES=60 +## Widget contact updates, per hour, per IP +# ENABLE_RACK_ATTACK_WIDGET_CONTACTS=true +# RATE_LIMIT_WIDGET_CONTACTS=60 +## Widget page loads (new session), per hour, per IP +# ENABLE_RACK_ATTACK_WIDGET_LOAD=true +# RATE_LIMIT_WIDGET_LOAD=200 +## Widget transcript requests, per hour, per IP +# ENABLE_RACK_ATTACK_WIDGET_TRANSCRIPT=true +# RATE_LIMIT_WIDGET_TRANSCRIPT=5 # Comma-separated list of trusted IPs that bypass Rack Attack throttling rules # RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 +## SafeFetch private network access +## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs. +# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false + ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints # CW_API_ONLY_SERVER=false @@ -266,9 +306,9 @@ AZURE_APP_SECRET= # ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false -# AI powered features -## OpenAI key -# OPENAI_API_KEY= +# AI powered features (Captain) +# The OpenAI API key and endpoint for Captain are not configured via .env. +# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # Housekeeping/Performance related configurations # Set to true if you want to remove stale contact inboxes @@ -277,3 +317,15 @@ AZURE_APP_SECRET= # REDIS_ALFRED_SIZE=10 # REDIS_VELMA_SIZE=10 + +# Publish Sidekiq queue metrics to AWS CloudWatch for autoscaling. Off by default. +# By default uses the instance role, which must allow cloudwatch:PutMetricData. +# ENABLE_SIDEKIQ_CLOUDWATCH=true +# SIDEKIQ_CLOUDWATCH_NAMESPACE=Chatwoot/Sidekiq +# Publish interval in seconds (default 60; values below 60 work but bill as high-resolution CloudWatch metrics) +# SIDEKIQ_CLOUDWATCH_INTERVAL=60 +# Optional: dedicated credentials for CloudWatch only, separate from the S3 (storage) keys. +# Set these to override the instance role when a role is not available. +# SIDEKIQ_CLOUDWATCH_AWS_ACCESS_KEY_ID= +# SIDEKIQ_CLOUDWATCH_AWS_SECRET_ACCESS_KEY= +# SIDEKIQ_CLOUDWATCH_AWS_REGION=us-east-1 diff --git a/.eslintrc.js b/.eslintrc.js index 6b5205ad70e77..ba07962fbe80e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -247,6 +247,7 @@ module.exports = { }, env: { browser: true, + es2020: true, node: true, }, globals: { diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py new file mode 100644 index 0000000000000..064361b2608ac --- /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 9f295a6c8f0ee..9f2ae42d8208c 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 1d1116d0c6fd2..3d992662a4bfd 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 0000000000000..a21fbea7f6917 --- /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 5c45ba635195a..cef07cc2fef4e 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 beef5727c5e87..e0c5ed88ebdc3 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 5da4fda052f06..c1b0e4e287e29 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 8e2c22481630b..982054a18d8c6 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 3075a7f3d23d0..994e5cef8a125 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/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index c2a626388c102..1cd0d5a3671f9 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -21,6 +21,24 @@ jobs: - name: Run Rubocop run: bundle exec rubocop --parallel + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Run Brakeman + # Non-blocking for now: Brakeman surfaces 35 pre-existing findings + # (13 High confidence) that need security-team triage before this + # can be turned into a hard gate. + continue-on-error: true + run: bundle exec brakeman -q --no-pager + - name: Run bundle-audit + run: | + bundle exec bundle-audit update + bundle exec bundle-audit check + lint-frontend: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 7869bf89c0bd7..909636a75c07b 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 b27d90408a8a3..96a6c69ac5d55 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/.gitignore b/.gitignore index 017c5c2242517..511cb9bfd01bd 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,4 @@ CLAUDE.local.md .pnpm-store/* local/ Procfile.worktree +.nodeterm/project.json diff --git a/.rubocop.yml b/.rubocop.yml index d87f08bfd5892..35361739698c1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,6 +18,7 @@ Metrics/ClassLength: Exclude: - 'app/models/message.rb' - 'app/models/conversation.rb' + - 'config/initializers/rack_attack.rb' Metrics/MethodLength: Max: 19 diff --git a/AGENTS.md b/AGENTS.md index 2ab6373b7d742..9f47606ae3ec9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,21 @@ ## General Guidelines -- MVP focus: Least code change, happy-path only -- No unnecessary defensive programming -- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer the smallest production-ready change that solves the current problem. +- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. +- Enforce eligibility and exclusivity rules at the earliest shared entry point. Do not repeat backup guards across downstream jobs, callbacks, services, or writes unless a proven independent path bypasses that point. +- Validate request parameters at the controller or request boundary, reusing existing errors so invalid input returns `422 Unprocessable Entity` instead of reaching models or Sentry. +- Accept only the documented type, shape, and value. Do not add compatibility coercions for malformed client values; fix official clients instead. +- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. +- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks. +- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully. +- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing. +- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read. - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked +- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity. - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs @@ -80,8 +88,10 @@ ## Project-Specific - **Translations**: - - Only update `en.yml` and `en.json` - - Other languages are handled by the community + - For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community + - Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files + - Preserve product and brand names, OAuth scopes, API values, and other machine-readable identifiers unless an official localized form exists + - When reviewing Crowdin syncs, verify protected terms remain unchanged. Add newly introduced product names, brand names, and machine-readable identifiers to the Crowdin glossary as non-translatable, and keep the glossary current - Backend i18n → `en.yml`, Frontend i18n → `en.json` - **Frontend**: - Use `components-next/` for message bubbles (the rest is being deprecated) @@ -90,6 +100,15 @@ - Use compact `module/class` definitions; avoid nested styles +## Frontend Conventions + +- Prefer existing design-system utilities and shared composables. +- Use typography utilities instead of manually recreating font styles. +- Use logical Tailwind utilities (`ms`, `me`, `start`, `end`) for direction-aware layouts. +- Use `rem` for arbitrary CSS dimensions; preserve native numeric values required by chart/SVG APIs. +- Extract repeated or domain-specific strings, thresholds, colors, and durations into named constants. +- Use shared request-cancellation utilities instead of local `AbortController` logic. + ## Enterprise Edition Notes - Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code. diff --git a/Gemfile b/Gemfile index a5068e7656bf1..cdce859af4ff1 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,10 @@ source 'https://rubygems.org' ruby '3.4.4' ##-- base gems for rails --## + gem 'rack-cors', '2.0.0', require: 'rack/cors' -gem 'rails', '~> 7.1' +gem 'rails', '7.2.3.1' + # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -22,6 +24,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 --## @@ -51,9 +54,7 @@ gem 'csv-safe' ##-- for active storage --## gem 'aws-sdk-s3', require: false -# original gem isn't maintained actively -# we wanted updated version of faraday which is a dependency for slack-ruby-client -gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false +gem 'azure-blob', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'image_processing' @@ -75,25 +76,27 @@ gem 'faraday_middleware-aws-sigv4' ##--- gems for server & infra configuration ---## gem 'dotenv-rails', '>= 3.0.0' gem 'foreman' -gem 'puma' +gem 'puma', '~> 7.2', '>= 7.2.1' gem 'vite_rails' # metrics on heroku gem 'barnes' ##--- gems for authentication & authorization ---## gem 'devise', '>= 4.9.4' -gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' +# 2.2.3 requires Devise 5; Chatwoot remains on Devise 4 for this upgrade stack. +gem 'devise-secure_password', '2.2.1' gem 'devise_token_auth', '>= 1.2.3' +gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization -gem 'jwt' +gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'pundit' # super admin gem 'administrate', '>= 0.20.1' gem 'administrate-field-active_storage', '>= 1.0.3' -gem 'administrate-field-belongs_to_search', '>= 0.9.0' +gem 'administrate-field-belongs_to_search', '>= 0.10.0' ##--- gems for pubsub service ---## # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ @@ -131,11 +134,13 @@ gem 'sentry-ruby', require: false gem 'sentry-sidekiq', '>= 5.19.0', require: false ##-- background job processing --## -gem 'sidekiq', '>= 7.3.1' +gem 'sidekiq', '~> 7.3.10' # We want cron jobs -gem 'sidekiq-cron', '>= 1.12.0' +gem 'sidekiq-cron', '>= 2.4.0' # for sidekiq healthcheck gem 'sidekiq_alive' +# publishes Sidekiq queue metrics to CloudWatch for autoscaling (opt-in via ENABLE_SIDEKIQ_CLOUDWATCH) +gem 'speedshop-cloudwatch', '~> 0.2.1', require: false ##-- Push notification service --## gem 'fcm' @@ -193,10 +198,10 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.9.1' +gem 'ai-agents', '>= 0.12.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' @@ -207,6 +212,8 @@ gem 'opentelemetry-exporter-otlp' gem 'shopify_api' +gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index b77e5880f948f..c9edf7e36e682 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,115 +1,87 @@ -GIT - remote: https://github.com/chatwoot/azure-storage-ruby - revision: 9957cf899d33a285b5dfe15bdb875292398e392b - branch: chatwoot - specs: - azure-storage-blob (2.0.3) - azure-storage-common (~> 2.0) - nokogiri (~> 1, >= 1.10.8) - azure-storage-common (2.0.4) - faraday (~> 2.0) - faraday-follow_redirects (~> 0.3.0) - faraday-net_http_persistent (~> 2.0) - net-http-persistent (~> 4.0) - nokogiri (~> 1, >= 1.10.8) - -GIT - remote: https://github.com/chatwoot/devise-secure_password - revision: adcc85fe1babfe40feae73dbcae64d14fff86e69 - branch: chatwoot - specs: - devise-secure_password (2.0.1) - devise (>= 4.0.0, < 5.0.0) - railties (>= 5.0.0, < 8.0.0) - GEM remote: https://rubygems.org/ specs: - actioncable (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) + actioncable (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.5.2) - actionpack (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activesupport (= 7.1.5.2) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) + actionmailer (7.2.3.1) + actionpack (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.5.2) - actionview (= 7.1.5.2) - activesupport (= 7.1.5.2) + actionpack (7.2.3.1) + actionview (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.3) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.5.2) - actionpack (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + useragent (~> 0.16) + actiontext (7.2.3.1) + actionpack (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.5.2) - activesupport (= 7.1.5.2) + actionview (7.2.3.1) + activesupport (= 7.2.3.1) builder (~> 3.1) + cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) active_record_query_trace (1.8) - activejob (7.1.5.2) - activesupport (= 7.1.5.2) + activejob (7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.3.6) - activemodel (7.1.5.2) - activesupport (= 7.1.5.2) - activerecord (7.1.5.2) - activemodel (= 7.1.5.2) - activesupport (= 7.1.5.2) + activemodel (7.2.3.1) + activesupport (= 7.2.3.1) + activerecord (7.2.3.1) + activemodel (= 7.2.3.1) + activesupport (= 7.2.3.1) timeout (>= 0.4.0) activerecord-import (2.1.0) activerecord (>= 4.2) - activestorage (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activesupport (= 7.1.5.2) + activestorage (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activesupport (= 7.2.3.1) marcel (~> 1.0) - activesupport (7.1.5.2) + activesupport (7.2.3.1) base64 benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1) - mutex_m + minitest (>= 5.1, < 6) securerandom (>= 0.3) - tzinfo (~> 2.0) + tzinfo (~> 2.0, >= 2.0.5) 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) @@ -121,13 +93,13 @@ GEM administrate-field-active_storage (1.0.3) administrate (>= 0.2.2) rails (>= 7.0) - administrate-field-belongs_to_search (0.9.0) + administrate-field-belongs_to_search (0.10.0) administrate (>= 0.3, < 1.0) jbuilder (~> 2) - rails (>= 4.2, < 7.2) + rails (>= 4.2, < 8.0) selectize-rails (~> 0.6) - ai-agents (0.9.1) - ruby_llm (~> 1.9.1) + ai-agents (0.12.0) + ruby_llm (~> 1.14) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -136,12 +108,17 @@ GEM audited (5.4.1) activerecord (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7) + auth-sanitizer (0.2.1) + version_gem (~> 1.1, >= 1.1.10) aws-actionmailbox-ses (0.1.0) actionmailbox (>= 7.1.0) aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-sns (~> 1, >= 1.61.0) aws-eventstream (1.4.0) aws-partitions (1.1198.0) + aws-sdk-cloudwatch (1.126.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -162,17 +139,21 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) + azure-blob (0.8.0) + cgi + rexml barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) bcrypt (3.1.22) - benchmark (0.4.1) - bigdecimal (3.2.2) + benchmark (0.5.0) + bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) - brakeman (5.4.1) + brakeman (8.0.5) + racc browser (5.3.1) builder (3.3.0) bullet (8.0.7) @@ -184,18 +165,22 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + cgi (0.5.2) childprocess (5.1.0) logger (~> 1.5) cld3 (3.7.0) climate_control (1.2.0) coderay (1.1.3) - commonmarker (0.23.10) - concurrent-ruby (1.3.5) + commonmarker (0.23.12) + concurrent-ruby (1.3.8) connection_pool (2.5.5) crack (1.0.0) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) csv (3.3.0) csv-safe (3.3.1) csv (~> 3.0) @@ -205,17 +190,18 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - datadog (2.19.0) - datadog-ruby_core_source (~> 3.4, >= 3.4.1) - libdatadog (~> 18.1.0.1.0) - libddwaf (~> 1.24.1.0.3) + datadog (2.38.0) + cgi + datadog-ruby_core_source (~> 3.5, >= 3.5.3) + libdatadog (~> 36.0.0.1.0) + libddwaf (~> 1.30.0.0.0) logger msgpack - datadog-ruby_core_source (3.4.1) - date (3.4.1) - debug (1.8.0) - irb (>= 1.5.0) - reline (>= 0.3.1) + datadog-ruby_core_source (3.5.3) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) declarative (0.0.20) devise (4.9.4) bcrypt (~> 3.0) @@ -223,6 +209,9 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-secure_password (2.2.1) + devise (>= 4.0.0, < 5.0.0) + railties (>= 5.0.0, < 9.0.0) devise-two-factor (6.1.0) activesupport (>= 7.0, < 8.1) devise (~> 4.0) @@ -268,8 +257,8 @@ GEM dry-logic (~> 1.5) dry-types (~> 1.8) zeitwerk (~> 2.6) - dry-types (1.8.3) - bigdecimal (~> 3.0) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -283,7 +272,8 @@ GEM ruby2_keywords email-provider-info (0.0.1) email_reply_trimmer (0.1.13) - erubi (1.13.0) + erb (6.0.6) + erubi (1.13.1) et-orbi (1.2.11) tzinfo event_stream_parser (1.0.0) @@ -298,22 +288,17 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.3.0) - faraday (>= 1, < 3) faraday-mashify (1.0.0) faraday (~> 2.0) hashie - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (3.4.2) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.4) net-http (~> 0.5) - faraday-net_http_persistent (2.1.0) - faraday (~> 2.5) - net-http-persistent (~> 4.0) faraday-retry (2.4.0) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) @@ -336,6 +321,7 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + firecrawl-sdk (1.4.1) flag_shih_tzu (0.3.23) foreman (0.87.2) fugit (1.11.1) @@ -349,6 +335,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 @@ -423,14 +410,15 @@ GEM googleapis-common-protos-types (~> 1.0) gserver (0.0.1) haikunator (1.1.1) - hairtrigger (1.0.0) - activerecord (>= 6.0, < 8) + hairtrigger (1.3.1) + activerecord (>= 6.0, < 9) ruby2ruby (~> 2.4) ruby_parser (~> 3.10) hana (1.3.7) hash_diff (1.1.1) hashdiff (1.1.0) - hashie (5.0.0) + hashie (5.1.0) + logger html2text (0.4.0) nokogiri (>= 1.0, < 2.0) http (5.1.1) @@ -447,25 +435,28 @@ GEM mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.14.7) + i18n (1.15.2) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - io-console (0.6.0) - irb (1.7.2) - reline (>= 0.3.6) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) iso-639 (0.3.8) csv - jbuilder (2.11.5) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) + jbuilder (2.15.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) jmespath (1.6.2) jquery-rails (4.6.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.19.2) + json (2.19.9) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -486,7 +477,7 @@ GEM judoscale-sidekiq (1.8.2) judoscale-ruby (= 1.8.2) sidekiq (>= 5.0) - jwt (2.10.1) + jwt (2.10.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -513,15 +504,16 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - libdatadog (18.1.0.1.0) - libdatadog (18.1.0.1.0-x86_64-linux) - libddwaf (1.24.1.0.3) + libdatadog (36.0.0.1.0) + libdatadog (36.0.0.1.0-arm64-darwin) + libdatadog (36.0.0.1.0-x86_64-linux) + libddwaf (1.30.0.0.2) ffi (~> 1.0) - libddwaf (1.24.1.0.3-arm64-darwin) + libddwaf (1.30.0.0.2-arm64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-darwin) + libddwaf (1.30.0.0.2-x86_64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-linux) + libddwaf (1.30.0.0.2-x86_64-linux) ffi (~> 1.0) line-bot-api (1.28.0) lint_roller (1.1.0) @@ -538,10 +530,11 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.23.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.1) + logger mini_mime (>= 0.1.1) net-imap net-pop @@ -558,12 +551,12 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) + minitest (5.27.0) mock_redis (0.36.0) ruby2_keywords - msgpack (1.8.0) + msgpack (1.8.3) multi_json (1.15.0) - multi_xml (0.8.0) + multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) mutex_m (0.3.0) @@ -571,9 +564,7 @@ GEM activerecord (>= 5.2) net-http (0.9.1) uri (>= 0.11.1) - net-http-persistent (4.0.2) - connection_pool (~> 2.2) - net-imap (0.4.20) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) @@ -588,30 +579,37 @@ GEM sidekiq newrelic_rpm (9.6.0) base64 - nio4r (2.7.3) - nokogiri (1.19.1) + nio4r (2.7.5) + nokogiri (1.19.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - oauth (1.1.0) - oauth-tty (~> 1.0, >= 1.0.1) - snaky_hash (~> 2.0) - version_gem (~> 1.1) - oauth-tty (1.0.5) - version_gem (~> 1.1, >= 1.1.1) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) + oauth (1.1.6) + auth-sanitizer (~> 0.2, >= 0.2.1) + base64 (~> 0.1) + cgi + oauth-tty (~> 1.0, >= 1.0.8) + snaky_hash (~> 2.0, >= 2.0.5) + version_gem (~> 1.1, >= 1.1.11) + oauth-tty (1.0.8) + auth-sanitizer (~> 0.1, >= 0.1.3) + cgi + version_gem (~> 1.1, >= 1.1.9) + oauth2 (2.0.22) + auth-sanitizer (~> 0.2, >= 0.2.1) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) - oj (3.16.10) + snaky_hash (~> 2.0, >= 2.0.5) + version_gem (~> 1.1, >= 1.1.11) + oj (3.17.3) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.4) @@ -658,7 +656,7 @@ GEM opentelemetry-api (~> 1.0) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.1) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.8.0) ast (~> 2.4.1) @@ -668,7 +666,10 @@ GEM activerecord (>= 5.2) activesupport (>= 5.2) pgvector (0.1.1) - prism (1.4.0) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) procore-sift (1.0.0) activerecord (>= 6.1) pry (0.14.2) @@ -676,21 +677,21 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (6.0.2) - puma (6.4.3) + public_suffix (7.0.5) + puma (7.2.1) 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) rack (< 4) rack-cors (2.0.0) rack (>= 2.0.0) - rack-mini-profiler (3.2.0) + rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-protection (4.2.1) base64 (>= 0.1.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) @@ -706,40 +707,54 @@ GEM rack-timeout (0.6.3) rackup (2.2.1) rack (>= 3) - rails (7.1.5.2) - actioncable (= 7.1.5.2) - actionmailbox (= 7.1.5.2) - actionmailer (= 7.1.5.2) - actionpack (= 7.1.5.2) - actiontext (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activemodel (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + rails (7.2.3.1) + actioncable (= 7.2.3.1) + actionmailbox (= 7.2.3.1) + actionmailer (= 7.2.3.1) + actionpack (= 7.2.3.1) + actiontext (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activemodel (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) bundler (>= 1.15.0) - railties (= 7.1.5.2) - rails-dom-testing (2.2.0) + railties (= 7.2.3.1) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.1) - loofah (~> 2.21) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) 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.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) - irb + rails-i18n (7.0.10) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) + railties (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort redis (5.0.6) redis-client (>= 0.9.0) redis-client (0.26.4) @@ -747,7 +762,7 @@ GEM redis-namespace (1.10.0) redis (>= 4) regexp_parser (2.10.0) - reline (0.3.6) + reline (0.6.3) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -831,18 +846,19 @@ 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_parser (3.20.0) + ruby_llm-schema (0.3.0) + ruby_parser (3.22.0) + racc (~> 1.5) sexp_processor (~> 4.16) sass (3.7.4) sass-listen (~> 4.0.0) @@ -892,16 +908,17 @@ GEM zeitwerk (~> 2.5) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.3.1) - concurrent-ruby (< 2) - connection_pool (>= 2.3.0) + sidekiq (7.3.10) + base64 + connection_pool (>= 2.3.0, < 3) logger - rack (>= 2.2.4) - redis-client (>= 0.22.2) - sidekiq-cron (1.12.0) - fugit (~> 1.8) + rack (>= 2.2.4, < 3.3) + redis-client (>= 0.23.0, < 1) + sidekiq-cron (2.4.0) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) - sidekiq (>= 6) + sidekiq (>= 6.5.0) sidekiq_alive (2.5.0) gserver (~> 0.0.1) sidekiq (>= 5, < 9) @@ -926,10 +943,12 @@ GEM gli hashie logger - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) + snaky_hash (2.0.5) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) sorbet-runtime (0.5.11934) + speedshop-cloudwatch (0.2.1) + aws-sdk-cloudwatch (>= 1.81.0) spring (4.1.1) spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) @@ -947,7 +966,8 @@ GEM statsd-ruby (1.5.0) stripe (18.0.1) telephone_number (1.4.20) - test-prof (1.2.1) + test-prof (1.6.3) + logger thor (1.4.0) tidewave (0.2.0) fast-mcp (~> 1.5.0) @@ -957,8 +977,9 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.3) + timeout (0.6.1) trailblazer-option (0.1.2) + tsort (0.2.0) twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) jwt (>= 1.5, < 3.0) @@ -975,6 +996,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) + unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) @@ -982,15 +1004,18 @@ GEM uri (1.1.1) uri-idna (0.3.1) uri_template (0.7.0) + useragent (0.16.11) valid_email2 (5.2.6) activemodel (>= 3.2) mail (~> 2.5) - version_gem (1.1.4) - vite_rails (3.0.17) - railties (>= 5.1, < 8) + version_gem (1.1.11) + vite_rails (3.10.0) + railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.8.0) + vite_ruby (3.10.2) dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m rack-proxy (~> 0.6, >= 0.6.1) zeitwerk (~> 2.2) warden (1.2.9) @@ -1007,7 +1032,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.7) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -1015,7 +1040,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS arm64-darwin-20 @@ -1034,14 +1059,14 @@ DEPENDENCIES acts-as-taggable-on administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) - administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.9.1) + administrate-field-belongs_to_search (>= 0.10.0) + ai-agents (>= 0.12.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) aws-actionmailbox-ses (~> 0) aws-sdk-s3 - azure-storage-blob! + azure-blob barnes bootsnap brakeman @@ -1057,7 +1082,7 @@ DEPENDENCIES datadog (~> 2.0) debug (~> 1.8) devise (>= 4.9.4) - devise-secure_password! + devise-secure_password (= 2.2.1) devise-two-factor (>= 5.0.0) devise_token_auth (>= 1.2.3) dotenv-rails (>= 3.0.0) @@ -1070,8 +1095,10 @@ DEPENDENCIES faker faraday_middleware-aws-sigv4 fcm + firecrawl-sdk (~> 1.0) flag_shih_tzu foreman + gemoji geocoder gmail_xoauth google-cloud-dialogflow-v2 (>= 0.24.0) @@ -1090,7 +1117,7 @@ DEPENDENCIES json_schemer judoscale-rails judoscale-sidekiq - jwt + jwt (~> 2.10, >= 2.10.3) kaminari koala letter_opener @@ -1118,13 +1145,14 @@ DEPENDENCIES pgvector procore-sift pry-rails - puma + puma (~> 7.2, >= 7.2.1) pundit rack-attack (>= 6.7.0) rack-cors (= 2.0.0) rack-mini-profiler (>= 3.2.0) rack-timeout - rails (~> 7.1) + rails (= 7.2.3.1) + rails-i18n (~> 7.0) redis redis-namespace responders (>= 3.1.1) @@ -1138,7 +1166,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 @@ -1149,13 +1177,14 @@ DEPENDENCIES sentry-sidekiq (>= 5.19.0) shopify_api shoulda-matchers - sidekiq (>= 7.3.1) - sidekiq-cron (>= 1.12.0) + sidekiq (~> 7.3.10) + sidekiq-cron (>= 2.4.0) sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter skooma slack-ruby-client (~> 2.7.0) + speedshop-cloudwatch (~> 0.2.1) spring spring-watcher-listen squasher diff --git a/LICENSE b/LICENSE index 8f80f35beac23..d0425a707ab6f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2024 Chatwoot Inc. +Copyright (c) 2017-2026 Chatwoot Inc. Portions of this software are licensed as follows: diff --git a/Procfile.tunnel b/Procfile.tunnel index bf3541dd05169..01c15fcdae41d 100644 --- a/Procfile.tunnel +++ b/Procfile.tunnel @@ -1,4 +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 +vite: bin/vite build --watch --force diff --git a/VERSION_CW b/VERSION_CW index 813b83b65323e..b30c4dcbff36b 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.13.0 +4.18.0 diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 2633c907d97fa..6fd01bbd25815 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -13,6 +13,7 @@ def perform merge_messages merge_contact_inboxes merge_contact_notes + merge_calls merge_and_remove_mergee_contact end @base_contact @@ -46,6 +47,10 @@ def merge_contact_inboxes ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) end + def merge_calls + # overridden in enterprise/app/actions/enterprise/contact_merge_action.rb + end + def merge_and_remove_mergee_contact mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes] base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank @@ -60,3 +65,5 @@ def merge_and_remove_mergee_contact @base_contact.update!(merged_attributes) end end + +ContactMergeAction.prepend_mod_with('ContactMergeAction') diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index 532487a1b01c1..5127ea6122b13 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -44,7 +44,11 @@ def validate_user 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 2fe11cae08ff6..fd26d523e9d61 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -2,6 +2,14 @@ # It initializes with necessary attributes and provides a perform method # to create a user and account user in a transaction. class AgentBuilder + LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze + + class LimitExceededError < StandardError + def initialize + super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE) + end + end + # Initializes an AgentBuilder with necessary attributes. # @param email [String] the email of the user. # @param name [String] the name of the user. @@ -14,29 +22,51 @@ class AgentBuilder # Creates a user and account user in a transaction. # @return [User] the created user. def perform - ActiveRecord::Base.transaction do - @user = find_or_create_user - create_account_user + account.with_lock do + raise LimitExceededError unless can_add_agent? + + ActiveRecord::Base.transaction(requires_new: true) do + @user = find_or_create_user + create_account_user + reserve_invitation_email_capacity if user_needs_confirmation? + end end + @user.send_confirmation_instructions if user_needs_confirmation? @user end private + def can_add_agent? + account.usage_limits[:agents] > account.account_users.count + end + # Finds a user by email or creates a new one with a temporary password. # @return [User] the found or created user. def find_or_create_user + @new_user = false 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.new(email: email, name: @name, password: temp_password, password_confirmation: temp_password).tap do |new_user| + new_user.skip_confirmation_notification! + new_user.save! + @new_user = true + end end # Checks if the user needs confirmation. # @return [Boolean] true if the user is persisted and not confirmed, false otherwise. def user_needs_confirmation? - @user.persisted? && !@user.confirmed? + @new_user && @user.persisted? && !@user.confirmed? + end + + def reserve_invitation_email_capacity + return if account.reserve_email_send_capacity + + raise CustomExceptions::Account::EmailLimitExceeded.new({}) end # Creates an account user linking the user to the current account. diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2c0e6087e0c95..940eaf4a2a0a6 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -50,7 +50,7 @@ def update_contact_avatar(contact) def create_contact account.contacts.create!( - name: contact_attributes[:name] || ::Haikunator.haikunate(1000), + name: contact_name, phone_number: contact_attributes[:phone_number], email: contact_attributes[:email], identifier: contact_attributes[:identifier], @@ -59,10 +59,15 @@ def create_contact ) end + def contact_name + name = contact_attributes[:name] || ::Haikunator.haikunate(1000) + name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '') + end + def find_contact contact = find_contact_by_identifier(contact_attributes[:identifier]) contact ||= find_contact_by_email(contact_attributes[:email]) - contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) + contact ||= find_contact_by_phone_numbers contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel? contact @@ -102,9 +107,14 @@ def find_contact_by_email(email) account.contacts.from_email(email) end - def find_contact_by_phone_number(phone_number) - return if phone_number.blank? + def find_contact_by_phone_numbers + phone_numbers = [contact_attributes[:phone_number], *Array(contact_attributes[:phone_number_candidates])].compact_blank.uniq + + phone_numbers.each do |phone_number| + contact = account.contacts.find_by(phone_number: phone_number) + return contact if contact + end - account.contacts.find_by(phone_number: phone_number) + nil end end diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb index 6f79d60186368..a797556a67db8 100644 --- a/app/builders/email/base_builder.rb +++ b/app/builders/email/base_builder.rb @@ -25,12 +25,13 @@ def sender_name(sender_email) # Friendly: from # Professional: if inbox.friendly? - I18n.t( - 'conversations.reply.email.header.friendly_name', + Email::SenderNameBuilder.new( + account: account, + sender: message&.sender, + sender_email: sender_email, sender_name: custom_sender_name, - business_name: business_name, - from_email: sender_email - ) + business_name: business_name + ).build else I18n.t( 'conversations.reply.email.header.professional_name', @@ -41,7 +42,7 @@ def sender_name(sender_email) end def business_name - inbox.business_name || inbox.sanitized_name + inbox.sanitized_business_name end def account_support_email diff --git a/app/builders/email/sender_name_builder.rb b/app/builders/email/sender_name_builder.rb new file mode 100644 index 0000000000000..6a1b00ace4775 --- /dev/null +++ b/app/builders/email/sender_name_builder.rb @@ -0,0 +1,46 @@ +class Email::SenderNameBuilder + TRANSLATION_KEY = 'conversations.reply.email.header.friendly_name'.freeze + + pattr_initialize [:account!, :sender!, :sender_email!, :sender_name!, :business_name!] + + def build + address = Mail::Address.new(sender_email) + address.display_name = localized_display_name(address.address) + address.format + rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError + localized_header(sender_email) + end + + private + + def localized_display_name(email_address) + localized_header(email_address).sub(mailbox_pattern(email_address), '').strip + end + + def localized_header(email_address) + I18n.t( + TRANSLATION_KEY, + sender_name: sender_name, + business_name: business_name, + from_email: email_address, + locale: sender_locale + ) + end + + def mailbox_pattern(email_address) + /\s*[<«]\s*(?:reply\+)?#{Regexp.escape(email_address)}\s*[>»]\s*/ + end + + def sender_locale + locale_candidates.find { |locale| valid_locale?(locale) && I18n.exists?(TRANSLATION_KEY, locale, fallback: false) } || I18n.default_locale + end + + def locale_candidates + sender_locale = sender.ui_settings&.dig('locale') if sender.is_a?(User) + [sender_locale, account.locale, I18n.default_locale].compact.map(&:to_s).uniq + end + + def valid_locale?(locale) + I18n.available_locales.map(&:to_s).include?(locale) + end +end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 1f59deadb3e2f..c7608399e635c 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -91,11 +91,21 @@ def location_params(attachment) def fallback_params(attachment) { - fallback_title: attachment['title'], - external_url: attachment['url'] + fallback_title: attachment['title'] || attachment.dig('payload', 'title'), + external_url: attachment['url'] || attachment.dig('payload', 'url') } end + # Facebook shared posts point to page URLs, not downloadable media URLs. + # Both `share` and `post` attachment types carry a page URL rather than a media file, + # so map them to `fallback` (which keeps the title/link without attempting a download). + # Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling. + def normalize_file_type(type) + return :fallback if [:share, :post].include?(type.to_sym) + + super + end + def conversation_params { account_id: @inbox.account_id, diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 7df72e14a0b58..297e77fcc7a3b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -13,6 +13,7 @@ def initialize(user, conversation, params) @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] + @is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message]) @automation_rule = content_attributes&.dig(:automation_rule_id) return unless params.instance_of?(ActionController::Parameters) @@ -56,16 +57,25 @@ def process_attachments file: uploaded_attachment ) - attachment.file_type = if uploaded_attachment.is_a?(String) - file_type_by_signed_id( - uploaded_attachment - ) - else - file_type(uploaded_attachment&.content_type) - end + attachment.file_type = attachment_file_type(uploaded_attachment) + tag_voice_message(attachment) end end + def attachment_file_type(uploaded_attachment) + if uploaded_attachment.is_a?(String) + file_type_by_signed_id(uploaded_attachment) + else + file_type(uploaded_attachment&.content_type) + end + end + + def tag_voice_message(attachment) + return unless @is_voice_message && attachment.file_type == 'audio' + + attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true) + end + def process_emails return unless @conversation.inbox&.inbox_type == 'Email' diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index be1f98b43d317..712a2460849ea 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -6,6 +6,11 @@ def process_attachment(attachment) return if unsupported_file_type?(attachment['type']) params = attachment_params(attachment) + # During Meta's sticker webhook transition, a sticker message carries both an `image` + # and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it + # isn't attached twice, while still storing legitimate duplicate attachments of other types. + return if duplicate_sticker?(attachment, params[:external_url]) + attachment_obj = @message.attachments.new(params.except(:remote_file_url)) attachment_obj.save! if facebook_reel?(attachment) @@ -13,10 +18,14 @@ def process_attachment(attachment) elsif params[:remote_file_url] attach_file(attachment_obj, params[:remote_file_url]) end + fetch_attachment_links(attachment_obj) + update_attachment_file_type(attachment_obj) + end + + def fetch_attachment_links(attachment_obj) fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' - update_attachment_file_type(attachment_obj) end def attach_file(attachment, file_url) @@ -28,6 +37,10 @@ def attach_file(attachment, file_url) 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) @@ -107,13 +120,20 @@ def get_story_object_from_source_id(_source_id) # Facebook may send attachment types that don't directly match our file_type enum. # Map known aliases to their canonical enum values. - FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze + FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze def normalize_file_type(type) sym = type.to_sym FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym) end + def duplicate_sticker?(attachment, url) + return false unless attachment['type'].to_sym == :sticker + return false if url.blank? + + @message.attachments.any? { |existing| existing.external_url == url } + end + # Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than # direct video URLs. Downloading these yields HTML, not video content. def facebook_reel?(attachment) diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index d5461eb4e8bf5..e09e44d505a5f 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -27,6 +27,8 @@ def build_notification 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 @@ def build_notification 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 fb986d335cdf0..b935bd52049db 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 9b4541aaa6118..58689aaad002c 100644 --- a/app/builders/v2/reports/agent_summary_builder.rb +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -11,10 +11,6 @@ def build 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 d4a9e7c0ba2b2..bd8ed5f561298 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -9,37 +9,13 @@ def build private def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end - - 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 + results = data_source.summary - 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 @@ def prepare_report # 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 c46daf978de75..f151e1a66561e 100644 --- a/app/builders/v2/reports/bot_metrics_builder.rb +++ b/app/builders/v2/reports/bot_metrics_builder.rb @@ -31,13 +31,24 @@ def bot_messages 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 a7961b0d6f27f..ee01551503445 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/drilldown_builder.rb b/app/builders/v2/reports/drilldown_builder.rb new file mode 100644 index 0000000000000..a3d2c073d5388 --- /dev/null +++ b/app/builders/v2/reports/drilldown_builder.rb @@ -0,0 +1,213 @@ +class V2::Reports::DrilldownBuilder + include DateRangeHelper + include TimezoneHelper + + DEFAULT_GROUP_BY = 'day'.freeze + DEFAULT_PAGE = 1 + DEFAULT_PER_PAGE = 25 + MAX_PER_PAGE = 100 + SUPPORTED_GROUP_BY = %w[hour day week month year].freeze + SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze + MESSAGE_METRICS = { + 'incoming_messages_count' => :incoming, + 'outgoing_messages_count' => :outgoing + }.freeze + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + pattr_initialize :account, :params + + def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s) + + def build + records = paginated_records.to_a + { meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } } + end + + private + + def meta + { + metric: metric, + record_type: record_type, + bucket: { + since: bucket_range.begin.to_i, + until: bucket_range.end.to_i + }, + current_page: current_page, + per_page: per_page, + total_count: paginated_records.total_count, + conversation_count: conversation_count + } + end + + def conversation_count + return paginated_records.total_count if conversation_metric? + + drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id) + end + + def paginated_records + @paginated_records ||= drilldown_scope.page(current_page).per(per_page) + end + + def drilldown_scope + if message_metric? + message_scope + elsif conversation_metric? + conversation_scope + else + reporting_event_scope + end + end + + def message_scope + scope.messages + .where(account_id: account.id, created_at: bucket_range) + .public_send(MESSAGE_METRICS.fetch(metric)) + .includes(:sender, conversation: [:assignee, :contact, :inbox]) + .reorder(created_at: :desc) + end + + def conversation_scope + scope.conversations + .where(account_id: account.id, created_at: bucket_range) + .includes(:assignee, :contact, :inbox) + .order(created_at: :desc) + end + + def reporting_event_scope + events = scope.reporting_events + .where(account_id: account.id, name: raw_event_name, created_at: bucket_range) + .includes(:user, :inbox, conversation: [:assignee, :contact, :inbox]) + .order(created_at: :desc) + + if raw_count_strategy == :exclude_bot_handoffs + events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery) + elsif raw_count_strategy == :distinct_conversation + events = events.where(id: distinct_conversation_event_ids(events)) + end + + events + end + + def bot_handoff_conversation_ids_subquery + scope.reporting_events + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) + end + + def distinct_conversation_event_ids(events) + events.reorder(nil) + .where.not(conversation_id: nil) + .select('MAX(reporting_events.id)') + .group(:conversation_id) + end + + def record_serializer(records) + @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new( + account, + metric, + use_business_hours?, + records + ) + end + + def bucket_range + @bucket_range ||= begin + bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone) + bucket_end = bucket_end_for(bucket_start) + requested_start = Time.zone.at(params[:since].to_i) + requested_end = Time.zone.at(params[:until].to_i) + + [bucket_start, requested_start].max...[bucket_end, requested_end].min + end + end + + def bucket_end_for(bucket_start) + { + 'hour' => bucket_start + 1.hour, + 'day' => bucket_start + 1.day, + 'week' => bucket_start + 1.week, + 'month' => bucket_start + 1.month, + 'year' => bucket_start + 1.year + }.fetch(group_by) + end + + def scope + case dimension_type + when 'account' then account + when 'inbox' then inbox + when 'agent' then user + when 'label' then label + when 'team' then team + else + raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}" + end + end + + def inbox = @inbox ||= account.inboxes.find(params[:id]) + + def user = @user ||= account.users.find(params[:id]) + + def label = @label ||= account.labels.find(params[:id]) + + def team = @team ||= account.teams.find(params[:id]) + + def metric + params[:metric].to_s + end + + def report_metric + @report_metric ||= Reports::ReportMetricRegistry.fetch(metric) + end + + def raw_event_name + report_metric&.raw_event_name + end + + def raw_count_strategy + report_metric&.raw_count_strategy + end + + def record_type + return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric) + + 'conversation' + end + + def message_metric? + MESSAGE_METRICS.key?(metric) + end + + def conversation_metric? + metric == 'conversations_count' + end + + def dimension_type + (params[:type].presence || 'account').to_s + end + + def group_by + @group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY + end + + def timezone + @timezone ||= timezone_name_from_offset(params[:timezone_offset]) + end + + def current_page + [params[:page].to_i, DEFAULT_PAGE].max + end + + def per_page + requested_per_page = params[:per_page].to_i + requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0 + + [requested_per_page, MAX_PER_PAGE].min + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end +end diff --git a/app/builders/v2/reports/drilldown_record_serializer.rb b/app/builders/v2/reports/drilldown_record_serializer.rb new file mode 100644 index 0000000000000..04edf65b421b5 --- /dev/null +++ b/app/builders/v2/reports/drilldown_record_serializer.rb @@ -0,0 +1,199 @@ +class V2::Reports::DrilldownRecordSerializer + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + attr_reader :account, :metric, :use_business_hours, :records + + def initialize(account, metric, use_business_hours, records = []) + @account = account + @metric = metric + @use_business_hours = use_business_hours + @records = records + end + + def serialize(record) + return serialize_message(record) if record.is_a?(Message) + return serialize_conversation_event(record) if record.is_a?(ReportingEvent) + + serialize_conversation(record) + end + + private + + def serialize_message(message, metric_value: nil, occurred_at: nil) + { + record_type: 'message', + conversation: conversation_attributes(message.conversation), + message: message_attributes(message), + metric_value: metric_value, + occurred_at: (occurred_at || message.created_at).to_i + } + end + + def serialize_conversation_event(event) + inferred_message = inferred_message_for(event) + if inferred_message.present? + return serialize_message( + inferred_message, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event) + ) + end + + serialize_conversation( + event.conversation, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event), + event_name: event.name + ) + end + + def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil) + serialized_record = { + record_type: 'conversation', + conversation: conversation_attributes(conversation), + message: nil, + metric_value: metric_value, + occurred_at: (occurred_at || conversation&.created_at)&.to_i + } + serialized_record[:event_name] = event_name if event_name.present? + serialized_record + end + + def conversation_attributes(conversation) + return {} if conversation.blank? + + { + id: conversation.id, + display_id: conversation.display_id, + contact_id: conversation.contact_id, + contact_name: conversation.contact&.name, + inbox_id: conversation.inbox_id, + inbox_name: conversation.inbox&.name, + assignee_id: conversation.assignee_id, + assignee_name: conversation.assignee&.name, + status: conversation.status, + created_at: conversation.created_at.to_i, + last_activity_at: conversation.last_activity_at.to_i, + last_message: last_message_attributes(conversation) + } + end + + def message_attributes(message) + { + id: message.id, + content: message.content, + message_type: message.message_type, + sender_name: message.sender&.try(:name), + created_at: message.created_at.to_i + } + end + + def last_message_attributes(conversation) + message = latest_messages_by_conversation_id[conversation.id] + return if message.blank? + + message_attributes(message) + end + + def inferred_message_for(event) + return unless MESSAGE_EVENT_METRICS.include?(metric) + return if event.conversation.blank? || event.event_end_time.blank? + + inferred_messages_by_event_id[event.id] + end + + def first_response_event_with_user?(event) + metric == 'avg_first_response_time' && event.user_id.present? + end + + def message_inference_range(event) + (event.event_end_time - 1.second)..(event.event_end_time + 1.second) + end + + def event_metric_value(event) + use_business_hours ? event.value_in_business_hours : event.value + end + + def event_timestamp(event) + event.event_end_time || event.created_at + end + + def latest_messages_by_conversation_id + @latest_messages_by_conversation_id ||= if conversation_ids.blank? + {} + else + latest_messages.index_by(&:conversation_id) + end + end + + def latest_messages + Message + .where(account_id: account.id, conversation_id: conversation_ids) + .where.not(message_type: :activity) + .select('DISTINCT ON (messages.conversation_id) messages.*') + .reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC')) + .includes(:sender) + end + + def inferred_messages_by_event_id + @inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id| + messages_by_event_id[event.id] = inferred_message_candidates.find do |message| + message_matches_event?(message, event) + end + end + end + + def inferred_message_candidates + @inferred_message_candidates ||= if inference_events.blank? + [] + else + inferred_messages.to_a + end + end + + def inferred_messages + Message + .where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq) + .where(created_at: inference_time_range) + .where(message_type: %i[outgoing template]) + .includes(:sender) + .reorder(created_at: :desc, id: :desc) + end + + def message_matches_event?(message, event) + message.conversation_id == event.conversation_id && + message.created_at.between?( + message_inference_range(event).begin, + message_inference_range(event).end + ) && + message_sender_matches_event?(message, event) + end + + def message_sender_matches_event?(message, event) + return true unless first_response_event_with_user?(event) + + message.sender_id == event.user_id && message.sender_type == 'User' + end + + def inference_time_range + event_end_times = inference_events.map(&:event_end_time) + + (event_end_times.min - 1.second)..(event_end_times.max + 1.second) + end + + def inference_events + @inference_events ||= records.select do |record| + record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present? + end + end + + def conversation_ids + @conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq + end + + def conversation_id_for(record) + return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent) + + record.id + end +end diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb index 935afeb82fdd8..fcfabc5992040 100644 --- a/app/builders/v2/reports/inbox_summary_builder.rb +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -11,15 +11,6 @@ def build 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 @@ def build_inbox_stats(inbox) 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 b98151bc65335..3bcf51816eaa7 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 5df718b6a2d58..0000000000000 --- 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 50699417d15b0..86fddba076086 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 @@ def scope 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 @@ def group_by 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 bb3b1250cd6df..0000000000000 --- 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 0000000000000..a3e83c78af0c6 --- /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/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index de3d100815a23..3f03090249ab8 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :check_authorization before_action :agent_bot, except: [:index, :create] diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 438944f04791c..7399b92fd10e7 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -1,8 +1,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :check_authorization - before_action :validate_limit, only: [:create] - before_action :validate_limit_for_bulk_create, only: [:bulk_create] def index @agents = agents @@ -20,6 +18,8 @@ def create ) @agent = builder.perform + rescue AgentBuilder::LimitExceededError => e + render_payment_required(e.message) end def update @@ -36,25 +36,13 @@ def destroy def bulk_create emails = params[:emails] - emails.each do |email| - builder = AgentBuilder.new( - email: email, - name: email.split('@').first, - inviter: current_user, - account: Current.account - ) - begin - builder.perform - rescue ActiveRecord::RecordInvalid => e - Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" - end - end - + bulk_create_agents(emails) # This endpoint is used to bulk create agents during onboarding # onboarding_step key in present in Current account custom attributes, since this is a one time operation - Current.account.custom_attributes.delete('onboarding_step') - Current.account.save! + clear_onboarding_step head :ok + rescue AgentBuilder::LimitExceededError => e + render_payment_required(e.message) end private @@ -87,22 +75,41 @@ def agents @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) end - def validate_limit_for_bulk_create - limit_available = params[:emails].count <= available_agent_count + def bulk_create_agents(emails) + email_limit_error = nil + + Current.account.with_lock do + raise AgentBuilder::LimitExceededError if emails.count > available_agent_count + + emails.each do |email| + create_agent_from_email(email) + rescue CustomExceptions::Account::EmailLimitExceeded => e + email_limit_error = e + end + end - render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available + raise email_limit_error if email_limit_error end - def validate_limit - render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent? + def create_agent_from_email(email) + builder = AgentBuilder.new( + email: email, + name: email.split('@').first, + inviter: current_user, + account: Current.account + ) + builder.perform + rescue ActiveRecord::RecordInvalid => e + Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" end - def available_agent_count - Current.account.usage_limits[:agents] - agents.count + def clear_onboarding_step + Current.account.custom_attributes.delete('onboarding_step') + Current.account.save! end - def can_add_agent? - available_agent_count.positive? + def available_agent_count + Current.account.usage_limits[:agents] - Current.account.account_users.count end def delete_user_record(agent) 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 0000000000000..7ea9cab5d505a --- /dev/null +++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb @@ -0,0 +1,59 @@ +class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController + before_action :portal + before_action :check_authorization + before_action :set_articles, only: [:update_status, :update_category, :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 update_category + 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.category_not_found')) unless category_valid? + + ActiveRecord::Base.transaction do + @articles.find_each { |article| article.update!(category_id: params[:category_id]) } + 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 + + def category_valid? + @portal.categories.exists?(id: params[:category_id]) + end +end +Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 5e1609b64e8a1..e05871a492cf9 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -3,6 +3,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :fetch_article, except: [:index, :create, :reorder] before_action :set_current_page, only: [:index] + before_action :validate_author, only: [:create] + before_action :discard_invalid_author, only: [:update] def index @portal_articles = @portal.articles @@ -30,8 +32,8 @@ def create end def update - @article.update!(article_params) if params[:article].present? - render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? + persist_article_changes if params[:article].present? + render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid? end def destroy @@ -40,8 +42,8 @@ def destroy end def reorder - Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) - head :ok + positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + render json: { positions: positions } end private @@ -63,16 +65,52 @@ def fetch_article @article = @portal.articles.find(params[:id]) end + def validate_author + author_id = params.dig(:article, :author_id) + return if author_id.blank? + return if author_belongs_to_account?(author_id) + + render json: { error: 'Invalid author ID' }, status: :unprocessable_entity + end + + def discard_invalid_author + submitted_article_params = params[:article] + return unless submitted_article_params&.key?(:author_id) + + author_id = submitted_article_params[:author_id] + return if author_belongs_to_account?(author_id) + + submitted_article_params.delete(:author_id) + end + + def author_belongs_to_account?(author_id) + author_id.to_s.match?(/\A\d+\z/) && Current.account.users.exists?(id: author_id.to_i) + end + def portal @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) end + # Draft-only autosaves must not bump the public-facing updated_at, so write + # them with update_columns (which skips the timestamp). update_columns also + # skips validations, so assign and validate first to avoid persisting content + # that exceeds the column length limit. + def persist_article_changes + keys = article_params.to_h.keys + if keys.any? && (keys - %w[draft_title draft_content]).empty? + @article.assign_attributes(article_params) + @article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations + else + @article.update!(article_params) + end + end + def article_params params.require(:article).permit( :title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status, - :locale, meta: [:title, - :description, - { tags: [] }] + :locale, :draft_title, :draft_content, meta: [:title, + :description, + { tags: [] }] ) end diff --git a/app/controllers/api/v1/accounts/assignable_agents_controller.rb b/app/controllers/api/v1/accounts/assignable_agents_controller.rb index a712342dd872e..99bd56d645f81 100644 --- a/app/controllers/api/v1/accounts/assignable_agents_controller.rb +++ b/app/controllers/api/v1/accounts/assignable_agents_controller.rb @@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon before_action :fetch_inboxes def index + # TODO: Remove this opt-in once mobile clients support AI assignees in this payload. + @include_ai_assignees = params[:include_ai_assignees].present? agent_ids = @inboxes.map do |inbox| authorize inbox, :show? member_ids = inbox.members.pluck(:user_id) @@ -10,6 +12,7 @@ def index agent_ids = agent_ids.inject(:&) agents = Current.account.users.where(id: agent_ids) @assignable_agents = (agents + Current.account.administrators).uniq + @agent_bots = @include_ai_assignees ? AgentBot.accessible_to(Current.account) : [] end private @@ -22,3 +25,5 @@ def permitted_params params.permit(inbox_ids: []) end end + +Api::V1::Accounts::AssignableAgentsController.prepend_mod_with('Api::V1::Accounts::AssignableAgentsController') diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afbfced..0150cb6777fbd 100644 --- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -30,7 +30,8 @@ def fetch_assignment_policy def assignment_policy_params params.require(:assignment_policy).permit( :name, :description, :assignment_order, :conversation_priority, - :fair_distribution_limit, :fair_distribution_window, :enabled + :fair_distribution_limit, :fair_distribution_window, :enabled, + :exclude_older_than_hours ) end end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 0840d0eea621e..d02979095b66b 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont before_action :check_authorization before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] + before_action :ensure_execution_delay_allowed, only: [:create, :update] def index @automation_rules = Current.account.automation_rules @@ -47,21 +48,42 @@ def destroy def clone automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id]) - new_rule = automation_rule.dup - new_rule.save! - @automation_rule = new_rule + # Dropping the delay here would clone a paused wait into a rule that fires instantly on the + # next matching event, so refuse rather than rewrite what the rule means. + return render_delayed_automations_error if automation_rule.execution_delay.present? && !delayed_automations_enabled? + + @automation_rule = automation_rule.dup + @automation_rule.save! end private def automation_rules_permit + permitted_attributes = [:name, :description, :event_name, :active] + permitted_attributes << :execution_delay if delayed_automations_enabled? + params.permit( - :name, :description, :event_name, :active, + *permitted_attributes, conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], actions: [:action_name, { action_params: [] }] ) end + def ensure_execution_delay_allowed + return if delayed_automations_enabled? + return if params[:execution_delay].blank? + + render_delayed_automations_error + end + + def render_delayed_automations_error + render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity + end + + def delayed_automations_enabled? + Current.account.feature_enabled?('delayed_automations') + end + def fetch_automation_rule @automation_rule = Current.account.automation_rules.find_by(id: params[:id]) end diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb index e30effc59c16e..f08b87e60fc8c 100644 --- a/app/controllers/api/v1/accounts/base_controller.rb +++ b/app/controllers/api/v1/accounts/base_controller.rb @@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController include SwitchLocale include EnsureCurrentAccountHelper before_action :current_account + before_action :validate_token_api_access, if: :authenticate_by_access_token? around_action :switch_locale_using_account_locale + + private + + def validate_token_api_access + return if Current.account.api_and_webhooks_enabled? + + render json: { error: 'API access is not enabled for this account' }, status: :forbidden + end end diff --git a/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb new file mode 100644 index 0000000000000..54f976b79e044 --- /dev/null +++ b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb @@ -0,0 +1,28 @@ +class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController + before_action :check_admin_authorization? + + def show + set_branded_email_layout + end + + def update + unless Current.account.feature_enabled?(:branded_email_templates) + render_could_not_create_error('Branded email templates feature is not enabled') + return + end + + branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout] + EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout) + set_branded_email_layout + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.record.errors.full_messages.join(', ')) + end + + private + + def set_branded_email_layout + @branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body + end +end + +Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController') diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 90cdf2418161b..0004eb386438e 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -6,27 +6,21 @@ def register_facebook_page page_access_token = params[:page_access_token] page_id = params[:page_id] inbox_name = params[:inbox_name] + ActiveRecord::Base.transaction do facebook_channel = Current.account.facebook_pages.create!( page_id: page_id, user_access_token: user_access_token, page_access_token: page_access_token ) @facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel) - set_instagram_id(page_access_token, facebook_channel) + sync_page_details(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) end + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception Rails.logger.error "Error in register_facebook_page: #{e.message}" - # Additional log statements - log_additional_info - end - - def log_additional_info - Rails.logger.debug do - "user_access_token: #{params[:user_access_token]} , page_access_token: #{params[:page_access_token]} , - page_id: #{params[:page_id]}, inbox_name: #{params[:inbox_name]}" - end end def facebook_pages @@ -40,15 +34,11 @@ def facebook_pages @page_details = mark_already_existing_facebook_pages(pages) end - def set_instagram_id(page_access_token, facebook_channel) - fb_object = Koala::Facebook::API.new(page_access_token) - response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' }) - return if response['instagram_business_account'].blank? - - instagram_id = response['instagram_business_account']['id'] - facebook_channel.update(instagram_id: instagram_id) + def sync_page_details(page_access_token, facebook_channel) + page_details = Facebook::PageDetailsService.new(access_token: page_access_token).perform + facebook_channel.update(page_details.compact) rescue StandardError => e - Rails.logger.error "Error in set_instagram_id: #{e.message}" + Rails.logger.error "Error in sync_page_details: #{e.message}" end # get params[:inbox_id], current_account. params[:omniauth_token] @@ -76,7 +66,7 @@ def update_fb_page(fb_page_id, access_token) fb_page = get_fb_page(fb_page_id) ActiveRecord::Base.transaction do fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token) - set_instagram_id(access_token, fb_page) + sync_page_details(access_token, fb_page) fb_page&.reauthorized! rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception diff --git a/app/controllers/api/v1/accounts/canned_responses_controller.rb b/app/controllers/api/v1/accounts/canned_responses_controller.rb index 5528610ec615d..4172976f09846 100644 --- a/app/controllers/api/v1/accounts/canned_responses_controller.rb +++ b/app/controllers/api/v1/accounts/canned_responses_controller.rb @@ -33,9 +33,10 @@ def canned_response_params def canned_responses if params[:search] + search = params[:search].delete("\0") Current.account.canned_responses - .where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%") - .order_by_search(params[:search]) + .where('short_code ILIKE :search OR content ILIKE :search', search: "%#{search}%") + .order_by_search(search) else Current.account.canned_responses diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 156c031faf317..3b7fafad52d82 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController - before_action :current_account before_action :authorize_account_update, only: [:update] def show @@ -8,8 +7,8 @@ def show def update params_to_update = captain_params - @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models] - @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features] + @current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models) + @current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features) @current_account.save! render json: preferences_payload @@ -38,7 +37,7 @@ def captain_params def merged_captain_models existing_models = @current_account.captain_models || {} - existing_models.merge(permitted_captain_models) + existing_models.merge(permitted_captain_models).compact_blank.presence end def merged_captain_features @@ -47,30 +46,38 @@ def merged_captain_features end def permitted_captain_models - params.require(:captain_models).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys end def permitted_captain_features - params.require(:captain_features).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys + end + + def captain_feature_keys + Llm::Models.feature_keys.map(&:to_sym) end def features_with_account_preferences preferences = Current.account.captain_preferences account_features = preferences[:features] || {} - account_models = preferences[:models] || {} Llm::Models.feature_keys.index_with do |feature_key| config = Llm::Models.feature_config(feature_key) + route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account) config.merge( + default: default_model_for(feature_key), enabled: account_features[feature_key] == true, - selected: account_models[feature_key] || config[:default] + model: route[:model], + selected: route[:model], + provider: route[:provider], + source: route[:source] ) end end + + def default_model_for(feature_key) + return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2') + + Llm::Models.default_model_for(feature_key) + end end diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index 686ffaeecc51c..655b3c890f860 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -53,7 +53,7 @@ def related_categories_records def category_params params.require(:category).permit( - :name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id + :name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id ) end diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index f3b14d49f59a2..8fa0c5a5decfe 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def create process_create + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e render_could_not_create_error(e.message) end @@ -20,7 +22,7 @@ def process_create ActiveRecord::Base.transaction do authenticate_twilio build_inbox - setup_webhooks if @twilio_channel.sms? + setup_webhooks end end @@ -34,7 +36,7 @@ def authenticate_twilio end def setup_webhooks - ::Twilio::WebhookSetupService.new(inbox: @inbox).perform + ::Twilio::WebhookSetupService.new(channel: @twilio_channel).perform end def phone_number diff --git a/app/controllers/api/v1/accounts/concerns/inbox_health_management.rb b/app/controllers/api/v1/accounts/concerns/inbox_health_management.rb new file mode 100644 index 0000000000000..2fe7cdc6177b5 --- /dev/null +++ b/app/controllers/api/v1/accounts/concerns/inbox_health_management.rb @@ -0,0 +1,122 @@ +module Api::V1::Accounts::Concerns::InboxHealthManagement + extend ActiveSupport::Concern + + included do + skip_before_action :check_authorization, only: [:health, :register_webhook] + before_action :check_admin_authorization?, only: [:register_webhook] + before_action :validate_health_supported_channel, only: [:health, :register_webhook] + end + + def sync_templates + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? + + trigger_template_sync + render status: :ok, json: { message: 'Template sync initiated successfully' } + rescue StandardError => e + render status: :internal_server_error, json: { error: e.message } + end + + def message_templates + unless whatsapp_channel? + return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' } + end + + templates, last_sync_attempt_at, name_key = message_template_data + templates = templates.select { |template| template[name_key] == params[:name] } if params[:name].present? + + render json: { + payload: templates, + meta: { last_sync_attempt_at: last_sync_attempt_at } + } + end + + def health + render json: fetch_health_data + rescue Whatsapp::HealthService::ApiError => e + Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" + render json: { + error: { + type: e.authorization_error? ? 'authorization' : 'api', + message: e.message, + http_status: e.http_status, + code: e.code, + subcode: e.subcode + }.compact + }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + + def register_webhook + register_channel_webhook + + render json: { message: 'Webhook registered successfully' }, status: :ok + rescue StandardError => e + Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + + def whatsapp_business_management_token + Whatsapp::BusinessManagementTokenService.new(whatsapp_channel).update!(params.require(:business_management_token)) + + head :no_content + rescue ArgumentError, ActiveRecord::RecordInvalid => e + render json: { error: e.message, message: e.message }, status: :unprocessable_entity + end + + private + + def fetch_health_data + return Whatsapp::HealthService.new(@inbox.channel).sync_health_status!(include_business_profile: true) if whatsapp_cloud_channel? + + Twilio::HealthService.new(channel: @inbox.channel).perform + end + + def register_channel_webhook + return Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback if whatsapp_cloud_channel? + + Twilio::WebhookSetupService.new(channel: @inbox.channel).perform + # No-op unless voice is enabled; keeps the number's voice webhooks in sync alongside messaging. + @inbox.channel.try(:reprovision_voice_webhooks!) + end + + def validate_health_supported_channel + return if whatsapp_cloud_channel? || twilio_sms_channel? + + render json: { error: 'Health data only available for WhatsApp Cloud API and Twilio SMS channels' }, status: :bad_request + end + + def whatsapp_channel + channel = @inbox.channel + raise ActiveRecord::RecordNotFound unless channel.is_a?(Channel::Whatsapp) + + channel + end + + def whatsapp_cloud_channel? + @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' + end + + def twilio_sms_channel? + @inbox.channel.is_a?(Channel::TwilioSms) && @inbox.channel.sms? + end + + def whatsapp_channel? + @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) + end + + def message_template_data + return [@inbox.channel.message_templates.presence || [], @inbox.channel.message_templates_last_updated, 'name'] unless @inbox.twilio_whatsapp? + + [@inbox.channel.content_templates&.dig('templates') || [], @inbox.channel.content_templates_last_updated, 'friendly_name'] + end + + def trigger_template_sync + if @inbox.whatsapp? + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + elsif @inbox.twilio? && @inbox.channel.whatsapp? + Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) + end + end +end diff --git a/app/controllers/api/v1/accounts/concerns/inbox_secret_management.rb b/app/controllers/api/v1/accounts/concerns/inbox_secret_management.rb new file mode 100644 index 0000000000000..dd43ab254ae4d --- /dev/null +++ b/app/controllers/api/v1/accounts/concerns/inbox_secret_management.rb @@ -0,0 +1,16 @@ +module Api::V1::Accounts::Concerns::InboxSecretManagement + extend ActiveSupport::Concern + + def reset_secret + return head :not_found unless @inbox.api? + + @inbox.channel.reset_secret! + end + + def rotate_hmac_token + return head :not_found unless @inbox.web_widget? || @inbox.api? + + @inbox.channel.regenerate_hmac_token + render :show + end +end diff --git a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb deleted file mode 100644 index 795d7f2a932c5..0000000000000 --- a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb +++ /dev/null @@ -1,55 +0,0 @@ -module Api::V1::Accounts::Concerns::WhatsappHealthManagement - extend ActiveSupport::Concern - - included do - skip_before_action :check_authorization, only: [:health, :register_webhook] - before_action :check_admin_authorization?, only: [:register_webhook] - before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook] - end - - def sync_templates - return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? - - trigger_template_sync - render status: :ok, json: { message: 'Template sync initiated successfully' } - rescue StandardError => e - render status: :internal_server_error, json: { error: e.message } - end - - def health - health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status - render json: health_data - rescue StandardError => e - Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" - render json: { error: e.message }, status: :unprocessable_entity - end - - def register_webhook - Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback - - render json: { message: 'Webhook registered successfully' }, status: :ok - rescue StandardError => e - Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}" - render json: { error: e.message }, status: :unprocessable_entity - end - - private - - def validate_whatsapp_cloud_channel - return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' - - render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request - end - - def whatsapp_channel? - @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) - end - - def trigger_template_sync - if @inbox.whatsapp? - Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) - elsif @inbox.twilio? && @inbox.channel.whatsapp? - Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) - end - end -end 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 0000000000000..761f00130e330 --- /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/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index 20d66fb4d0936..2946f5b00b8ed 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -1,17 +1,46 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController + RESULTS_PER_PAGE = 25 + + # Scoped to a conversation, this returns it with the ones created around it: the in-thread + # navigation walks the whole history and never needs more than that. def index - # Start with all conversations for this contact + @conversations = if params[:conversation_id].present? + conversation_with_neighbours(params[:conversation_id]) + else + permitted_conversations.order(created_at: :desc, id: :desc).limit(RESULTS_PER_PAGE) + end + end + + private + + def permitted_conversations conversations = Current.account.conversations.includes( - :assignee, :contact, :inbox, :taggings + :assignee, :contact, :taggings, :contact_inbox + ).preload( + inbox: :channel, + ai_assignee: { avatar_attachment: [:blob] } ).where(contact_id: @contact.id) - # Apply permission-based filtering using the existing service - conversations = Conversations::PermissionFilterService.new( + Conversations::PermissionFilterService.new( conversations, Current.user, Current.account ).perform + end + + def conversation_with_neighbours(display_id) + conversation = permitted_conversations.find_by!(display_id: display_id) + + [neighbour(conversation, :older), conversation, neighbour(conversation, :newer)].compact + end + + def neighbour(conversation, direction) + comparison = direction == :older ? '<' : '>' + order = direction == :older ? :desc : :asc - @conversations = conversations.order(last_activity_at: :desc).limit(20) + permitted_conversations + .where("(conversations.created_at, conversations.id) #{comparison} (?, ?)", conversation.created_at, conversation.id) + .order(created_at: order, id: order) + .first end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index dd5346bd68dba..bc10825838290 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] @@ -214,3 +214,5 @@ def render_error(error, error_status) render json: error, status: error_status end end + +Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController') diff --git a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb index 49806e97c2522..d8df44c70e189 100644 --- a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb @@ -34,8 +34,11 @@ def render_agent(resource) end def set_team - @team = Current.account.teams.find_by(id: params[:team_id]) - @conversation.update!(team: @team) + team_id = params[:team_id].to_i + @team = team_id.positive? ? Current.account.teams.find(team_id) : nil + @conversation.with_lock do + @conversation.update!(team: @team) + end render json: @team end @@ -43,3 +46,7 @@ def agent_bot_assignment? params[:assignee_type].to_s == 'AgentBot' end end + +Api::V1::Accounts::Conversations::AssignmentsController.prepend_mod_with( + 'Api::V1::Accounts::Conversations::AssignmentsController' +) diff --git a/app/controllers/api/v1/accounts/conversations/contact_info_requests_controller.rb b/app/controllers/api/v1/accounts/conversations/contact_info_requests_controller.rb new file mode 100644 index 0000000000000..134d9ff61a1a4 --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/contact_info_requests_controller.rb @@ -0,0 +1,8 @@ +class Api::V1::Accounts::Conversations::ContactInfoRequestsController < Api::V1::Accounts::Conversations::BaseController + def create + @message = Whatsapp::ContactInfoRequestService.new(conversation: @conversation, sender: Current.user).perform + render 'api/v1/accounts/conversations/messages/create' + rescue CustomExceptions::WhatsappContactInfoRequestError => e + render json: { error: e.message }, status: e.http_status + end +end diff --git a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb index f4ac05d6e3f51..915ade8a3908e 100644 --- a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb @@ -1,6 +1,17 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController + include DeviseTokenAuth::Concerns::SetUserByToken + include RequestExceptionHandler + include AccessTokenAuthHelper include EnsureCurrentAccountHelper + + skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token? + + around_action :handle_with_exception + before_action :authenticate_access_token!, if: :authenticate_by_access_token? + before_action :validate_bot_access_token!, if: :authenticate_by_access_token? + before_action :authenticate_user!, unless: :authenticate_by_access_token? before_action :current_account + before_action :validate_token_api_access, if: :authenticate_by_access_token? before_action :conversation def create @@ -11,6 +22,16 @@ def create private + def authenticate_by_access_token? + request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present? + end + + def validate_token_api_access + return if Current.account.api_and_webhooks_enabled? + + render json: { error: 'API access is not enabled for this account' }, status: :forbidden + end + def conversation @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a7155bff..b321d07b27cef 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -28,10 +28,7 @@ def destroy def retry return if message.blank? - service = Messages::StatusUpdateService.new(message, 'sent') - service.perform - message.update!(content_attributes: {}) - ::SendReplyJob.perform_later(message.id) + ::SendReplyJob.perform_later(message.id) if claim_message_retry rescue StandardError => e render_could_not_create_error(e.message) end @@ -52,6 +49,9 @@ def translate end render json: { content: translated_content } + rescue Google::Cloud::Error => e + # `details` carries the clean human message; `message` includes gRPC debug noise + render_could_not_create_error(e.details.presence || e.message) end private @@ -64,6 +64,28 @@ def message_finder @message_finder ||= MessageFinder.new(@conversation, params) end + def claim_message_retry + message.with_lock do + next false unless message.failed? + + Messages::StatusUpdateService.new(message, 'sent').perform + previous_source_id = message.source_id + retry_attributes = { content_attributes: retry_content_attributes } + retry_attributes[:source_id] = nil unless @conversation.inbox.api? || @conversation.inbox.web_widget? + message.update!(retry_attributes) + if retry_attributes.key?(:source_id) && previous_source_id.present? + Rails.logger.info "Cleared older source ID #{previous_source_id} for message #{message.id}" + end + true + end + end + + def retry_content_attributes + return message.content_attributes if message.content_attributes.dig('whatsapp_contact_info', 'type') == 'request' + + {} + end + def permitted_params params.permit(:id, :target_language, :status, :external_error) end @@ -78,3 +100,5 @@ def ensure_api_inbox render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api? end end + +Api::V1::Accounts::Conversations::MessagesController.prepend_mod_with('Api::V1::Accounts::Conversations::MessagesController') diff --git a/app/controllers/api/v1/accounts/conversations/participants_controller.rb b/app/controllers/api/v1/accounts/conversations/participants_controller.rb index ebd02380f9acb..7814d47795c27 100644 --- a/app/controllers/api/v1/accounts/conversations/participants_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/participants_controller.rb @@ -1,41 +1,74 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + + before_action :validate_participant_ids, only: [:create, :update] + def show @participants = @conversation.conversation_participants end def create + participant_ids_to_add = participants_to_be_added_ids + ActiveRecord::Base.transaction do - @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + @participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by!(user_id: user_id) } end + notify_unread_count_change if participant_ids_to_add.any? end def update + participant_ids_to_add = participants_to_be_added_ids + participant_ids_to_remove = participants_to_be_removed_ids + changed_participant_ids = participant_ids_to_add + participant_ids_to_remove + ActiveRecord::Base.transaction do - participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } - participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } + participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by!(user_id: user_id) } + participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } end - @participants = @conversation.conversation_participants + notify_unread_count_change if changed_participant_ids.any? + @participants = @conversation.conversation_participants.reload render action: 'show' end def destroy + participant_ids_to_remove = current_participant_ids & params[:user_ids] + ActiveRecord::Base.transaction do params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } end + notify_unread_count_change if participant_ids_to_remove.any? head :ok end private def participants_to_be_added_ids - params[:user_ids] - current_participant_ids + participant_ids - current_participant_ids end def participants_to_be_removed_ids - current_participant_ids - params[:user_ids] + current_participant_ids - participant_ids end def current_participant_ids @current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id) end + + def participant_ids + @participant_ids ||= params[:user_ids].map(&:to_i) + end + + def validate_participant_ids + invalid_ids = participants_to_be_added_ids - @conversation.inbox.assignable_agents.map(&:id) + return if invalid_ids.empty? + + render json: { error: 'Invalid participant IDs' }, status: :unprocessable_entity + end + + def notify_unread_count_change + return unless Current.account.feature_enabled?('conversation_unread_counts') + return unless Current.account.feature_enabled?('unread_count_for_filters') + + Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation) + end end diff --git a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb new file mode 100644 index 0000000000000..0b233547572e9 --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb @@ -0,0 +1,32 @@ +class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController + before_action :ensure_unread_counts_enabled + + def index + counts = if filtered_unread_counts_enabled? + instrumentation.summarize_request(account_id: Current.account.id) { unread_counts } + else + unread_counts + end + render json: { payload: counts } + end + + private + + def unread_counts + ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform + end + + def filtered_unread_counts_enabled? + Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG) + end + + def instrumentation + ::Conversations::UnreadCounts::FilteredCountInstrumentation + end + + def ensure_unread_counts_enabled + return if Current.account.feature_enabled?('conversation_unread_counts') + + render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden + end +end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 6159f804de18b..002847982b12d 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro include Events::Types include DateRangeHelper include HmacConcern + include ConversationCustomAttributesConcern before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :inbox, :contact, :contact_inbox, only: [:create] @@ -28,7 +29,7 @@ def search 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) @@ -80,7 +81,7 @@ def transcript def toggle_status # FIXME: move this logic into a service object - if pending_to_open_by_bot? + if bot_handoff? @conversation.bot_handoff! elsif params[:status].present? set_conversation_status @@ -88,21 +89,17 @@ def toggle_status else @status = @conversation.toggle_status end - assign_conversation if should_assign_conversation? + handle_human_open if @conversation.open? && Current.user.is_a?(User) end - def pending_to_open_by_bot? + def bot_handoff? return false unless Current.user.is_a?(AgentBot) @conversation.status == 'pending' && params[:status] == 'open' end - def should_assign_conversation? - @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent? - end - def toggle_priority - @conversation.toggle_priority(params[:priority]) + @conversation.toggle_priority(permitted_update_params[:priority]) head :ok end @@ -133,14 +130,9 @@ def unread update_last_seen_on_conversation(last_seen_at, true) end - def custom_attributes - @conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes] - @conversation.save! - end - def destroy authorize @conversation, :destroy? - ::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip) + ::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform head :ok end @@ -148,6 +140,8 @@ def destroy def permitted_update_params # TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute + raise ActionController::ParameterMissing, :priority unless params[:priority].nil? || Conversation.priorities.key?(params[:priority]) + params.permit(:priority) end @@ -162,6 +156,9 @@ def update_last_seen_on_conversation(last_seen_at, update_assignee) # rubocop:disable Rails/SkipsModelValidations @conversation.update_columns(updates) # rubocop:enable Rails/SkipsModelValidations + + ::Conversations::UnreadCounts::Notifier.new(@conversation).perform + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed! end def should_update_last_seen? @@ -180,9 +177,12 @@ def set_conversation_status @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] end - def assign_conversation - @conversation.assignee = current_user - @conversation.save! + def handle_human_open + @conversation.with_lock do + @conversation.ai_assignee = nil + @conversation.assignee = Current.user if Current.user.agent? + @conversation.save! + end end def conversation @@ -209,7 +209,8 @@ def contact_inbox # fallback for the old case where we do look up only using source id # In future we need to change this and make sure we do look up on combination of inbox_id and source_id # and deprecate the support of passing only source_id as the param - @contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id]) + lookup_scope = @inbox ? @inbox.contact_inboxes : ContactInbox.joins(:inbox).where(inboxes: { account_id: Current.account.id }) + @contact_inbox ||= lookup_scope.find_by!(source_id: params[:source_id]) authorize @contact_inbox.inbox, :show? rescue ActiveRecord::RecordNotUnique render json: { error: 'source_id should be unique' }, status: :unprocessable_entity 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 69df99e14c2b8..7bd9ae0c3e7f2 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/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb index a8d7ebcb99185..4226db1ccf132 100644 --- a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController + before_action :check_authorization before_action :fetch_dashboard_apps, except: [:create] before_action :fetch_dashboard_app, only: [:show, :update, :destroy] diff --git a/app/controllers/api/v1/accounts/data_imports_controller.rb b/app/controllers/api/v1/accounts/data_imports_controller.rb new file mode 100644 index 0000000000000..790cac87d7e72 --- /dev/null +++ b/app/controllers/api/v1/accounts/data_imports_controller.rb @@ -0,0 +1,199 @@ +require 'csv' + +class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController + DATA_IMPORT_FEATURE = 'data_import'.freeze + + before_action :ensure_data_import_feature_enabled + before_action :set_data_import, only: [:show, :start, :retry_import, :abandon, :error_logs, :skip_logs] + before_action :check_authorization + + def index + @data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc) + data_import_ids = @data_imports.map(&:id) + @import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count + @skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count + end + + def show + render_show + end + + def validate_source + totals = source_class.credentials_validator(source_params: permitted_params.to_h, import_types: import_types).perform + render json: { valid: true, totals: totals } + rescue ArgumentError => e + render_source_validation_error(e.message) + rescue StandardError => e + render_source_client_error(e) + end + + def create + @data_import = creation_service.perform + unless @data_import + render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity + return + end + + enqueue_import(@data_import) + render_show + rescue ArgumentError => e + render_source_validation_error(e.message) + rescue StandardError => e + render_source_client_error(e) + end + + def start + restart_service = DataImports::RestartService.new(account: Current.account, data_import: @data_import) + restart_result = restart_service.perform + @data_import = restart_service.data_import + if restart_result == :access_token_missing + render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity + return + end + + enqueue_import(@data_import) if restart_result == :enqueue + render_show + end + + def retry_import + retry_service = DataImports::RetryService.new(account: Current.account, data_import: @data_import) + retry_result = retry_service.perform + @data_import = retry_service.data_import + + case retry_result + when :enqueue + enqueue_import(@data_import) + render_show + when :not_stalled + render json: { message: "This #{source_name} import is no longer stalled." }, status: :unprocessable_entity + when :active_import_exists + render json: { message: "Another #{source_name} import is already in progress." }, status: :unprocessable_entity + when :access_token_missing + render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity + end + end + + def abandon + @data_import.abandon! + render_show + end + + def skip_logs + send_data( + skip_logs_csv, + filename: "data-import-#{@data_import.id}-skip-logs.csv", + type: 'text/csv' + ) + end + + def error_logs + send_data( + error_logs_csv, + filename: "data-import-#{@data_import.id}-error-logs.csv", + type: 'text/csv' + ) + end + + private + + def ensure_data_import_feature_enabled + raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE) + end + + def set_data_import + @data_import = Current.account.data_imports.find(params[:id]) + end + + def check_authorization + authorize(@data_import || DataImport) + end + + def permitted_params + params.permit(:name, :source_provider, :access_token, :domain, import_types: []) + end + + def creation_service + DataImports::CreationService.new( + account: Current.account, + initiated_by: Current.user, + source_params: permitted_params.to_h + ) + end + + def import_types + return DataImports::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types) + + Array(permitted_params[:import_types]).compact_blank + end + + def source_class + provider = @data_import&.source_provider || permitted_params[:source_provider] + DataImports::Source.source_class(provider) + end + + def source_name + return 'Integration' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider]) + + source_class::DISPLAY_NAME + end + + def credential_name + return 'credential' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider]) + + source_class.credential_name + end + + def enqueue_import(data_import) + DataImports::Source.source_class(data_import.source_provider).import_job_class.perform_later( + data_import, + data_import.active_import_run_id + ) + end + + def render_source_validation_error(message) + render json: { valid: false, message: message }, status: :unprocessable_entity + end + + def render_source_client_error(error) + raise error unless source_class.client_error?(error) + + message = if source_class.authentication_error?(error) + "We could not validate this #{source_name} #{credential_name}. Check the key and its permissions." + else + "#{source_name} could not be reached. Please try again." + end + render_source_validation_error(message) + end + + def render_show + @import_errors_finder = DataImportErrorFinder.new(@data_import) + @skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params) + render :show + end + + def skip_logs_csv + logs_csv(@data_import.import_errors.skip_logs) + end + + def error_logs_csv + logs_csv(@data_import.import_errors.non_skip_logs) + end + + def logs_csv(logs) + CSV.generate(headers: true) do |csv| + csv << %w[created_at kind source_object_type source_object_id error_code message details] + + logs.order(:created_at).find_each do |log| + csv << [ + log.created_at.iso8601, + log.details['kind'], + log.source_object_type, + log.source_object_id, + log.error_code, + log.message, + log.details.to_json + ] + end + end + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index c7d3e27371738..0ff50bbd0296d 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -2,14 +2,16 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] - before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox before_action :check_authorization, except: [:show] - include Api::V1::Accounts::Concerns::WhatsappHealthManagement + include Api::V1::Accounts::Concerns::InboxHealthManagement + include Api::V1::Accounts::Concerns::InboxSecretManagement 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 @@ -44,11 +46,20 @@ def create end def update - inbox_params = permitted_params.except(:channel, :csat_config) - inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? - @inbox.update!(inbox_params) - update_inbox_working_hours - update_channel if channel_update_required? + continue_update = false + + ActiveRecord::Base.transaction do + continue_update = update_branded_email_layout + raise ActiveRecord::Rollback unless continue_update + + inbox_params = permitted_params.except(:channel, :csat_config) + inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? + @inbox.update!(inbox_params) + update_inbox_working_hours + update_channel if channel_update_required? + end + + return unless continue_update end def agent_bot @@ -66,12 +77,6 @@ def set_agent_bot head :ok end - def reset_secret - return head :not_found unless @inbox.api? - - @inbox.channel.reset_secret! - end - def destroy ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } @@ -85,7 +90,7 @@ def fetch_inbox 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 @@ -123,8 +128,8 @@ def validate_and_update_email_channel(channel_attributes) end def reauthorize_and_update_channel(channel_attributes) - @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) + @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) end def update_channel_feature_flags @@ -154,6 +159,34 @@ def format_template_config(config, formatted) formatted['template'] = config['template'] if config['template'].present? end + def update_branded_email_layout + return true unless params.key?(:branded_email_layout) + + branded_email_layout = normalized_branded_email_layout + + unless Current.account.feature_enabled?(:branded_email_templates) + return true if branded_email_layout.blank? + + render_could_not_create_error('Branded email templates feature is not enabled') + return false + end + + unless @inbox.email? + return true if branded_email_layout.blank? + + render_could_not_create_error('Branded email layout is only supported for email inboxes') + return false + end + + @inbox.update_branded_email_layout!(branded_email_layout) + true + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.record.errors.full_messages.join(', ')) + false + end + + def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout] + def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index 053c297317359..d3f6bdba0e0cb 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -9,9 +9,9 @@ def create redirect_uri: "#{base_url}/instagram/callback", scope: REQUIRED_SCOPES.join(','), enable_fb_login: '0', - force_authentication: '1', + force_reauth: 'true', response_type: 'code', - state: generate_instagram_token(Current.account.id) + state: generate_instagram_token(Current.account.id, params[:return_to]) } ) if redirect_url diff --git a/app/controllers/api/v1/accounts/integrations/apps_controller.rb b/app/controllers/api/v1/accounts/integrations/apps_controller.rb index c2284bc2edcd4..c0629e07f2132 100644 --- a/app/controllers/api/v1/accounts/integrations/apps_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/apps_controller.rb @@ -15,5 +15,9 @@ def fetch_apps def fetch_app @app = Integrations::App.find(id: params[:id]) + return unless @app&.id == 'shopify' + + # Keep direct Shopify lookups subject to the same availability checks as the integrations list. + raise ActiveRecord::RecordNotFound unless @app.active?(Current.account) end end diff --git a/app/controllers/api/v1/accounts/integrations/base_controller.rb b/app/controllers/api/v1/accounts/integrations/base_controller.rb new file mode 100644 index 0000000000000..1301b3bba9586 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/base_controller.rb @@ -0,0 +1,11 @@ +class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController + private + + # Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy. + # Subclasses opt in per action with `before_action :check_authorization, only: [...]`. + def check_authorization + authorize(:hook) + end +end + +Api::V1::Accounts::Integrations::BaseController.prepend_mod_with('Api::V1::Accounts::Integrations::BaseController') diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e2de0..7bda1c8021833 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -15,7 +15,7 @@ def add_participant_to_meeting end render_response( - dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) ) end diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb index 087a9b78d3ea3..10ae00f4c833d 100644 --- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb @@ -1,6 +1,7 @@ -class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_hook, except: [:create] before_action :check_authorization + before_action :ensure_shopify_enabled, if: :shopify_hook? def create @hook = Current.account.hooks.create!(permitted_params) @@ -35,11 +36,15 @@ def fetch_hook @hook = Current.account.hooks.find(params[:id]) end - def check_authorization - authorize(:hook) - end - def permitted_params params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {}) end + + def shopify_hook? + action_name == 'create' ? permitted_params[:app_id] == 'shopify' : @hook.app_id == 'shopify' + end + + def ensure_shopify_enabled + head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account) + end end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 9ca0c72fdb264..93c05e1d3b94a 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,6 +1,7 @@ -class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] + before_action :check_authorization, only: [:destroy] def destroy revoke_linear_token @@ -28,7 +29,7 @@ def team_entities end def create_issue - issue = linear_processor_service.create_issue(permitted_params, Current.user) + issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb index ecf6bae6e1bb7..29343e4f54dd1 100644 --- a/app/controllers/api/v1/accounts/integrations/notion_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -1,5 +1,6 @@ -class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_hook, only: [:destroy] + before_action :check_authorization, only: [:destroy] def destroy @hook.destroy! diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb index 7fe31889b2620..c847a85df5a01 100644 --- a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb @@ -1,7 +1,8 @@ -class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController include Shopify::IntegrationHelper before_action :setup_shopify_context, only: [:orders] before_action :fetch_hook, except: [:auth] + before_action :check_authorization, only: [:destroy] before_action :validate_contact, only: [:orders] def auth diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb index eaa18c2de1527..c97725d53be87 100644 --- a/app/controllers/api/v1/accounts/integrations/slack_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -16,6 +16,8 @@ def create end def update + return update_message_mode if permitted_params[:message_mode].present? + @hook = channel_builder.update(permitted_params[:reference_id]) render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank? end @@ -35,7 +37,11 @@ def channel_builder Integrations::Slack::ChannelBuilder.new(hook: @hook) end + def update_message_mode + @hook.update!(settings: @hook.settings.merge('message_mode' => permitted_params[:message_mode])) + end + def permitted_params - params.permit(:reference_id) + params.permit(:reference_id, :message_mode) end end diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 54455943b0cc3..f678fee42dc14 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :fetch_label, except: [:index, :create] before_action :check_authorization @@ -18,7 +17,16 @@ def update 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/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index a300b5f593e6e..07decfde0296a 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -7,7 +7,9 @@ def create redirect_uri: "#{base_url}/microsoft/callback", scope: scope, state: state, - prompt: 'consent' + # Force the Microsoft account picker so an already-signed-in account does not + # silently authorize and re-bind to an existing inbox in the new-inbox flow. + prompt: 'select_account' } ) if redirect_url diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 52035ce64cf83..bb5c57ca6b3b2 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -41,9 +41,9 @@ def destroy 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 @@ def set_primary_actor 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/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb index feb218b59a80e..7fbca86da6867 100644 --- a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -8,7 +8,15 @@ def scope end def state - Current.account.to_sgid(expires_in: 15.minutes).to_s + # The sgid purpose doubles as a return hint: onboarding tags it so the callback + # can route the user back to inbox setup. The purpose is part of the signed + # payload (tamper-proof), and a non-onboarding request keeps the default + # purpose, leaving callers like Notion byte-identical. + Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s + end + + def state_purpose + params[:return_to] == 'onboarding' ? 'onboarding' : 'default' end def base_url diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb new file mode 100644 index 0000000000000..d7c49b35d3d9f --- /dev/null +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -0,0 +1,89 @@ +class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController + before_action :check_admin_authorization? + + ONBOARDING_STEP_KEY = 'onboarding_step'.freeze + STEP_ACCOUNT_DETAILS = 'account_details'.freeze + STEP_INBOX_SETUP = 'inbox_setup'.freeze + ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze + + def update + return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step]) + + @account = Current.account + # The client declares the step it is completing; `account_details` runs + # `complete_account_details`, and so on. The known-step guard above keeps the + # client value from `send`-ing an arbitrary method. + send("complete_#{params[:onboarding_step]}") + + render 'api/v1/accounts/update', format: :json + end + + def help_center_generation + render json: help_center_generation_status + end + + private + + def complete_account_details + # Only act while the cursor still points here, so a stale replay after + # onboarding finished can't re-enter it. + return unless current_step == STEP_ACCOUNT_DETAILS + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + + # inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded + # environment check); self-hosted finishes onboarding here. + if ChatwootApp.chatwoot_cloud? + move_to_step(STEP_INBOX_SETUP) + create_onboarding_inboxes + else + finish_onboarding + end + end + + def complete_inbox_setup + # Only finalize while the cursor still points here, so a stale or out-of-order + # request can't end onboarding early. Replays are no-ops. + return unless current_step == STEP_INBOX_SETUP + + finish_onboarding + end + + def current_step + @account.custom_attributes[ONBOARDING_STEP_KEY] + end + + def move_to_step(step) + @account.custom_attributes[ONBOARDING_STEP_KEY] = step + @account.save! + end + + def finish_onboarding + @account.custom_attributes.delete(ONBOARDING_STEP_KEY) + @account.save! + end + + def create_onboarding_inboxes + Onboarding::WebWidgetCreationService.new(@account, Current.user).perform + end + + def account_params + params.permit(:name, :locale) + end + + def custom_attributes_params + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) + end + + def help_center_generation_status + { + generation_id: nil, + state: nil, + articles_count: 0, + categories_count: 0 + } + end +end + +Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController') diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 972b244fada99..173def9ad6a31 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController before_action :fetch_portal, except: [:index, :create] before_action :check_authorization + before_action :validate_analytics_params, only: [:create, :update] before_action :set_current_page, only: [:index] def index @@ -18,11 +19,14 @@ def create @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 ActiveRecord::Base.transaction do + # Lock the row so concurrent saves merge onto the latest committed config + # instead of a stale snapshot, which would drop the other save's keys. + @portal.lock! @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? # @portal.custom_domain = parsed_custom_domain process_attached_logo if params[:blob_id].present? @@ -61,34 +65,65 @@ def send_instructions 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 def fetch_portal - @portal = Current.account.portals.find_by(slug: permitted_params[:id]) + @portal = Current.account.portals.find_by!(slug: permitted_params[:id]) end def permitted_params params.permit(:id, :email) end + def validate_analytics_params + analytics = params.dig(:portal, :config, :analytics) + return if analytics.blank? + + valid = analytics.respond_to?(:each_pair) && + analytics.keys.all? { |key| Portal::ANALYTICS_CONFIG_FORMATS.key?(key.to_s) } && + analytics.values.all?(String) + return if valid + + render json: { error: I18n.t('portals.analytics.invalid_configuration') }, status: :unprocessable_entity + end + def portal_params params.require(:portal).permit( :id, :color, :custom_domain, :header_text, :homepage_link, - :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] } + :name, :page_title, :slug, :archived, + { config: config_param_keys } ) end + def config_param_keys + keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, + { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }, + { popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] + # Analytics injects tracking scripts into every public page, so keep it admin-only even though + # Enterprise lets knowledge_base_manage roles edit other portal settings. + keys << { analytics: Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym) } if Current.account_user&.administrator? + keys + end + + def locale_translation_keys + params.dig(:portal, :config, :locale_translations)&.keys || [] + end + + def popular_content_keys + params.dig(:portal, :config, :popular_content)&.keys || [] + end + def live_chat_widget_params permitted_params = params.permit(:inbox_id) 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 +134,8 @@ def set_current_page 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/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index e8688dcfb125c..6239e00eba3f9 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -29,6 +29,6 @@ def fetch_team end def team_params - params.require(:team).permit(:name, :description, :allow_auto_assign) + params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color) end end diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb index 7c73203931aa7..1f19609e2c979 100644 --- a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -1,9 +1,11 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include Tiktok::IntegrationHelper + before_action :ensure_tiktok_enabled + def create redirect_url = Tiktok::AuthClient.authorize_url( - state: generate_tiktok_token(Current.account.id) + state: generate_tiktok_token(Current.account.id, params[:return_to]) ) if redirect_url @@ -12,4 +14,10 @@ def create render json: { success: false }, status: :unprocessable_entity end end + + private + + def ensure_tiktok_enabled + raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_tiktok') + end end diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index d52f396fcff70..089964a293015 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + before_action :ensure_embedded_signup_enabled + # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. + before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } # POST /api/v1/accounts/:account_id/whatsapp/authorization @@ -8,16 +11,26 @@ def create validate_embedded_signup_params! channel = process_embedded_signup render_success_response(channel.inbox) - rescue StandardError => e + rescue CustomExceptions::Inbox::LimitExceeded => e render_error_response(e) + rescue StandardError => e + render_embedded_signup_error(e) end private + def ensure_embedded_signup_enabled + return if params[:inbox_id].present? + return unless ChatwootApp.chatwoot_cloud? + return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation') + + raise Pundit::NotAuthorizedError + end + def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, - params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys, + params: params.permit(:code, :business_id, :waba_id, :phone_number_id, :is_coexistence).to_h.symbolize_keys, inbox_id: params[:inbox_id] ) service.perform @@ -29,7 +42,7 @@ def fetch_and_validate_inbox end def validate_reauthorization_required - return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? + return if @inbox.channel.reauthorization_required? || can_reconfigure_channel? render json: { success: false, @@ -37,11 +50,9 @@ def validate_reauthorization_required }, status: :unprocessable_entity end - def can_upgrade_to_embedded_signup? + def can_reconfigure_channel? channel = @inbox.channel - return false unless channel.provider == 'whatsapp_cloud' - - true + channel.provider == 'whatsapp_cloud' end def render_success_response(inbox) @@ -55,7 +66,7 @@ def render_success_response(inbox) render json: response end - def render_error_response(error) + def render_embedded_signup_error(error) Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}" Rails.logger.error error.backtrace.join("\n") render json: { @@ -67,7 +78,6 @@ def render_error_response(error) def validate_embedded_signup_params! missing_params = [] missing_params << 'code' if params[:code].blank? - missing_params << 'business_id' if params[:business_id].blank? missing_params << 'waba_id' if params[:waba_id].blank? return if missing_params.empty? diff --git a/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb new file mode 100644 index 0000000000000..5b4cf20cb75fb --- /dev/null +++ b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb @@ -0,0 +1,80 @@ +class Api::V1::Accounts::Whatsapp::ManualSetupController < Api::V1::Accounts::BaseController + before_action :authorize_create, only: [:preview, :connect] + before_action :fetch_inbox, only: [:webhook_status, :setup_webhook] + + def preview + render json: validation_service.perform + rescue StandardError => e + render_setup_error(e) + end + + def connect + setup = Whatsapp::ManualSetupService.new(account: Current.account, **connect_params.to_h.symbolize_keys).perform + render json: connection_response(setup), status: :created + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) + rescue StandardError => e + render_setup_error(e) + end + + def webhook_status + render json: Whatsapp::ManualWebhookStatusService.new(@inbox.channel).perform + rescue StandardError => e + render_setup_error(e) + end + + def setup_webhook + channel = @inbox.channel + webhook_setup = Whatsapp::WebhookSetupService.new(channel) + webhook_setup.perform + raise webhook_setup.registration_error if webhook_setup.registration_error + + render json: Whatsapp::ManualWebhookStatusService.new(channel.reload).perform + rescue StandardError => e + render_setup_error(e) + end + + private + + def authorize_create + authorize ::Inbox, :create? + end + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :update? + channel = @inbox.channel + return if channel.is_a?(Channel::Whatsapp) && channel.provider_config['source'] == 'manual_setup_v2' + + raise ActiveRecord::RecordNotFound + end + + def validation_service + Whatsapp::ManualSetupValidationService.new(**connection_params.to_h.symbolize_keys) + end + + def connection_params + params.permit(:waba_id, :phone_number_id, :access_token) + end + + def connect_params + params.permit(:waba_id, :phone_number_id, :access_token, :inbox_name) + end + + def connection_response(setup) + channel = setup.channel.reload + { + id: channel.inbox.id, + name: channel.inbox.name, + number_access: true, + template_access: true, + webhook_setup: setup.webhook_setup?, + webhook_error: setup.webhook_error + } + end + + def render_setup_error(error) + Rails.logger.error "[WHATSAPP MANUAL SETUP] account_id=#{Current.account.id} error=#{error.class}: #{error.message}" + render json: { message: error.message }, status: :unprocessable_entity + end +end diff --git a/app/controllers/api/v1/accounts/working_hours_controller.rb b/app/controllers/api/v1/accounts/working_hours_controller.rb deleted file mode 100644 index 96d98293ab2c3..0000000000000 --- a/app/controllers/api/v1/accounts/working_hours_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController - before_action :check_authorization - before_action :fetch_webhook, only: [:update] - - def update - @working_hour.update!(working_hour_params) - end - - private - - def working_hour_params - params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day) - end - - def fetch_working_hour - @working_hour = Current.account.working_hours.find(params[:id]) - end -end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 7176d6e1bb25b..dcb41d614a3f2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,6 +1,5 @@ class Api::V1::AccountsController < Api::BaseController include AuthHelper - include CacheKeysHelper skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, only: [:create], raise: false @@ -8,6 +7,7 @@ class Api::V1::AccountsController < Api::BaseController before_action :ensure_account_name, only: [:create] before_action :validate_captcha, only: [:create] before_action :fetch_account, except: [:create] + before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create] before_action :check_authorization, except: [:create] rescue_from CustomExceptions::Account::InvalidEmail, @@ -17,7 +17,7 @@ class Api::V1::AccountsController < Api::BaseController with: :render_error_response def show - @latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION) + @latest_chatwoot_version = latest_chatwoot_version render 'api/v1/accounts/show', format: :json end @@ -51,7 +51,7 @@ def create def cache_keys expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes - render json: { cache_keys: cache_keys_for_account }, status: :ok + render json: { cache_keys: @account.cache_keys }, status: :ok end def update @@ -70,10 +70,15 @@ def update_active_at private + def latest_chatwoot_version + Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION) + end + 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 @@ -91,25 +96,23 @@ def ensure_account_name raise CustomExceptions::Account::InvalidParams.new({}) end - def cache_keys_for_account - { - label: fetch_value_for_key(params[:id], Label.name.underscore), - inbox: fetch_value_for_key(params[:id], Inbox.name.underscore), - team: fetch_value_for_key(params[:id], Team.name.underscore) - } - end - def fetch_account @account = current_user.accounts.find(params[:id]) @current_account_user = @account.account_users.find_by(user_id: current_user.id) end + def validate_token_api_access + return if @account.api_and_webhooks_enabled? + + render json: { error: 'API access is not enabled for this account' }, status: :forbidden + end + def account_params params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name) 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/integrations/webhooks_controller.rb b/app/controllers/api/v1/integrations/webhooks_controller.rb index 283be3d775d62..35e1d0bbde5cd 100644 --- a/app/controllers/api/v1/integrations/webhooks_controller.rb +++ b/app/controllers/api/v1/integrations/webhooks_controller.rb @@ -1,4 +1,8 @@ class Api::V1::Integrations::WebhooksController < ApplicationController + SIGNATURE_TOLERANCE = 5.minutes.to_i + + prepend_before_action :verify_slack_signature!, only: [:create] + def create builder = Integrations::Slack::IncomingMessageBuilder.new(permitted_params) response = builder.perform @@ -7,6 +11,35 @@ def create private + # Skip (rather than reject) when no secret is configured, so existing installs keep working. + def verify_slack_signature! + secret = slack_signing_secret + if secret.blank? + Rails.logger.warn('[SLACK] SLACK_SIGNING_SECRET not configured; skipping webhook signature verification') + return + end + + head :unauthorized unless valid_slack_signature?(secret) + end + + # Config reconciliation creates a blank row for this key on upgrade, and that blank row + # makes GlobalConfigService skip its ENV fallback, so read ENV directly as a last resort. + def slack_signing_secret + GlobalConfigService.load('SLACK_SIGNING_SECRET', nil).presence || ENV.fetch('SLACK_SIGNING_SECRET', nil) + end + + def valid_slack_signature?(secret) + timestamp = request.headers['X-Slack-Request-Timestamp'] + signature = request.headers['X-Slack-Signature'] + return false if timestamp.blank? || signature.blank? + return false if (Time.current.to_i - timestamp.to_i).abs > SIGNATURE_TOLERANCE + + # Build over raw bytes so a payload with invalid UTF-8 can't raise on interpolation. + basestring = "v0:#{timestamp}:".b << request.raw_post + expected = "v0=#{OpenSSL::HMAC.hexdigest('SHA256', secret, basestring)}" + ActiveSupport::SecurityUtils.secure_compare(expected, signature) + end + # TODO: This is a temporary solution to permit all params for slack unfurling job. # We should only permit the params that we use. Handle all the params based on events and send it to the respective services. def permitted_params diff --git a/app/controllers/api/v1/notification_subscriptions_controller.rb b/app/controllers/api/v1/notification_subscriptions_controller.rb index 1a797a74dec40..bb20c64aeb1e1 100644 --- a/app/controllers/api/v1/notification_subscriptions_controller.rb +++ b/app/controllers/api/v1/notification_subscriptions_controller.rb @@ -8,7 +8,8 @@ def create 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 dd874f2222ecc..8480b64fbe1f9 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 @@ def check_mfa_disabled 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 @@ def validate_password 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/profile/sessions_controller.rb b/app/controllers/api/v1/profile/sessions_controller.rb new file mode 100644 index 0000000000000..72e9451ebda41 --- /dev/null +++ b/app/controllers/api/v1/profile/sessions_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Profile::SessionsController < Api::BaseController + before_action :set_session, only: [:destroy] + + def index + @sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc) + @current_client_id = request.headers['client'] + end + + def destroy + if @session.current?(request.headers['client']) + render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity + return + end + + revoke_token!(@session.client_id) + @session.destroy! + head :ok + end + + private + + def set_session + @session = current_user.user_sessions.find(params[:id]) + end + + def revoke_token!(client_id) + tokens = current_user.tokens + tokens.delete(client_id) + current_user.update!(tokens: tokens) + end + + def active_token_client_ids + now = Time.current.to_i + (current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys + end +end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 5b87e2d1a7348..90f3728fbf5ce 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -29,7 +29,6 @@ def inbox end def conversation_params - # FIXME: typo referrer in additional attributes, will probably require a migration. { account_id: inbox.account_id, inbox_id: inbox.id, @@ -59,6 +58,10 @@ def contact_phone_number permitted_params.dig(:contact, :phone_number) end + def contact_custom_attributes + permitted_params.dig(:contact, :custom_attributes)&.to_h + end + def browser_params { browser_name: browser.name, diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 6c595ab5985b3..9a7d5193ab108 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController include WidgetHelper before_action :validate_hmac, only: [:set_user] + before_action :validate_hmac_for_identified_update, only: [:update] def show; end @@ -46,6 +47,16 @@ def a_different_contact? @contact.identifier.present? && @contact.identifier != permitted_params[:identifier] end + # The plain update endpoint is also used for anonymous prechat updates + # (name/email/phone/custom_attributes with no identifier), which must keep + # working on hmac_mandatory inboxes. Only the identity-binding path, where an + # identifier is supplied and the contact can be rebound, requires HMAC. + def validate_hmac_for_identified_update + return if params[:identifier].blank? + + validate_hmac + end + def validate_hmac return unless should_verify_hmac? @@ -62,11 +73,15 @@ def should_verify_hmac? end def valid_hmac? - params[:identifier_hash] == OpenSSL::HMAC.hexdigest( + expected_hash = OpenSSL::HMAC.hexdigest( 'sha256', @web_widget.hmac_token, params[:identifier].to_s ) + identifier_hash = params[:identifier_hash].to_s + return false unless identifier_hash.bytesize == expected_hash.bytesize + + ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash) end def permitted_params diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 00e7186141fd1..8f5977d54f03a 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -19,7 +19,7 @@ def create def process_update_contact @contact = ContactIdentifyAction.new( contact: @contact, - params: { email: contact_email, phone_number: contact_phone_number, name: contact_name }, + params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes }, retain_original_contact_name: true, discard_invalid_attrs: true ).perform @@ -95,7 +95,7 @@ def render_not_found_if_empty end def permitted_params - params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], + params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }], message: [:content, :referer_url, :timestamp, :echo_id], custom_attributes: {}) end diff --git a/app/controllers/api/v1/widget/direct_uploads_controller.rb b/app/controllers/api/v1/widget/direct_uploads_controller.rb index a6abdb3e17522..5203972a3e423 100644 --- a/app/controllers/api/v1/widget/direct_uploads_controller.rb +++ b/app/controllers/api/v1/widget/direct_uploads_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController + skip_before_action :verify_authenticity_token include WebsiteTokenHelper before_action :set_web_widget before_action :set_contact diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3cac2a..fde425b2601e2 100644 --- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -10,7 +10,8 @@ def add_participant_to_meeting response = dyte_processor_service.add_participant_to_meeting( @message.content_attributes['data']['meeting_id'], - @conversation.contact + @conversation.contact, + @message ) render_response(response) end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 83b3dc8b11ad7..c4fd9c82027a5 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController + before_action :prevent_reply_to_resolved_conversation, only: [:create] before_action :set_conversation, only: [:create] before_action :set_message, only: [:update] @@ -42,8 +43,17 @@ def build_attachment end end + # Hiding the reply box does not stop requests reaching this endpoint, so the setting is + # enforced here. + def prevent_reply_to_resolved_conversation + return unless conversation&.resolved? + return if inbox.allow_messages_after_resolved + + render json: { error: I18n.t('errors.conversations.resolved') }, status: :forbidden + end + def set_conversation - return unless conversation.nil? + return if conversation.present? @conversation = create_conversation apply_labels if permitted_params[:labels].present? @@ -83,6 +93,10 @@ def permitted_params 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/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 192b3619ce144..93be19eb97cb2 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -51,6 +51,13 @@ def conversation_traffic generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic') end + def drilldown + return head :unauthorized unless Current.account_user.administrator? + return head :unprocessable_entity unless valid_drilldown_params? + + render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build + end + def conversations return head :unprocessable_entity if params[:type].blank? @@ -133,6 +140,22 @@ def report_params }) end + def drilldown_params + permitted_params = params.permit( + :metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page + ).to_h.symbolize_keys + permitted_params.merge( + type: (params[:type].presence || 'account').to_sym, + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + ) + end + + def valid_drilldown_params? + %i[metric bucket_timestamp since until].all? { |param| params[param].present? } && + Reports::ReportMetricRegistry.supported?(params[:metric]) && + V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params) + end + def conversation_params { type: params[:type].to_sym, diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 98b3f05d76983..40d5947b97457 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 @@ def prepare_builder_params } 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/application_controller.rb b/app/controllers/application_controller.rb index 2f389049de1d2..9dea4b4da3b6c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include RequestExceptionHandler include Pundit::Authorization include SwitchLocale + include TrackSessionActivity skip_before_action :verify_authenticity_token diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index b7fc14e74fc99..fb52a8eeb6769 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,8 +1,9 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes], + 'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations/messages' => ['create'], - 'api/v1/accounts/conversations/assignments' => ['create'] + 'api/v1/accounts/conversations/assignments' => ['create'], + 'api/v1/accounts/conversations/labels' => %w[index create] }.freeze def ensure_access_token diff --git a/app/controllers/concerns/conversation_custom_attributes_concern.rb b/app/controllers/concerns/conversation_custom_attributes_concern.rb new file mode 100644 index 0000000000000..835395e0cb704 --- /dev/null +++ b/app/controllers/concerns/conversation_custom_attributes_concern.rb @@ -0,0 +1,15 @@ +module ConversationCustomAttributesConcern + def custom_attributes + attributes = params.permit(custom_attributes: {})[:custom_attributes] + # When `merge` is truthy, only the keys sent are updated and the rest are kept, matching the contacts endpoint. + # Replace stays the default so existing integrations are unaffected. + attributes = @conversation.custom_attributes.merge(attributes || {}) if ActiveModel::Type::Boolean.new.cast(params[:merge]) + @conversation.custom_attributes = attributes + @conversation.save! + end + + def destroy_custom_attributes + @conversation.custom_attributes = @conversation.custom_attributes.excluding(params[:custom_attributes]) + @conversation.save! + end +end diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index ea36a48f26483..7ed39fa981aba 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -14,6 +14,8 @@ def ensure_current_account account_accessible_for_user?(account) elsif @resource.is_a?(AgentBot) account_accessible_for_bot?(account) + else + render_unauthorized(I18n.t('errors.account.not_authorized')) end account end @@ -21,7 +23,7 @@ def ensure_current_account def account_accessible_for_user?(account) @current_account_user = account.account_users.find_by(user_id: current_user.id) Current.account_user = @current_account_user - render_unauthorized('You are not authorized to access this account') unless @current_account_user + render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user end def account_accessible_for_bot?(account) diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb index 04de9e63cb0b5..8c76f64540799 100644 --- a/app/controllers/concerns/instagram_concern.rb +++ b/app/controllers/concerns/instagram_concern.rb @@ -7,7 +7,7 @@ def instagram_client client_secret, { site: 'https://api.instagram.com', - authorize_url: 'https://api.instagram.com/oauth/authorize', + authorize_url: 'https://www.instagram.com/oauth/authorize', token_url: 'https://api.instagram.com/oauth/access_token', auth_scheme: :request_body, token_method: :post @@ -38,13 +38,7 @@ def exchange_for_long_lived_token(short_lived_token) end def fetch_instagram_user_details(access_token) - endpoint = 'https://graph.instagram.com/v22.0/me' - params = { - fields: 'id,username,user_id,name,profile_picture_url,account_type', - access_token: access_token - } - - make_api_request(endpoint, params, 'Failed to fetch Instagram user details') + Instagram::UserDetailsService.new(access_token: access_token).perform end def make_api_request(endpoint, params, error_prefix) diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb index b3f920644f05f..42fe918ccecd2 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 @@ def verify 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/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb new file mode 100644 index 0000000000000..71eaad081f64f --- /dev/null +++ b/app/controllers/concerns/portal_home_data.rb @@ -0,0 +1,65 @@ +module PortalHomeData + extend ActiveSupport::Concern + + private + + def load_home_data + load_recommended_content + # The classic hero only needs the recommendations above; the rest is + # documentation-layout home data (also used on custom-domain home pages). + return unless @portal.layout == 'documentation' + + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @recommended_categories.presence || @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def load_recommended_content + @recommended_categories = recommended_categories + @recommended_articles = recommended_articles + end + + def base_articles + @base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category) + end + + # Admin-recommended categories for the locale, in the chosen order. Unlike the + # position-based fallback, published articles aren't required: the admin's pick wins. + def recommended_categories + ids = @portal.popular_category_ids(@locale) + ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids) + end + + # Admin-recommended articles for the locale, in the chosen order, limited to + # published articles that still exist. + def recommended_articles + ids = @portal.popular_article_ids(@locale) + ordered_by_ids(base_articles.where(id: ids), ids) + end + + # Loads the scope and returns its records ordered to match `ids`, dropping any + # that no longer exist. Skips the query entirely when `ids` is blank. + def ordered_by_ids(scope, ids) + return [] if ids.blank? + + by_id = scope.index_by(&:id) + ids.filter_map { |id| by_id[id] } + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end +end diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index ccab0090a44a7..b132f7eeb6e82 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -1,8 +1,17 @@ module RequestExceptionHandler extend ActiveSupport::Concern + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [ + 'ActiveRecord::QueryCanceled', + 'PG::QueryCanceled', + 'canceling statement due to statement timeout' + ].freeze + included do rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + rescue_from CustomExceptions::Inbox::LimitExceeded, + CustomExceptions::Account::EmailLimitExceeded, + with: :render_error_response end private @@ -18,6 +27,9 @@ def handle_with_exception rescue ActionController::ParameterMissing => e log_handled_error(e) render_could_not_create_error(e.message) + rescue ActiveRecord::QueryCanceled => e + log_handled_error(e) + render_could_not_create_error(database_query_canceled_message) ensure # to address the thread variable leak issues in Puma/Thin webserver Current.reset @@ -31,8 +43,8 @@ def render_not_found_error(message) render json: { error: message }, status: :not_found end - def render_could_not_create_error(message) - render json: { error: message }, status: :unprocessable_entity + def render_could_not_create_error(error) + render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity end def render_payment_required(message) @@ -59,4 +71,19 @@ def render_error_response(exception) def log_handled_error(exception) logger.info("Handled error: #{exception.inspect}") end + + def sanitized_error_message(message) + return database_query_canceled_message if database_query_canceled_message?(message) + + message + end + + def database_query_canceled_message?(message) + error_message = message.to_s + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) } + end + + def database_query_canceled_message + I18n.t('errors.database.query_canceled') + end end diff --git a/app/controllers/concerns/track_session_activity.rb b/app/controllers/concerns/track_session_activity.rb new file mode 100644 index 0000000000000..f6a512922c548 --- /dev/null +++ b/app/controllers/concerns/track_session_activity.rb @@ -0,0 +1,22 @@ +module TrackSessionActivity + extend ActiveSupport::Concern + + included do + after_action :update_session_activity + end + + private + + def update_session_activity + return unless current_user + return if request.headers['client'].blank? + + UserSessionTrackingService.new( + user: current_user, + request: request, + client_id: request.headers['client'] + ).update_activity! + rescue StandardError => e + Rails.logger.warn "Session activity update failed: #{e.message}" + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d57ad0e5364ab..1dc1921b952d3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ class DashboardController < ActionController::Base include SwitchLocale + include PortalHomeData GLOBAL_CONFIG_KEYS = %w[ LOGO @@ -22,6 +23,8 @@ class DashboardController < ActionController::Base HCAPTCHA_SITE_KEY LOGOUT_REDIRECT_LINK DISABLE_USER_PROFILE_UPDATE + DISABLE_META_INBOX_CREATION + DISABLE_META_MESSAGE_SENDING DEPLOYMENT_ENV INSTALLATION_PRICING_PLAN ].freeze @@ -63,6 +66,8 @@ def render_hc_if_custom_domain return unless @portal @locale = @portal.default_locale + request.variant = :documentation if @portal.layout == 'documentation' + load_home_data render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end @@ -80,10 +85,17 @@ def app_config 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/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index 2c8387142f9bc..1086de0543c24 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -52,13 +52,17 @@ def sign_up_user end def login_page_url(error: nil, email: nil, sso_auth_token: nil) - frontend_url = ENV.fetch('FRONTEND_URL', nil) + frontend_url = omniauth_frontend_url params = { email: email, sso_auth_token: sso_auth_token }.compact params[:error] = error if error.present? "#{frontend_url}/app/login?#{params.to_query}" end + def omniauth_frontend_url + ENV.fetch('FRONTEND_URL', nil) + end + def account_signup_allowed? GlobalConfigService.account_signup_enabled? end diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 974fb05e487ae..76429d46fea5a 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -2,6 +2,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController # Prevent session parameter from being passed # Unpermitted parameter: session wrap_parameters format: [] + # DTA's params_for_resource copies these headers into params during super. + # Mirror that up front so every pre-authentication check in create sees the + # same credentials a header-only request would authenticate with. + before_action :merge_credential_headers, only: [:create] before_action :process_sso_auth_token, only: [:create] def new @@ -14,17 +18,32 @@ def create user = find_user_for_authentication return handle_mfa_required(user) if user&.mfa_enabled? + return if user && enforce_session_limit_for_password_login(user) # Only proceed with standard authentication if no MFA is required super end def render_create_success + track_user_session unless @impersonation render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } end 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 merge_credential_headers + params[:email] ||= request.headers['email'] unless request.headers['email'].nil? + params[:password] ||= request.headers['password'] unless request.headers['password'].nil? + end + def find_user_for_authentication return nil unless params[:email].present? && params[:password].present? @@ -45,6 +64,8 @@ def sso_authentication_request? end def handle_sso_authentication + return if !@impersonation && enforce_session_limit_for_password_login(@resource) + authenticate_resource_with_sso_token yield @resource if block_given? render_create_success @@ -57,7 +78,10 @@ def login_page_url(error: nil) end def authenticate_resource_with_sso_token - @token = @resource.create_token + # DTA evicts the earliest-expiring token after save when at max_number_of_devices. + # The short-lived impersonation token would always be that one, so pre-evict to make room. + make_room_for_impersonation_token if @impersonation + @token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil) @resource.save! sign_in(:user, @resource, store: false, bypass: false) @@ -65,11 +89,21 @@ def authenticate_resource_with_sso_token @resource.invalidate_sso_auth_token(params[:sso_auth_token]) end + def make_room_for_impersonation_token + return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices + + oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first + @resource.tokens.delete(oldest_client_id) if oldest_client_id + end + def process_sso_auth_token return if params[:email].blank? user = User.from_email(params[:email]) - @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) + return unless user&.valid_sso_auth_token?(params[:sso_auth_token]) + + @resource = user + @impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token]) end def handle_mfa_required(user) @@ -95,6 +129,7 @@ def handle_mfa_verification end def sign_in_mfa_user(user) + evict_oldest_session(user) if sessions_limit_reached?(user) @resource = user @token = @resource.create_token @resource.save! @@ -106,6 +141,104 @@ def sign_in_mfa_user(user) def render_mfa_error(message_key, status = :bad_request) render json: { error: I18n.t(message_key) }, status: status end + + def sessions_limit_reached?(user) + limit = ENV.fetch('MAX_USER_SESSIONS', 25).to_i + active_token_count(user) >= limit + end + + def active_token_count(user) + now = Time.current.to_i + (user.tokens || {}).count { |_, v| v['expiry'].to_i > now } + end + + # Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients + # auto-evict instead of getting stuck on a UI they can't render. + def enforce_session_limit_for_password_login(user) + if revoking_sessions? + revoke_sessions_for_login(user) + return false + end + + return false unless sessions_limit_reached?(user) + + # Picker only when every token has a tracked session; partial tracking would + # show a misleading count, so fall through to silent eviction instead. + if browser_request? && user.user_sessions.count >= user.tokens.size + handle_sessions_limit_for_login(user) + true + else + evict_oldest_session(user) + false + end + end + + def browser_request? + request.user_agent.to_s.include?('Mozilla') + end + + def revoking_sessions? + params[:revoke_session_id].present? || params[:revoke_all_sessions].present? + end + + def revoke_sessions_for_login(user) + if params[:revoke_all_sessions].present? + user.tokens = {} + user.save! + user.user_sessions.destroy_all + elsif params[:revoke_session_id].present? + session = user.user_sessions.find_by(id: params[:revoke_session_id]) + return unless session + + user.tokens.delete(session.client_id) + user.save! + session.destroy! + end + end + + def evict_oldest_session(user) + # Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted. + return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size + + oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first + return evict_oldest_token(user) unless oldest_session + + user.tokens.delete(oldest_session.client_id) + user.save! + oldest_session.destroy! + end + + # Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped). + def evict_oldest_token(user) + return if user.tokens.blank? + + oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first + return unless oldest_client_id + + user.tokens.delete(oldest_client_id) + user.save! + end + + PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version + ip_address city country last_activity_at created_at].freeze + + def handle_sessions_limit_for_login(user) + sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) } + render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict + end + + def track_user_session + client_id = @token&.try(:client) || response.headers['client'] + return unless client_id.present? && @resource.present? + + UserSessionTrackingService.new( + user: @resource, + request: request, + client_id: client_id + ).create_or_update! + rescue StandardError => e + Rails.logger.warn "Session tracking failed: #{e.message}" + end end DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb index 4dc8ece1c0381..671959ae44855 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -11,6 +11,8 @@ def show end process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -28,6 +30,8 @@ def process_successful_authorization @long_lived_token_response = exchange_for_long_lived_token(@response.token) inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -45,6 +49,14 @@ def handle_error(error) redirect_to_error_page(error_info) end + def handle_limit_error(error) + redirect_to_error_page( + 'error_type' => error.class.name, + 'code' => Rack::Utils.status_code(error.http_status), + 'error_message' => error.message + ) + end + # Extract error details from the exception def extract_error_info(error) if error.is_a?(OAuth2::Error) @@ -114,11 +126,10 @@ def update_channel(channel_instagram, user_details) channel_instagram.update!( access_token: @long_lived_token_response['access_token'], - expires_at: expires_at + expires_at: expires_at, + provider_name: user_details['username'] ) - # Update inbox name if username changed - channel_instagram.inbox.update!(name: user_details['username']) channel_instagram end @@ -130,7 +141,8 @@ def create_channel_with_inbox(user_details) access_token: @long_lived_token_response['access_token'], instagram_id: user_details['user_id'].to_s, account: account, - expires_at: expires_at + expires_at: expires_at, + provider_name: user_details['username'] ) account.inboxes.create!( @@ -149,6 +161,10 @@ def account_id verify_instagram_token(params[:state]) end + def return_to + instagram_token_return_to(params[:state]) + end + def oauth_code params[:code] end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 2f07505fcd9dd..045789f75e9b9 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -14,4 +14,11 @@ def provider_name def imap_address 'outlook.office365.com' end + + # Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field; + # it must match the token's UPN. `preferred_username` is the documented v2.0 claim; + # `upn` is the v1.0 fallback. + def imap_login_identity + users_data['preferred_username'] || users_data['upn'] || super + end end diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index be0fa50085213..4a3d049a684af 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -16,6 +16,8 @@ def show def handle_response inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding' + if already_exists redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) else @@ -44,7 +46,7 @@ def find_channel_by_email def update_channel(channel_email) channel_email.update!({ - imap_login: users_data['email'], imap_address: imap_address, + imap_login: imap_login_identity, imap_address: imap_address, imap_port: '993', imap_enabled: true, provider: provider_name, provider_config: { @@ -55,6 +57,13 @@ def update_channel(channel_email) }) end + # Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the + # id_token's email claim; providers override when their server requires a different + # claim (e.g. Microsoft SMTP requires UPN). + def imap_login_identity + users_data['email'] + end + def provider_name raise NotImplementedError end @@ -81,10 +90,19 @@ def users_data decoded_token[0] end + # The sgid purpose carries the onboarding return hint (see + # OauthAuthorizationController#state). Try the onboarding purpose first — a match + # both resolves the account and records the return target — then fall back to the + # default purpose used by every other caller. def account_from_signed_id raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? - account = GlobalID::Locator.locate_signed(params[:state]) + if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) + @return_to = 'onboarding' + else + account = GlobalID::Locator.locate_signed(params[:state]) + end + raise 'Invalid or expired state' if account.nil? account @@ -94,6 +112,11 @@ def account @account ||= account_from_signed_id end + def return_to + account # resolving the sgid records which purpose matched + @return_to + end + # Fallback name, for when name field is missing from users_data def fallback_name users_data['email'].split('@').first.parameterize.titleize diff --git a/app/controllers/platform/api/v1/agent_bots_controller.rb b/app/controllers/platform/api/v1/agent_bots_controller.rb index dd70a1ba5e96a..594bb856e6f6c 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/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 835c2596bfe8d..764e02a729253 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -18,7 +18,8 @@ def update contact: @contact_inbox.contact, params: permitted_params.to_h.deep_symbolize_keys.except(:identifier) ) - render json: contact_identify_action.perform + contact_identify_action.perform + @contact_inbox.reload end private @@ -35,11 +36,15 @@ def process_hmac end def valid_hmac? - params[:identifier_hash] == OpenSSL::HMAC.hexdigest( + expected_hash = OpenSSL::HMAC.hexdigest( 'sha256', @inbox_channel.hmac_token, params[:identifier].to_s ) + identifier_hash = params[:identifier_hash].to_s + return false unless identifier_hash.bytesize == expected_hash.bytesize + + ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash) end def permitted_params diff --git a/app/controllers/public/api/v1/inboxes_controller.rb b/app/controllers/public/api/v1/inboxes_controller.rb index 65fad57b1af49..5679a4a3e5900 100644 --- a/app/controllers/public/api/v1/inboxes_controller.rb +++ b/app/controllers/public/api/v1/inboxes_controller.rb @@ -24,6 +24,10 @@ def set_contact_inbox 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/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 2bbfafcc7af86..6a83d08fb7ffb 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,9 +1,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController - before_action :ensure_custom_domain_request, only: [:show, :index] + before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] - before_action :set_article, only: [:show] + before_action :set_article, only: [:show, :show_markdown] layout 'portal' def index @@ -21,6 +23,13 @@ def index def show @og_image_url = helpers.set_og_image_url(@portal.name, @article.title) + @parsed_content = render_article_content(@article.content.to_s) + end + + def show_markdown + return head :not_found unless @article&.published? + + render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8' end def tracking_pixel @@ -62,7 +71,6 @@ def order_by_sort_param def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content.to_s) end def set_category diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 46158bce97fbf..7b592fbd8bb06 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController around_action :set_locale after_action :allow_iframe_requests + PORTAL_LAYOUTS = %w[classic documentation].freeze + private def show_plain_layout @@ -17,6 +19,18 @@ def set_color_scheme @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme]) end + def set_portal_layout + @portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic' + end + + def set_view_variant + request.variant = if @is_plain_layout_enabled + :plain + elsif @portal_layout == 'documentation' + :documentation + end + end + def portal @portal ||= Portal.find_by!(slug: params[:slug], archived: false) end @@ -29,9 +43,11 @@ def set_locale(&) end def switch_locale_with_portal(&) - @locale = validate_and_get_locale(params[:locale]) + # Keep @locale as the portal's own locale code (e.g. th_TH) for content queries, + # while UI translations fall back to an available I18n locale (e.g. th). + @locale = params[:locale] - I18n.with_locale(@locale, &) + I18n.with_locale(validate_and_get_locale(@locale), &) end def switch_locale_with_article(&) @@ -39,13 +55,12 @@ def switch_locale_with_article(&) Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - article_locale = if article.category.present? - article.category.locale - else - article.portal.default_locale - end - @locale = validate_and_get_locale(article_locale) - I18n.with_locale(@locale, &) + @locale = if article.category.present? + article.category.locale + else + article.locale + end + I18n.with_locale(validate_and_get_locale(@locale), &) end def allow_iframe_requests @@ -54,6 +69,8 @@ def allow_iframe_requests def render_404 portal + # set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less + set_view_variant render 'public/api/v1/portals/error/404', status: :not_found end diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index 3fb2002694f65..d1ebe92c60f52 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,12 +1,18 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, only: [:show] + before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def index - @categories = @portal.categories.order(position: :asc) + respond_to do |format| + format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently } + format.json { @categories = @portal.categories.order(position: :asc) } + end end def show @@ -21,4 +27,9 @@ def set_category Rails.logger.info "Category: not found for slug: #{params[:category_slug]}" render_404 && return if @category.blank? end + + def load_category_articles + @articles = @category.articles.published.order(:position).includes(:author) + @category_authors = @articles.filter_map(&:author).uniq + end end diff --git a/app/controllers/public/api/v1/portals/search_controller.rb b/app/controllers/public/api/v1/portals/search_controller.rb new file mode 100644 index 0000000000000..1047417737252 --- /dev/null +++ b/app/controllers/public/api/v1/portals/search_controller.rb @@ -0,0 +1,31 @@ +class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController + before_action :ensure_custom_domain_request, only: [:index] + before_action :portal + before_action :set_portal_layout + before_action :set_view_variant + before_action :ensure_portal_feature_enabled + layout 'portal' + + def index + @query = params[:query].to_s.strip + @articles = @portal.articles.published.includes(:category).where(locale: params[:locale]) + + search_articles + + @articles = @articles.page(params[:page]).per(10) + end + + private + + def search_articles + @articles = @query.present? ? @articles.search(search_params) : @articles.none + end + + def search_params + params.permit(:query, :locale, :sort, :status, :page).tap do |permitted| + permitted[:query] = @query + end + end +end + +Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController') diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index a187ca8a88068..da7d9e6a50250 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,12 +1,17 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController + include PortalHomeData + before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled + before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled } layout 'portal' def show - @og_image_url = helpers.set_og_image_url('', @portal.header_text) + @og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale)) end def sitemap diff --git a/app/controllers/robots_controller.rb b/app/controllers/robots_controller.rb new file mode 100644 index 0000000000000..e93db810ac258 --- /dev/null +++ b/app/controllers/robots_controller.rb @@ -0,0 +1,7 @@ +# Public crawler rules do not need application authentication or callbacks. +class RobotsController < ActionController::Base # rubocop:disable Rails/ApplicationController + def show + # Widget loads create contacts before rendering, so preserve their crawl exclusion. + render plain: "User-agent: *\nDisallow: /widget\n", layout: false + end +end diff --git a/app/controllers/shopify/callbacks_controller.rb b/app/controllers/shopify/callbacks_controller.rb index 7fb8b5a47adae..f58551b19ed98 100644 --- a/app/controllers/shopify/callbacks_controller.rb +++ b/app/controllers/shopify/callbacks_controller.rb @@ -23,15 +23,18 @@ def verify_account! end def handle_response - account.hooks.create!( - app_id: 'shopify', - access_token: parsed_body['access_token'], - status: 'enabled', - reference_id: params[:shop], - settings: { - scope: parsed_body['scope'] - } - ) + account.with_lock do + hook = account.hooks.find_or_initialize_by(app_id: 'shopify', reference_id: Shopify::ShopDomain.normalize(params[:shop])) + hook.update!( + access_token: parsed_body['access_token'], + status: 'enabled', + settings: { + scope: parsed_body['scope'], + connected_at: Time.current.utc.iso8601(6), + installation_id: SecureRandom.uuid + } + ) + end redirect_to shopify_integration_url end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 27ce587f72848..584e87a73855e 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -1,4 +1,6 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController + before_action :validate_suspension_metadata, only: :update + # 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. # @@ -35,11 +37,18 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # def resource_params permitted_params = super - permitted_params[:limits] = permitted_params[:limits].to_h.compact + permitted_params.extract!(:suspension_category, :suspension_reason) + permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits) + permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models) permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present? permitted_params end + def update + apply_suspension_metadata + super + end + # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # for more information @@ -65,6 +74,88 @@ def destroy redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.') # rubocop:enable Rails/I18nLocaleTexts end + + private + + def validate_suspension_metadata + return unless suspension_metadata_required? + + validate_suspension_category + validate_suspension_reason + return if requested_resource.errors.empty? + + requested_resource.assign_attributes(resource_params.except(:manually_managed_features)) + render :edit, + locals: { page: Administrate::Page::Form.new(dashboard, requested_resource) }, + status: :unprocessable_entity + end + + def validate_suspension_category + if suspension_details[:category].blank? + requested_resource.errors.add(:suspension_category, :blank) + elsif Account::SUSPENSION_CATEGORIES.exclude?(suspension_details[:category]) + requested_resource.errors.add(:suspension_category, :inclusion) + end + end + + def validate_suspension_reason + if suspension_details[:reason].blank? + requested_resource.errors.add(:suspension_reason, :blank) + elsif suspension_details[:reason].length > 256 + requested_resource.errors.add(:suspension_reason, :too_long, count: 256) + end + end + + def suspension_metadata_required? + return false unless target_status == 'suspended' + + requested_resource.active? || + requested_resource.suspension_history.present? || + suspension_details.values.any?(&:present?) + end + + def apply_suspension_metadata + return unless target_status == 'suspended' + + history = suspension_history_with_changes + return if history.blank? + + requested_resource.internal_attributes = requested_resource.internal_attributes.merge('suspensions' => history) + end + + def suspension_history_with_changes + history = requested_resource.suspension_history.map(&:dup) + return history << new_suspension_event if requested_resource.active? + return append_legacy_suspension(history) if history.empty? + return unless suspension_metadata_changed?(history.last) + + history.tap { |events| events[-1] = events.last.merge(suspension_details.stringify_keys) } + end + + def append_legacy_suspension(history) + return if suspension_details.values.none?(&:present?) + + history << new_suspension_event + end + + def new_suspension_event + suspension_details.stringify_keys.merge('suspended_at' => Time.current.iso8601) + end + + def suspension_metadata_changed?(latest_suspension) + latest_suspension.values_at('category', 'reason') != suspension_details.values_at(:category, :reason) + end + + def suspension_details + @suspension_details ||= { + category: params.dig(:account, :suspension_category).to_s, + reason: params.dig(:account, :suspension_reason).to_s.strip + } + end + + def target_status + params.dig(:account, :status).to_s + end end SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController') diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef16866..03b54d9611967 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -1,4 +1,18 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController + GENERAL_CONFIGS = %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE + WIDGET_TOKEN_EXPIRY].freeze + META_INCIDENT_CONFIGS = %w[DISABLE_META_INBOX_CREATION DISABLE_META_MESSAGE_SENDING].freeze + SHOPIFY_CONFIGS = %w[ + ENABLE_SHOPIFY_INTEGRATION + SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET + SHOPIFY_APP_STORE_URL + SHOPIFY_PARTNER_ORGANIZATION_ID + SHOPIFY_PARTNER_APP_ID + SHOPIFY_PARTNER_ACCESS_TOKEN + SHOPIFY_PARTNER_API_VERSION + ].freeze + before_action :set_config before_action :allowed_configs def show @@ -15,8 +29,9 @@ def show end def create - errors = [] + errors = shopify_partner_config_errors params['app_config'].each do |key, value| + break if errors.any? next unless @allowed_configs.include?(key) i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) @@ -27,7 +42,7 @@ def create 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 @@ -38,13 +53,15 @@ def set_config end def allowed_configs + general_configs = GENERAL_CONFIGS + (ChatwootApp.chatwoot_cloud? ? META_INCIDENT_CONFIGS : []) + 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], + 'shopify' => SHOPIFY_CONFIGS, 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], - 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], + 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], @@ -53,10 +70,35 @@ def allowed_configs 'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT] } - @allowed_configs = mapping.fetch( - @config, - %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY] - ) + @allowed_configs = mapping.fetch(@config, general_configs) + end + + def shopify_partner_config_errors + return [] unless @config == 'shopify' + + saved_values = InstallationConfig.where(name: Shopify::PartnerConfiguration::KEYS).each_with_object({}) do |config, values| + values[config.name] = config.value + end + submitted_values = params.fetch('app_config', {}).permit(*SHOPIFY_CONFIGS).to_h + Shopify::PartnerConfiguration.validate_for_save!(saved_values.merge(submitted_values)) + [] + rescue Shopify::PartnerClient::ConfigurationError => e + [e.message] + 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 diff --git a/app/controllers/super_admin/dashboard_controller.rb b/app/controllers/super_admin/dashboard_controller.rb index 56d19d5639642..8bc22cc59338a 100644 --- a/app/controllers/super_admin/dashboard_controller.rb +++ b/app/controllers/super_admin/dashboard_controller.rb @@ -2,10 +2,33 @@ class SuperAdmin::DashboardController < SuperAdmin::ApplicationController include ActionView::Helpers::NumberHelper def index - @data = Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a - @accounts_count = number_with_delimiter(Account.count) - @users_count = number_with_delimiter(User.count) - @inboxes_count = number_with_delimiter(Inbox.count) - @conversations_count = number_with_delimiter(Conversation.count) + respond_to do |format| + format.html + format.json { render json: dashboard_stats } + end + end + + private + + def dashboard_stats + Rails.cache.fetch('super_admin:dashboard_stats', expires_in: 30.minutes) do + { + chartData: Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a, + accountsCount: number_with_delimiter(Account.count), + usersCount: number_with_delimiter(User.count), + inboxesCount: number_with_delimiter(Inbox.count), + conversationsCount: number_with_delimiter(conversations_count_estimate) + } + end + end + + # Exact COUNT(*) scans the whole table; the planner estimate is instant + # and close enough for dashboard display. + def conversations_count_estimate + estimate = ActiveRecord::Base.connection.select_value( + "SELECT reltuples::bigint FROM pg_class WHERE relname = 'conversations'" + ).to_i + # reltuples is -1 until the table is first vacuumed/analyzed + estimate.negative? ? Conversation.count : estimate end end diff --git a/app/controllers/super_admin/devise/sessions_controller.rb b/app/controllers/super_admin/devise/sessions_controller.rb index 96389cfb50ecf..8549b0a5e2b2b 100644 --- a/app/controllers/super_admin/devise/sessions_controller.rb +++ b/app/controllers/super_admin/devise/sessions_controller.rb @@ -10,7 +10,7 @@ def create sign_in(:super_admin, @super_admin) flash.discard - redirect_to super_admin_users_path + redirect_to super_admin_root_path end def destroy diff --git a/app/controllers/super_admin/installation_configs_controller.rb b/app/controllers/super_admin/installation_configs_controller.rb index b1f15b518c11b..e888b91e7ace8 100644 --- a/app/controllers/super_admin/installation_configs_controller.rb +++ b/app/controllers/super_admin/installation_configs_controller.rb @@ -25,6 +25,29 @@ def scoped_resource 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 @@ def resource_params .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/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index b0e97b95d9cf5..3c6adc4c1bcce 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -20,7 +20,9 @@ def chatwoot_edition end def instance_meta - @metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed' + migrations_paths = ActiveRecord::Migrator.migrations_paths + migrations_context = ActiveRecord::MigrationContext.new(migrations_paths) + @metrics['Database Migrations'] = migrations_context.needs_migration? ? 'pending' : 'completed' end def chatwoot_version 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 0000000000000..e4bf727a0e8b8 --- /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 0000000000000..c33cfdc1e33c4 --- /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/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index e98e61c9583a7..4254767bee115 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -52,6 +52,16 @@ def destroy_avatar redirect_back(fallback_location: super_admin_users_path) end + def resend_confirmation + user = requested_resource + if user.confirmed? + redirect_back(fallback_location: super_admin_user_path(user), alert: I18n.t('super_admin.users.resend_confirmation.already_confirmed')) + else + user.send_confirmation_instructions + redirect_back(fallback_location: super_admin_user_path(user), notice: I18n.t('super_admin.users.resend_confirmation.sent')) + end + end + def scoped_resource resource_class.with_attached_avatar end diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb index af5a4b03916c9..680bf92fc7214 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 @@ def respond 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/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index e484905c3f358..a61d4392d2486 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -6,6 +6,8 @@ def show return handle_ungranted_scopes_error unless all_scopes_granted? process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -20,6 +22,8 @@ def all_scopes_granted? def process_successful_authorization inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -34,6 +38,14 @@ def handle_error(error) redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) end + def handle_limit_error(error) + redirect_to_error_page( + error_type: error.class.name, + code: Rack::Utils.status_code(error.http_status), + error_message: error.message + ) + end + # Handles the case when a user denies permissions or cancels the authorization flow def handle_authorization_error redirect_to_error_page( @@ -91,7 +103,8 @@ def create_channel_with_inbox(business_details) access_token: short_term_access_token[:access_token], refresh_token: short_term_access_token[:refresh_token], expires_at: short_term_access_token[:expires_at], - refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at], + provider_name: business_details[:username] ) account.inboxes.create!( @@ -113,10 +126,9 @@ def update_channel(channel_tiktok, business_details) access_token: short_term_access_token[:access_token], refresh_token: short_term_access_token[:refresh_token], expires_at: short_term_access_token[:expires_at], - refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at], + provider_name: business_details[:username] ) - - channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username]) end def set_avatar(inbox, avatar_url) @@ -127,6 +139,10 @@ def account_id @account_id ||= verify_tiktok_token(params[:state]) end + def return_to + tiktok_token_return_to(params[:state]) + end + def account @account ||= Account.find(account_id) end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index d607ba1510108..689f1b9c4a94b 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -16,6 +16,7 @@ def permitted_params # rubocop:disable Metrics/MethodLength :ToZip, :AccountSid, :MessageSid, + :OriginalRepliedMessageSid, :FromCountry, :ToCity, :FromCity, @@ -31,7 +32,21 @@ def permitted_params # rubocop:disable Metrics/MethodLength :Latitude, :Longitude, :MessageType, - :ProfileName + :ProfileName, + :ExternalUserId, + :ParentExternalUserId, + :ProfileUsername, + :Username, + :ReferralBody, + :ReferralHeadline, + :ReferralSourceId, + :ReferralSourceType, + :ReferralSourceUrl, + :ReferralMediaId, + :ReferralMediaContentType, + :ReferralMediaUrl, + :ReferralNumMedia, + :ReferralCtwaClid ) end end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 569c2524b9e22..6e51686340125 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 @@ def valid_token?(token) 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/shopify_controller.rb b/app/controllers/webhooks/shopify_controller.rb index efc6a51225076..cb1f53eb7b466 100644 --- a/app/controllers/webhooks/shopify_controller.rb +++ b/app/controllers/webhooks/shopify_controller.rb @@ -1,8 +1,15 @@ class Webhooks::ShopifyController < ActionController::API + COMPLIANCE_TOPICS = %w[customers/data_request customers/redact shop/redact].freeze + MANDATORY_CLEANUP_TOPICS = (COMPLIANCE_TOPICS + %w[app/uninstalled]).freeze + class CleanupIncomplete < StandardError; end + + before_action :ensure_shopify_enabled, unless: :mandatory_cleanup_event? before_action :verify_hmac! def events case request.headers['X-Shopify-Topic'] + when 'app/uninstalled' + handle_app_uninstalled when 'shop/redact' handle_shop_redact end @@ -12,6 +19,14 @@ def events private + def ensure_shopify_enabled + head :not_found unless Shopify::FeatureGate.enabled? + end + + def mandatory_cleanup_event? + MANDATORY_CLEANUP_TOPICS.include?(request.headers['X-Shopify-Topic']) + end + def verify_hmac! secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) return head :unauthorized if secret.blank? @@ -27,9 +42,34 @@ def verify_hmac! end def handle_shop_redact - shop_domain = params[:shop_domain] - return if shop_domain.blank? + Shopify::PendingInstallation.invalidate_shop!(shop: params[:shop_domain]) + hooks = shopify_hooks(params[:shop_domain]) + hooks.find_each do |hook| + Shopify::UninstallationService.new(hook: hook, occurred_at: webhook_triggered_at, delete_hook: true).perform + end + raise CleanupIncomplete, 'Shopify shop redaction is incomplete' if hooks.any? { |hook| redaction_required?(hook) } + end + + def handle_app_uninstalled + Shopify::PendingInstallation.invalidate_shop!(shop: params[:myshopify_domain]) + shopify_hooks(params[:myshopify_domain]).find_each do |hook| + Shopify::UninstallationService.new(hook: hook, occurred_at: webhook_triggered_at).perform + end + end + + def webhook_triggered_at + @webhook_triggered_at ||= Time.iso8601(request.headers.fetch('X-Shopify-Triggered-At')) + end + + def redaction_required?(hook) + connected_at = hook.settings['connected_at'] + connected_at.blank? || Time.iso8601(connected_at) <= webhook_triggered_at + end + + def shopify_hooks(shop_domain) + normalized_domain = Shopify::ShopDomain.normalize(shop_domain) + return Integrations::Hook.none unless Shopify::ShopDomain.valid?(normalized_domain) - Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all + Integrations::Hook.where(app_id: 'shopify').where('LOWER(reference_id) = ?', normalized_domain) end end diff --git a/app/controllers/webhooks/telegram_controller.rb b/app/controllers/webhooks/telegram_controller.rb index bc65061e759fc..d1a307de7b4e6 100644 --- a/app/controllers/webhooks/telegram_controller.rb +++ b/app/controllers/webhooks/telegram_controller.rb @@ -1,6 +1,11 @@ class Webhooks::TelegramController < ActionController::API def process_payload Webhooks::TelegramEventsJob.perform_later(params.to_unsafe_hash) - head :ok + + callback_query = params[:callback_query] + callback_query_id = callback_query[:id] if callback_query.respond_to?(:key?) + return head :ok unless callback_query_id.is_a?(String) && callback_query_id.present? + + render json: { method: 'answerCallbackQuery', callback_query_id: callback_query_id } end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c6bfb..7bfa723ae453f 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]}") @@ -8,18 +10,58 @@ def process_payload return end + return head :ok if tracking_events_only? + Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) head :ok end private + def tracking_events_only? + return false unless params[:object] == 'whatsapp_business_account' + + changes = params.fetch(:entry, []).flat_map { |entry| entry.fetch(:changes, []) } + changes.present? && changes.all? { |change| change[:field] == 'tracking_events' } + end + def valid_token?(token) channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present? token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + def 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? + + Whatsapp::WebhookChannelFinderService.new( + display_phone_number: metadata[:display_phone_number], + phone_number_id: metadata[:phone_number_id] + ).perform + end + def inactive_whatsapp_number? phone_number = params[:phone_number] return false if phone_number.blank? diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f1110a9..84246fff8340e 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard # Add all_features last so it appears after manually_managed_features attributes[:all_features] = AccountFeaturesField + attributes[:captain_models] = CaptainModelOverridesField attributes else @@ -32,7 +33,8 @@ class AccountDashboard < Administrate::BaseDashboard users: CountField, conversations: CountField, locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), - status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]), + status: AccountStatusField.with_options(collection: [%w[Active active], %w[Suspended suspended]]), + suspension_history: SuspensionHistoryField, account_users: Field::HasMany, custom_attributes: Field::String }.merge(enterprise_attribute_types).freeze @@ -57,6 +59,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[custom_attributes limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -68,6 +71,7 @@ class AccountDashboard < Administrate::BaseDashboard updated_at locale status + suspension_history conversations account_users ] + enterprise_show_page_attributes).freeze @@ -79,6 +83,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -117,7 +122,8 @@ def display_resource(account) # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) - attrs = super + [limits: {}] + attrs = super + [limits: {}, captain_models: {}] + attrs += %i[suspension_category suspension_reason] if action == 'update' # Add manually_managed_features to permitted attributes only for Chatwoot Cloud attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? diff --git a/app/dashboards/platform_banner_dashboard.rb b/app/dashboards/platform_banner_dashboard.rb new file mode 100644 index 0000000000000..41674406abc18 --- /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/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 7416b7861775b..abf3ca3547139 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -17,6 +17,7 @@ def listeners InstallationWebhookListener.instance, NotificationListener.instance, ParticipationListener.instance, + Conversations::UnreadCounts::Listener.instance, ReportingEventListener.instance, WebhookListener.instance ] diff --git a/app/dispatchers/sync_dispatcher.rb b/app/dispatchers/sync_dispatcher.rb index 509a4272704f4..837e8af7c4ef2 100644 --- a/app/dispatchers/sync_dispatcher.rb +++ b/app/dispatchers/sync_dispatcher.rb @@ -8,3 +8,5 @@ def listeners [ActionCableListener.instance, AgentBotListener.instance] end end + +SyncDispatcher.prepend_mod_with('SyncDispatcher') diff --git a/app/drops/contact_drop.rb b/app/drops/contact_drop.rb index 1d450adb76086..16240ec060ef7 100644 --- a/app/drops/contact_drop.rb +++ b/app/drops/contact_drop.rb @@ -12,7 +12,7 @@ def phone_number 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) end def last_name diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb index d62642885bee5..f25435ff035a2 100644 --- a/app/drops/conversation_drop.rb +++ b/app/drops/conversation_drop.rb @@ -1,6 +1,10 @@ class ConversationDrop < BaseDrop include MessageFormatHelper + def id + @obj.try(:display_id) + end + def display_id @obj.try(:display_id) end diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb index 563916d5f2a50..29698fb4b5ddb 100644 --- a/app/drops/inbox_drop.rb +++ b/app/drops/inbox_drop.rb @@ -2,4 +2,18 @@ class InboxDrop < BaseDrop def name @obj.try(:name) end + + def business_name + @obj.try(:sanitized_business_name) + end + + def avatar_url + @obj.try(:avatar_url) + end + + def email + return unless @obj.try(:email?) + + @obj.try(:email_address).presence || @obj.try(:channel).try(:email) + end end diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb index 7cafea1bbf2df..83d5e23474c02 100644 --- a/app/drops/user_drop.rb +++ b/app/drops/user_drop.rb @@ -7,11 +7,15 @@ def available_name @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) 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/fields/account_status_field.rb b/app/fields/account_status_field.rb new file mode 100644 index 0000000000000..52c56123d8248 --- /dev/null +++ b/app/fields/account_status_field.rb @@ -0,0 +1,23 @@ +require 'administrate/field/select' + +class AccountStatusField < Administrate::Field::Select + def to_partial_path + return '/fields/account_status_field/form' if page == :form + + "/fields/select/#{page}" + end + + def suspension_category_options + Account::SUSPENSION_CATEGORIES.map do |category| + [I18n.t("super_admin.account_suspension.categories.#{category}"), category] + end + end + + def latest_suspension + resource.suspension_history.last || {} + end + + def original_status + resource.status_in_database || resource.status + end +end diff --git a/app/fields/suspension_history_field.rb b/app/fields/suspension_history_field.rb new file mode 100644 index 0000000000000..e70ab86def938 --- /dev/null +++ b/app/fields/suspension_history_field.rb @@ -0,0 +1,15 @@ +require 'administrate/field/base' + +class SuspensionHistoryField < Administrate::Field::Base + def events + data.reverse + end + + def category_label(event) + I18n.t("super_admin.account_suspension.categories.#{event.fetch('category')}") + end + + def suspended_at(event) + I18n.l(Time.zone.parse(event.fetch('suspended_at')), format: :long) + end +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index fa437327dd59b..994fced74bf36 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -2,23 +2,6 @@ class ConversationFinder attr_reader :current_user, :current_account, :params DEFAULT_STATUS = 'open'.freeze - SORT_OPTIONS = { - 'last_activity_at_asc' => %w[sort_on_last_activity_at asc], - 'last_activity_at_desc' => %w[sort_on_last_activity_at desc], - 'created_at_asc' => %w[sort_on_created_at asc], - 'created_at_desc' => %w[sort_on_created_at desc], - 'priority_asc' => %w[sort_on_priority asc], - 'priority_desc' => %w[sort_on_priority desc], - 'waiting_since_asc' => %w[sort_on_waiting_since asc], - 'waiting_since_desc' => %w[sort_on_waiting_since desc], - 'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc], - - # To be removed in v3.5.0 - 'latest' => %w[sort_on_last_activity_at desc], - 'sort_on_created_at' => %w[sort_on_created_at asc], - 'sort_on_priority' => %w[sort_on_priority desc], - 'sort_on_waiting_since' => %w[sort_on_waiting_since asc] - }.with_indifferent_access # assumptions # inbox_id if not given, take from all conversations, else specific to inbox # assignee_type if not given, take 'all' @@ -40,7 +23,7 @@ def initialize(current_user, params) def perform set_up - mine_count, unassigned_count, all_count, = set_count_for_all_conversations + mine_count, unassigned_count, all_count = set_count_for_all_conversations assigned_count = all_count - unassigned_count filter_by_assignee_type @@ -141,7 +124,8 @@ def filter_by_conversation_type conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) @conversations = @conversations.where(id: conversation_ids) when 'participating' - @conversations = current_user.participating_conversations.where(account_id: current_account.id) + participant_conversation_ids = ConversationParticipant.where(account_id: current_account.id, user_id: current_user.id).select(:conversation_id) + @conversations = @conversations.where(id: participant_conversation_ids) when 'unattended' @conversations = @conversations.unattended end @@ -184,6 +168,17 @@ def filter_by_source_id end def set_count_for_all_conversations + return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading? + + counts = @conversations.unscope(:order).pick( + Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"), + Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL AND assignee_agent_bot_id IS NULL)'), + Arel.sql('COUNT(*)') + ) + counts || [0, 0, 0] + end + + def legacy_count_for_all_conversations [ @conversations.assigned_to(current_user).count, @conversations.unassigned.count, @@ -197,15 +192,13 @@ def current_page def conversations_base_query @conversations.includes( - :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox - ) + :taggings, :team, :contact_inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } } + ).preload(inbox: :channel, ai_assignee: { avatar_attachment: [:blob] }) end def conversations @conversations = conversations_base_query - - sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc'] - @conversations = @conversations.send(sort_by, sort_order) + @conversations = Conversations::SortService.apply(@conversations, params[:sort_by]) if params[:updated_within].present? @conversations.where('conversations.updated_at > ?', Time.zone.now - params[:updated_within].to_i.seconds) diff --git a/app/finders/data_import_error_finder.rb b/app/finders/data_import_error_finder.rb new file mode 100644 index 0000000000000..69d85b23ca0ae --- /dev/null +++ b/app/finders/data_import_error_finder.rb @@ -0,0 +1,11 @@ +class DataImportErrorFinder + RESULTS_LIMIT = 5 + + def initialize(data_import) + @data_import = data_import + end + + def import_errors + @data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT) + end +end diff --git a/app/finders/data_import_skip_log_finder.rb b/app/finders/data_import_skip_log_finder.rb new file mode 100644 index 0000000000000..ee932f1dae6a7 --- /dev/null +++ b/app/finders/data_import_skip_log_finder.rb @@ -0,0 +1,35 @@ +class DataImportSkipLogFinder + RESULTS_LIMIT = 5 + SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze + + attr_reader :selected_source_object_type + + def initialize(data_import, params = {}) + @data_import = data_import + @selected_source_object_type = valid_source_object_type(params[:skip_logs_type]) + end + + def skip_logs + filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT) + end + + def counts_by_type + base_scope.group(:source_object_type).count + end + + private + + def base_scope + @base_scope ||= @data_import.import_errors.skip_logs + end + + def filtered_scope + return base_scope if selected_source_object_type.blank? + + base_scope.where(source_object_type: selected_source_object_type) + end + + def valid_source_object_type(source_object_type) + source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type) + end +end diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 8854e239ae4bb..915019f435a28 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -1,4 +1,6 @@ class MessageFinder + MESSAGE_ID_MAX = 2_147_483_647 + def initialize(conversation, params) @conversation = conversation @params = params @@ -21,12 +23,14 @@ def messages end def current_messages + return messages.none if oversized_message_id?(@params[:after]) + if @params[:after].present? && @params[:before].present? - messages_between(@params[:after].to_i, @params[:before].to_i) + messages_between(normalized_message_id(@params[:after]), @params[:before].to_i) elsif @params[:before].present? messages_before(@params[:before].to_i) elsif @params[:after].present? - messages_after(@params[:after].to_i) + messages_after(normalized_message_id(@params[:after])) else messages_latest end @@ -37,14 +41,29 @@ def messages_after(after_id) end def messages_before(before_id) + return messages_latest if oversized_message_id?(before_id) + + before_id = normalized_message_id(before_id) messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse end def messages_between(after_id, before_id) - messages.reorder('created_at asc').where('id >= ? AND id < ?', after_id, before_id).limit(1000) + message_scope = messages.reorder('created_at asc').where('id >= ?', after_id) + message_scope = message_scope.where('id < ?', normalized_message_id(before_id)) unless oversized_message_id?(before_id) + message_scope.limit(1000) end def messages_latest messages.reorder('created_at desc').limit(20).reverse end + + def normalized_message_id(value) + value.to_i.clamp(0, MESSAGE_ID_MAX) + end + + def oversized_message_id?(value) + value.to_i > MESSAGE_ID_MAX + 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 3d6b559c861f5..6c64dd00955de 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -17,15 +17,12 @@ def validate_email_channel(attributes) 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 @@ def validate_smtp(channel_data) 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 @@ def check_imap_connection(channel_data) 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], @@ -106,10 +114,4 @@ def account_channels_method 'sms' => Current.account.sms_channels }[permitted_params[:channel][:type]] end - - def validate_limit - return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes] - - render_payment_required('Account limit exceeded. Upgrade to a higher plan') - end end diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86c5c9..dacca554634ea 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -22,4 +22,25 @@ def non_web_inboxes(account) def agents(account) account.users.count end + + # current_period_end moved to the subscription item in newer Stripe API versions; read both. + # Stripe objects don't respond to dig, so the items path is walked with []. + def subscription_period_end(subscription) + subscription['current_period_end'] || subscription['items']&.[]('data')&.first&.[]('current_period_end') + end + + def subscription_ends_on(subscription) + period_end = subscription_period_end(subscription) + return if period_end.blank? + + Time.zone.at(period_end) + end + + # Stripe schedules cancellations on cancel_at; older subscriptions only set cancel_at_period_end. + def subscription_cancels_on(subscription) + cancel_at = subscription['cancel_at'] + return Time.zone.at(cancel_at) if cancel_at.present? + + subscription_ends_on(subscription) if subscription['cancel_at_period_end'] + end end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index fcc8b463d8231..67e8d953d06d8 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -7,7 +7,7 @@ def extract_domain_without_tld(email) 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/file_type_helper.rb b/app/helpers/file_type_helper.rb index 03b807aad7657..6bae0d3d1ec26 100644 --- a/app/helpers/file_type_helper.rb +++ b/app/helpers/file_type_helper.rb @@ -17,6 +17,7 @@ def file_type_by_signed_id(signed_id) def image_file?(content_type) [ 'image/jpeg', + 'image/jpg', 'image/png', 'image/gif', 'image/bmp', diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index 4f345676e65c5..adb1d8a5b8099 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -68,6 +68,8 @@ def handle_standard_attributes(current_filter, query_hash, current_index, filter when 'text_case_insensitive' text_case_insensitive_filter(query_hash, filter_operator_value) else + return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash) + default_filter(query_hash, filter_operator_value) end end @@ -82,16 +84,38 @@ def text_case_insensitive_filter(query_hash, filter_operator_value) "#{filter_operator_value} #{query_hash[:query_operator]}" end + def text_cast_filter(query_hash, filter_operator_value) + "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}" + end + def default_filter(query_hash, filter_operator_value) + if query_hash[:attribute_key] == 'assignee_id' && query_hash[:filter_operator].in?(%w[is_present is_not_present]) + return assignee_presence_filter(filter_config[:table_name], query_hash) + end + "#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}" end + # Assignee ownership can live in either column until it is standardized as a polymorphic association. + def assignee_presence_filter(table_name, query_hash) + if query_hash[:filter_operator] == 'is_present' + return "(#{table_name}.assignee_id IS NOT NULL OR #{table_name}.assignee_agent_bot_id IS NOT NULL) #{query_hash[:query_operator]}" + end + + "(#{table_name}.assignee_id IS NULL AND #{table_name}.assignee_agent_bot_id IS NULL) #{query_hash[:query_operator]}" + end + + def text_search_on_display_id?(query_hash) + query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator]) + end + def validate_single_condition(condition) - return if condition['query_operator'].nil? - return if condition['query_operator'].empty? + values = Array.wrap(condition['values']) + raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: condition['attribute_key']) if values.any? { |v| v.respond_to?(:to_h) } + + return if condition['query_operator'].to_s.empty? - operator = condition['query_operator'].upcase - raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator) + raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(condition['query_operator'].to_s.upcase) end def conversation_status_values(values) diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb index 8ba57bf95dc5c..2f91eb6b042e0 100644 --- a/app/helpers/instagram/integration_helper.rb +++ b/app/helpers/instagram/integration_helper.rb @@ -4,21 +4,21 @@ module Instagram::IntegrationHelper # Generates a signed JWT token for Instagram integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_instagram_token(account_id) + def generate_instagram_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') rescue StandardError => e Rails.logger.error("Failed to generate Instagram token: #{e.message}") nil end - def token_payload(account_id) - { - sub: account_id, - iat: Time.current.to_i - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end # Verifies and decodes a Instagram JWT token @@ -28,7 +28,14 @@ def token_payload(account_id) def verify_instagram_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Instagram JWT token, if present. + def instagram_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -41,7 +48,7 @@ def decode_token(token, secret) JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") nil diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 15de0fbd76151..64e46f0b7b7be 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -17,15 +17,11 @@ def set_og_image_url(portal_name, title) uri.to_s end - def generate_portal_bg_color(portal_color, theme) + def generate_portal_bg(portal_color, theme) base_color = theme == 'dark' ? 'black' : 'white' "color-mix(in srgb, #{portal_color} 20%, #{base_color})" end - def generate_portal_bg(portal_color, theme) - generate_portal_bg_color(portal_color, theme) - end - def generate_gradient_to_bottom(theme) base_color = theme == 'dark' ? '#151718' : 'white' "linear-gradient(to bottom, transparent, #{base_color})" @@ -41,13 +37,24 @@ def language_name(locale) language_map[locale] || locale end + def html_lang_attribute(locale) + locale.to_s.tr('_', '-') + end + def theme_query_string(theme) theme.present? && theme != 'system' ? "?theme=#{theme}" : '' end + def portal_query_string(theme, is_plain_layout_enabled) + query_params = {} + query_params[:theme] = theme if theme.present? && theme != 'system' + query_params[:show_plain_layout] = true if is_plain_layout_enabled + query_params.present? ? "?#{query_params.to_query}" : '' + end + def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{portal_locale}" end @@ -61,7 +68,7 @@ def generate_category_link(params) is_plain_layout_enabled = params[:is_plain_layout_enabled] if is_plain_layout_enabled - "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}" end @@ -69,7 +76,7 @@ def generate_category_link(params) def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/articles/#{article_slug}" end @@ -90,10 +97,33 @@ def render_category_content(content) ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text end + # Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character. + def render_emoji_or_icon(value, color = nil) + return '' if value.blank? + + # Emojis are non-ascii; bare icon names match this safe charset. + return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/) + + icon_class = value.start_with?('i-') ? value : "i-ri-#{value}" + style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/) + tag.span(class: icon_class, style: style, 'aria-hidden': true) + end + def thumbnail_bg_color(username) colors = ['#6D95BA', '#A4C3C3', '#E19191'] return colors.sample if username.blank? colors[username.length % colors.size] end + + def format_authors_label(authors) + return if authors.blank? + + names = authors.map(&:available_name) + return names.to_sentence if names.size <= 3 + + I18n.t('public_portal.sidebar.authors_others', + names: names.first(2).join(', '), + count: authors.size - 2) + end end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 09a84b110c359..d5b773c87d589 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -53,13 +53,12 @@ def outgoing_messages 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 @@ def bot_handoffs 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/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index f21a97f78dd3f..6489d01948cf8 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -34,6 +34,12 @@ disable_branding: enabled: <%= (ChatwootHub.pricing_plan != 'community') %> icon: 'icon-sailbot-fill' enterprise: true +voice_calls: + name: 'Voice Calls' + description: 'Enable voice calling capabilities for your agents and customers.' + enabled: <%= (ChatwootHub.pricing_plan != 'community') %> + icon: 'icon-voice-line' + enterprise: true # ------- Product Features ------- # help_center: diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb index b2de4a092bb0e..7bc8bc4ab4aa0 100644 --- a/app/helpers/tiktok/integration_helper.rb +++ b/app/helpers/tiktok/integration_helper.rb @@ -2,11 +2,12 @@ module Tiktok::IntegrationHelper # Generates a signed JWT token for Tiktok integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_tiktok_token(account_id) + def generate_tiktok_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') rescue StandardError => e Rails.logger.error("Failed to generate TikTok token: #{e.message}") nil @@ -19,7 +20,14 @@ def generate_tiktok_token(account_id) def verify_tiktok_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Tiktok JWT token, if present. + def tiktok_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -28,18 +36,17 @@ def client_secret @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) end - def token_payload(account_id) - { - sub: account_id, - iat: Time.current.to_i - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end def decode_token(token, secret) JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") nil diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index a706e2df53665..1a5eb24690e3d 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -3,8 +3,10 @@ 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 LowBackupCodesBanner from './components/app/LowBackupCodesBanner.vue'; import vueActionCable from './helper/actionCable'; import { useRouter } from 'vue-router'; import { useStore } from 'dashboard/composables/store'; @@ -27,9 +29,11 @@ export default { LoadingState, NetworkNotification, UpdateBanner, + StatusBanner, PaymentPendingBanner, WootSnackbarBox, PendingEmailVerificationBanner, + LowBackupCodesBanner, }, setup() { const router = useRouter(); @@ -59,7 +63,6 @@ export default { isRTL: 'accounts/isRTL', currentUser: 'getCurrentUser', authUIFlags: 'getAuthUIFlags', - accountUIFlags: 'accounts/getUIFlags', }), hideOnOnboardingView() { return !isOnOnboardingView(this.$route); @@ -107,8 +110,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,15 +135,17 @@ export default {