Compare commits

..
Author SHA1 Message Date
Shivam Mishra e1e05e103e Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/rollup-refactor 2026-03-13 10:45:19 +05:30
Shivam Mishra 5454f5bfe1 refactor: frontend IANA 2026-03-12 13:45:42 +05:30
Shivam Mishra 05813e63f8 style: format metrics 2026-03-11 19:49:14 +05:30
Shivam Mishra 5e2fb35b0c refactor: derive summary selects from metric registry 2026-03-11 19:41:55 +05:30
Shivam Mishra 295c5f8fc0 refactor: cleanup registry 2026-03-11 19:35:44 +05:30
Shivam Mishra c2b81f9ea9 refactor: aggregate backfill rollups in sql 2026-03-11 19:33:33 +05:30
Shivam Mishra b6d64ac870 refactor: simplify rollup service 2026-03-11 19:27:46 +05:30
Shivam Mishra 34a2e72d19 refactor: remove dead timeseries wrappers 2026-03-11 19:26:52 +05:30
Shivam Mishra 629133b56a refactor: share report timeseries builder 2026-03-11 19:21:26 +05:30
Shivam Mishra 7fcf04a3a4 refactor: extend reporting metric registry 2026-03-11 19:21:06 +05:30
Shivam Mishra 9939b3bcfa refactor: unify summary report data source contract 2026-03-11 19:13:21 +05:30
Shivam Mishra efb4a2feca refactor: route reports through data sources 2026-03-11 18:59:19 +05:30
Shivam Mishra 692b05766b refactor: add report data sources 2026-03-11 18:58:52 +05:30
Shivam Mishra cdcb540e71 feat: add reporting metric registry 2026-03-11 18:58:30 +05:30
Shivam Mishra ee6c0b3138 style: fix example length 2026-03-11 18:26:11 +05:30
Shivam Mishra d50050b328 refactor: use upsert_all 2026-03-11 18:24:41 +05:30
Shivam Mishra 455c46288e feat: remove team from rollup 2026-03-11 18:20:03 +05:30
Shivam Mishra 9e918e0a9f chore: remove dead code 2026-03-10 13:32:47 +05:30
Shivam Mishra f56b0c9f2e fix: coerce nil rollup metric values to zero 2026-03-10 13:22:48 +05:30
Shivam MishraandGitHub c194f10f49 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-10 12:54:44 +05:30
Shivam MishraandGitHub 63172eb08c Merge branch 'develop' into feat/reporting-events-rollup 2026-03-09 19:32:03 +05:30
Shivam Mishra 275a76a1a0 fix: preserve 404s for invalid rollup filters 2026-03-09 14:20:27 +05:30
Shivam Mishra 6f127d86b9 feat: add validity gate 2026-03-09 13:57:49 +05:30
Shivam Mishra 014393297b feat: validate timezone 2026-03-09 13:49:21 +05:30
Shivam Mishra 3d67ef02ef chore: disable team rollup read 2026-03-09 13:34:44 +05:30
Shivam Mishra d86c8d1ede style: rubcop issues 2026-03-09 12:55:49 +05:30
Shivam Mishra 8324d84fbf fix: start od week 2026-03-09 12:50:29 +05:30
Shivam Mishra 0bc80bf95f fix: data gaps for missing dates 2026-03-09 12:28:03 +05:30
Shivam Mishra e230e97320 chore: remove compare job 2026-03-09 12:14:03 +05:30
Shivam Mishra 8f331ea7be refactor: split rake task 2026-03-09 11:31:16 +05:30
Shivam Mishra 7feb0ecaef style: fix abc size 2026-03-09 11:20:33 +05:30
Shivam Mishra 5a7c615efa style: simplify output 2026-03-09 11:16:31 +05:30
Shivam MishraandGitHub dcb2198147 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-09 11:07:31 +05:30
Shivam MishraandGitHub d0f07c8f5f Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 20:39:27 +05:30
Shivam MishraandGitHub a7274bc8e8 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 15:14:15 +05:30
Shivam Mishra 7c6097e4c8 feat: add task to set timezone for reporting events 2026-03-05 15:00:05 +05:30
Shivam Mishra 35d936ae04 chore: cleaner output for rake 2026-03-05 14:54:10 +05:30
Shivam Mishra 65ccf8bae2 feat: add plan 2026-03-05 14:51:07 +05:30
Shivam Mishra 0d27e76c3f feat: simplify backfill 2026-03-05 14:48:40 +05:30
Shivam Mishra 8c98b619bd feat: allow timezone offset for summary reports 2026-03-05 14:34:03 +05:30
Shivam Mishra 47fc8072ad feat: allow rollup path for inbox 2026-03-05 14:34:03 +05:30
Shivam Mishra dfd92acd4a feat: don't allow without timezone 2026-03-05 14:34:03 +05:30
Shivam MishraandGitHub ea5a46ee0c Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 14:11:45 +05:30
Shivam Mishra 9534d7157d fix: pass summary dimension type to rollup-backed builders 2026-03-03 18:11:42 +05:30
Shivam MishraandGitHub 51f298c129 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-03 17:40:59 +05:30
Shivam MishraandGitHub 2e18cb3b15 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-24 20:24:15 +05:30
Shivam MishraandGitHub c8637bf0de Merge branch 'develop' into feat/reporting-events-rollup 2026-02-24 16:07:37 +05:30
Shivam MishraandGitHub a15dffeadb Merge branch 'develop' into feat/reporting-events-rollup 2026-02-23 17:55:15 +05:30
Shivam Mishra 22ba4a6dfe Merge branch 'feat/reporting-events-rollup' of github.com:chatwoot/chatwoot into feat/reporting-events-rollup 2026-02-18 18:22:39 +05:30
Shivam Mishra e840419029 refactor: extract backfill rake task into a class
Reduces block length by moving logic from the rake task block
into a dedicated ReportingEventsRollupBackfill class with
focused methods.
2026-02-18 18:22:18 +05:30
Shivam Mishra b56765d9ba style: split migration into smaller methods to reduce method length 2026-02-18 18:22:11 +05:30
Shivam Mishra 63dce7778e style: fix rubocop offenses in timeseries report builders
Remove duplicate branches, multiline block chains, and reduce
method complexity in average and count report builders.
2026-02-18 18:22:03 +05:30
Shivam Mishra 8e74ee6716 style: extract methods from upsert_rollup to reduce method length 2026-02-18 18:21:55 +05:30
Shivam Mishra 325afc5ab1 style: replace case-when with hash lookup in dimension_type_to_rollup 2026-02-18 18:21:49 +05:30
Shivam MishraandGitHub addc179c01 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-18 18:18:52 +05:30
Shivam Mishra 4b9b1ed7c6 fix: DST spec 2026-02-18 18:16:08 +05:30
Shivam Mishra 4992cb379a refactor: remove redundant scan 2026-02-18 18:12:32 +05:30
Shivam Mishra 98035d5f62 style: rspec name 2026-02-18 18:12:16 +05:30
Shivam Mishra 164b916670 style: rubocop issues in rake task 2026-02-18 18:10:46 +05:30
Shivam Mishra 308accab6d style: rubocop issues 2026-02-18 16:13:13 +05:30
Shivam Mishra 254271a37a style: rubocop autofix changes 2026-02-18 16:05:57 +05:30
Shivam MishraandGitHub b339bf128a Merge branch 'develop' into feat/reporting-events-rollup 2026-02-17 14:47:25 +05:30
Shivam Mishra 0313cd87c2 fix: emit zero-value periods in rollup count timeseries
The rollup path only emitted periods with data, while the raw path
uses group_by_period with default_value: 0 to include inactive periods.
This also reads group_by from params instead of calling a method that
summary builders don't implement.
2026-02-13 15:21:26 +05:30
Shivam Mishra eb08245b3d fix: pass epoch timestamps to comparison builders
The comparison rake task was passing YYYY-MM-DD strings as since/until,
but DateRangeHelper parses those fields as Unix seconds via
DateTime.strptime(value, '%s'), causing validation to run against
the wrong time window.
2026-02-13 13:24:45 +05:30
Shivam Mishra d5d9d3ad7d fix: exclude until boundary from rollup date range
DateRangeHelper#range builds an exclusive range (since...until), but
rollup_date_range was converting range.last directly to a date, causing
an extra day to be included when until falls at midnight.
2026-02-13 13:22:00 +05:30
Shivam Mishra 17f5112015 fix: skip per-metric rollup check in summary builders
Summary builders fetch all metrics in a single query and don't use
params[:metric], so metric_covered? was always returning false and
preventing rollup usage for summary reports.
2026-02-13 12:44:03 +05:30
Shivam Mishra 649f58a57a chore: update rake task to better find correct IANA timzone 2026-02-13 12:39:05 +05:30
Shivam Mishra 69a75e538e feat: add some implementation notes 2026-02-13 12:38:45 +05:30
Shivam Mishra 4f124f64d4 chore: remove use_rollup override 2026-02-13 12:38:35 +05:30
Shivam MishraandGitHub f24b931527 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-12 12:14:01 +05:30
Shivam Mishra b6a84af227 fix: Address three PR review bugs in rollup system
Fix metric_covered? NoMethodError when metric param is nil - summary
builders don't pass a metric param, so add nil guard before calling
to_sym.

