diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 000000000..07162a22d --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,65 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: json,jsonb,hstore +:hide_limit_column_types: integer,bigint,boolean +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +- enterprise/app/models +:require: [] +:root_dir: +- '' diff --git a/.bundler-audit.yml b/.bundler-audit.yml index afe8702ac..ffbfa18e0 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -1,3 +1,23 @@ --- 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+) + # 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 diff --git a/.circleci/config.yml b/.circleci/config.yml index 99ac1c29a..59702c139 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -93,8 +93,8 @@ jobs: exit 1 fi mkdir -p ~/tmp - curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar - java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar + java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json # Bundle audit - run: diff --git a/.env.example b/.env.example index bc7380a29..c9f3c855c 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,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 @@ -232,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # 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 +272,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 diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py new file mode 100644 index 000000000..064361b26 --- /dev/null +++ b/.github/scripts/ghsa_linear_sync.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Sync triage GitHub security advisories to Linear issues.""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +import requests + +GITHUB_API = "https://api.github.com" +LINEAR_API = "https://api.linear.app/graphql" + +SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4} +SEVERITY_COLOR = { + "critical": 15548997, + "high": 15105570, + "medium": 15844367, + "low": 3066993, +} +DEFAULT_COLOR = 9807270 + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + sys.exit(f"Missing required env var: {name}") + return value + + +def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]: + url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories" + params: dict[str, Any] | None = {"state": "triage", "per_page": 100} + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + advisories: list[dict[str, Any]] = [] + while url: + r = requests.get(url, headers=headers, params=params, timeout=30) + r.raise_for_status() + advisories.extend(r.json()) + next_link = r.links.get("next") + url = next_link["url"] if next_link else None + params = None + return advisories + + +def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]: + r = requests.post( + LINEAR_API, + headers={"Authorization": api_key}, + json={"query": query, "variables": variables}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def linear_issue_exists(ghsa_id: str, api_key: str) -> bool: + query = ( + "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) " + "{ nodes { id } } }" + ) + resp = linear_call(query, {"q": ghsa_id}, api_key) + return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0 + + +def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None: + query = ( + "mutation($input: IssueCreateInput!) { issueCreate(input: $input) " + "{ success issue { identifier url } } }" + ) + resp = linear_call(query, {"input": input_data}, api_key) + create = resp.get("data", {}).get("issueCreate") or {} + if not create.get("success"): + return None + return create.get("issue") + + +def reporter_login(advisory: dict[str, Any]) -> str: + for credit in advisory.get("credits") or []: + user = (credit or {}).get("user") or {} + if user.get("login"): + return user["login"] + return "unknown" + + +def cvss_score(advisory: dict[str, Any]) -> str: + score = (advisory.get("cvss") or {}).get("score") + return str(score) if score is not None else "n/a" + + +def build_description(adv: dict[str, Any]) -> str: + return ( + f"**GHSA:** {adv['ghsa_id']}\n" + f"**CVE:** {adv.get('cve_id') or 'n/a'}\n" + f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n" + f"**Reporter:** {reporter_login(adv)}\n" + f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n" + f"**Advisory:** {adv['html_url']}\n\n" + f"---\n\n" + f"{adv.get('description') or 'No description provided.'}" + ) + + +def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None: + severity = adv.get("severity") or "unknown" + title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250] + payload = { + "username": "GHSA Sync", + "embeds": [ + { + "title": title, + "url": issue["url"], + "color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR), + "fields": [ + {"name": "Linear", "value": issue["identifier"], "inline": True}, + { + "name": "Severity", + "value": f"{severity} (CVSS {cvss_score(adv)})", + "inline": True, + }, + { + "name": "Advisory", + "value": f"[GitHub]({adv['html_url']})", + "inline": True, + }, + ], + } + ], + } + try: + requests.post(webhook_url, json=payload, timeout=10) + except requests.RequestException: + pass + + +def main() -> int: + repo = required_env("GITHUB_REPOSITORY") + gh_token = required_env("GHSA_READ_TOKEN") + linear_api_key = required_env("LINEAR_API_KEY") + team_id = required_env("LINEAR_TEAM_ID") + project_id = required_env("LINEAR_PROJECT_ID") + label_id = required_env("LINEAR_LABEL_ID") + discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None + + advisories = fetch_triage_advisories(repo, gh_token) + print(f"Fetched {len(advisories)} triage advisories") + + created = skipped = failed = 0 + + for adv in advisories: + ghsa_id = adv.get("ghsa_id") + if not ghsa_id: + failed += 1 + continue + + try: + if linear_issue_exists(ghsa_id, linear_api_key): + skipped += 1 + continue + + severity = adv.get("severity") or "unknown" + issue = linear_create_issue( + { + "title": f"[{ghsa_id}] {adv.get('summary', '')}", + "description": build_description(adv), + "teamId": team_id, + "projectId": project_id, + "labelIds": [label_id], + "priority": SEVERITY_PRIORITY.get(severity, 3), + }, + linear_api_key, + ) + except requests.RequestException: + failed += 1 + continue + + if not issue: + failed += 1 + continue + + created += 1 + if discord_webhook: + post_discord(adv, issue, discord_webhook) + + print(f"Created {created}, skipped {skipped}, failed {failed}") + return 1 if failed > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/deploy_check.yml b/.github/workflows/deploy_check.yml index 9f295a6c8..9f2ae42d8 100644 --- a/.github/workflows/deploy_check.yml +++ b/.github/workflows/deploy_check.yml @@ -11,6 +11,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: deployment_check: name: Check Deployment diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 1d1116d0c..3d992662a 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -8,6 +8,9 @@ on: branches: - develop +permissions: + contents: read + jobs: test: runs-on: ubuntu-22.04 diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml new file mode 100644 index 000000000..a21fbea7f --- /dev/null +++ b/.github/workflows/ghsa-linear-sync.yml @@ -0,0 +1,29 @@ +name: Sync GHSA advisories to Linear + +on: + schedule: + - cron: '0 4 * * *' # daily at 09:30 IST + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install requests==2.32.3 + - name: Sync advisories + env: + GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }} + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} + LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: python3 .github/scripts/ghsa_linear_sync.py diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml index 5c45ba635..cef07cc2f 100644 --- a/.github/workflows/logging_percentage_check.yml +++ b/.github/workflows/logging_percentage_check.yml @@ -10,6 +10,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: log_lines_check: runs-on: ubuntu-latest diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index beef5727c..e0c5ed88e 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -14,6 +14,9 @@ on: - cron: "0 0 * * *" workflow_dispatch: +permissions: + contents: read + jobs: nightly: runs-on: ubuntu-24.04 diff --git a/.github/workflows/publish_codespace_image.yml b/.github/workflows/publish_codespace_image.yml index 5da4fda05..c1b0e4e28 100644 --- a/.github/workflows/publish_codespace_image.yml +++ b/.github/workflows/publish_codespace_image.yml @@ -3,6 +3,10 @@ name: Publish Codespace Base Image on: workflow_dispatch: +permissions: + contents: read + packages: write + jobs: publish-code-space-image: runs-on: ubuntu-latest diff --git a/.github/workflows/publish_ee_docker.yml b/.github/workflows/publish_ee_docker.yml index 8e2c22481..982054a18 100644 --- a/.github/workflows/publish_ee_docker.yml +++ b/.github/workflows/publish_ee_docker.yml @@ -18,6 +18,9 @@ on: env: DOCKER_REPO: chatwoot/chatwoot +permissions: + contents: read + jobs: build: strategy: diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml index 3075a7f3d..994e5cef8 100644 --- a/.github/workflows/publish_foss_docker.yml +++ b/.github/workflows/publish_foss_docker.yml @@ -18,6 +18,9 @@ on: env: DOCKER_REPO: chatwoot/chatwoot +permissions: + contents: read + jobs: build: strategy: diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 7869bf89c..909636a75 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -10,6 +10,9 @@ concurrency: group: pr-${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test: runs-on: ubuntu-22.04 diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml index b27d90408..96a6c69ac 100644 --- a/.github/workflows/test_docker_build.yml +++ b/.github/workflows/test_docker_build.yml @@ -7,6 +7,9 @@ on: - master workflow_dispatch: +permissions: + contents: read + jobs: test-build: strategy: diff --git a/AGENTS.md b/AGENTS.md index 301633d7f..2430fae2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,18 @@ ## 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. +- 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 @@ -68,6 +73,15 @@ - Example: `feat(auth): add user authentication` - Don't reference Claude in commit messages +## PR Description Format + +- Start with a short, user-facing paragraph describing the product change. +- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.). +- For feature PRs, add `How to test` from a product/UX standpoint. +- For bugfix PRs, use `How to reproduce` when helpful. +- Optionally add a `What changed` section for implementation highlights. +- Do not add a `How this was tested` section listing specs/commands. + ## Project-Specific - **Translations**: diff --git a/Gemfile b/Gemfile index 0636b7b2b..7735dc099 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' gem 'email-provider-info' +gem 'gemoji' # compress javascript config.assets.js_compressor gem 'uglifier' ##-- used for single column multiple binary flags in notification settings/feature flagging --## @@ -40,6 +41,8 @@ gem 'json_refs' gem 'rack-attack', '>= 6.7.0' # a utility tool for streaming, flexible and safe downloading of remote files gem 'down' +# SSRF-safe URL fetching +gem 'ssrf_filter', '~> 1.5' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 @@ -73,7 +76,7 @@ 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' @@ -82,10 +85,11 @@ gem 'barnes' gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth', '>= 1.2.3' +gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization -gem 'jwt' +gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'pundit' # super admin @@ -129,9 +133,9 @@ 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', '>= 7.3.1' # We want cron jobs -gem 'sidekiq-cron', '>= 1.12.0' +gem 'sidekiq-cron', '>= 2.4.0' # for sidekiq healthcheck gem 'sidekiq_alive' @@ -191,10 +195,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' @@ -205,6 +209,8 @@ gem 'opentelemetry-exporter-otlp' gem 'shopify_api' +gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl' + ### Gems required only in specific deployment environments ### ############################################################## @@ -268,6 +274,7 @@ group :development, :test do gem 'seed_dump' gem 'shoulda-matchers' gem 'simplecov', '>= 0.21', require: false + gem 'skooma' gem 'spring' gem 'spring-watcher-listen' end diff --git a/Gemfile.lock b/Gemfile.lock index 7a7316e3c..bd41474a3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,8 +108,8 @@ GEM acts-as-taggable-on (12.0.0) activerecord (>= 7.1, < 8.1) zeitwerk (>= 2.4, < 3.0) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) administrate (0.20.1) actionpack (>= 6.0, < 8.0) actionview (>= 6.0, < 8.0) @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.9.1) - ruby_llm (~> 1.9.1) + ai-agents (0.12.0) + ruby_llm (~> 1.14) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -136,6 +136,8 @@ 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) @@ -166,9 +168,9 @@ GEM multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) - bcrypt (3.1.20) + bcrypt (3.1.22) benchmark (0.4.1) - bigdecimal (3.2.2) + bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -184,18 +186,22 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + cgi (0.5.1) 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) - connection_pool (2.5.3) + concurrent-ruby (1.3.7) + 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) @@ -212,7 +218,7 @@ GEM logger msgpack datadog-ruby_core_source (3.4.1) - date (3.4.1) + date (3.5.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -268,8 +274,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) @@ -298,7 +304,7 @@ 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 @@ -307,9 +313,9 @@ GEM 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) @@ -336,6 +342,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 +356,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 @@ -430,7 +438,8 @@ GEM 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) @@ -465,7 +474,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.18.1) + json (2.19.9) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -473,6 +482,12 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) + json_skooma (0.2.5) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + uri-idna (~> 0.2) + zeitwerk (~> 2.6) judoscale-rails (1.8.2) judoscale-ruby (= 1.8.2) railties @@ -480,7 +495,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) @@ -555,9 +570,9 @@ GEM minitest (5.25.5) 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) @@ -567,7 +582,7 @@ GEM 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) @@ -582,30 +597,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) @@ -652,7 +674,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) @@ -670,14 +692,14 @@ 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) @@ -692,7 +714,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) @@ -721,6 +743,9 @@ GEM rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (7.0.10) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) railties (7.1.5.2) actionpack (= 7.1.5.2) activesupport (= 7.1.5.2) @@ -736,7 +761,7 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.22.2) + redis-client (0.26.4) connection_pool redis-namespace (1.10.0) redis (>= 4) @@ -825,17 +850,17 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.9.2) + ruby_llm (1.15.0) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) faraday-multipart (>= 1) faraday-net_http (>= 1) faraday-retry (>= 1) - marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) + marcel (~> 1) + ruby_llm-schema (~> 0) zeitwerk (~> 2) - ruby_llm-schema (0.2.5) + ruby_llm-schema (0.3.0) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -892,10 +917,11 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) - sidekiq-cron (1.12.0) - fugit (~> 1.8) + 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) @@ -910,6 +936,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + skooma (0.3.7) + json_skooma (~> 0.2.5) + zeitwerk (~> 2.6) slack-ruby-client (2.7.0) faraday (>= 2.0.1) faraday-mashify @@ -917,9 +946,9 @@ 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) spring (4.1.1) spring-watcher-listen (2.1.0) @@ -933,6 +962,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) squasher (0.7.2) + ssrf_filter (1.5.0) stackprof (0.2.25) statsd-ruby (1.5.0) stripe (18.0.1) @@ -947,7 +977,7 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.3) + timeout (0.6.1) trailblazer-option (0.1.2) twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) @@ -965,21 +995,25 @@ 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) uniform_notifier (1.17.0) uri (1.1.1) + uri-idna (0.3.1) uri_template (0.7.0) 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) @@ -1004,7 +1038,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS arm64-darwin-20 @@ -1024,7 +1058,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.9.1) + ai-agents (>= 0.12.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) @@ -1059,8 +1093,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) @@ -1079,7 +1115,7 @@ DEPENDENCIES json_schemer judoscale-rails judoscale-sidekiq - jwt + jwt (~> 2.10, >= 2.10.3) kaminari koala letter_opener @@ -1107,13 +1143,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-i18n (~> 7.0) redis redis-namespace responders (>= 3.1.1) @@ -1127,7 +1164,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 @@ -1138,15 +1175,17 @@ DEPENDENCIES sentry-sidekiq (>= 5.19.0) shopify_api shoulda-matchers - sidekiq (>= 7.3.1) - sidekiq-cron (>= 1.12.0) + sidekiq (~> 7.3, >= 7.3.1) + sidekiq-cron (>= 2.4.0) sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter + skooma slack-ruby-client (~> 2.7.0) spring spring-watcher-listen squasher + ssrf_filter (~> 1.5) stackprof stripe (~> 18.0) telephone_number diff --git a/Makefile b/Makefile index 552ebe659..684adacc6 100644 --- a/Makefile +++ b/Makefile @@ -40,8 +40,12 @@ run: fi force_run: - rm -f ./.overmind.sock - rm -f tmp/pids/*.pid + @echo "Cleaning up Overmind processes..." + @lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true + @lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true + @rm -f ./.overmind.sock + @rm -f tmp/pids/*.pid + @echo "Cleanup complete" overmind start -f Procfile.dev force_run_tunnel: diff --git a/VERSION_CW b/VERSION_CW index d782fca8f..fb0557132 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.11.1 +4.15.1 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index bcf5a93c3..9afa5d019 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -104,7 +104,7 @@ class ContactIdentifyAction # blank identifier or email will throw unique index error # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.discard_invalid_attrs if discard_invalid_attrs - @contact.save! + @contact.save! if @contact.changed? enqueue_avatar_job end diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index 532487a1b..5127ea612 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -44,7 +44,11 @@ class AccountBuilder end def create_account - @account = Account.create!(name: account_name, locale: I18n.locale) + @account = Account.create!( + name: account_name, + locale: I18n.locale, + custom_attributes: { 'onboarding_step' => 'account_details' } + ) Current.account = @account end diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 2fe11cae0..d2715011c 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -29,8 +29,9 @@ class AgentBuilder user = User.from_email(email) return user if user + @name = email.split('@').first if @name.blank? temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" - User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) + User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password) end # Checks if the user needs confirmation. diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2c0e6087e..2ba9179ed 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder 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,6 +59,11 @@ class ContactInboxWithContactBuilder ) 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]) diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb index 731b1b0f5..a6da58792 100644 --- a/app/builders/email/base_builder.rb +++ b/app/builders/email/base_builder.rb @@ -1,4 +1,6 @@ class Email::BaseBuilder + include EmailAddressParseable + pattr_initialize [:inbox!] private @@ -39,7 +41,7 @@ class Email::BaseBuilder end def business_name - inbox.business_name || inbox.sanitized_name + inbox.sanitized_business_name end def account_support_email @@ -47,8 +49,4 @@ class Email::BaseBuilder # can save it in the format "Name " parse_email(account.support_email) end - - def parse_email(email_string) - Mail::Address.new(email_string).address - end end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 1f59deadb..c7608399e 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -91,11 +91,21 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder 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 7df72e14a..297e77fcc 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -13,6 +13,7 @@ class Messages::MessageBuilder @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 @@ class Messages::MessageBuilder 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 8821da9d5..712a24608 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -2,16 +2,30 @@ class Messages::Messenger::MessageBuilder include ::FileTypeHelper def process_attachment(attachment) - # This check handles very rare case if there are multiple files to attach with only one usupported file + # This check handles very rare case if there are multiple files to attach with only one unsupported file return if unsupported_file_type?(attachment['type']) - attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) + 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! - attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] + if facebook_reel?(attachment) + update_facebook_reel_content(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) @@ -23,10 +37,14 @@ class Messages::Messenger::MessageBuilder filename: attachment_file.original_filename, content_type: attachment_file.content_type ) + # The Attachment row is saved before the blob is attached, so the + # after_create_commit broadcast bails on `file.attached?`. Re-fire here + # for audio so the bubble updates without waiting on transcription. + attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio end def attachment_params(attachment) - file_type = attachment['type'].to_sym + file_type = normalize_file_type(attachment['type']) params = { file_type: file_type, account_id: @message.account_id } if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type @@ -100,6 +118,35 @@ class Messages::Messenger::MessageBuilder private + # 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, 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) + attachment['type'].to_sym == :reel + end + + def update_facebook_reel_content(attachment) + url = attachment.dig('payload', 'url') + return if url.blank? + + @message.update!(content: url) if @message.content.blank? + end + def unsupported_file_type?(attachment_type) [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym end diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index d5461eb4e..e09e44d50 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -27,6 +27,8 @@ class NotificationBuilder return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? # skip notifications for blocked conversations except for user mentions return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' + # respect conversation access (inbox/team membership and custom-role permissions) + return unless user_can_access_conversation? user.notifications.create!( notification_type: notification_type, @@ -36,4 +38,17 @@ class NotificationBuilder secondary_actor: secondary_actor || current_user ) end + + def user_can_access_conversation? + conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation) + return true if conversation.blank? + + account_user = AccountUser.find_by(account_id: account.id, user_id: user.id) + return false if account_user.blank? + + ConversationPolicy.new( + { user: user, account: account, account_user: account_user }, + conversation + ).show? + end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index fb986d335..b935bd520 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -1,6 +1,7 @@ class V2::ReportBuilder include DateRangeHelper include ReportHelper + attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze diff --git a/app/builders/v2/reports/agent_summary_builder.rb b/app/builders/v2/reports/agent_summary_builder.rb index 9b4541aaa..58689aaad 100644 --- a/app/builders/v2/reports/agent_summary_builder.rb +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group('assignee_id').count - end - def prepare_report account.account_users.map do |account_user| build_agent_stats(account_user) diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb index d4a9e7c0b..bd8ed5f56 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder private def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end + results = data_source.summary - def load_reporting_events_data - # Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id') - index_key = group_by_key.to_s.split('.').last - - results = reporting_events - .select( - "#{group_by_key} as #{index_key}", - "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count", - "AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time", - "AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time", - "AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time" - ) - .group(group_by_key) - .index_by { |record| record.public_send(index_key) } - - @resolved_count = results.transform_values(&:resolved_count) - @avg_resolution_time = results.transform_values(&:avg_resolution_time) - @avg_first_response_time = results.transform_values(&:avg_first_response_time) - @avg_reply_time = results.transform_values(&:avg_reply_time) - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range) - end - - def fetch_conversations_count - # Override this method + @conversations_count = results.transform_values { |data| data[:conversations_count] } + @resolved_count = results.transform_values { |data| data[:resolved_conversations_count] } + @avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] } + @avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] } + @avg_reply_time = results.transform_values { |data| data[:avg_reply_time] } end def group_by_key @@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: nil, + dimension_type: summary_dimension_type, + dimension_id: nil, + scope: nil, + range: range, + group_by: 'day', + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + + def summary_dimension_type + { + 'account_id' => 'account', + 'user_id' => 'agent', + 'inbox_id' => 'inbox', + 'conversations.team_id' => 'team' + }.fetch(group_by_key.to_s) end end diff --git a/app/builders/v2/reports/bot_metrics_builder.rb b/app/builders/v2/reports/bot_metrics_builder.rb index c46daf978..f151e1a66 100644 --- a/app/builders/v2/reports/bot_metrics_builder.rb +++ b/app/builders/v2/reports/bot_metrics_builder.rb @@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder end def bot_resolutions_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range).distinct.count + # Exclude conversations that also had a handoff in the same range — handoff wins + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) + .distinct.count end def bot_handoffs_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, - created_at: range).distinct.count + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .distinct.count + end + + def bot_handoff_conversation_ids_subquery + account.reporting_events + .where(name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) end def bot_resolution_rate diff --git a/app/builders/v2/reports/conversations/base_report_builder.rb b/app/builders/v2/reports/conversations/base_report_builder.rb index a7961b0d6..ee0155150 100644 --- a/app/builders/v2/reports/conversations/base_report_builder.rb +++ b/app/builders/v2/reports/conversations/base_report_builder.rb @@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder private - AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze - COUNT_METRICS = %w[ - conversations_count - incoming_messages_count - outgoing_messages_count - resolutions_count - bot_resolutions_count - bot_handoffs_count - ].freeze - def builder_class(metric) - case metric - when *AVG_METRICS - V2::Reports::Timeseries::AverageReportBuilder - when *COUNT_METRICS - V2::Reports::Timeseries::CountReportBuilder - end + return unless Reports::ReportMetricRegistry.supported?(metric) + + V2::Reports::Timeseries::ReportBuilder end def log_invalid_metric diff --git a/app/builders/v2/reports/drilldown_builder.rb b/app/builders/v2/reports/drilldown_builder.rb new file mode 100644 index 000000000..a3d2c073d --- /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 000000000..04edf65b4 --- /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 935afeb82..fcfabc599 100644 --- a/app/builders/v2/reports/inbox_summary_builder.rb +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end - - def fetch_conversations_count - account.conversations.where(created_at: range).group(group_by_key).count - end - def prepare_report account.inboxes.map do |inbox| build_inbox_stats(inbox) @@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder def group_by_key :inbox_id end - - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value - end end diff --git a/app/builders/v2/reports/team_summary_builder.rb b/app/builders/v2/reports/team_summary_builder.rb index b98151bc6..3bcf51816 100644 --- a/app/builders/v2/reports/team_summary_builder.rb +++ b/app/builders/v2/reports/team_summary_builder.rb @@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group(:team_id).count - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation) - end - def prepare_report account.teams.map do |team| build_team_stats(team) diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb deleted file mode 100644 index 5df718b6a..000000000 --- a/app/builders/v2/reports/timeseries/average_report_builder.rb +++ /dev/null @@ -1,48 +0,0 @@ -class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_average_time = reporting_events.average(average_value_key) - grouped_event_count = reporting_events.count - grouped_average_time.each_with_object([]) do |element, arr| - event_date, average_time = element - arr << { - value: average_time, - timestamp: event_date.in_time_zone(timezone).to_i, - count: grouped_event_count[event_date] - } - end - end - - def aggregate_value - object_scope.average(average_value_key) - end - - private - - def event_name - metric_to_event_name = { - avg_first_response_time: :first_response, - avg_resolution_time: :conversation_resolved, - reply_time: :reply_time - } - metric_to_event_name[params[:metric].to_sym] - end - - def object_scope - scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) - end - - def reporting_events - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ) - end - - def average_value_key - @average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value - end -end diff --git a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb index 50699417d..86fddba07 100644 --- a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb +++ b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb @@ -1,12 +1,13 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder include TimezoneHelper include DateRangeHelper + DEFAULT_GROUP_BY = 'day'.freeze pattr_initialize :account, :params def scope - case params[:type].to_sym + case dimension_type.to_sym when :account account when :inbox @@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder end end + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: params[:metric], + dimension_type: dimension_type, + dimension_id: params[:id], + scope: scope, + range: range, + group_by: group_by, + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder def timezone @timezone ||= timezone_name_from_offset(params[:timezone_offset]) end + + private + + def dimension_type + (params[:type].presence || 'account').to_s + end end diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb deleted file mode 100644 index bb3b1250c..000000000 --- a/app/builders/v2/reports/timeseries/count_report_builder.rb +++ /dev/null @@ -1,78 +0,0 @@ -class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_count.each_with_object([]) do |element, arr| - event_date, event_count = element - - # The `event_date` is in Date format (without time), such as "Wed, 15 May 2024". - # We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i` - # because it converts the date to 12:00 AM server timezone. - # The desired output should be 12:00 AM in the specified timezone. - arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i } - end - end - - def aggregate_value - object_scope.count - end - - private - - def metric - @metric ||= params[:metric] - end - - def object_scope - send("scope_for_#{metric}") - end - - def scope_for_conversations_count - scope.conversations.where(account_id: account.id, created_at: range) - end - - def scope_for_incoming_messages_count - scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) - end - - def scope_for_outgoing_messages_count - scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) - end - - def scope_for_resolutions_count - scope.reporting_events.where( - name: :conversation_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_resolutions_count - scope.reporting_events.where( - name: :conversation_bot_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_handoffs_count - scope.reporting_events.joins(:conversation).select(:conversation_id).where( - name: :conversation_bot_handoff, - account_id: account.id, - created_at: range - ).distinct - end - - def grouped_count - # IMPORTANT: time_zone parameter affects both data grouping AND output timestamps - # It converts timestamps to the target timezone before grouping, which means - # the same event can fall into different day buckets depending on timezone - # Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day) - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ).count - end -end diff --git a/app/builders/v2/reports/timeseries/report_builder.rb b/app/builders/v2/reports/timeseries/report_builder.rb new file mode 100644 index 000000000..a3e83c78a --- /dev/null +++ b/app/builders/v2/reports/timeseries/report_builder.rb @@ -0,0 +1,9 @@ +class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + data_source.timeseries + end + + def aggregate_value + data_source.aggregate + end +end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index c2f919659..de3d10081 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController @agent_bot.reload end + def reset_secret + @agent_bot.reset_secret! + end + private def agent_bot diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb new file mode 100644 index 000000000..7ea9cab5d --- /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 8a6fd61f8..4a8363fdd 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end def reorder - Article.update_positions(params[:positions_hash]) - head :ok + positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + render json: { positions: positions } end private diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afb..0150cb677 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 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC 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/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 156c031fa..04eeff92b 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas 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 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas 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,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas 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( 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 diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/app/controllers/api/v1/accounts/captain/tasks_controller.rb similarity index 93% rename from enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb rename to app/controllers/api/v1/accounts/captain/tasks_controller.rb index d7208d678..9ba197a3c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb +++ b/app/controllers/api/v1/accounts/captain/tasks_controller.rb @@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr if result.nil? render json: { message: nil } elsif result[:error] - render json: { error: result[:error] }, status: :unprocessable_entity + render json: { error: result[:error] }, status: :unprocessable_content else response_data = { message: result[:message] } response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context] @@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr authorize(:'captain/tasks') end end + +Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController') diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index 834b19ed9..655b3c890 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization - before_action :fetch_category, except: [:index, :create] + before_action :fetch_category, except: [:index, :create, :reorder] before_action :set_current_page, only: [:index] def index @@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle head :ok end + def reorder + Category.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + head :ok + end + private def fetch_category @@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle end def portal - @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) end def related_categories_records @@ -48,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle 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/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb new file mode 100644 index 000000000..795d7f2a9 --- /dev/null +++ b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb @@ -0,0 +1,55 @@ +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 000000000..761f00130 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/attachments_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController + RESULTS_PER_PAGE = 100 + + def index + conversations = Conversations::PermissionFilterService.new( + Current.account.conversations.where(contact_id: @contact.id), + Current.user, + Current.account + ).perform + + @attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id)) + .includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }]) + .order(created_at: :desc) + .page(params[:page]) + .per(RESULTS_PER_PAGE) + @attachments_count = @attachments.total_count + end +end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 14d4f2c89..bc1082583 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] @@ -201,7 +201,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def fetch_contact - @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) + contact_scope = Current.account.contacts + contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes + @contact = contact_scope.find(params[:id]) end def process_avatar_from_url @@ -212,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController 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/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a715..b632ac78d 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: 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 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 000000000..d9f15613b --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb @@ -0,0 +1,16 @@ +class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController + before_action :ensure_unread_counts_enabled + + def index + counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform + render json: { payload: counts } + end + + private + + 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 ab1cae17d..2e53fa7c9 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def attachments @attachments_count = @conversation.attachments.count @attachments = @conversation.attachments - .includes(:message) + .includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }]) .order(created_at: :desc) .page(attachment_params[:page]) .per(ATTACHMENT_RESULTS_PER_PAGE) @@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def toggle_typing_status - typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params) + typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params) typing_status_manager.toggle_typing_status head :ok end @@ -116,6 +116,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. # Always update immediately if there are unread messages to maintain accurate read/unread state. + # Visiting a conversation should clear any unread inbox notifications for this conversation. + Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? @@ -138,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro 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 @@ -160,6 +162,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # rubocop:disable Rails/SkipsModelValidations @conversation.update_columns(updates) # rubocop:enable Rails/SkipsModelValidations + + ::Conversations::UnreadCounts::Notifier.new(@conversation).perform end def should_update_last_seen? diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 69df99e14..7bd9ae0c3 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -1,6 +1,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController before_action :fetch_custom_attributes_definitions, except: [:create] before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy] + before_action :check_authorization DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze def index; end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 322c7c7fe..757af9b62 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -4,11 +4,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController 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, :health] - before_action :validate_whatsapp_cloud_channel, only: [:health] + before_action :check_authorization, except: [:show] + + include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index - @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) + @inboxes = policy_scope(Current.account.inboxes) + .includes(:channel, :portal, :working_hours, { avatar_attachment: :blob }) + .order_by_name end def show; end @@ -65,28 +68,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController 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') } 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 - private def fetch_inbox @@ -95,13 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def fetch_agent_bot - @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] - end - - 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 + @agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot] end def create_channel @@ -200,18 +186,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def get_channel_attributes(channel_type) channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] 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 Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index 053c29731..116a31e85 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts enable_fb_login: '0', force_authentication: '1', 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/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e..7bda1c802 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 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC 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/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index eb6525bb1..9ca0c72fd 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas return unless @hook&.access_token begin - linear_client = Linear.new(@hook.access_token) + linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token')) linear_client.revoke_token rescue StandardError => e Rails.logger.error "Failed to revoke Linear token: #{e.message}" diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 54455943b..6889d30a4 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -18,7 +18,16 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController end def destroy + label_title = @label.title + account_id = Current.account.id + label_deleted_at = Time.current + @label.destroy! + Labels::RemoveAssociationsJob.perform_later( + label_title: label_title, + account_id: account_id, + label_deleted_at: label_deleted_at + ) head :ok end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index a300b5f59..c65a3031d 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts { redirect_uri: "#{base_url}/microsoft/callback", scope: scope, - state: state, - prompt: 'consent' + state: state } ) if redirect_url diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 52035ce64..bb5c57ca6 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro def destroy_all if params[:type] == 'read' - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read) else - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all) end head :ok end @@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro end def fetch_notification - @notification = current_user.notifications.find(params[:id]) + @notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id]) end def set_current_page diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb index feb218b59..7fbca86da 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 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC 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 000000000..d7c49b35d --- /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 8eb24b757..c74c0ecfc 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params)) @portal.custom_domain = parsed_custom_domain @portal.save! - process_attached_logo + process_attached_logo if params[:blob_id].present? end def update @@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def process_attached_logo - blob_id = params[:blob_id] - blob = ActiveStorage::Blob.find_signed(blob_id) - @portal.logo.attach(blob) + blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s) + @portal.logo.attach(blob) if blob end private @@ -79,16 +78,23 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController 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: [] }] } + :name, :page_title, :slug, :archived, + { config: [: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] } }] } ) end + def locale_translation_keys + params.dig(:portal, :config, :locale_translations)&.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 +105,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def parsed_custom_domain + return @portal.custom_domain if @portal.custom_domain.blank? + domain = URI.parse(@portal.custom_domain) domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain end diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index e8688dcfb..6239e00eb 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController 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 7c7320393..64bf38775 100644 --- a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O 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 diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 479d8ae1b..bf20bc6ff 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController elsif params[:external_url].present? create_from_url else - render_error('No file or URL provided', :unprocessable_entity) + render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity) end render_success(result) if result.is_a?(ActiveStorage::Blob) @@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController end def create_from_url - uri = parse_uri(params[:external_url]) - return if performed? - - fetch_and_process_file_from_uri(uri) - end - - def parse_uri(url) - uri = URI.parse(url) - validate_uri(uri) - uri - rescue URI::InvalidURIError, SocketError - render_error('Invalid URL provided', :unprocessable_entity) - nil - end - - def validate_uri(uri) - raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - end - - def fetch_and_process_file_from_uri(uri) - uri.open do |file| - create_and_save_blob(file, File.basename(uri.path), file.content_type) + SafeFetch.fetch(params[:external_url].to_s) do |result| + create_and_save_blob(result.tempfile, result.filename, result.content_type) end - rescue OpenURI::HTTPError => e - render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity) - rescue SocketError - render_error('Invalid URL provided', :unprocessable_entity) + rescue SafeFetch::HttpError => e + render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity) + rescue SafeFetch::FetchError + render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity) + rescue SafeFetch::FileTooLargeError + render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity) + rescue SafeFetch::UnsupportedContentTypeError + render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity) + rescue SafeFetch::Error + render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity) rescue StandardError - render_error('An unexpected error occurred', :internal_server_error) + render_error(I18n.t('errors.upload.unexpected'), :internal_server_error) end def create_and_save_blob(io, filename, content_type) 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 96d98293a..000000000 --- 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 bcbf80355..fb991949a 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController locale: account_params[:locale], user: current_user ).perform + enqueue_branding_enrichment if @user - send_auth_headers(@user) - render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + # Authenticated users (dashboard "add account") and api_only signups + # need the full response with account_id. API-only deployments have no + # frontend to handle the email confirmation flow, so they need auth + # tokens to proceed. + # Unauthenticated web signup returns only the email — no session is + # created until the user confirms via the email link. + if current_user || api_only_signup? + send_auth_headers(@user) + render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + else + render json: { email: @user.email } + end else render_error_response(CustomExceptions::Account::SignupFailed.new({})) end @@ -59,6 +70,17 @@ class Api::V1::AccountsController < Api::BaseController private + def enqueue_branding_enrichment + email = account_params[:email].presence || @user&.email + return if email.blank? + + 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 + ChatwootExceptionTracker.new(e).capture_exception + end + def ensure_account_name # ensure that account_name and user_full_name is present # this is becuase the account builder and the models validations are not triggered @@ -88,7 +110,7 @@ class Api::V1::AccountsController < Api::BaseController end def custom_attributes_params - params.permit(:industry, :company_size, :timezone) + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) end def settings_params @@ -100,7 +122,16 @@ class Api::V1::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? + end + + def api_only_signup? + # CW_API_ONLY_SERVER is the canonical flag for API-only deployments. + # ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose. + # Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get + # typecasts it to boolean, coercing 'api_only' to true. + ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || + InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only' end def validate_captcha diff --git a/app/controllers/api/v1/notification_subscriptions_controller.rb b/app/controllers/api/v1/notification_subscriptions_controller.rb index 1a797a74d..bb20c64ae 100644 --- a/app/controllers/api/v1/notification_subscriptions_controller.rb +++ b/app/controllers/api/v1/notification_subscriptions_controller.rb @@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController end def destroy - notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first + notification_subscription = current_user.notification_subscriptions + .where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first notification_subscription.destroy! if notification_subscription.present? head :ok end diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb index dd874f222..8480b64fb 100644 --- a/app/controllers/api/v1/profile/mfa_controller.rb +++ b/app/controllers/api/v1/profile/mfa_controller.rb @@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController before_action :check_mfa_feature_available before_action :check_mfa_enabled, only: [:destroy, :backup_codes] before_action :check_mfa_disabled, only: [:create, :verify] - before_action :validate_otp, only: [:verify, :backup_codes, :destroy] before_action :validate_password, only: [:destroy] + before_action :validate_otp, only: [:verify, :backup_codes, :destroy] def show; end @@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController def validate_otp authenticated = Mfa::AuthenticationService.new( user: current_user, - otp_code: mfa_params[:otp_code] + otp_code: mfa_params[:otp_code], + backup_code: mfa_params[:backup_code] ).authenticate return if authenticated @@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController end def mfa_params - params.permit(:otp_code, :password) + params.permit(:otp_code, :backup_code, :password) end end diff --git a/app/controllers/api/v1/profile/sessions_controller.rb b/app/controllers/api/v1/profile/sessions_controller.rb new file mode 100644 index 000000000..72e9451eb --- /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/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 5138fe675..6c595ab59 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController contact = @contact end - @contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac? + @contact_inbox.update(hmac_verified: true) if should_verify_hmac? identify_contact(contact) end diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3c..fde425b26 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 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr 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 a51b4c2d6..8b1e8ef48 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_conversation - @conversation = create_conversation if conversation.nil? + return unless conversation.nil? + + @conversation = create_conversation + apply_labels if permitted_params[:labels].present? + end + + def apply_labels + valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title) + @conversation.update_labels(valid_labels) if valid_labels.present? end def message_finder_params @@ -64,10 +72,21 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) + # custom_attributes and labels are applied when a new conversation is created alongside the first message + params.permit( + :id, :before, :after, :website_token, + contact: [:name, :email], + message: [:content, :referer_url, :timestamp, :echo_id, :reply_to], + custom_attributes: {}, + labels: [] + ) 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 192b3619c..93be19eb9 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController 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 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController }) 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 98b3f05d7..40d5947b9 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel] def agent - render_report_with(V2::Reports::AgentSummaryBuilder) + render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent) end def team - render_report_with(V2::Reports::TeamSummaryBuilder) + render_report_with(V2::Reports::TeamSummaryBuilder, type: :team) end def inbox - render_report_with(V2::Reports::InboxSummaryBuilder) + render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox) end def label @@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr } end - def render_report_with(builder_class) - builder = builder_class.new(account: Current.account, params: @builder_params) + def render_report_with(builder_class, type: nil) + builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params + builder = builder_class.new(account: Current.account, params: builder_params) render json: builder.build end diff --git a/app/controllers/api/v2/accounts_controller.rb b/app/controllers/api/v2/accounts_controller.rb index bed0a212a..5a19ddeed 100644 --- a/app/controllers/api/v2/accounts_controller.rb +++ b/app/controllers/api/v2/accounts_controller.rb @@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end def validate_captcha diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2f389049d..9dea4b4da 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/auth/resend_confirmations_controller.rb b/app/controllers/auth/resend_confirmations_controller.rb new file mode 100644 index 000000000..b2c778c46 --- /dev/null +++ b/app/controllers/auth/resend_confirmations_controller.rb @@ -0,0 +1,18 @@ +# Unauthenticated endpoint for resending confirmation emails during signup. +# This is a standalone controller (not on DeviseOverrides::ConfirmationsController) +# because OmniAuth middleware intercepts all POST /auth/* routes as provider +# callbacks, and Devise controller filters cause 307 redirects for custom actions. +# Inherits from ActionController::API to avoid both issues entirely. +# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha. +class Auth::ResendConfirmationsController < ActionController::API + def create + return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? + + email = params[:email] + return head(:ok) unless email.is_a?(String) + + user = User.from_email(email.strip.downcase) + user&.send_confirmation_instructions unless user&.confirmed? + head :ok + end +end diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 338b290da..fb52a8eeb 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_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 @@ -28,7 +29,7 @@ module AccessTokenAuthHelper def validate_bot_access_token! return if Current.user.is_a?(User) - return if agent_bot_accessible? + return if @resource.is_a?(AgentBot) && agent_bot_accessible? render_unauthorized('Access to this endpoint is not authorized for bots') end diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb index b3f920644..42fe918cc 100644 --- a/app/controllers/concerns/meta_token_verify_concern.rb +++ b/app/controllers/concerns/meta_token_verify_concern.rb @@ -2,6 +2,10 @@ # This concern handles the token verification step. module MetaTokenVerifyConcern + CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze + META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze + META_SIGNATURE_PREFIX = 'sha256='.freeze + def verify service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' if valid_token?(params['hub.verify_token']) @@ -14,6 +18,53 @@ module MetaTokenVerifyConcern private + def verify_meta_signature! + return unless meta_signature_verification_required? + return if valid_meta_signature? + + head :unauthorized + end + + def valid_meta_signature? + signature = request.headers[META_SIGNATURE_HEADER] + return false unless signature&.start_with?(META_SIGNATURE_PREFIX) + + meta_app_secrets.any? do |secret| + next false if secret.blank? + + expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}" + ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature) + end + end + + def meta_request_body + @meta_request_body ||= request.raw_post + end + + def meta_app_secrets + raise 'Overwrite this method in your controller' + end + + def meta_signature_verification_required? + true + end + + def channel_meta_app_secrets(channel) + return [] if channel.blank? + + secrets = [] + secrets << channel.app_secret if channel.respond_to?(:app_secret) + secrets.concat(provider_config_meta_app_secrets(channel)) + secrets.compact_blank.uniq + end + + def provider_config_meta_app_secrets(channel) + return [] unless channel.respond_to?(:provider_config) + + provider_config = channel.provider_config.to_h.with_indifferent_access + CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence } + end + def valid_token?(_token) raise 'Overwrite this method your controller' end diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb new file mode 100644 index 000000000..633071301 --- /dev/null +++ b/app/controllers/concerns/portal_home_data.rb @@ -0,0 +1,29 @@ +module PortalHomeData + extend ActiveSupport::Concern + + private + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + 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/track_session_activity.rb b/app/controllers/concerns/track_session_activity.rb new file mode 100644 index 000000000..f6a512922 --- /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 d57ad0e53..a369830b6 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 @@ -63,6 +64,10 @@ class DashboardController < ActionController::Base return unless @portal @locale = @portal.default_locale + if @portal.layout == 'documentation' + request.variant = :documentation + load_home_data + end render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end @@ -80,10 +85,17 @@ class DashboardController < ActionController::Base IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), GIT_SHA: GIT_HASH, - ALLOWED_LOGIN_METHODS: allowed_login_methods + ALLOWED_LOGIN_METHODS: allowed_login_methods, + ACTIVE_PLATFORM_BANNERS: active_platform_banners } end + def active_platform_banners + return [] unless ChatwootApp.chatwoot_cloud? + + PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at]) + end + def allowed_login_methods methods = ['email'] methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false' diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index 900125670..2c8387142 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -51,8 +60,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def account_signup_allowed? - # set it to true by default, this is the behaviour across the app - GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false' + GlobalConfigService.account_signup_enabled? end def resource_class(_mapping = nil) @@ -82,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' end diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 974fb05e4..587b52c83 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -1,4 +1,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController + MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i + # Prevent session parameter from being passed # Unpermitted parameter: session wrap_parameters format: [] @@ -14,17 +16,27 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController 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 find_user_for_authentication return nil unless params[:email].present? && params[:password].present? @@ -45,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController 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 +71,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController 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 +82,21 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController @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 +122,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController 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 +134,103 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController def render_mfa_error(message_key, status = :bad_request) render json: { error: I18n.t(message_key) }, status: status end + + def sessions_limit_reached?(user) + active_token_count(user) >= MAX_SESSIONS + 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 4dc8ece1c..cd317363c 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -28,6 +28,8 @@ class Instagram::CallbacksController < ApplicationController @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 @@ -149,6 +151,10 @@ class Instagram::CallbacksController < ApplicationController 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/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb index 2eea49333..cdf6f630e 100644 --- a/app/controllers/linear/callbacks_controller.rb +++ b/app/controllers/linear/callbacks_controller.rb @@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController include Linear::IntegrationHelper def show + return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank? + @response = oauth_client.auth_code.get_token( params[:code], redirect_uri: "#{base_url}/linear/callback" @@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController handle_response rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end private @@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController end def handle_response - hook = account.hooks.new( + raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank? + + hook = account.hooks.find_or_initialize_by(app_id: 'linear') + hook.assign_attributes( access_token: parsed_body['access_token'], status: 'enabled', - app_id: 'linear', - settings: { - token_type: parsed_body['token_type'], - expires_in: parsed_body['expires_in'], - scope: parsed_body['scope'] - } + settings: merged_integration_settings(hook.settings) ) - # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 hook.save! redirect_to linear_redirect_uri rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end def account @@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController end def account_id - return unless params[:state] + return @account_id if instance_variable_defined?(:@account_id) - verify_linear_token(params[:state]) + @account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil end def linear_redirect_uri "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" end + def safe_linear_redirect_uri + return base_url if account_id.blank? + + linear_redirect_uri + rescue StandardError + base_url + end + def parsed_body @parsed_body ||= @response.response.parsed end + def integration_settings + { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + expires_on: expires_on, + scope: parsed_body['scope'], + refresh_token: parsed_body['refresh_token'] + }.compact + end + + def merged_integration_settings(existing_settings) + existing_settings.to_h.with_indifferent_access.merge(integration_settings) + end + + def expires_on + return if parsed_body['expires_in'].blank? + + (Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s + end + def base_url ENV.fetch('FRONTEND_URL', 'http://localhost:3000') end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 2f07505fc..045789f75 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController 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 be0fa5008..4a3d049a6 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController 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 @@ class OauthCallbackController < ApplicationController 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 @@ class OauthCallbackController < ApplicationController }) 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 @@ class OauthCallbackController < ApplicationController 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 @@ class OauthCallbackController < ApplicationController @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 dd70a1ba5..594bb856e 100644 --- a/app/controllers/platform/api/v1/agent_bots_controller.rb +++ b/app/controllers/platform/api/v1/agent_bots_controller.rb @@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController before_action :validate_platform_app_permissible, except: [:index, :create] def index - @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all + @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible) end def show; end diff --git a/app/controllers/platform/api/v1/email_channel_migrations_controller.rb b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb new file mode 100644 index 000000000..3e9e8defd --- /dev/null +++ b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb @@ -0,0 +1,101 @@ +class Platform::Api::V1::EmailChannelMigrationsController < PlatformController + before_action :set_account + before_action :validate_account_permissible + before_action :validate_feature_flag + before_action :validate_params + + def create + results = migrate_email_channels + render json: { results: results }, status: :ok + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def validate_account_permissible + return if @platform_app.platform_app_permissibles.find_by(permissible: @account) + + render json: { error: 'Non permissible resource' }, status: :unauthorized + end + + def validate_feature_flag + return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false)) + + render json: { error: 'Email channel migration is not enabled' }, status: :forbidden + end + + def validate_params + return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank? + + return unless migration_params.size > MAX_MIGRATIONS + + return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" }, + status: :unprocessable_entity + end + + def migrate_email_channels + migration_params.map { |entry| migrate_single(entry) } + end + + MAX_MIGRATIONS = 25 + SUPPORTED_PROVIDERS = %w[google microsoft].freeze + + def migrate_single(entry) + validate_provider!(entry[:provider]) + + ActiveRecord::Base.transaction do + channel = create_channel(entry) + inbox = create_inbox(channel, entry) + + { email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' } + end + rescue StandardError => e + { email: entry[:email], status: 'error', message: e.message } + end + + def create_channel(entry) + Channel::Email.create!( + account_id: @account.id, + email: entry[:email], + provider: entry[:provider], + provider_config: entry[:provider_config]&.to_h, + imap_enabled: entry.fetch(:imap_enabled, true), + imap_address: entry[:imap_address] || default_imap_address(entry[:provider]), + imap_port: entry[:imap_port] || 993, + imap_login: entry[:imap_login] || entry[:email], + imap_enable_ssl: entry.fetch(:imap_enable_ssl, true) + ) + end + + def create_inbox(channel, entry) + @account.inboxes.create!( + name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}", + channel: channel + ) + end + + def validate_provider!(provider) + return if SUPPORTED_PROVIDERS.include?(provider) + + raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}" + end + + def default_imap_address(provider) + case provider + when 'google' then 'imap.gmail.com' + when 'microsoft' then 'outlook.office365.com' + else '' + end + end + + def migration_params + params.permit(migrations: [ + :email, :provider, :inbox_name, + :imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl, + { provider_config: {} } + ])[:migrations] + end +end diff --git a/app/controllers/public/api/v1/inboxes_controller.rb b/app/controllers/public/api/v1/inboxes_controller.rb index 65fad57b1..5679a4a3e 100644 --- a/app/controllers/public/api/v1/inboxes_controller.rb +++ b/app/controllers/public/api/v1/inboxes_controller.rb @@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController def set_conversation return if params[:conversation_id].blank? - @conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + @conversation = if @contact_inbox.hmac_verified? + @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + else + @contact_inbox.conversations.find_by!(display_id: params[:conversation_id]) + end end end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index e6cc2aa69..6a83d08fb 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,11 +1,15 @@ 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 + @search_query = list_params[:query] @articles = @portal.articles.published.includes(:category, :author) @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? @@ -19,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B 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 @@ -60,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content) end def set_category @@ -73,7 +83,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B end def list_params - params.permit(:query, :locale, :sort, :status, :page, :per_page) + @list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted| + permitted[:query] = permitted[:query].to_s.strip.presence + end end def permitted_params diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 46158bce9..323440304 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,14 @@ class Public::Api::V1::Portals::BaseController < PublicController @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 = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled + end + def portal @portal ||= Portal.find_by!(slug: params[:slug], archived: false) end @@ -29,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController 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 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - 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 diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index ebfcb310a..d1ebe92c6 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,11 +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 @@ -20,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: 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 000000000..104741773 --- /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 df4552432..4982278d7 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,11 +1,17 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController + include PortalHomeData + before_action :ensure_custom_domain_request, only: [:show] - before_action :portal 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], if: -> { @portal_layout == 'documentation' } 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 @@ -24,6 +30,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl def redirect_to_portal_with_locale return if params[:locale].present? + portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end end diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 3b83a2210..b266b725b 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -18,4 +18,11 @@ class PublicController < ActionController::Base Please send us an email at support@chatwoot.com with the custom domain name and account API key" }, status: :unauthorized and return end + + def ensure_portal_feature_enabled + return unless ChatwootApp.chatwoot_cloud? + return if @portal.account.feature_enabled?('help_center') + + render 'public/api/v1/portals/not_active', status: :payment_required + end end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 27ce587f7..59b99c37e 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # def resource_params permitted_params = super - permitted_params[:limits] = permitted_params[:limits].to_h.compact + 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 diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef1..86d1b70ef 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController if errors.any? redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ') else - redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" + redirect_to super_admin_settings_path, flash: success_flash end end @@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY] ) end + + def success_notice + message = "#{@config.titleize} settings updated successfully" + return message unless restart_required_config_saved? + + "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." + end + + def success_flash + restart_required_config_saved? ? { success: success_notice } : { notice: success_notice } + end + + def restart_required_config_saved? + params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end end SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController') diff --git a/app/controllers/super_admin/installation_configs_controller.rb b/app/controllers/super_admin/installation_configs_controller.rb index b1f15b518..e888b91e7 100644 --- a/app/controllers/super_admin/installation_configs_controller.rb +++ b/app/controllers/super_admin/installation_configs_controller.rb @@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl resource_class.editable end + def create + resource = new_resource(resource_params) + authorize_resource(resource) + + if resource.save + redirect_to after_resource_created_path(resource), flash: success_flash(resource) + else + render :new, locals: { + page: Administrate::Page::Form.new(dashboard, resource) + }, status: :unprocessable_entity + end + end + + def update + if requested_resource.update(resource_params) + redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource) + else + render :edit, locals: { + page: Administrate::Page::Form.new(dashboard, requested_resource) + }, status: :unprocessable_entity + end + end + # Override `resource_params` if you want to transform the submitted # data before it's persisted. For example, the following would turn all # empty values into nil values. It uses other APIs such as `resource_class` @@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl .transform_values { |value| value == '' ? nil : value }.merge(locked: false) end + private + + def success_flash(resource) + message = translate_with_resource('update.success') + message = translate_with_resource('create.success') if action_name == 'create' + return { notice: message } unless restart_required_config?(resource) + + { success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." } + end + + def restart_required_config?(resource) + resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end + # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # for more information end diff --git a/app/controllers/super_admin/platform_banners_controller.rb b/app/controllers/super_admin/platform_banners_controller.rb new file mode 100644 index 000000000..e4bf727a0 --- /dev/null +++ b/app/controllers/super_admin/platform_banners_controller.rb @@ -0,0 +1,9 @@ +class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController + before_action :ensure_chatwoot_cloud + + private + + def ensure_chatwoot_cloud + raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud? + end +end diff --git a/app/controllers/super_admin/push_diagnostics_controller.rb b/app/controllers/super_admin/push_diagnostics_controller.rb new file mode 100644 index 000000000..c33cfdc1e --- /dev/null +++ b/app/controllers/super_admin/push_diagnostics_controller.rb @@ -0,0 +1,68 @@ +class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController + def show + @query = params[:user_query].to_s.strip + @user = resolve_user(@query) + @subscriptions = @user ? @user.notification_subscriptions.order(:id) : [] + @results = [] + end + + def create + @user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: @user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test') + end + + run_test_and_render(ids) + end + + def destroy_subscriptions + user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete') + end + + deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size + log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}") + redirect_to super_admin_push_diagnostics_path(user_query: user.id), + notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count) + end + + private + + def run_test_and_render(ids) + @query = @user.id.to_s + @subscriptions = @user.notification_subscriptions.order(:id) + @results = Notification::PushTestService.new( + user: @user, subscription_ids: ids, + title: params[:push_title], body: params[:push_body] + ).perform + + log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}") + render :show + end + + def log_super_admin_action(message) + Rails.logger.info( + "[SuperAdmin] push diagnostics #{message} " \ + "(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})" + ) + end + + def resolve_user(query) + return if query.blank? + + query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query) + end + + def parsed_subscription_ids + Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i) + end +end diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb index af5a4b039..680bf92fc 100644 --- a/app/controllers/swagger_controller.rb +++ b/app/controllers/swagger_controller.rb @@ -1,7 +1,12 @@ class SwaggerController < ApplicationController def respond if Rails.env.development? || Rails.env.test? - render inline: Rails.root.join('swagger', derived_path).read + swagger_root = Rails.root.join('swagger') + file_path = swagger_root.join(derived_path).cleanpath + + return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file? + + render inline: file_path.read else head :not_found end @@ -11,8 +16,8 @@ class SwaggerController < ApplicationController def derived_path params[:path] ||= 'index.html' - path = Rack::Utils.clean_path_info(params[:path]) - path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s) + path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/') + path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s) path end end diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index e484905c3..20c0ee9c0 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -20,6 +20,8 @@ class Tiktok::CallbacksController < ApplicationController 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 @@ -127,6 +129,10 @@ class Tiktok::CallbacksController < ApplicationController @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 d607ba151..ed1a05376 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -31,7 +31,21 @@ class Twilio::CallbackController < ApplicationController :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 569c2524b..6e5168634 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -1,6 +1,8 @@ class Webhooks::InstagramController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :events + def events Rails.logger.info('Instagram webhook received events') if params['object'].casecmp('instagram').zero? @@ -39,4 +41,38 @@ class Webhooks::InstagramController < ActionController::API token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end + + def meta_app_secrets + [ + *instagram_channel_meta_app_secrets, + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil), + GlobalConfigService.load('FB_APP_SECRET', nil) + ] + end + + def instagram_channel_meta_app_secrets + instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) } + end + + def instagram_channels_from_payload + Array(params.to_unsafe_hash[:entry]).flat_map do |entry| + instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id| + [ + Channel::Instagram.find_by(instagram_id: instagram_id), + Channel::FacebookPage.find_by(instagram_id: instagram_id) + ] + end + end.compact.uniq + end + + def instagram_ids_from_entry(entry) + messages = entry[:messaging].presence || entry[:standby] || [] + messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) } + end + + def instagram_id_from_messaging(messaging) + return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present? + + messaging.dig(:recipient, :id) + end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c..ee71f3c92 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -1,6 +1,8 @@ class Webhooks::WhatsappController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :process_payload + def process_payload if inactive_whatsapp_number? Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}") @@ -20,6 +22,45 @@ class Webhooks::WhatsappController < ActionController::API token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + def meta_app_secrets + [ + *channel_meta_app_secrets(whatsapp_channel), + GlobalConfigService.load('WHATSAPP_APP_SECRET', nil) + ] + end + + def whatsapp_channel + @whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + end + + def meta_signature_verification_required? + return true if whatsapp_channel.blank? + return false unless whatsapp_channel.provider == 'whatsapp_cloud' + return true if channel_meta_app_secrets(whatsapp_channel).present? + + whatsapp_channel.provider_config['source'] == 'embedded_signup' + end + + def whatsapp_business_payload_channel + return unless params[:object] == 'whatsapp_business_account' + + metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) + return if metadata.blank? + + phone_number = normalized_phone_number(metadata[:display_phone_number]) + phone_number_id = metadata[:phone_number_id] + channel = Channel::Whatsapp.find_by(phone_number: phone_number) + + return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id + end + + def normalized_phone_number(phone_number) + return if phone_number.blank? + + phone_number = phone_number.to_s + phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + end + def inactive_whatsapp_number? phone_number = params[:phone_number] return false if phone_number.blank? diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 7f45ce636..913319303 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base end def allow_iframe_requests - if @web_widget.allowed_domains.blank? + if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin? response.headers.delete('X-Frame-Options') else domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ') response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}" end end + + # Mobile WebViews (iOS/Android) load content from file:// or null origins, + # which cannot match any domain in frame-ancestors. When the per-inbox flag + # is enabled, skip frame-ancestors for these requests. + def embedded_from_non_web_origin? + return false unless @web_widget.allow_mobile_webview? + + origin = request.headers['Origin'] + origin.blank? || origin == 'null' || origin&.start_with?('file://') + end end WidgetsController.prepend_mod_with('WidgetsController') diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f11..b2683f2e0 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 @@ -57,6 +58,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 [] @@ -79,6 +81,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 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) - attrs = super + [limits: {}] + attrs = super + [limits: {}, captain_models: {}] # 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 000000000..41674406a --- /dev/null +++ b/app/dashboards/platform_banner_dashboard.rb @@ -0,0 +1,20 @@ +require 'administrate/base_dashboard' + +class PlatformBannerDashboard < Administrate::BaseDashboard + ATTRIBUTE_TYPES = { + id: Field::Number, + banner_message: Field::Text.with_options(truncate: 200), + banner_type: Field::Select.with_options(collection: %w[info warning error]), + active: Field::Boolean, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze + SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze + FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze + + def display_resource(platform_banner) + "Banner ##{platform_banner.id} (#{platform_banner.banner_type})" + end +end diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 753b617ef..b499193cc 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard current_sign_in_ip: Field::String, last_sign_in_ip: Field::String, confirmation_token: Field::String, - confirmed_at: Field::DateTime, + confirmed_at: ConfirmedAtField, confirmation_sent_at: Field::DateTime, unconfirmed_email: Field::String, name: Field::String.with_options(searchable: true), diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 7416b7861..abf3ca354 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher InstallationWebhookListener.instance, NotificationListener.instance, ParticipationListener.instance, + Conversations::UnreadCounts::Listener.instance, ReportingEventListener.instance, WebhookListener.instance ] diff --git a/app/drops/contact_drop.rb b/app/drops/contact_drop.rb index 1d450adb7..16240ec06 100644 --- a/app/drops/contact_drop.rb +++ b/app/drops/contact_drop.rb @@ -12,7 +12,7 @@ class ContactDrop < BaseDrop 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/user_drop.rb b/app/drops/user_drop.rb index 7cafea1bb..83d5e2347 100644 --- a/app/drops/user_drop.rb +++ b/app/drops/user_drop.rb @@ -7,11 +7,15 @@ class UserDrop < BaseDrop @obj.try(:available_name) end + def email + @obj.try(:email) + end + def first_name - @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:first).try(:capitalize) 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/confirmed_at_field.rb b/app/fields/confirmed_at_field.rb new file mode 100644 index 000000000..67e04c4a0 --- /dev/null +++ b/app/fields/confirmed_at_field.rb @@ -0,0 +1,4 @@ +require 'administrate/field/base' + +class ConfirmedAtField < Administrate::Field::DateTime +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index fa437327d..74bf903f5 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -12,6 +12,7 @@ class ConversationFinder '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], + 'unread' => %w[sort_on_unread desc], # To be removed in v3.5.0 'latest' => %w[sort_on_last_activity_at desc], @@ -40,7 +41,7 @@ class ConversationFinder 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 @@ -184,6 +185,17 @@ class ConversationFinder 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)'), + Arel.sql('COUNT(*)') + ) + counts || [0, 0, 0] + end + + def legacy_count_for_all_conversations [ @conversations.assigned_to(current_user).count, @conversations.unassigned.count, diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 8854e239a..bf82d61d5 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -48,3 +48,5 @@ class MessageFinder messages.reorder('created_at desc').limit(20).reverse end end + +MessageFinder.prepend_mod_with('MessageFinder') diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 3d6b559c8..8a10fa99c 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -17,15 +17,12 @@ module Api::V1::InboxesHelper def validate_imap(channel_data) return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled] - Mail.defaults do - retriever_method :imap, { address: channel_data[:imap_address], - port: channel_data[:imap_port], - user_name: channel_data[:imap_login], - password: channel_data[:imap_password], - enable_ssl: channel_data[:imap_enable_ssl] } - end + # Validate the user-selected auth mechanism before opening the connection. + authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication]) - check_imap_connection(channel_data) + # Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command, + # not SASL AUTH=LOGIN. + check_imap_connection(channel_data, authentication) end def validate_smtp(channel_data) @@ -37,8 +34,8 @@ module Api::V1::InboxesHelper check_smtp_connection(channel_data, smtp) end - def check_imap_connection(channel_data) - Mail.connection {} # rubocop:disable:block + def check_imap_connection(channel_data, authentication) + imap = open_imap_connection(channel_data, authentication) rescue SocketError => e raise StandardError, I18n.t('errors.inboxes.imap.socket_error') rescue Net::IMAP::NoResponseError => e @@ -53,9 +50,20 @@ module Api::V1::InboxesHelper rescue StandardError => e raise StandardError, e.message ensure + imap.disconnect if imap.present? && !imap.disconnected? Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present? end + def open_imap_connection(channel_data, authentication) + imap = build_imap_connection(channel_data) + Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password]) + imap + end + + def build_imap_connection(channel_data) + Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl]) + end + def check_smtp_connection(channel_data, smtp) smtp.open_timeout = 10 smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index fcc8b463d..67e8d953d 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -7,7 +7,7 @@ module EmailHelper def render_email_html(content) return '' if content.blank? - ChatwootMarkdownRenderer.new(content).render_message.to_s + ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s end # Raise a standard error if any email address is invalid diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb index 03b807aad..6bae0d3d1 100644 --- a/app/helpers/file_type_helper.rb +++ b/app/helpers/file_type_helper.rb @@ -17,6 +17,7 @@ module FileTypeHelper 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 fe03dae28..4f345676e 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -47,11 +47,15 @@ module Filters::FilterHelper def handle_additional_attributes(query_hash, filter_operator_value, data_type) if data_type == 'text_case_insensitive' - "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ - "#{filter_operator_value} #{query_hash[:query_operator]}" + ActiveRecord::Base.sanitize_sql_array( + ["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}", + query_hash[:attribute_key]] + ) else - "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \ - "#{filter_operator_value} #{query_hash[:query_operator]} " + ActiveRecord::Base.sanitize_sql_array( + ["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ", + query_hash[:attribute_key]] + ) end end @@ -70,7 +74,7 @@ module Filters::FilterHelper def date_filter(current_filter, query_hash, filter_operator_value) "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ - "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}" + "#{filter_operator_value} #{query_hash[:query_operator]}" end def text_case_insensitive_filter(query_hash, filter_operator_value) diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb index 8ba57bf95..2f91eb6b0 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 @@ module Instagram::IntegrationHelper 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 @@ module Instagram::IntegrationHelper 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 15de0fbd7..64e46f0b7 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -17,15 +17,11 @@ module PortalHelper 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 @@ module PortalHelper 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 @@ module PortalHelper 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 @@ module PortalHelper 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 @@ module PortalHelper 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 09a84b110..d5b773c87 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -53,13 +53,12 @@ module ReportHelper end def resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range) end def bot_resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) end def bot_handoffs @@ -67,6 +66,10 @@ module ReportHelper created_at: range).distinct end + def bot_handoff_conversation_ids_subquery + bot_handoffs + end + def avg_first_response_time grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index d4385c29d..2975f395e 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 b2de4a092..7bc8bc4ab 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 @@ module Tiktok::IntegrationHelper 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 @@ module Tiktok::IntegrationHelper @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/helpers/timezone_helper.rb b/app/helpers/timezone_helper.rb index b016cc9d9..d4a05f054 100644 --- a/app/helpers/timezone_helper.rb +++ b/app/helpers/timezone_helper.rb @@ -1,4 +1,10 @@ module TimezoneHelper + def timezone_name_from_params(timezone, offset) + return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present? + + timezone_name_from_offset(offset) + end + # ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset] # would return the timezone without considering day light savings. To get the correct timezone, # this method uses zone.now.utc_offset for comparison as referenced in the issues below diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8912c03d1..99aebfd1a 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -3,6 +3,7 @@ import { mapGetters } from 'vuex'; import LoadingState from './components/widgets/LoadingState.vue'; import NetworkNotification from './components/NetworkNotification.vue'; import UpdateBanner from './components/app/UpdateBanner.vue'; +import StatusBanner from './components/app/StatusBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import vueActionCable from './helper/actionCable'; @@ -27,6 +28,7 @@ export default { LoadingState, NetworkNotification, UpdateBanner, + StatusBanner, PaymentPendingBanner, WootSnackbarBox, PendingEmailVerificationBanner, @@ -59,7 +61,6 @@ export default { isRTL: 'accounts/isRTL', currentUser: 'getCurrentUser', authUIFlags: 'getAuthUIFlags', - accountUIFlags: 'accounts/getUIFlags', }), hideOnOnboardingView() { return !isOnOnboardingView(this.$route); @@ -98,15 +99,18 @@ export default { mql.onchange = e => setColorTheme(e.matches); }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, async initializeAccount() { await this.$store.dispatch('accounts/get'); 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); @@ -129,12 +133,13 @@ export default {