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
3031 changed files with 58139 additions and 219616 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:
- 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
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
-6
View File
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Maximum time in seconds to process a single IMAP email
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
@@ -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
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## SafeFetch private network access
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
-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 }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deployment_check:
name: Check Deployment
-3
View File
@@ -8,9 +8,6 @@ on:
branches:
- develop
permissions:
contents: read
jobs:
test:
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 }}
cancel-in-progress: true
permissions:
contents: read
jobs:
log_lines_check:
runs-on: ubuntu-latest
-3
View File
@@ -14,9 +14,6 @@ on:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
runs-on: ubuntu-24.04
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
-3
View File
@@ -18,9 +18,6 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
@@ -18,9 +18,6 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
-3
View File
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
-3
View File
@@ -7,9 +7,6 @@ on:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
test-build:
strategy:
+5 -11
View File
@@ -22,7 +22,6 @@ gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -41,8 +40,6 @@ gem 'json_refs'
gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
@@ -85,11 +82,10 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'jwt'
gem 'pundit'
# super admin
@@ -133,9 +129,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
@@ -195,10 +191,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.10.0'
gem 'ai-agents', '>= 0.9.1'
# 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 'cld3', '~> 3.7'
@@ -209,8 +205,6 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+33 -48
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1)
actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0)
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -166,7 +166,7 @@ GEM
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.22)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
bindex (0.8.1)
@@ -196,9 +196,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -215,7 +212,7 @@ GEM
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.5.1)
date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -301,7 +298,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.2)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -310,8 +307,8 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
@@ -339,7 +336,6 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@@ -353,7 +349,6 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1)
gli (2.22.2)
ostruct
@@ -470,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.18.1)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -491,7 +486,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.10.3)
jwt (2.10.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -578,7 +573,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.4.20)
date
net-protocol
net-pop (0.1.2)
@@ -594,14 +589,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.3)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -681,14 +676,14 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack (3.2.5)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -703,7 +698,7 @@ GEM
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.2)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -732,9 +727,6 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
@@ -839,17 +831,17 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.15.0)
ruby_llm (1.9.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
ruby_llm-schema (~> 0)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.3.0)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -906,11 +898,10 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
sidekiq (>= 6)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -951,7 +942,6 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
squasher (0.7.2)
ssrf_filter (1.5.0)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (18.0.1)
@@ -966,7 +956,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.6.1)
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
@@ -984,7 +974,6 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1025,7 +1014,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.5)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1045,7 +1034,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.10.0)
ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1080,10 +1069,8 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
@@ -1102,7 +1089,7 @@ DEPENDENCIES
json_schemer
judoscale-rails
judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3)
jwt
kaminari
koala
letter_opener
@@ -1137,7 +1124,6 @@ DEPENDENCIES
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails-i18n (~> 7.0)
redis
redis-namespace
responders (>= 3.1.1)
@@ -1151,7 +1137,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.14.1)
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1162,8 +1148,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
@@ -1172,7 +1158,6 @@ DEPENDENCIES
spring
spring-watcher-listen
squasher
ssrf_filter (~> 1.5)
stackprof
stripe (~> 18.0)
telephone_number
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.11.2
+1 -5
View File
@@ -44,11 +44,7 @@ class AccountBuilder
end
def create_account
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
@account = Account.create!(name: account_name, locale: I18n.locale)
Current.account = @account
end
+1 -2
View File
@@ -29,9 +29,8 @@ class AgentBuilder
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
+5 -3
View File
@@ -1,6 +1,4 @@
class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!]
private
@@ -41,7 +39,7 @@ class Email::BaseBuilder
end
def business_name
inbox.sanitized_business_name
inbox.business_name || inbox.sanitized_name
end
def account_support_email
@@ -49,4 +47,8 @@ class Email::BaseBuilder
# can save it in the format "Name <email@domain.com>"
parse_email(account.support_email)
end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end
@@ -28,10 +28,6 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
# The Attachment row is saved before the blob is attached, so the
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
# for audio so the bubble updates without waiting on transcription.
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
end
def attachment_params(attachment)
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!(
notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user
)
end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end
@@ -1,5 +1,6 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include TimezoneHelper
def build
load_data
@@ -35,6 +36,7 @@ class V2::Reports::BaseSummaryBuilder
scope: nil,
range: range,
group_by: 'day',
timezone: timezone_name_from_params(params[:timezone], params[:timezone_offset]),
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end
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, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.distinct.count
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)
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
end
def bot_resolution_rate
@@ -4,7 +4,7 @@ class V2::Reports::Conversations::BaseReportBuilder
private
def builder_class(metric)
return unless Reports::ReportMetricRegistry.supported?(metric)
return unless ReportingEvents::MetricRegistry.supported_metric?(metric)
V2::Reports::Timeseries::ReportBuilder
end
@@ -7,8 +7,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
@timezone = timezone_name_from_params(params[:timezone], params[:timezone_offset])
end
# rubocop:enable Lint/MissingSuper
@@ -30,6 +30,7 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
scope: scope,
range: range,
group_by: group_by,
timezone: timezone,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
@@ -56,7 +57,7 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
@timezone ||= timezone_name_from_params(params[:timezone], params[:timezone_offset])
end
private
@@ -34,10 +34,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
@agent_bot.reload
end
def reset_secret
@agent_bot.reset_secret!
end
private
def agent_bot
@@ -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')
@@ -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 :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company_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 :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -1,16 +0,0 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[: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.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
# Visiting a conversation should clear any unread inbox notifications for this conversation.
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
@@ -162,8 +160,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
end
def should_update_last_seen?
@@ -1,7 +1,6 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
@@ -4,14 +4,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index
@inboxes = policy_scope(Current.account.inboxes)
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
end
def show; end
@@ -68,17 +65,28 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok
end
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -87,7 +95,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
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
def create_channel
@@ -186,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
@@ -18,16 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok
end
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
prompt: 'consent'
}
)
if redirect_url
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def fetch_notification
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
@notification = current_user.notifications.find(params[:id])
end
def set_current_page
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo if params[:blob_id].present?
process_attached_logo
end
def update
@@ -61,8 +61,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end
private
@@ -78,9 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -89,7 +88,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
@@ -100,8 +99,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
elsif params[:external_url].present?
create_from_url
else
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
render_error('No file or URL provided', :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
@@ -19,21 +19,35 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def create_from_url
SafeFetch.fetch(params[:external_url].to_s) do |result|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue SafeFetch::HttpError => e
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
rescue SafeFetch::FetchError
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
rescue SafeFetch::FileTooLargeError
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
rescue SafeFetch::UnsupportedContentTypeError
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
rescue SafeFetch::Error
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
render_error('An unexpected error occurred', :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
@@ -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 -35
View File
@@ -30,20 +30,9 @@ class Api::V1::AccountsController < Api::BaseController
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @user
# Authenticated users (dashboard "add account") and api_only signups
# need the full response with account_id. API-only deployments have no
# frontend to handle the email confirmation flow, so they need auth
# tokens to proceed.
# Unauthenticated web signup returns only the email — no session is
# created until the user confirms via the email link.
if current_user || api_only_signup?
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render json: { email: @user.email }
end
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@@ -58,7 +47,6 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -71,17 +59,6 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
email = account_params[:email].presence || @user&.email
return if email.blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
ChatwootExceptionTracker.new(e).capture_exception
end
def ensure_account_name
# ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered
@@ -111,7 +88,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
params.permit(:industry, :company_size, :timezone)
end
def settings_params
@@ -126,15 +103,6 @@ class Api::V1::AccountsController < Api::BaseController
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def api_only_signup?
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
# typecasts it to boolean, coercing 'api_only' to true.
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
end
def validate_captcha
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end
def destroy
notification_subscription = current_user.notification_subscriptions
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end
@@ -48,8 +48,7 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
otp_code: mfa_params[:otp_code]
).authenticate
return if authenticated
@@ -64,6 +63,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :backup_code, :password)
params.permit(:otp_code, :password)
end
end
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact)
end
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_conversation
return unless conversation.nil?
@conversation = create_conversation
apply_labels if permitted_params[:labels].present?
end
def apply_labels
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
@conversation.update_labels(valid_labels) if valid_labels.present?
@conversation = create_conversation if conversation.nil?
end
def message_finder_params
@@ -72,21 +64,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# 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],
custom_attributes: {},
labels: []
)
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
end
def set_message
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
# which means a visitor with more than one open thread could not edit a message in any
# but their most recent one. Scoping across all of the visitor's conversations keeps the
# happy path correct for that future multi-conversation widget flow.
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
@message = @web_widget.inbox.messages.find(permitted_params[:id])
end
end
@@ -112,6 +112,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:current][:since],
until: range[:current][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -120,6 +121,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -129,6 +131,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -36,6 +36,8 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
until: permitted_params[:until],
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
def render_report_with(builder_class, type: nil)
@@ -45,7 +47,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
end
def permitted_params
params.permit(:since, :until, :business_hours)
params.permit(:since, :until, :business_hours, :timezone, :timezone_offset)
end
def date_range_too_long?
@@ -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
@@ -2,10 +2,6 @@
# This concern handles the token verification step.
module MetaTokenVerifyConcern
CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze
META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze
META_SIGNATURE_PREFIX = 'sha256='.freeze
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
@@ -18,53 +14,6 @@ module MetaTokenVerifyConcern
private
def verify_meta_signature!
return unless meta_signature_verification_required?
return if valid_meta_signature?
head :unauthorized
end
def valid_meta_signature?
signature = request.headers[META_SIGNATURE_HEADER]
return false unless signature&.start_with?(META_SIGNATURE_PREFIX)
meta_app_secrets.any? do |secret|
next false if secret.blank?
expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}"
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
end
end
def meta_request_body
@meta_request_body ||= request.raw_post
end
def meta_app_secrets
raise 'Overwrite this method in your controller'
end
def meta_signature_verification_required?
true
end
def channel_meta_app_secrets(channel)
return [] if channel.blank?
secrets = []
secrets << channel.app_secret if channel.respond_to?(:app_secret)
secrets.concat(provider_config_meta_app_secrets(channel))
secrets.compact_blank.uniq
end
def provider_config_meta_app_secrets(channel)
return [] unless channel.respond_to?(:provider_config)
provider_config = channel.provider_config.to_h.with_indifferent_access
CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence }
end
def valid_token?(_token)
raise 'Overwrite this method your controller'
end
+1 -8
View File
@@ -80,17 +80,10 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
ALLOWED_LOGIN_METHODS: allowed_login_methods
}
end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -10,12 +10,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private
def sign_in_user
# Capture before skip_confirmation! sets confirmed_at, which would
# make oauth_user_needs_password_reset? return false and skip the
# password reset for persisted unconfirmed users.
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@@ -25,10 +20,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def sign_in_user_on_mobile
# See comment in sign_in_user for why this is captured before skip_confirmation!
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@@ -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?
create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
@@ -90,15 +81,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end
def oauth_user_needs_password_reset?
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
end
def set_random_password_if_oauth_user
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
@resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
end
def default_devise_mapping
'user'
end
@@ -25,14 +25,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def render_create_error_not_confirmed
render_error(
:unauthorized,
I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email),
error_code: 'user_not_confirmed'
)
end
def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present?
+12 -39
View File
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri
redirect_to linear_redirect_uri
end
private
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
end
def handle_response
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
hook = account.hooks.new(
access_token: parsed_body['access_token'],
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!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to safe_linear_redirect_uri
redirect_to linear_redirect_uri
end
def account
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
end
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
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def integration_settings
{
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
expires_on: expires_on,
scope: parsed_body['scope'],
refresh_token: parsed_body['refresh_token']
}.compact
end
def merged_integration_settings(existing_settings)
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
end
def expires_on
return if parsed_body['expires_in'].blank?
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
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
@@ -24,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation
return if params[:conversation_id].blank?
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
else
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
end
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
end
end
@@ -1,15 +1,11 @@
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 :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show, :show_markdown]
before_action :set_article, only: [:show]
layout 'portal'
def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@@ -23,13 +19,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
@parsed_content = render_article_content(@article.content.to_s)
end
def show_markdown
return head :not_found unless @article&.published?
render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8'
end
def tracking_pixel
@@ -71,6 +60,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content)
end
def set_category
@@ -83,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
permitted[:query] = permitted[:query].to_s.strip.presence
end
params.permit(:query, :locale, :sort, :status, :page, :per_page)
end
def permitted_params
@@ -7,8 +7,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
around_action :set_locale
after_action :allow_iframe_requests
PORTAL_LAYOUTS = %w[classic documentation].freeze
private
def show_plain_layout
@@ -19,14 +17,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def set_portal_layout
@portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic'
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
@@ -52,7 +42,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
article_locale = if article.category.present?
article.category.locale
else
article.locale
article.portal.default_locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@@ -1,18 +1,11 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def index
respond_to do |format|
format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently }
format.json { @categories = @portal.categories.order(position: :asc) }
end
@categories = @portal.categories.order(position: :asc)
end
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]}"
render_404 && return if @category.blank?
end
def load_category_articles
@articles = @category.articles.published.order(:position).includes(:author)
@category_authors = @articles.filter_map(&:author).uniq
end
end
@@ -1,11 +1,7 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show
@@ -28,31 +24,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale
return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
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
-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"
}, status: :unauthorized and return
end
def ensure_portal_feature_enabled
return unless ChatwootApp.chatwoot_cloud?
return if @portal.account.feature_enabled?('help_center')
render 'public/api/v1/portals/not_active', status: :payment_required
end
end
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, flash: success_flash
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
end
@@ -58,21 +58,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
def success_notice
message = "#{@config.titleize} settings updated successfully"
return message unless restart_required_config_saved?
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
end
def success_flash
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
end
def restart_required_config_saved?
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -25,29 +25,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
resource_class.editable
end
def create
resource = new_resource(resource_params)
authorize_resource(resource)
if resource.save
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
else
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, resource)
}, status: :unprocessable_entity
end
end
def update
if requested_resource.update(resource_params)
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
else
render :edit, locals: {
page: Administrate::Page::Form.new(dashboard, requested_resource)
}, status: :unprocessable_entity
end
end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
@@ -65,20 +42,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
end
private
def success_flash(resource)
message = translate_with_resource('update.success')
message = translate_with_resource('create.success') if action_name == 'create'
return { notice: message } unless restart_required_config?(resource)
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
end
def restart_required_config?(resource)
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
@@ -1,9 +0,0 @@
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
before_action :ensure_chatwoot_cloud
private
def ensure_chatwoot_cloud
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
end
end
@@ -1,68 +0,0 @@
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
def show
@query = params[:user_query].to_s.strip
@user = resolve_user(@query)
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
@results = []
end
def create
@user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
end
run_test_and_render(ids)
end
def destroy_subscriptions
user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
end
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
end
private
def run_test_and_render(ids)
@query = @user.id.to_s
@subscriptions = @user.notification_subscriptions.order(:id)
@results = Notification::PushTestService.new(
user: @user, subscription_ids: ids,
title: params[:push_title], body: params[:push_body]
).perform
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
render :show
end
def log_super_admin_action(message)
Rails.logger.info(
"[SuperAdmin] push diagnostics #{message} " \
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
)
end
def resolve_user(query)
return if query.blank?
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
end
def parsed_subscription_ids
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
end
end
+3 -8
View File
@@ -1,12 +1,7 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
swagger_root = Rails.root.join('swagger')
file_path = swagger_root.join(derived_path).cleanpath
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
render inline: file_path.read
render inline: Rails.root.join('swagger', derived_path).read
else
head :not_found
end
@@ -16,8 +11,8 @@ class SwaggerController < ApplicationController
def derived_path
params[:path] ||= 'index.html'
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
path = Rack::Utils.clean_path_info(params[:path])
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
path
end
end
@@ -31,11 +31,7 @@ class Twilio::CallbackController < ApplicationController
:Latitude,
:Longitude,
:MessageType,
:ProfileName,
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:ProfileName
)
end
end
@@ -1,8 +1,6 @@
class Webhooks::InstagramController < ActionController::API
include MetaTokenVerifyConcern
before_action :verify_meta_signature!, only: :events
def events
Rails.logger.info('Instagram webhook received events')
if params['object'].casecmp('instagram').zero?
@@ -41,38 +39,4 @@ class Webhooks::InstagramController < ActionController::API
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
end
def meta_app_secrets
[
*instagram_channel_meta_app_secrets,
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil),
GlobalConfigService.load('FB_APP_SECRET', nil)
]
end
def instagram_channel_meta_app_secrets
instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) }
end
def instagram_channels_from_payload
Array(params.to_unsafe_hash[:entry]).flat_map do |entry|
instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id|
[
Channel::Instagram.find_by(instagram_id: instagram_id),
Channel::FacebookPage.find_by(instagram_id: instagram_id)
]
end
end.compact.uniq
end
def instagram_ids_from_entry(entry)
messages = entry[:messaging].presence || entry[:standby] || []
messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) }
end
def instagram_id_from_messaging(messaging)
return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present?
messaging.dig(:recipient, :id)
end
end
@@ -1,8 +1,6 @@
class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
before_action :verify_meta_signature!, only: :process_payload
def process_payload
if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
@@ -22,45 +20,6 @@ class Webhooks::WhatsappController < ActionController::API
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
def meta_app_secrets
[
*channel_meta_app_secrets(whatsapp_channel),
GlobalConfigService.load('WHATSAPP_APP_SECRET', nil)
]
end
def whatsapp_channel
@whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number])
end
def meta_signature_verification_required?
return true if whatsapp_channel.blank?
return false unless whatsapp_channel.provider == 'whatsapp_cloud'
return true if channel_meta_app_secrets(whatsapp_channel).present?
whatsapp_channel.provider_config['source'] == 'embedded_signup'
end
def whatsapp_business_payload_channel
return unless params[:object] == 'whatsapp_business_account'
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
end
def inactive_whatsapp_number?
phone_number = params[:phone_number]
return false if phone_number.blank?
+1 -11
View File
@@ -77,23 +77,13 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
if @web_widget.allowed_domains.blank?
response.headers.delete('X-Frame-Options')
else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end
end
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
# which cannot match any domain in frame-ancestors. When the per-inbox flag
# is enabled, skip frame-ancestors for these requests.
def embedded_from_non_web_origin?
return false unless @web_widget.allow_mobile_webview?
origin = request.headers['Origin']
origin.blank? || origin == 'null' || origin&.start_with?('file://')
end
end
WidgetsController.prepend_mod_with('WidgetsController')
@@ -1,20 +0,0 @@
require 'administrate/base_dashboard'
class PlatformBannerDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
banner_message: Field::Text.with_options(truncate: 200),
banner_type: Field::Select.with_options(collection: %w[info warning error]),
active: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end
end
-1
View File
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance,
WebhookListener.instance
]
+2 -6
View File
@@ -7,15 +7,11 @@ class UserDrop < BaseDrop
@obj.try(:available_name)
end
def email
@obj.try(:email)
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
end
-2
View File
@@ -48,5 +48,3 @@ class MessageFinder
messages.reorder('created_at desc').limit(20).reverse
end
end
MessageFinder.prepend_mod_with('MessageFinder')
+10 -18
View File
@@ -17,12 +17,15 @@ module Api::V1::InboxesHelper
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
# Validate the user-selected auth mechanism before opening the connection.
authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication])
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
# Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command,
# not SASL AUTH=LOGIN.
check_imap_connection(channel_data, authentication)
check_imap_connection(channel_data)
end
def validate_smtp(channel_data)
@@ -34,8 +37,8 @@ module Api::V1::InboxesHelper
check_smtp_connection(channel_data, smtp)
end
def check_imap_connection(channel_data, authentication)
imap = open_imap_connection(channel_data, authentication)
def check_imap_connection(channel_data)
Mail.connection {} # rubocop:disable:block
rescue SocketError => e
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
rescue Net::IMAP::NoResponseError => e
@@ -50,20 +53,9 @@ module Api::V1::InboxesHelper
rescue StandardError => e
raise StandardError, e.message
ensure
imap.disconnect if imap.present? && !imap.disconnected?
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end
def open_imap_connection(channel_data, authentication)
imap = build_imap_connection(channel_data)
Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password])
imap
end
def build_imap_connection(channel_data)
Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl])
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
+1 -1
View File
@@ -7,7 +7,7 @@ module EmailHelper
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
-1
View File
@@ -17,7 +17,6 @@ module FileTypeHelper
def image_file?(content_type)
[
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp',
-11
View File
@@ -96,15 +96,4 @@ module PortalHelper
colors[username.length % colors.size]
end
def format_authors_label(authors)
return if authors.blank?
names = authors.map(&:available_name)
return names.to_sentence if names.size <= 3
I18n.t('public_portal.sidebar.authors_others',
names: names.first(2).join(', '),
count: authors.size - 2)
end
end
+4 -7
View File
@@ -53,12 +53,13 @@ module ReportHelper
end
def resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
created_at: range)
end
def bot_resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range)
end
def bot_handoffs
@@ -66,10 +67,6 @@ module ReportHelper
created_at: range).distinct
end
def bot_handoff_conversation_ids_subquery
bot_handoffs
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
+4 -9
View File
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import StatusBanner from './components/app/StatusBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
@@ -28,7 +27,6 @@ export default {
LoadingState,
NetworkNotification,
UpdateBanner,
StatusBanner,
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
@@ -61,6 +59,7 @@ export default {
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
@@ -99,18 +98,15 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
if (locale) {
this.$root.$i18n.locale = locale;
}
this.$root.$i18n.locale = locale;
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
@@ -133,13 +129,12 @@ export default {
<template>
<div
v-if="!authUIFlags.isFetching"
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<StatusBanner />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
@@ -25,10 +25,6 @@ class AgentBotsAPI extends ApiClient {
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
resetSecret(botId) {
return axios.post(`${this.url}/${botId}/reset_secret`);
}
}
export default new AgentBotsAPI();
@@ -31,12 +31,6 @@ class CaptainCustomTools extends ApiClient {
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
test(data = {}) {
return axios.post(`${this.url}/test`, {
custom_tool: data,
});
}
}
export default new CaptainCustomTools();
@@ -6,22 +6,15 @@ class CaptainDocument extends ApiClient {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
search_key: searchKey,
searchKey,
assistant_id: assistantId,
filter,
source,
sort,
},
});
}
sync(id) {
return axios.post(`${this.url}/${id}/sync`);
}
}
export default new CaptainDocument();
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null;
}
async joinClientCall({ to, conversationId, callSid }) {
async joinClientCall({ to, conversationId }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
@@ -76,7 +76,6 @@ class TwilioVoiceClient extends EventTarget {
To: to,
is_agent: 'true',
conversation_id: conversationId,
call_sid: callSid,
};
const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference({ inboxId, conversationId, callSid }) {
leaveConference(inboxId, conversationId) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId, call_sid: callSid },
params: { conversation_id: conversationId },
})
.then(r => r.data);
}
@@ -1,45 +0,0 @@
/* global axios */
import ApiClient from '../../ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
super('whatsapp_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
}
accept(callId, sdpAnswer) {
return axios
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
.then(r => r.data);
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
}
uploadRecording(callId, blob, filename = 'call-recording.webm') {
const formData = new FormData();
formData.append('recording', blob, filename);
return axios
.post(`${this.url}/${callId}/upload_recording`, formData)
.then(r => r.data);
}
}
export default new WhatsappCallsAPI();
+17 -43
View File
@@ -1,12 +1,21 @@
/* global axios */
import ApiClient from './ApiClient';
const buildParams = params =>
new URLSearchParams(
Object.entries(params).filter(
([key, value]) => value !== undefined && (value !== '' || key === 'q')
)
).toString();
export const buildCompanyParams = (page, sort) => {
let params = `page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
class CompanyAPI extends ApiClient {
constructor() {
@@ -15,49 +24,14 @@ class CompanyAPI extends ApiClient {
get(params = {}) {
const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildParams({ page, sort })}`;
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
return axios.get(requestURL);
}
search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`;
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
return axios.get(requestURL);
}
listContacts(id, page = 1) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
}
createContact(id, payload) {
return axios.post(`${this.url}/${id}/contacts`, payload);
}
removeContact(id, contactId) {
return axios.delete(`${this.url}/${id}/contacts/${contactId}`);
}
destroyCustomAttributes(id, customAttributes) {
return axios.post(`${this.url}/${id}/destroy_custom_attributes`, {
custom_attributes: customAttributes,
});
}
destroyAvatar(id) {
return axios.delete(`${this.url}/${id}/avatar`);
}
}
export default new CompanyAPI();
+3 -5
View File
@@ -35,9 +35,8 @@ class ContactAPI extends ApiClient {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId, { inboxId } = {}) {
const params = inboxId ? { inbox_id: inboxId } : {};
return axios.get(`${this.url}/${contactId}/conversations`, { params });
getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`);
}
getContactableInboxes(contactId) {
@@ -48,10 +47,9 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId, conversationId = null) {
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
conversation_id: conversationId,
});
}
@@ -13,10 +13,6 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
}
getUnreadCounts() {
return axios.get(`${this.url}/unread_counts`);
}
}
export default new ConversationApi();
@@ -72,34 +72,6 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
return axios.post(
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
bulkUpdateStatus({ portalSlug, articleIds, status }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
{ ids: articleIds, status }
);
}
bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
{ ids: articleIds, category_id: categoryId }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
{ data: { ids: articleIds } }
);
}
}
export default new ArticlesAPI();
@@ -9,10 +9,6 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
}
export default new InboxHealthAPI();
-12
View File
@@ -48,18 +48,6 @@ class Inboxes extends CacheEnabledApiClient {
template,
});
}
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
enableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
}
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
}
export default new Inboxes();
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, { otpCode, backupCode } = {}) {
disable(password, otpCode) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode, backup_code: backupCode },
data: { password, otp_code: otpCode },
});
}
+5
View File
@@ -2,6 +2,7 @@
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
class ReportsAPI extends ApiClient {
constructor() {
@@ -26,6 +27,7 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
@@ -41,6 +43,7 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
@@ -105,6 +108,8 @@ class ReportsAPI extends ApiClient {
type: 'account',
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -153,33 +153,4 @@ describe('#PortalAPI', () => {
);
});
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#bulkUpdateCategory', () => {
articlesAPI.bulkUpdateCategory({
portalSlug: 'room-rental',
articleIds: [1, 2, 3],
categoryId: 7,
});
expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/bulk_actions/update_category',
{ ids: [1, 2, 3], category_id: 7 }
);
});
});
});
@@ -1,4 +1,7 @@
import companyAPI from '../companies';
import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => {
@@ -6,6 +9,7 @@ describe('#CompanyAPI', () => {
expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search');
@@ -28,69 +32,111 @@ describe('#CompanyAPI', () => {
window.axios = originalAxios;
});
it('#get includes pagination and sorting params', () => {
it('#get with default params', () => {
companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name'
);
});
it('#search encodes query params', () => {
companyAPI.search('acme & co', 2, 'domain');
it('#get with page and sort params', () => {
companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain'
'/api/v1/companies?page=2&sort=domain'
);
});
it('#search keeps empty query param for backend validation', () => {
it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name'
);
});
});
});
it('#destroyAvatar deletes the company avatar endpoint', () => {
companyAPI.destroyAvatar(1);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/avatar'
);
});
it('#listContacts fetches company contacts', () => {
companyAPI.listContacts(1, 2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts?page=2'
);
});
it('#searchContacts encodes contact search params', () => {
companyAPI.searchContacts(1, 'jane & co', 3);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3'
);
});
it('#createContact links a contact to the company', () => {
companyAPI.createContact(1, { contact_id: 2 });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts',
{ contact_id: 2 }
);
});
it('#removeContact unlinks a contact from the company', () => {
companyAPI.removeContact(1, 2);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/2'
);
});
it('#destroyCustomAttributes removes company custom attributes', () => {
companyAPI.destroyCustomAttributes(1, ['plan']);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/destroy_custom_attributes',
{ custom_attributes: ['plan'] }
);
});
describe('#buildCompanyParams', () => {
it('returns correct string with page only', () => {
expect(buildCompanyParams(1)).toBe('page=1');
});
it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
});
it('returns correct string with different page', () => {
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
});
it('returns correct string with descending sort', () => {
expect(buildCompanyParams(1, '-created_at')).toBe(
'page=1&sort=-created_at'
);
});
it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
});
});
describe('#buildSearchParams', () => {
it('returns correct string with all parameters', () => {
expect(buildSearchParams('acme', 1, 'name')).toBe(
'q=acme&page=1&sort=name'
);
});
it('returns correct string with special characters', () => {
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
'q=acme%20%26%20co&page=2&sort=domain'
);
});
it('returns correct string with empty query', () => {
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
});
it('returns correct string without sort parameter', () => {
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
});
});

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