Fix business_hours not respected in rollup paths - rollup queries were
always using sum_value instead of switching to sum_value_business_hours
when business_hours param is true. Add rollup_value_column helper to
RollupConditions and use it across all rollup read paths.

Fix date range timezone mismatch in rollup queries - rollup date column
stores account-local dates but queries filtered with UTC DateTime
boundaries from DateRangeHelper. Add rollup_date_range helper that
converts UTC range to account-local dates and use it across all rollup
query paths.
2026-02-12 12:12:07 +05:30
Shivam MishraandGitHub 2380218b05 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-11 22:55:11 +05:30
Shivam Mishra af59acd6a9 docs: Add TODO to remove use_rollup override before merge
Add TODO comment noting that the use_rollup parameter override is only
for validation purposes via the comparison rake task and should be
removed before merging to production.
2026-02-11 22:52:12 +05:30
Shivam Mishra f29f9517d8 refactor: Extend comparison period from 6 months to 1 year
Increase comparison period to 1 year for more comprehensive validation.
Will now compare ~365 daily summaries instead of ~180 days.
2026-02-11 22:47:21 +05:30
Shivam Mishra ab013b9e2b refactor: Change comparison from weekly to daily granularity
Update comparison rake task to compare daily summaries instead of weekly.
Provides more granular comparison (180+ days vs 26 weeks for 6 months) and
better detection of specific date discrepancies. Progress now shows day-by-day
comparison with date in output.
2026-02-11 22:45:11 +05:30
Shivam Mishra 9a17ae6a76 refactor: Add use_rollup parameter to base summary builder
Add explicit use_rollup parameter to BaseSummaryBuilder that can override
the automatic rollup detection logic. This allows the comparison rake task
to explicitly control whether to use rollup or raw data without toggling
feature flags. Cleaner approach that avoids database writes during comparison.
2026-02-11 22:43:12 +05:30
Shivam Mishra d0713408a9 feat: Add comparison rake task to validate rollup accuracy
Add interactive rake task that compares rollup data vs raw events data for
summary reports. Generates weekly summaries for past 6 months across agent,
inbox, and team dimensions. Compares metrics (conversations count, resolution
time, first response time, reply time) and reports any mismatches. Useful
for validating rollup data accuracy after backfill.
2026-02-11 22:39:10 +05:30
Shivam Mishra dcfb1599c8 feat: Add interactive backfill rake task for reporting rollups
Add BackfillService and rake task to backfill historical reporting events
into the rollup table. Service uses delete-first idempotency strategy with
hybrid SQL+Ruby aggregation. Rake task provides interactive prompts for
account ID, timezone, date range override, dry run, and confirmation before
proceeding. Includes live progress tracking and fail-fast error handling.
2026-02-11 22:28:19 +05:30
Shivam Mishra de0d6ca8a5 test: add unit tests for RollupConditions concern 2026-02-11 21:56:03 +05:30
Shivam Mishra a89cdd57cf test: add unit tests for RollupService 2026-02-11 21:55:57 +05:30
Shivam Mishra 67abd78568 test: add unit tests for ReportingEventsRollup model 2026-02-11 21:55:51 +05:30
Shivam Mishra d28f11e119 test: add factory for ReportingEventsRollup 2026-02-11 21:55:44 +05:30
Shivam Mishra d080407a66 refactor: update report builders to use ReportingEventsRollup model 2026-02-11 21:55:35 +05:30
Shivam Mishra 2234a6707c fix: resolve ambiguous column references in rollup upsert SQL 2026-02-11 21:55:29 +05:30
Shivam Mishra be540a08ec fix: add concurrent index creation to reporting events rollup migration 2026-02-11 21:55:21 +05:30
Shivam Mishra 4f53d32924 feat: Add reporting events rollup feature flag 2026-02-11 21:28:51 +05:30
Shivam Mishra 078d880a0e feat: Add reporting timezone to account settings 2026-02-11 21:28:29 +05:30
Shivam Mishra 46ab6c3946 feat: Add rollup read path to report builders 2026-02-11 21:28:17 +05:30
Shivam Mishra 0e35067bc8 feat: Add rollup conditions concern for report builders 2026-02-11 21:28:11 +05:30
Shivam Mishra 8474295a5d feat: Add rollup service for writing aggregated metrics 2026-02-11 21:28:07 +05:30
Shivam Mishra 1fa461327a feat: Add reporting events rollup database migration and model 2026-02-11 21:24:47 +05:30
3629 changed files with 68567 additions and 267708 deletions
-65
View File
@@ -1,65 +0,0 @@
---
: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:
- ''
-20
View File
@@ -1,23 +1,3 @@
--- ---
ignore: ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - 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
-7
View File
@@ -1,21 +1,14 @@
.bundle .bundle
.codex
.env .env
.env.* .env.*
.git
.github
.agents
docker-compose.* docker-compose.*
docker/Dockerfile docker/Dockerfile
docker/dockerfiles docker/dockerfiles
log log
spec
storage storage
tests
public/system public/system
tmp tmp
.codeclimate.yml .codeclimate.yml
coverage
public/packs public/packs
node_modules node_modules
vendor/bundle vendor/bundle
+3 -9
View File
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN= 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 # Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are : # Possible values are :
# relay for Exim, Postfix, Qmail # relay for Exim, Postfix, Qmail
@@ -234,10 +232,6 @@ 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 # 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 # 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 ## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints ## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false # CW_API_ONLY_SERVER=false
@@ -272,9 +266,9 @@ AZURE_APP_SECRET=
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features (Captain) # AI powered features
# The OpenAI API key and endpoint for Captain are not configured via .env. ## OpenAI key
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # OPENAI_API_KEY=
# Housekeeping/Performance related configurations # Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes # Set to true if you want to remove stale contact inboxes
-195
View File
@@ -1,195 +0,0 @@
#!/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())
-3
View File
@@ -11,9 +11,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
deployment_check: deployment_check:
name: Check Deployment name: Check Deployment
-3
View File
@@ -8,9 +8,6 @@ on:
branches: branches:
- develop - develop
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
-29
View File
@@ -1,29 +0,0 @@
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
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
log_lines_check: log_lines_check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-3
View File
@@ -14,9 +14,6 @@ on:
- cron: "0 0 * * *" - cron: "0 0 * * *"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
nightly: nightly:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
packages: write
jobs: jobs:
publish-code-space-image: publish-code-space-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2 -11
View File
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
@@ -78,12 +75,6 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ee
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ee
- name: Export digest - name: Export digest
run: | run: |
@@ -131,7 +122,7 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}" TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi fi
docker buildx imagetools create -t $TAG \ docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -145,5 +136,5 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}" TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi fi
docker buildx imagetools inspect $TAG docker buildx imagetools inspect $TAG
+2 -11
View File
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
@@ -83,12 +80,6 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ce
- name: Export digest - name: Export digest
run: | run: |
@@ -136,7 +127,7 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi fi
docker buildx imagetools create -t $TAG \ docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -150,5 +141,5 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi fi
docker buildx imagetools inspect $TAG docker buildx imagetools inspect $TAG
-3
View File
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
+2 -9
View File
@@ -7,9 +7,6 @@ on:
- master - master
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
test-build: test-build:
strategy: strategy:
@@ -39,9 +36,5 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: false push: false
load: false load: false
build-args: | cache-from: type=gha,scope=${{ matrix.platform }}
GIT_SHA=${{ github.sha }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
type=gha,scope=${{ matrix.platform }}-ee
+3 -8
View File
@@ -43,18 +43,13 @@
## General Guidelines ## General Guidelines
- Prefer the smallest production-ready change that solves the current problem. - MVP focus: Least code change, happy-path only
- 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. - No unnecessary defensive programming
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. - Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- 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 - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units - Break down complex tasks into small, testable units
- Iterate after confirmation - Iterate after confirmation
- Avoid writing specs unless explicitly asked - 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 - Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it - 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 - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
+6 -12
View File
@@ -22,7 +22,6 @@ gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info' gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -41,8 +40,6 @@ gem 'json_refs'
gem 'rack-attack', '>= 6.7.0' gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files # a utility tool for streaming, flexible and safe downloading of remote files
gem 'down' gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
@@ -76,7 +73,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1' gem 'puma'
gem 'vite_rails' gem 'vite_rails'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
@@ -85,11 +82,10 @@ gem 'barnes'
gem 'devise', '>= 4.9.4' gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3' gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication # two-factor authentication
gem 'devise-two-factor', '>= 5.0.0' gem 'devise-two-factor', '>= 5.0.0'
# authorization # authorization
gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
@@ -133,9 +129,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1' gem 'sidekiq', '>= 7.3.1'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0' gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck # for sidekiq healthcheck
gem 'sidekiq_alive' gem 'sidekiq_alive'
@@ -195,10 +191,10 @@ gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'ai-agents', '>= 0.12.0' gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents # TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1' gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema' gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7' gem 'cld3', '~> 3.7'
@@ -209,8 +205,6 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api' gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
+65 -93
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0) acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1) activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0) zeitwerk (>= 2.4, < 3.0)
addressable (2.9.0) addressable (2.8.7)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1) administrate (0.20.1)
actionpack (>= 6.0, < 8.0) actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0) actionview (>= 6.0, < 8.0)
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2) jbuilder (~> 2)
rails (>= 4.2, < 7.2) rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
ai-agents (0.12.0) ai-agents (0.9.1)
ruby_llm (~> 1.14) ruby_llm (~> 1.9.1)
annotaterb (4.20.0) annotaterb (4.20.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
@@ -136,8 +136,6 @@ GEM
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
activesupport (>= 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) aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0) actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -168,9 +166,9 @@ GEM
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.22) bcrypt (3.1.20)
benchmark (0.4.1) benchmark (0.4.1)
bigdecimal (4.1.2) bigdecimal (3.2.2)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -186,22 +184,18 @@ GEM
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
cld3 (3.7.0) cld3 (3.7.0)
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.7) concurrent-ruby (1.3.5)
connection_pool (2.5.5) connection_pool (2.5.5)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
crass (1.0.7) crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0) csv (3.3.0)
csv-safe (3.3.1) csv-safe (3.3.1)
csv (~> 3.0) csv (~> 3.0)
@@ -218,7 +212,7 @@ GEM
logger logger
msgpack msgpack
datadog-ruby_core_source (3.4.1) datadog-ruby_core_source (3.4.1)
date (3.5.1) date (3.4.1)
debug (1.8.0) debug (1.8.0)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
@@ -274,8 +268,8 @@ GEM
dry-logic (~> 1.5) dry-logic (~> 1.5)
dry-types (~> 1.8) dry-types (~> 1.8)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
dry-types (1.9.1) dry-types (1.8.3)
bigdecimal (>= 3.0) bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-core (~> 1.0) dry-core (~> 1.0)
dry-inflector (~> 1.0) dry-inflector (~> 1.0)
@@ -304,7 +298,7 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.3) faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -313,9 +307,9 @@ GEM
faraday-mashify (1.0.0) faraday-mashify (1.0.0)
faraday (~> 2.0) faraday (~> 2.0)
hashie hashie
faraday-multipart (1.2.0) faraday-multipart (1.0.4)
multipart-post (~> 2.0) multipart-post (~> 2)
faraday-net_http (3.4.4) faraday-net_http (3.4.2)
net-http (~> 0.5) net-http (~> 0.5)
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
@@ -342,7 +336,6 @@ GEM
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.2) foreman (0.87.2)
fugit (1.11.1) fugit (1.11.1)
@@ -356,7 +349,6 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a) googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0) googleauth (~> 1.0)
grpc (~> 1.36) grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1) geocoder (1.8.1)
gli (2.22.2) gli (2.22.2)
ostruct ostruct
@@ -438,8 +430,7 @@ GEM
hana (1.3.7) hana (1.3.7)
hash_diff (1.1.1) hash_diff (1.1.1)
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.1.0) hashie (5.0.0)
logger
html2text (0.4.0) html2text (0.4.0)
nokogiri (>= 1.0, < 2.0) nokogiri (>= 1.0, < 2.0)
http (5.1.1) http (5.1.1)
@@ -474,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.19.9) json (2.18.1)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -495,7 +486,7 @@ GEM
judoscale-sidekiq (1.8.2) judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0) sidekiq (>= 5.0)
jwt (2.10.3) jwt (2.10.1)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -570,9 +561,9 @@ GEM
minitest (5.25.5) minitest (5.25.5)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.8.3) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.9.1) multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5) bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
@@ -582,7 +573,7 @@ GEM
uri (>= 0.11.1) uri (>= 0.11.1)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.6.4.1) net-imap (0.4.20)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -597,37 +588,30 @@ GEM
sidekiq sidekiq
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.5) nio4r (2.7.3)
nokogiri (1.19.4) nokogiri (1.19.1)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin) nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin) nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu) nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.6) oauth (1.1.0)
auth-sanitizer (~> 0.2, >= 0.2.1) oauth-tty (~> 1.0, >= 1.0.1)
base64 (~> 0.1) snaky_hash (~> 2.0)
cgi version_gem (~> 1.1)
oauth-tty (~> 1.0, >= 1.0.8) oauth-tty (1.0.5)
snaky_hash (~> 2.0, >= 2.0.5) version_gem (~> 1.1, >= 1.1.1)
version_gem (~> 1.1, >= 1.1.11) oauth2 (2.0.9)
oauth-tty (1.0.8) faraday (>= 0.17.3, < 3.0)
auth-sanitizer (~> 0.1, >= 0.1.3) jwt (>= 1.0, < 3.0)
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) multi_xml (~> 0.5)
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5) snaky_hash (~> 2.0)
version_gem (~> 1.1, >= 1.1.11) version_gem (~> 1.1)
oj (3.17.3) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.4) omniauth (2.1.4)
@@ -674,7 +658,7 @@ GEM
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.3) ostruct (0.6.1)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.8.0) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
@@ -692,14 +676,14 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (7.0.5) public_suffix (6.0.2)
puma (7.2.1) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.6) rack (3.2.5)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -714,7 +698,7 @@ GEM
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.2) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
@@ -743,9 +727,6 @@ GEM
rails-html-sanitizer (1.6.1) rails-html-sanitizer (1.6.1)
loofah (~> 2.21) 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) 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) railties (7.1.5.2)
actionpack (= 7.1.5.2) actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2) activesupport (= 7.1.5.2)
@@ -850,17 +831,17 @@ GEM
ruby2ruby (2.5.0) ruby2ruby (2.5.0)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_llm (1.15.0) ruby_llm (1.9.2)
base64 base64
event_stream_parser (~> 1) event_stream_parser (~> 1)
faraday (>= 1.10.0) faraday (>= 1.10.0)
faraday-multipart (>= 1) faraday-multipart (>= 1)
faraday-net_http (>= 1) faraday-net_http (>= 1)
faraday-retry (>= 1) faraday-retry (>= 1)
marcel (~> 1) marcel (~> 1.0)
ruby_llm-schema (~> 0) ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2) zeitwerk (~> 2)
ruby_llm-schema (0.3.0) ruby_llm-schema (0.2.5)
ruby_parser (3.20.0) ruby_parser (3.20.0)
sexp_processor (~> 4.16) sexp_processor (~> 4.16)
sass (3.7.4) sass (3.7.4)
@@ -917,11 +898,10 @@ GEM
logger logger
rack (>= 2.2.4) rack (>= 2.2.4)
redis-client (>= 0.22.2) redis-client (>= 0.22.2)
sidekiq-cron (2.4.0) sidekiq-cron (1.12.0)
cronex (>= 0.13.0) fugit (~> 1.8)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1) globalid (>= 1.0.1)
sidekiq (>= 6.5.0) sidekiq (>= 6)
sidekiq_alive (2.5.0) sidekiq_alive (2.5.0)
gserver (~> 0.0.1) gserver (~> 0.0.1)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
@@ -946,9 +926,9 @@ GEM
gli gli
hashie hashie
logger logger
snaky_hash (2.0.5) snaky_hash (2.0.1)
hashie (>= 0.1.0, < 6) hashie
version_gem (>= 1.1.8, < 3) version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934) sorbet-runtime (0.5.11934)
spring (4.1.1) spring (4.1.1)
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
@@ -962,7 +942,6 @@ GEM
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.7.2) squasher (0.7.2)
ssrf_filter (1.5.0)
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stripe (18.0.1) stripe (18.0.1)
@@ -977,7 +956,7 @@ GEM
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.6.1) timeout (0.4.3)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (7.6.0) twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
@@ -995,7 +974,6 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
@@ -1006,14 +984,12 @@ GEM
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.11) version_gem (1.1.4)
vite_rails (3.10.0) vite_rails (3.0.17)
railties (>= 5.1, < 9) railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2) vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2) dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1) rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2) zeitwerk (~> 2.2)
warden (1.2.9) warden (1.2.9)
@@ -1038,7 +1014,7 @@ GEM
working_hours (1.4.1) working_hours (1.4.1)
activesupport (>= 3.2) activesupport (>= 3.2)
tzinfo tzinfo
zeitwerk (2.7.5) zeitwerk (2.7.4)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@@ -1058,7 +1034,7 @@ DEPENDENCIES
administrate (>= 0.20.1) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.12.0) ai-agents (>= 0.9.1)
annotaterb annotaterb
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
@@ -1093,10 +1069,8 @@ DEPENDENCIES
faker faker
faraday_middleware-aws-sigv4 faraday_middleware-aws-sigv4
fcm fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu flag_shih_tzu
foreman foreman
gemoji
geocoder geocoder
gmail_xoauth gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0) google-cloud-dialogflow-v2 (>= 0.24.0)
@@ -1115,7 +1089,7 @@ DEPENDENCIES
json_schemer json_schemer
judoscale-rails judoscale-rails
judoscale-sidekiq judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3) jwt
kaminari kaminari
koala koala
letter_opener letter_opener
@@ -1143,14 +1117,13 @@ DEPENDENCIES
pgvector pgvector
procore-sift procore-sift
pry-rails pry-rails
puma (~> 7.2, >= 7.2.1) puma
pundit pundit
rack-attack (>= 6.7.0) rack-attack (>= 6.7.0)
rack-cors (= 2.0.0) rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0) rack-mini-profiler (>= 3.2.0)
rack-timeout rack-timeout
rails (~> 7.1) rails (~> 7.1)
rails-i18n (~> 7.0)
redis redis
redis-namespace redis-namespace
responders (>= 3.1.1) responders (>= 3.1.1)
@@ -1164,7 +1137,7 @@ DEPENDENCIES
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-openai ruby-openai
ruby_llm (>= 1.14.1) ruby_llm (>= 1.8.2)
ruby_llm-schema ruby_llm-schema
scout_apm scout_apm
scss_lint scss_lint
@@ -1175,8 +1148,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0) sentry-sidekiq (>= 5.19.0)
shopify_api shopify_api
shoulda-matchers shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1) sidekiq (>= 7.3.1)
sidekiq-cron (>= 2.4.0) sidekiq-cron (>= 1.12.0)
sidekiq_alive sidekiq_alive
simplecov (>= 0.21) simplecov (>= 0.21)
simplecov_json_formatter simplecov_json_formatter
@@ -1185,7 +1158,6 @@ DEPENDENCIES
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher
ssrf_filter (~> 1.5)
stackprof stackprof
stripe (~> 18.0) stripe (~> 18.0)
telephone_number telephone_number
+1 -2
View File
@@ -1,7 +1,6 @@
# Variables # Variables
APP_NAME := chatwoot APP_NAME := chatwoot
RAILS_ENV ?= development RAILS_ENV ?= development
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown)
# Targets # Targets
setup: setup:
@@ -62,6 +61,6 @@ debug_worker:
overmind connect worker overmind connect worker
docker: docker:
docker build --build-arg GIT_SHA=$(GIT_SHA) -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker .PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
+1 -1
View File
@@ -1 +1 @@
4.15.1 4.11.2
+1 -5
View File
@@ -44,11 +44,7 @@ class AccountBuilder
end end
def create_account def create_account
@account = Account.create!( @account = Account.create!(name: account_name, locale: I18n.locale)
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
Current.account = @account Current.account = @account
end end
+1 -2
View File
@@ -29,9 +29,8 @@ class AgentBuilder
user = User.from_email(email) user = User.from_email(email)
return user if user return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" 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 end
# Checks if the user needs confirmation. # Checks if the user needs confirmation.
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact def create_contact
account.contacts.create!( account.contacts.create!(
name: contact_name, name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number], phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email], email: contact_attributes[:email],
identifier: contact_attributes[:identifier], identifier: contact_attributes[:identifier],
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
) )
end end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
+5 -3
View File
@@ -1,6 +1,4 @@
class Email::BaseBuilder class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!] pattr_initialize [:inbox!]
private private
@@ -41,7 +39,7 @@ class Email::BaseBuilder
end end
def business_name def business_name
inbox.sanitized_business_name inbox.business_name || inbox.sanitized_name
end end
def account_support_email def account_support_email
@@ -49,4 +47,8 @@ class Email::BaseBuilder
# can save it in the format "Name <email@domain.com>" # can save it in the format "Name <email@domain.com>"
parse_email(account.support_email) parse_email(account.support_email)
end end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end end
@@ -91,21 +91,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment) def fallback_params(attachment)
{ {
fallback_title: attachment['title'] || attachment.dig('payload', 'title'), fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url') external_url: attachment['url']
} }
end 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 def conversation_params
{ {
account_id: @inbox.account_id, account_id: @inbox.account_id,
+7 -17
View File
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
@account = conversation.account @account = conversation.account
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
file: uploaded_attachment file: uploaded_attachment
) )
attachment.file_type = attachment_file_type(uploaded_attachment) attachment.file_type = if uploaded_attachment.is_a?(String)
tag_voice_message(attachment) file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end end
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 def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type']) return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment) 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 = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save! attachment_obj.save!
if facebook_reel?(attachment) if facebook_reel?(attachment)
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
elsif params[:remote_file_url] elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url]) attach_file(attachment_obj, params[:remote_file_url])
end 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_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_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end end
def attach_file(attachment, file_url) def attach_file(attachment, file_url)
@@ -37,10 +28,6 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename, filename: attachment_file.original_filename,
content_type: attachment_file.content_type 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 end
def attachment_params(attachment) def attachment_params(attachment)
@@ -120,20 +107,13 @@ class Messages::Messenger::MessageBuilder
# Facebook may send attachment types that don't directly match our file_type enum. # Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values. # Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type) def normalize_file_type(type)
sym = type.to_sym sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym) FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end 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 # Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content. # direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment) def facebook_reel?(attachment)
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions # skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' 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!( user.notifications.create!(
notification_type: notification_type, notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user secondary_actor: secondary_actor || current_user
) )
end 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 end
@@ -1,5 +1,6 @@
class V2::Reports::BaseSummaryBuilder class V2::Reports::BaseSummaryBuilder
include DateRangeHelper include DateRangeHelper
include TimezoneHelper
def build def build
load_data load_data
@@ -35,6 +36,7 @@ class V2::Reports::BaseSummaryBuilder
scope: nil, scope: nil,
range: range, range: range,
group_by: 'day', group_by: 'day',
timezone: timezone_name_from_params(params[:timezone], params[:timezone_offset]),
timezone_offset: params[:timezone_offset], timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours] business_hours: params[:business_hours]
) )
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end end
def bot_resolutions_count def bot_resolutions_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,
account.reporting_events.joins(:conversation).select(:conversation_id) created_at: range).distinct.count
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
end end
def bot_handoffs_count def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id) account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) created_at: range).distinct.count
.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 end
def bot_resolution_rate def bot_resolution_rate
@@ -4,7 +4,7 @@ class V2::Reports::Conversations::BaseReportBuilder
private private
def builder_class(metric) def builder_class(metric)
return unless Reports::ReportMetricRegistry.supported?(metric) return unless ReportingEvents::MetricRegistry.supported_metric?(metric)
V2::Reports::Timeseries::ReportBuilder V2::Reports::Timeseries::ReportBuilder
end end
@@ -1,213 +0,0 @@
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
@@ -1,199 +0,0 @@
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
@@ -7,8 +7,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
@account = account @account = account
@params = params @params = params
timezone_offset = (params[:timezone_offset] || 0).to_f @timezone = timezone_name_from_params(params[:timezone], params[:timezone_offset])
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end end
# rubocop:enable Lint/MissingSuper # rubocop:enable Lint/MissingSuper
@@ -30,6 +30,7 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
scope: scope, scope: scope,
range: range, range: range,
group_by: group_by, group_by: group_by,
timezone: timezone,
timezone_offset: params[:timezone_offset], timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours] business_hours: params[:business_hours]
) )
@@ -56,7 +57,7 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end end
def timezone def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset]) @timezone ||= timezone_name_from_params(params[:timezone], params[:timezone_offset])
end end
private private
@@ -34,10 +34,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
@agent_bot.reload @agent_bot.reload
end end
def reset_secret
@agent_bot.reset_secret!
end
private private
def agent_bot def agent_bot
@@ -1,59 +0,0 @@
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')
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end end
def reorder def reorder
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
render json: { positions: positions } head :ok
end end
private private
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes before_action :fetch_inboxes
def index def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox| agent_ids = @inboxes.map do |inbox|
authorize inbox, :show? authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id) member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +10,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&) agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids) agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq @assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end end
private private
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params def assignment_policy_params
params.require(:assignment_policy).permit( params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority, :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
end end
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token] page_access_token = params[:page_access_token]
page_id = params[:page_id] page_id = params[:page_id]
inbox_name = params[:inbox_name] inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!( facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token, page_id: page_id, user_access_token: user_access_token,
@@ -16,8 +15,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel) set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id) set_avatar(@facebook_inbox, page_id)
end end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}" Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update def update
params_to_update = captain_params params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models) @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.key?(:captain_features) @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save! @current_account.save!
render json: preferences_payload render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models def merged_captain_models
existing_models = @current_account.captain_models || {} existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models).compact_blank.presence existing_models.merge(permitted_captain_models)
end end
def merged_captain_features def merged_captain_features
@@ -47,38 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end end
def permitted_captain_models def permitted_captain_models
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end end
def permitted_captain_features def permitted_captain_features
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys params.require(:captain_features).permit(
end :editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
def captain_feature_keys ).to_h.stringify_keys
Llm::Models.feature_keys.map(&:to_sym)
end end
def features_with_account_preferences def features_with_account_preferences
preferences = Current.account.captain_preferences preferences = Current.account.captain_preferences
account_features = preferences[:features] || {} account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key| Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key) config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge( config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true, enabled: account_features[feature_key] == true,
model: route[:model], selected: account_models[feature_key] || config[:default]
selected: route[:model],
provider: route[:provider],
source: route[:source]
) )
end end
end end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end end
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params def category_params
params.require(:category).permit( params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id :name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
) )
end end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create def create
process_create process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -1,55 +0,0 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -1,18 +0,0 @@
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
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string 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 :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 :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] sort_on :company, 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 :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] sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -214,5 +214,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status render json: error, status: error_status
end end
end end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
render json: { content: translated_content } 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 end
private private
@@ -1,40 +1,27 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show def show
@participants = @conversation.conversation_participants @participants = @conversation.conversation_participants
end end
def create def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end end
notify_unread_count_change if participant_ids_to_add.any?
end end
def update def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants @participants = @conversation.conversation_participants
render action: 'show' render action: 'show'
end end
def destroy def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok head :ok
end end
@@ -51,11 +38,4 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id) @current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end end
@@ -1,32 +0,0 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments def attachments
@attachments_count = @conversation.attachments.count @attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments @attachments = @conversation.attachments
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }]) .includes(:message)
.order(created_at: :desc) .order(created_at: :desc)
.page(attachment_params[:page]) .page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE) .per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. # 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. # 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. # 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, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
@@ -140,7 +138,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def destroy def destroy
authorize @conversation, :destroy? authorize @conversation, :destroy?
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform ::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok head :ok
end end
@@ -162,9 +160,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates) @conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end end
def should_update_last_seen? def should_update_last_seen?
@@ -1,7 +1,6 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create] before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy] before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end def index; end
@@ -2,15 +2,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show] before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index def index
@inboxes = policy_scope(Current.account.inboxes) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
end end
def show; end def show; end
@@ -67,17 +65,28 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def destroy def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end 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 private
def fetch_inbox def fetch_inbox
@@ -86,7 +95,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def fetch_agent_bot def fetch_agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[: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
end end
def create_channel def create_channel
@@ -185,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type) def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end 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 end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0', enable_fb_login: '0',
force_authentication: '1', force_authentication: '1',
response_type: 'code', response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to]) state: generate_instagram_token(Current.account.id)
} }
) )
if redirect_url if redirect_url
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end end
render_response( render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
) )
end end
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token return unless @hook&.access_token
begin begin
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token')) linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token linear_client.revoke_token
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}" Rails.logger.error "Failed to revoke Linear token: #{e.message}"
@@ -18,16 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy! @label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok head :ok
end end
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: scope,
state: state state: state,
prompt: 'consent'
} }
) )
if redirect_url if redirect_url
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all def destroy_all
if params[:type] == 'read' if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read) ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else else
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all) ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end end
head :ok head :ok
end end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end end
def fetch_notification def fetch_notification
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id]) @notification = current_user.notifications.find(params[:id])
end end
def set_current_page def set_current_page
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end end
def state def state
# The sgid purpose doubles as a return hint: onboarding tags it so the callback Current.account.to_sgid(expires_in: 15.minutes).to_s
# 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 end
def base_url def base_url
@@ -1,89 +0,0 @@
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')
@@ -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 = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain @portal.custom_domain = parsed_custom_domain
@portal.save! @portal.save!
process_attached_logo if params[:blob_id].present? process_attached_logo
end end
def update def update
@@ -61,8 +61,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end end
def process_attached_logo def process_attached_logo
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s) blob_id = params[:blob_id]
@portal.logo.attach(blob) if blob blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end end
private private
@@ -78,23 +79,16 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params def portal_params
params.require(:portal).permit( params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link, :id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
{ 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 end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params def live_chat_widget_params
permitted_params = params.permit(:inbox_id) permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id) return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget? return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id } { channel_web_widget_id: inbox.channel.id }
@@ -105,8 +99,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end end
def parsed_custom_domain def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain) domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end end
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end end
def team_params def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color) params.require(:team).permit(:name, :description, :allow_auto_assign)
end end
end end
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
def create def create
redirect_url = Tiktok::AuthClient.authorize_url( redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id, params[:return_to]) state: generate_tiktok_token(Current.account.id)
) )
if redirect_url if redirect_url
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
elsif params[:external_url].present? elsif params[:external_url].present?
create_from_url create_from_url
else else
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity) render_error('No file or URL provided', :unprocessable_entity)
end end
render_success(result) if result.is_a?(ActiveStorage::Blob) render_success(result) if result.is_a?(ActiveStorage::Blob)
@@ -19,21 +19,35 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end end
def create_from_url def create_from_url
SafeFetch.fetch(params[:external_url].to_s) do |result| uri = parse_uri(params[:external_url])
create_and_save_blob(result.tempfile, result.filename, result.content_type) 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)
end end
rescue SafeFetch::HttpError => e rescue OpenURI::HTTPError => e
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity) render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SafeFetch::FetchError rescue SocketError
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity) render_error('Invalid URL provided', :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 rescue StandardError
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error) render_error('An unexpected error occurred', :internal_server_error)
end end
def create_and_save_blob(io, filename, content_type) def create_and_save_blob(io, filename, content_type)
@@ -8,10 +8,8 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params! validate_embedded_signup_params!
channel = process_embedded_signup channel = process_embedded_signup
render_success_response(channel.inbox) render_success_response(channel.inbox)
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
render_embedded_signup_error(e) render_error_response(e)
end end
private private
@@ -57,7 +55,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response render json: response
end end
def render_embedded_signup_error(error) def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}" Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n") Rails.logger.error error.backtrace.join("\n")
render json: { render json: {
@@ -0,0 +1,18 @@
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
+3 -34
View File
@@ -30,20 +30,9 @@ class Api::V1::AccountsController < Api::BaseController
locale: account_params[:locale], locale: account_params[:locale],
user: current_user user: current_user
).perform ).perform
enqueue_branding_enrichment
if @user if @user
# Authenticated users (dashboard "add account") and api_only signups send_auth_headers(@user)
# need the full response with account_id. API-only deployments have no render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
# 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 else
render_error_response(CustomExceptions::Account::SignupFailed.new({})) render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end end
@@ -70,17 +59,6 @@ class Api::V1::AccountsController < Api::BaseController
private 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 def ensure_account_name
# ensure that account_name and user_full_name is present # ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered # this is becuase the account builder and the models validations are not triggered
@@ -110,7 +88,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def custom_attributes_params def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) params.permit(:industry, :company_size, :timezone)
end end
def settings_params def settings_params
@@ -125,15 +103,6 @@ class Api::V1::AccountsController < Api::BaseController
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end 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 def validate_captcha
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end end
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end end
def destroy def destroy
notification_subscription = current_user.notification_subscriptions notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present? notification_subscription.destroy! if notification_subscription.present?
head :ok head :ok
end end
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes] before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify] before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy] before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end def show; end
@@ -48,8 +48,7 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp def validate_otp
authenticated = Mfa::AuthenticationService.new( authenticated = Mfa::AuthenticationService.new(
user: current_user, user: current_user,
otp_code: mfa_params[:otp_code], otp_code: mfa_params[:otp_code]
backup_code: mfa_params[:backup_code]
).authenticate ).authenticate
return if authenticated return if authenticated
@@ -64,6 +63,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end end
def mfa_params def mfa_params
params.permit(:otp_code, :backup_code, :password) params.permit(:otp_code, :password)
end end
end end
@@ -1,36 +0,0 @@
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
@@ -2,7 +2,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper include WidgetHelper
before_action :validate_hmac, only: [:set_user] before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end def show; end
@@ -20,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact contact = @contact
end end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? @contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact) identify_contact(contact)
end end
@@ -47,16 +46,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier] @contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac def validate_hmac
return unless should_verify_hmac? return unless should_verify_hmac?
@@ -73,15 +62,11 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end end
def valid_hmac? def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest( params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256', 'sha256',
@web_widget.hmac_token, @web_widget.hmac_token,
params[:identifier].to_s params[:identifier].to_s
) )
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end end
def permitted_params def permitted_params
@@ -10,8 +10,7 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting( response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'], @message.content_attributes['data']['meeting_id'],
@conversation.contact, @conversation.contact
@message
) )
render_response(response) render_response(response)
end end
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end end
def set_conversation def set_conversation
return unless conversation.nil? @conversation = create_conversation if 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 end
def message_finder_params def message_finder_params
@@ -72,21 +64,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params def permitted_params
# timestamp parameter is used in create conversation method # timestamp parameter is used in create conversation method
# 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])
params.permit(
:id, :before, :after, :website_token,
contact: [:name, :email],
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
custom_attributes: {},
labels: []
)
end end
def set_message def set_message
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`, @message = @web_widget.inbox.messages.find(permitted_params[:id])
# 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
end end
@@ -51,13 +51,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic') generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end 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 def conversations
return head :unprocessable_entity if params[:type].blank? return head :unprocessable_entity if params[:type].blank?
@@ -119,6 +112,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({ common_params.merge({
since: range[:current][:since], since: range[:current][:since],
until: range[:current][:until], until: range[:current][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset] timezone_offset: params[:timezone_offset]
}) })
end end
@@ -127,6 +121,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({ common_params.merge({
since: range[:previous][:since], since: range[:previous][:since],
until: range[:previous][:until], until: range[:previous][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset] timezone_offset: params[:timezone_offset]
}) })
end end
@@ -136,26 +131,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
metric: params[:metric], metric: params[:metric],
since: params[:since], since: params[:since],
until: params[:until], until: params[:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset] timezone_offset: params[:timezone_offset]
}) })
end 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 def conversation_params
{ {
type: params[:type].to_sym, type: params[:type].to_sym,
@@ -36,6 +36,8 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
until: permitted_params[:until], until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours]) business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
} }
@builder_params[:timezone] = permitted_params[:timezone] if permitted_params[:timezone].present?
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
end end
def render_report_with(builder_class, type: nil) def render_report_with(builder_class, type: nil)
@@ -45,7 +47,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
end end
def permitted_params def permitted_params
params.permit(:since, :until, :business_hours) params.permit(:since, :until, :business_hours, :timezone, :timezone_offset)
end end
def date_range_too_long? def date_range_too_long?
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler include RequestExceptionHandler
include Pundit::Authorization include Pundit::Authorization
include SwitchLocale include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
@@ -1,18 +0,0 @@
# 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
@@ -1,9 +1,8 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'], '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 }.freeze
def ensure_access_token def ensure_access_token
@@ -2,10 +2,6 @@
# This concern handles the token verification step. # This concern handles the token verification step.
module MetaTokenVerifyConcern 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 def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token']) if valid_token?(params['hub.verify_token'])
@@ -18,53 +14,6 @@ module MetaTokenVerifyConcern
private 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) def valid_token?(_token)
raise 'Overwrite this method your controller' raise 'Overwrite this method your controller'
end end
@@ -1,29 +0,0 @@
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
@@ -1,15 +1,8 @@
module RequestExceptionHandler module RequestExceptionHandler
extend ActiveSupport::Concern extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end end
private private
@@ -25,9 +18,6 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e rescue ActionController::ParameterMissing => e
log_handled_error(e) log_handled_error(e)
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure ensure
# to address the thread variable leak issues in Puma/Thin webserver # to address the thread variable leak issues in Puma/Thin webserver
Current.reset Current.reset
@@ -41,8 +31,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found render json: { error: message }, status: :not_found
end end
def render_could_not_create_error(error) def render_could_not_create_error(message)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity render json: { error: message }, status: :unprocessable_entity
end end
def render_payment_required(message) def render_payment_required(message)
@@ -69,19 +59,4 @@ module RequestExceptionHandler
def log_handled_error(exception) def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}") logger.info("Handled error: #{exception.inspect}")
end end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end end
@@ -1,22 +0,0 @@
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
+1 -13
View File
@@ -1,6 +1,5 @@
class DashboardController < ActionController::Base class DashboardController < ActionController::Base
include SwitchLocale include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[ GLOBAL_CONFIG_KEYS = %w[
LOGO LOGO
@@ -64,10 +63,6 @@ class DashboardController < ActionController::Base
return unless @portal return unless @portal
@locale = @portal.default_locale @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 render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end end
@@ -85,17 +80,10 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH, GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods, ALLOWED_LOGIN_METHODS: allowed_login_methods
ACTIVE_PLATFORM_BANNERS: active_platform_banners
} }
end 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 def allowed_login_methods
methods = ['email'] methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false' methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -10,12 +10,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private private
def sign_in_user 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? @resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified # once the resource is found and verified
# we can just send them to the login page again with the SSO params # we can just send them to the login page again with the SSO params
@@ -25,10 +20,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end end
def sign_in_user_on_mobile 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? @resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified # once the resource is found and verified
# we can just send them to the login page again with the SSO params # we can just send them to the login page again with the SSO params
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token) token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil) frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
@@ -90,15 +81,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end 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 def default_devise_mapping
'user' 'user'
end end
@@ -1,6 +1,4 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
# Prevent session parameter from being passed # Prevent session parameter from being passed
# Unpermitted parameter: session # Unpermitted parameter: session
wrap_parameters format: [] wrap_parameters format: []
@@ -16,27 +14,17 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled? 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 # Only proceed with standard authentication if no MFA is required
super super
end end
def render_create_success def render_create_success
track_user_session unless @impersonation
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end end
private 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 def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present? return nil unless params[:email].present? && params[:password].present?
@@ -57,8 +45,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end end
def handle_sso_authentication def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token authenticate_resource_with_sso_token
yield @resource if block_given? yield @resource if block_given?
render_create_success render_create_success
@@ -71,10 +57,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end end
def authenticate_resource_with_sso_token def authenticate_resource_with_sso_token
# DTA evicts the earliest-expiring token after save when at max_number_of_devices. @token = @resource.create_token
# 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! @resource.save!
sign_in(:user, @resource, store: false, bypass: false) sign_in(:user, @resource, store: false, bypass: false)
@@ -82,21 +65,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource.invalidate_sso_auth_token(params[:sso_auth_token]) @resource.invalidate_sso_auth_token(params[:sso_auth_token])
end 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 def process_sso_auth_token
return if params[:email].blank? return if params[:email].blank?
user = User.from_email(params[:email]) user = User.from_email(params[:email])
return unless user&.valid_sso_auth_token?(params[:sso_auth_token]) @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
end end
def handle_mfa_required(user) def handle_mfa_required(user)
@@ -122,7 +95,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end end
def sign_in_mfa_user(user) def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user @resource = user
@token = @resource.create_token @token = @resource.create_token
@resource.save! @resource.save!
@@ -134,103 +106,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request) def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status render json: { error: I18n.t(message_key) }, status: status
end 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 end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -11,8 +11,6 @@ class Instagram::CallbacksController < ApplicationController
end end
process_successful_authorization process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e rescue StandardError => e
handle_error(e) handle_error(e)
end end
@@ -30,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token) @long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox 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 if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else else
@@ -49,14 +45,6 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info) redirect_to_error_page(error_info)
end end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception # Extract error details from the exception
def extract_error_info(error) def extract_error_info(error)
if error.is_a?(OAuth2::Error) if error.is_a?(OAuth2::Error)
@@ -161,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state]) verify_instagram_token(params[:state])
end end
def return_to
instagram_token_return_to(params[:state])
end
def oauth_code def oauth_code
params[:code] params[:code]
end end
+12 -39
View File
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper include Linear::IntegrationHelper
def show def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token( @response = oauth_client.auth_code.get_token(
params[:code], params[:code],
redirect_uri: "#{base_url}/linear/callback" redirect_uri: "#{base_url}/linear/callback"
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
handle_response handle_response
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}") Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri redirect_to linear_redirect_uri
end end
private private
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
end end
def handle_response def handle_response
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank? hook = account.hooks.new(
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
access_token: parsed_body['access_token'], access_token: parsed_body['access_token'],
status: 'enabled', status: 'enabled',
settings: merged_integration_settings(hook.settings) app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
) )
# 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! hook.save!
redirect_to linear_redirect_uri redirect_to linear_redirect_uri
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}") Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri redirect_to linear_redirect_uri
end end
def account def account
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
end end
def account_id def account_id
return @account_id if instance_variable_defined?(:@account_id) return unless params[:state]
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil verify_linear_token(params[:state])
end end
def linear_redirect_uri def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body def parsed_body
@parsed_body ||= @response.response.parsed @parsed_body ||= @response.response.parsed
end 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 def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address def imap_address
'outlook.office365.com' 'outlook.office365.com'
end 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 end
+2 -25
View File
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
def handle_response def handle_response
inbox, already_exists = find_or_create_inbox 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 if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else else
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email) def update_channel(channel_email)
channel_email.update!({ channel_email.update!({
imap_login: imap_login_identity, imap_address: imap_address, imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true, imap_port: '993', imap_enabled: true,
provider: provider_name, provider: provider_name,
provider_config: { provider_config: {
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
}) })
end 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 def provider_name
raise NotImplementedError raise NotImplementedError
end end
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end 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 def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) account = GlobalID::Locator.locate_signed(params[:state])
@return_to = 'onboarding'
else
account = GlobalID::Locator.locate_signed(params[:state])
end
raise 'Invalid or expired state' if account.nil? raise 'Invalid or expired state' if account.nil?
account account
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id @account ||= account_from_signed_id
end 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 # Fallback name, for when name field is missing from users_data
def fallback_name def fallback_name
users_data['email'].split('@').first.parameterize.titleize users_data['email'].split('@').first.parameterize.titleize
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
before_action :validate_platform_app_permissible, except: [:index, :create] before_action :validate_platform_app_permissible, except: [:index, :create]
def index def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible) @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
end end
def show; end def show; end
@@ -1,101 +0,0 @@
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
@@ -35,15 +35,11 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end end
def valid_hmac? def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest( params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256', 'sha256',
@inbox_channel.hmac_token, @inbox_channel.hmac_token,
params[:identifier].to_s params[:identifier].to_s
) )
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end end
def permitted_params def permitted_params
@@ -24,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation def set_conversation
return if params[:conversation_id].blank? return if params[:conversation_id].blank?
@conversation = if @contact_inbox.hmac_verified? @conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
@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
end end
@@ -1,15 +1,11 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal 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_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show, :show_markdown] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
def index def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author) @articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@@ -23,13 +19,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title) @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 end
def tracking_pixel def tracking_pixel
@@ -71,6 +60,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content)
end end
def set_category def set_category
@@ -83,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end end
def list_params def list_params
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted| params.permit(:query, :locale, :sort, :status, :page, :per_page)
permitted[:query] = permitted[:query].to_s.strip.presence
end
end end
def permitted_params def permitted_params
@@ -7,8 +7,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
around_action :set_locale around_action :set_locale
after_action :allow_iframe_requests after_action :allow_iframe_requests
PORTAL_LAYOUTS = %w[classic documentation].freeze
private private
def show_plain_layout def show_plain_layout
@@ -19,14 +17,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme]) @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end 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 def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false) @portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end end
@@ -39,11 +29,9 @@ class Public::Api::V1::Portals::BaseController < PublicController
end end
def switch_locale_with_portal(&) def switch_locale_with_portal(&)
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries, @locale = validate_and_get_locale(params[:locale])
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
I18n.with_locale(validate_and_get_locale(@locale), &) I18n.with_locale(@locale, &)
end end
def switch_locale_with_article(&) def switch_locale_with_article(&)
@@ -51,12 +39,13 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank? render_404 && return if article.blank?
@locale = if article.category.present? article_locale = if article.category.present?
article.category.locale article.category.locale
else else
article.locale article.portal.default_locale
end end
I18n.with_locale(validate_and_get_locale(@locale), &) @locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
end end
def allow_iframe_requests def allow_iframe_requests
@@ -1,18 +1,11 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal 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 :set_category, only: [:show]
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal' layout 'portal'
def index def index
respond_to do |format| @categories = @portal.categories.order(position: :asc)
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 end
def show def show
@@ -27,9 +20,4 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}" Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank? render_404 && return if @category.blank?
end end
def load_category_articles
@articles = @category.articles.published.order(:position).includes(:author)
@category_authors = @articles.filter_map(&:author).uniq
end
end end
@@ -1,31 +0,0 @@
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')
@@ -1,17 +1,11 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show] before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal before_action :portal
before_action :set_portal_layout before_action :redirect_to_portal_with_locale, only: [:show]
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal' layout 'portal'
def show def show
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale)) @og_image_url = helpers.set_og_image_url('', @portal.header_text)
end end
def sitemap def sitemap
@@ -30,7 +24,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale def redirect_to_portal_with_locale
return if params[:locale].present? return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end end
end end
-7
View File
@@ -18,11 +18,4 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key" Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return }, status: :unauthorized and return
end 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 end

Some files were not shown because too many files have changed in this diff Show More