Compare commits

..

1 Commits

Author SHA1 Message Date
Sony Mathew
b026f27e31 fix(search): support Elastic Searchkick client 2026-07-29 18:40:39 +05:30
3695 changed files with 31071 additions and 186301 deletions

View File

@@ -21,17 +21,3 @@ ignore:
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
# No Rails 7.1 patch for this Active Storage advisory; mitigated locally.
# Remove once on Rails 7.2.3.1+.
- CVE-2026-66066
# RubyLLM's name-normalization ReDoS is reported on Ruby 3.1; we require Ruby 3.4.4.
# Built-in tool names are fixed; custom tools use bounded slugs and override normalization.
# Patched 2.0 releases need an ai-agents migration (currently constrained to RubyLLM 1.x).
# Remove when a supported patched RubyLLM/ai-agents combination is adopted.
- CVE-2026-67991
# Think-tag parsing and Mistral matching ReDoS are reported on Ruby 3.1.
# We pin Ruby 3.4.4, where the affected regexes are linear-time.
# Remove when a supported patched RubyLLM/ai-agents combination is adopted;
# reassess if the supported Ruby runtime changes.
- CVE-2026-67987
- CVE-2026-67989

View File

@@ -15,15 +15,6 @@ SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# Enterprise conversation monitors (enable the conversation_monitors account feature separately)
# Set CAPTAIN_OPENROUTER_API_KEY and optional CAPTAIN_OPENROUTER_DECISION_MODEL_ENDPOINT
# in Super Admin -> Settings -> Captain (shared installation settings). The endpoint is the API base URL.
# CONVERSATION_MONITORS_MODEL=typesafe/jev-1.13
# CONVERSATION_MONITORS_LIMIT=20
# Allowlisted contact/conversation custom attribute keys to send as customer context
# CONVERSATION_MONITORS_ATTRIBUTES=deployment_type
# Each account can make 500,000 monitor calls per UTC day and 10,000,000 per UTC month.
# To use a dedicated URL for help center pages
# HELPCENTER_URL=http://0.0.0.0:3000
@@ -89,13 +80,6 @@ RAILS_ENV=development
# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required.
# POSTGRES_STATEMENT_TIMEOUT=14s
RAILS_MAX_THREADS=5
# Number of Puma worker processes (clustered mode). Defaults to 0 (single
# process) when unset. On a multi-core production server, set this to run Puma
# in clustered mode so CPU-bound requests don't serialize behind Ruby's GVL.
# A common starting point is the number of available CPU cores; total
# concurrency is roughly WEB_CONCURRENCY * RAILS_MAX_THREADS, so size it
# against available RAM. See config/puma.rb.
# WEB_CONCURRENCY=2
# The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
@@ -158,11 +142,6 @@ ACTION_MAILBOX_SES_SNS_TOPIC=
# Storage
ACTIVE_STORAGE_SERVICE=local
# Restrict libvips to its trusted, well-tested image loaders when Active Storage
# generates variants. Hardens image processing against untrusted uploads.
# Requires libvips >= 8.13 (ignored on older versions).
VIPS_BLOCK_UNTRUSTED=1
# Amazon S3
# documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage
S3_BUCKET_NAME=
@@ -182,8 +161,6 @@ LOG_SIZE=500
# Facebook
# documentation: https://www.chatwoot.com/docs/facebook-setup
# Connected Facebook pages are subscribed by Chatwoot to Messenger webhook
# fields, including postbacks used by Get Started and button flows.
FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
@@ -201,7 +178,6 @@ TWITTER_ENVIRONMENT=
#slack integration
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=
@@ -321,10 +297,6 @@ AZURE_APP_SECRET=
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# Sidekiq health check server (defaults to 0.0.0.0:7433).
# Uncomment SIDEKIQ_ALIVE_HOST to restrict the health check to localhost.
# SIDEKIQ_ALIVE_HOST=127.0.0.1
# SIDEKIQ_ALIVE_PORT=7433
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
@@ -337,15 +309,3 @@ AZURE_APP_SECRET=
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
# Publish Sidekiq queue metrics to AWS CloudWatch for autoscaling. Off by default.
# By default uses the instance role, which must allow cloudwatch:PutMetricData.
# ENABLE_SIDEKIQ_CLOUDWATCH=true
# SIDEKIQ_CLOUDWATCH_NAMESPACE=Chatwoot/Sidekiq
# Publish interval in seconds (default 60; values below 60 work but bill as high-resolution CloudWatch metrics)
# SIDEKIQ_CLOUDWATCH_INTERVAL=60
# Optional: dedicated credentials for CloudWatch only, separate from the S3 (storage) keys.
# Set these to override the instance role when a role is not available.
# SIDEKIQ_CLOUDWATCH_AWS_ACCESS_KEY_ID=
# SIDEKIQ_CLOUDWATCH_AWS_SECRET_ACCESS_KEY=
# SIDEKIQ_CLOUDWATCH_AWS_REGION=us-east-1

View File

@@ -247,7 +247,6 @@ module.exports = {
},
env: {
browser: true,
es2020: true,
node: true,
},
globals: {

View File

@@ -106,19 +106,6 @@ def build_description(adv: dict[str, Any]) -> str:
)
def post_slack(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']}"[:200]
text = (
f"*<{issue['url']}|{title}>*\n"
f"Linear: {issue['identifier']} · Severity: {severity} (CVSS {cvss_score(adv)}) · <{adv['html_url']}|advisory>"
)
try:
requests.post(webhook_url, json={"text": text}, timeout=10)
except requests.RequestException:
pass
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]
@@ -159,7 +146,6 @@ def main() -> int:
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
slack_webhook = os.environ.get("SLACK_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
@@ -200,8 +186,6 @@ def main() -> int:
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
if slack_webhook:
post_slack(adv, issue, slack_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0

View File

@@ -26,5 +26,4 @@ jobs:
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py

View File

@@ -1,41 +0,0 @@
name: Migration Version Check
on:
pull_request:
paths:
- 'db/migrate/**'
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
migration_version_check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Reject hand-written migration versions
run: |
rounded=$(git diff --name-only --diff-filter=AR HEAD^1 HEAD -- db/migrate | grep -E '^db/migrate/[0-9]{10}0000_' || true)
if [ -n "$rounded" ]; then
echo "::error::Rounded migration versions collide across branches. Regenerate with 'bundle exec rails g migration':"
echo "$rounded"
exit 1
fi
- name: Reject duplicate migration versions
run: |
duplicates=$(ls db/migrate | cut -d_ -f1 | sort | uniq -d)
if [ -n "$duplicates" ]; then
echo "::error::Duplicate migration versions break db:migrate:"
echo "$duplicates"
exit 1
fi

View File

@@ -21,24 +21,6 @@ jobs:
- name: Run Rubocop
run: bundle exec rubocop --parallel
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run Brakeman
# Non-blocking for now: Brakeman surfaces 35 pre-existing findings
# (13 High confidence) that need security-team triage before this
# can be turned into a hard gate.
continue-on-error: true
run: bundle exec brakeman -q --no-pager
- name: Run bundle-audit
run: |
bundle exec bundle-audit update
bundle exec bundle-audit check
lint-frontend:
runs-on: ubuntu-latest
steps:

View File

@@ -67,20 +67,9 @@ jobs:
run: |
bundle exec rspec \
spec/services/mfa/token_service_spec.rb \
spec/services/mfa/setup_token_service_spec.rb \
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/api/v1/profiles_controller_spec.rb \
spec/controllers/api/v1/notification_subscriptions_controller_spec.rb \
spec/requests/api/v1/profile/sessions_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/requests/devise_overrides/passwords_controller_spec.rb \
spec/requests/devise_overrides/confirmations_controller_spec.rb \
spec/controllers/api/v1/accounts_controller_spec.rb \
spec/controllers/api/v2/accounts_controller_spec.rb \
spec/requests/api/v1/accounts/base_controller_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb \
spec/enterprise/controllers/enterprise/devise_overrides/device_verification_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \
--format documentation
@@ -98,13 +87,6 @@ jobs:
-e "backup code" \
--profile=10 \
--format documentation
- name: Run MFA-related tests in account_spec
run: |
bundle exec rspec spec/models/account_spec.rb \
-e "enforce_mfa" \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider

1
.gitignore vendored
View File

@@ -104,4 +104,3 @@ CLAUDE.local.md
.pnpm-store/*
local/
Procfile.worktree
.nodeterm/project.json

View File

@@ -16,7 +16,6 @@ Layout/LineLength:
Metrics/ClassLength:
Max: 175
Exclude:
- 'app/builders/messages/message_builder.rb'
- 'app/models/message.rb'
- 'app/models/conversation.rb'
- 'config/initializers/rack_attack.rb'

View File

@@ -43,13 +43,8 @@
## General Guidelines
- **Database foreign keys**: Prefer Rails associations and callbacks over database foreign-key constraints. Add a database foreign key only when there is a clear need and after checking deletion behavior and production migration/locking risks. Existing foreign keys alone are not a reason to add new ones.
- **Migrations**: Create migrations with `bundle exec rails g migration <Name>` so the version is a real timestamp. Never hand-write or round the version (e.g. `20260925000000`); rounded versions collide across branches and break `db:migrate`.
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- Enforce eligibility and exclusivity rules at the earliest shared entry point. Do not repeat backup guards across downstream jobs, callbacks, services, or writes unless a proven independent path bypasses that point.
- Validate request parameters at the controller or request boundary, reusing existing errors so invalid input returns `422 Unprocessable Entity` instead of reaching models or Sentry.
- Accept only the documented type, shape, and value. Do not add compatibility coercions for malformed client values; fix official clients instead.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
@@ -92,8 +87,6 @@
- **Translations**:
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Preserve product and brand names, OAuth scopes, API values, and other machine-readable identifiers unless an official localized form exists
- When reviewing Crowdin syncs, verify protected terms remain unchanged. Add newly introduced product names, brand names, and machine-readable identifiers to the Crowdin glossary as non-translatable, and keep the glossary current
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
@@ -102,15 +95,6 @@
- Use compact `module/class` definitions; avoid nested styles
## Frontend Conventions
- Prefer existing design-system utilities and shared composables.
- Use typography utilities instead of manually recreating font styles.
- Use logical Tailwind utilities (`ms`, `me`, `start`, `end`) for direction-aware layouts.
- Use `rem` for arbitrary CSS dimensions; preserve native numeric values required by chart/SVG APIs.
- Extract repeated or domain-specific strings, thresholds, colors, and durations into named constants.
- Use shared request-cancellation utilities instead of local `AbortController` logic.
## Enterprise Edition Notes
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.

19
Gemfile
View File

@@ -3,10 +3,8 @@ source 'https://rubygems.org'
ruby '3.4.4'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '7.2.3.1'
gem 'rails', '~> 7.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -54,8 +52,9 @@ gem 'csv-safe'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-sesv2', require: false
gem 'azure-blob', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
@@ -71,6 +70,7 @@ gem 'redis-namespace'
gem 'activerecord-import'
gem 'searchkick'
gem 'elasticsearch', '>= 7', '< 10'
gem 'opensearch-ruby'
gem 'faraday_middleware-aws-sigv4'
@@ -84,8 +84,7 @@ gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise', '>= 4.9.4'
# 2.2.3 requires Devise 5; Chatwoot remains on Devise 4 for this upgrade stack.
gem 'devise-secure_password', '2.2.1'
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
@@ -97,7 +96,7 @@ gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-belongs_to_search', '>= 0.10.0'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -135,13 +134,11 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3.10'
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
# publishes Sidekiq queue metrics to CloudWatch for autoscaling (opt-in via ENABLE_SIDEKIQ_CLOUDWATCH)
gem 'speedshop-cloudwatch', '~> 0.2.1', require: false
##-- Push notification service --##
gem 'fcm'

View File

@@ -1,82 +1,110 @@
GIT
remote: https://github.com/chatwoot/azure-storage-ruby
revision: 9957cf899d33a285b5dfe15bdb875292398e392b
branch: chatwoot
specs:
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 2.0)
faraday-follow_redirects (~> 0.3.0)
faraday-net_http_persistent (~> 2.0)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
GIT
remote: https://github.com/chatwoot/devise-secure_password
revision: adcc85fe1babfe40feae73dbcae64d14fff86e69
branch: chatwoot
specs:
devise-secure_password (2.0.1)
devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 8.0.0)
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.3.1)
actionpack (= 7.2.3.1)
activesupport (= 7.2.3.1)
actioncable (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.3.1)
actionpack (= 7.2.3.1)
activejob (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
actionmailer (7.2.3.1)
actionpack (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
actionmailbox (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.5.2)
actionpack (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activesupport (= 7.1.5.2)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
actionpack (7.2.3.1)
actionview (= 7.2.3.1)
activesupport (= 7.2.3.1)
cgi
actionpack (7.1.5.2)
actionview (= 7.1.5.2)
activesupport (= 7.1.5.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.3)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.3.1)
actionpack (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
actiontext (7.1.5.2)
actionpack (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.3.1)
activesupport (= 7.2.3.1)
actionview (7.1.5.2)
activesupport (= 7.1.5.2)
builder (~> 3.1)
cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8)
activejob (7.2.3.1)
activesupport (= 7.2.3.1)
activejob (7.1.5.2)
activesupport (= 7.1.5.2)
globalid (>= 0.3.6)
activemodel (7.2.3.1)
activesupport (= 7.2.3.1)
activerecord (7.2.3.1)
activemodel (= 7.2.3.1)
activesupport (= 7.2.3.1)
activemodel (7.1.5.2)
activesupport (= 7.1.5.2)
activerecord (7.1.5.2)
activemodel (= 7.1.5.2)
activesupport (= 7.1.5.2)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2)
activestorage (7.2.3.1)
actionpack (= 7.2.3.1)
activejob (= 7.2.3.1)
activerecord (= 7.2.3.1)
activesupport (= 7.2.3.1)
activestorage (7.1.5.2)
actionpack (= 7.1.5.2)
activejob (= 7.1.5.2)
activerecord (= 7.1.5.2)
activesupport (= 7.1.5.2)
marcel (~> 1.0)
activesupport (7.2.3.1)
activesupport (7.1.5.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1, < 6)
minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
tzinfo (~> 2.0)
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
@@ -93,10 +121,10 @@ GEM
administrate-field-active_storage (1.0.3)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.10.0)
administrate-field-belongs_to_search (0.9.0)
administrate (>= 0.3, < 1.0)
jbuilder (~> 2)
rails (>= 4.2, < 8.0)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.12.0)
ruby_llm (~> 1.14)
@@ -116,9 +144,6 @@ GEM
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-cloudwatch (1.126.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -134,29 +159,22 @@ GEM
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-sesv2 (1.92.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.8.0)
cgi
rexml
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.5.0)
benchmark (0.4.1)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (8.0.5)
racc
brakeman (5.4.1)
browser (5.3.1)
builder (3.3.0)
bullet (8.0.7)
@@ -168,14 +186,14 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.2)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.12)
concurrent-ruby (1.3.8)
commonmarker (0.23.10)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -202,9 +220,9 @@ GEM
msgpack
datadog-ruby_core_source (3.5.3)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
declarative (0.0.20)
devise (4.9.4)
bcrypt (~> 3.0)
@@ -212,9 +230,6 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-secure_password (2.2.1)
devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 9.0.0)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
@@ -273,10 +288,18 @@ GEM
concurrent-ruby (~> 1.0)
http (>= 3.0)
ruby2_keywords
elastic-transport (8.5.3)
faraday (< 3)
multi_json
elasticsearch (9.4.3)
elastic-transport (~> 8.3)
elasticsearch-api (= 9.4.3)
elasticsearch-api (9.4.3)
base64
multi_json
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13)
erb (6.0.6)
erubi (1.13.1)
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
@@ -295,6 +318,8 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
@@ -302,6 +327,9 @@ GEM
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
@@ -413,8 +441,8 @@ GEM
googleapis-common-protos-types (~> 1.0)
gserver (0.0.1)
haikunator (1.1.1)
hairtrigger (1.3.1)
activerecord (>= 6.0, < 9)
hairtrigger (1.0.0)
activerecord (>= 6.0, < 8)
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
hana (1.3.7)
@@ -438,22 +466,19 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.15.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
iso-639 (0.3.8)
csv
jbuilder (2.15.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.2)
jquery-rails (4.6.0)
rails-dom-testing (>= 1, < 3)
@@ -536,8 +561,7 @@ GEM
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
logger
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
@@ -554,7 +578,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.27.0)
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.3)
@@ -567,6 +591,8 @@ GEM
activerecord (>= 5.2)
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.6.4.1)
date
net-protocol
@@ -669,10 +695,7 @@ GEM
activerecord (>= 5.2)
activesupport (>= 5.2)
pgvector (0.1.1)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
prism (1.4.0)
procore-sift (1.0.0)
activerecord (>= 6.1)
pry (0.14.2)
@@ -694,14 +717,14 @@ GEM
rack (< 4)
rack-cors (2.0.0)
rack (>= 2.0.0)
rack-mini-profiler (4.0.1)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-proxy (1.0.3)
rack (>= 2.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -710,21 +733,21 @@ GEM
rack-timeout (0.6.3)
rackup (2.2.1)
rack (>= 3)
rails (7.2.3.1)
actioncable (= 7.2.3.1)
actionmailbox (= 7.2.3.1)
actionmailer (= 7.2.3.1)
actionpack (= 7.2.3.1)
actiontext (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activemodel (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
rails (7.1.5.2)
actioncable (= 7.1.5.2)
actionmailbox (= 7.1.5.2)
actionmailer (= 7.1.5.2)
actionpack (= 7.1.5.2)
actiontext (= 7.1.5.2)
actionview (= 7.1.5.2)
activejob (= 7.1.5.2)
activemodel (= 7.1.5.2)
activerecord (= 7.1.5.2)
activestorage (= 7.1.5.2)
activesupport (= 7.1.5.2)
bundler (>= 1.15.0)
railties (= 7.2.3.1)
rails-dom-testing (2.3.0)
railties (= 7.1.5.2)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
@@ -734,30 +757,19 @@ GEM
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.2.3.1)
actionpack (= 7.2.3.1)
activesupport (= 7.2.3.1)
cgi
irb (~> 1.13)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rbs (4.0.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.26.4)
@@ -765,7 +777,7 @@ GEM
redis-namespace (1.10.0)
redis (>= 4)
regexp_parser (2.10.0)
reline (0.6.3)
reline (0.3.6)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -860,8 +872,7 @@ GEM
ruby_llm-schema (~> 0)
zeitwerk (~> 2)
ruby_llm-schema (0.3.0)
ruby_parser (3.22.0)
racc (~> 1.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
sass-listen (~> 4.0.0)
@@ -911,12 +922,12 @@ GEM
zeitwerk (~> 2.5)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.10)
base64
connection_pool (>= 2.3.0, < 3)
sidekiq (7.3.1)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4, < 3.3)
redis-client (>= 0.23.0, < 1)
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
@@ -950,8 +961,6 @@ GEM
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934)
speedshop-cloudwatch (0.2.1)
aws-sdk-cloudwatch (>= 1.81.0)
spring (4.1.1)
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
@@ -969,8 +978,7 @@ GEM
statsd-ruby (1.5.0)
stripe (18.0.1)
telephone_number (1.4.20)
test-prof (1.6.3)
logger
test-prof (1.2.1)
thor (1.4.0)
tidewave (0.2.0)
fast-mcp (~> 1.5.0)
@@ -982,7 +990,6 @@ GEM
i18n
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
@@ -991,7 +998,7 @@ GEM
oauth
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2026.4)
tzinfo-data (1.2023.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
uglifier (4.2.0)
@@ -1007,7 +1014,6 @@ GEM
uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0)
useragent (0.16.11)
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
@@ -1015,11 +1021,11 @@ GEM
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.6)
vite_ruby (3.10.2)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (>= 0.6.1)
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
@@ -1062,15 +1068,14 @@ DEPENDENCIES
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.10.0)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.12.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3
aws-sdk-sesv2
azure-blob
azure-storage-blob!
barnes
bootsnap
brakeman
@@ -1086,12 +1091,13 @@ DEPENDENCIES
datadog (~> 2.0)
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password (= 2.2.1)
devise-secure_password!
devise-two-factor (>= 5.0.0)
devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
down
elastic-apm
elasticsearch (>= 7, < 10)
email-provider-info
email_reply_trimmer
facebook-messenger
@@ -1155,7 +1161,7 @@ DEPENDENCIES
rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (= 7.2.3.1)
rails (~> 7.1)
rails-i18n (~> 7.0)
redis
redis-namespace
@@ -1181,14 +1187,13 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3.10)
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
speedshop-cloudwatch (~> 0.2.1)
spring
spring-watcher-listen
squasher

View File

@@ -1,4 +1,4 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch --force
vite: bin/vite build --watch

View File

@@ -1 +1 @@
4.18.0
4.16.2

View File

@@ -13,7 +13,6 @@ class ContactMergeAction
merge_messages
merge_contact_inboxes
merge_contact_notes
merge_calls
merge_and_remove_mergee_contact
end
@base_contact
@@ -47,10 +46,6 @@ class ContactMergeAction
ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
end
def merge_calls
# overridden in enterprise/app/actions/enterprise/contact_merge_action.rb
end
def merge_and_remove_mergee_contact
mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
@@ -58,17 +53,10 @@ class ContactMergeAction
# attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
merged_attributes['contact_type'] = higher_contact_type
@mergee_contact.reload.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
end
def higher_contact_type
[base_contact, mergee_contact].map(&:contact_type).max_by { |type| Contact.contact_types[type] }
end
end
ContactMergeAction.prepend_mod_with('ContactMergeAction')

View File

@@ -2,3 +2,4 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link secretField.js

View File

@@ -0,0 +1,45 @@
// eslint-disable-next-line
function toggleSecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
const textElement = secretField.querySelector('[data-secret-masked]');
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
return;
}
textElement.textContent = secretField.dataset.secretText;
textElement.dataset.secretMasked = 'false';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-hide');
}
// eslint-disable-next-line
function copySecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});

View File

@@ -1,5 +1,32 @@
@charset 'utf-8';
// The super admin is styled by app/javascript/entrypoints/superadmin.scss.
// Sprockets only serves the selectize base styles its pickers need.
@import 'reset/normalize';
@import 'utilities/variables';
@import 'utilities/text-color';
@import 'selectize';
@import 'library/clearfix';
@import 'library/data-label';
@import 'library/variables';
@import 'base/forms';
@import 'base/layout';
@import 'base/lists';
@import 'base/tables';
@import 'base/typography';
@import 'components/app-container';
@import 'components/attributes';
@import 'components/buttons';
@import 'components/cells';
@import 'components/field-unit';
@import 'components/flashes';
@import 'components/form-actions';
@import 'components/main-content';
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@import 'custom_styles';

View File

@@ -0,0 +1,96 @@
fieldset {
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
}
legend {
font-weight: $font-weight-medium;
margin: 0;
padding: 0;
}
label {
display: block;
font-weight: $font-weight-medium;
margin: 0;
}
input,
select {
display: block;
font-family: $base-font-family;
font-size: $base-font-size;
}
[type="color"],
[type="date"],
[type="datetime-local"],
[type="email"],
[type="month"],
[type="number"],
[type="password"],
[type="search"],
[type="tel"],
[type="text"],
[type="time"],
[type="url"],
[type="week"],
input:not([type]),
textarea {
appearance: none;
background-color: $white;
border: $base-border;
border-radius: $base-border-radius;
font-family: $base-font-family;
padding: 0.5em;
transition: border-color $base-duration $base-timing;
width: 100%;
&:hover {
border-color: mix($black, $base-border-color, 20%);
}
&:focus {
border-color: $action-color;
outline: none;
}
&:disabled {
background-color: mix($black, $white, 5%);
cursor: not-allowed;
&:hover {
border: $base-border;
}
}
}
textarea {
resize: vertical;
}
[type="checkbox"],
[type="radio"] {
display: inline;
margin-right: $small-spacing / 2;
}
[type="file"] {
width: 100%;
}
select {
width: 100%;
}
[type="checkbox"],
[type="radio"],
[type="file"],
select {
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}

View File

@@ -0,0 +1,22 @@
html {
background-color: $color-white;
box-sizing: border-box;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
figure {
margin: 0;
}
img,
picture {
margin: 0;
max-width: 100%;
}

View File

@@ -0,0 +1,19 @@
ul,
ol {
list-style-type: none;
margin: 0;
padding: 0;
}
dl {
margin-bottom: $small-spacing;
dt {
font-weight: $font-weight-medium;
margin-top: $small-spacing;
}
dd {
margin: 0;
}
}

View File

@@ -0,0 +1,71 @@
table {
border-collapse: collapse;
font-size: $font-size-default;
text-align: left;
width: 100%;
a {
color: inherit;
text-decoration: none;
}
}
tr {
border-bottom: $base-border;
th {
font-weight: $font-weight-medium;
&.cell-label--avatar-field {
a {
display: none;
}
}
}
}
tbody tr {
&:hover {
background-color: $base-background-color;
cursor: pointer;
}
&:focus {
outline: $focus-outline;
outline-offset: -($focus-outline-width);
}
td {
&.cell-data--avatar-field {
line-height: 1;
text-align: center;
img {
border-radius: 50%;
height: $space-large;
max-height: $space-large;
width: $space-large;
}
}
}
}
td,
th {
padding: $space-slab;
vertical-align: middle;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
td img {
max-height: 2rem;
}

View File

@@ -0,0 +1,44 @@
body {
color: $base-font-color;
font-family: $base-font-family;
font-size: $base-font-size;
line-height: $base-line-height;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: $heading-font-family;
font-size: $base-font-size;
line-height: $heading-line-height;
margin: 0;
}
p {
margin: 0 0 $small-spacing;
}
a {
color: $action-color;
transition: color $base-duration $base-timing;
&:hover {
color: mix($black, $action-color, 25%);
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}
hr {
border-bottom: $base-border;
border-left: 0;
border-right: 0;
border-top: 0;
margin: $base-spacing 0;
}

View File

@@ -0,0 +1,8 @@
.app-container {
align-items: stretch;
display: flex;
margin-left: auto;
margin-right: auto;
max-width: 100rem;
min-height: 100vh;
}

View File

@@ -0,0 +1,26 @@
.attribute-label {
@include data-label;
clear: left;
float: left;
margin-bottom: $base-spacing;
margin-top: 0.25em;
text-align: left;
width: calc(16% - 1rem);
}
.preserve-whitespace {
white-space: pre-wrap;
word-wrap: break-word;
}
.attribute-data {
float: left;
margin-bottom: $base-spacing;
margin-left: 1.25rem;
width: calc(84% - 0.625rem);
}
.attribute--nested {
border: $base-border;
padding: $small-spacing;
}

View File

@@ -0,0 +1,50 @@
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
appearance: none;
background-color: $color-woot;
border: 0;
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
display: inline-flex;
font-size: $font-size-small;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
line-height: 1;
padding: $space-one $space-two;
text-decoration: none;
transition: background-color $base-duration $base-timing;
user-select: none;
vertical-align: middle;
white-space: nowrap;
&:hover {
background-color: mix($black, $color-woot, 20%);
color: $white;
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
background-color: $color-woot;
}
}
}
.button--alt {
background-color: transparent;
border: $base-border;
border-color: $blue;
color: $blue;
margin-bottom: $base-spacing;
}

View File

@@ -0,0 +1,70 @@
.cell-label {
&:hover {
a {
color: $action-color;
}
svg {
fill: $action-color;
transform: rotate(180deg);
}
}
a {
color: inherit;
display: inline-block;
transition: color $base-duration $base-timing;
width: 100%;
}
}
.cell-label--asc,
.cell-label--desc {
font-weight: $font-weight-medium;
}
.cell-label__sort-indicator {
float: right;
margin-left: 5px;
svg {
fill: $hint-grey;
height: 13px;
transition: transform $base-duration $base-timing;
width: 13px;
}
}
.cell-label__sort-indicator--desc {
transform: rotate(180deg);
}
.cell-data--number,
.cell-label--number {
text-align: right;
}
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}

View File

@@ -0,0 +1,54 @@
.field-unit {
@include administrate-clearfix;
align-items: center;
display: flex;
margin-bottom: $base-spacing;
position: relative;
width: 100%;
}
.field-unit__label {
float: left;
margin-left: 0.625rem;
text-align: right;
width: calc(15% - 0.625rem);
}
.field-unit__field {
float: left;
margin-left: 1.25rem;
max-width: 31.15rem;
width: 100%;
}
.field-unit--nested {
border: $base-border;
margin-left: 7.5%;
max-width: 37.5rem;
padding: $small-spacing;
width: 100%;
.field-unit__field {
width: 100%;
}
.field-unit__label {
width: 10rem;
}
}
.field-unit--required {
label::after {
color: $red;
content: ' *';
}
}
.attribute-data--avatar-field {
height: $space-larger;
width: $space-larger;
img {
border-radius: 50%;
}
}

View File

@@ -0,0 +1,28 @@
$base-spacing: 1.5em !default;
$flashes: (
"alert": #fff6bf,
"error": #fbe3e4,
"notice": #e5edf8,
"success": #e6efc2,
) !default;
@each $flash-type, $color in $flashes {
.flash-#{$flash-type} {
background-color: $color;
color: mix($black, $color, 60%);
display: block;
margin-bottom: $base-spacing / 2;
padding: $base-spacing / 2;
text-align: center;
a {
color: mix($black, $color, 70%);
text-decoration: underline;
&:focus,
&:hover {
color: mix($black, $color, 90%);
}
}
}
}

View File

@@ -0,0 +1,3 @@
.form-actions {
margin-left: calc(15% + 1.25rem);
}

View File

@@ -0,0 +1,35 @@
.main-content {
font-size: $font-size-default;
left: 21rem;
position: absolute;
right: 0;
top: 0;
}
.main-content__body {
font-size: $font-size-small;
padding: $space-two;
table {
font-size: $font-size-small;
}
form {
margin-top: $space-two;
}
}
.main-content__header {
align-items: center;
background-color: $color-white;
border-bottom: 1px solid $color-border;
display: flex;
min-height: 3.5rem;
padding: $space-small $space-normal;
}
.main-content__page-title {
font-size: $font-size-medium;
font-weight: $font-weight-medium;
margin-right: auto;
}

View File

@@ -0,0 +1,19 @@
.pagination {
font-size: $font-size-default;
margin-top: $base-spacing;
padding-left: $base-spacing;
padding-right: $base-spacing;
text-align: center;
.first,
.prev,
.page,
.next,
.last {
margin: $small-spacing;
}
.current {
font-weight: $font-weight-medium;
}
}

View File

@@ -0,0 +1,15 @@
.report--list {
display: flex;
padding: 0 $space-two $space-larger;
}
.report-card {
flex: 1;
font-size: $font-size-small;
text-align: center;
.metric {
font-size: $font-size-bigger;
font-weight: 200;
}
}

View File

@@ -0,0 +1,44 @@
.search {
margin-left: auto;
margin-right: 1.25rem;
max-width: 27.5rem;
position: relative;
width: 100%;
}
.search__input {
background: $grey-1;
padding-left: $space-normal * 2.5;
padding-right: $space-normal * 2.5;
}
.search__eyeglass-icon {
fill: $grey-7;
height: $space-normal;
left: $space-normal;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-link {
height: $space-normal;
position: absolute;
right: $space-normal * 0.75;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-icon {
fill: $grey-5;
height: $space-normal;
position: absolute;
transition: fill $base-duration $base-timing;
width: $space-normal;
&:hover {
fill: $action-color;
}
}

View File

@@ -0,0 +1,23 @@
// custom styles for the dashboard
.feature-cell {
background: $color-extra-light-blue;
border-radius: 10px;
float: left;
margin-left: 8px;
margin-top: 6px;
padding: 4px 12px;
.icon-container {
margin-right: 2px;
}
.value-container {
margin-left: 6px;
}
}
.feature-container {
max-width: 100rem;
}

View File

@@ -0,0 +1,7 @@
@mixin administrate-clearfix {
&::after {
clear: both;
content: '';
display: block;
}
}

View File

@@ -0,0 +1,8 @@
@mixin data-label {
color: $hint-grey;
font-size: 0.8em;
font-weight: 400;
letter-spacing: 0.0357em;
position: relative;
text-transform: uppercase;
}

View File

@@ -0,0 +1,61 @@
// Typography
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default;
$heading-font-family: $base-font-family !default;
$base-font-size: 16px !default;
$base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default;
// Other Sizes
$base-border-radius: 4px !default;
$base-spacing: $base-line-height * 1em !default;
$small-spacing: $base-spacing / 2 !default;
// Colors
$white: #fff !default;
$black: #000 !default;
$blue: #1f93ff !default;
$red: #ff382d !default;
$light-yellow: #ffc532 !default;
$light-green: #44ce4b !default;
$grey-0: #f6f7f7 !default;
$grey-1: #f0f4f5 !default;
$grey-2: #cfd8dc !default;
$grey-5: #adb5bd !default;
$grey-7: #293f54 !default;
$hint-grey: #7b808c !default;
// Font Colors
$base-font-color: $grey-7 !default;
$action-color: $blue !default;
// Background Colors
$base-background-color: $grey-0 !default;
// Focus
$focus-outline-color: transparentize($action-color, 0.4);
$focus-outline-width: 3px;
$focus-outline: $focus-outline-width solid $focus-outline-color;
$focus-outline-offset: 1px;
// Flash Colors
$flash-colors: (
alert: $light-yellow,
error: $red,
notice: mix($white, $blue, 50%),
success: $light-green
);
// Border
$base-border-color: $grey-1 !default;
$base-border: 1px solid $base-border-color !default;
// Transitions
$base-duration: 250ms !default;
$base-timing: ease-in-out !default;

View File

@@ -0,0 +1,448 @@
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in
* IE on Windows Phone and in iOS.
*/
html {
line-height: 1.15; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-osx-font-smoothing: grayscale;
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Add the correct display in IE 9-.
*/
article,
aside,
footer,
header,
nav,
section {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in IE.
*/
figcaption,
figure,
main { /* 1 */
display: block;
}
/**
* Add the correct margin in IE 8.
*/
figure {
margin: 1em 40px;
}
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* 1. Remove the bottom border in Chrome 57- and Firefox 39-.
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct background and color in IE 9-.
*/
mark {
background-color: #ff0;
color: #000;
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers (opinionated).
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: sans-serif; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"], /* 1 */
[type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in IE 9-.
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the default vertical scrollbar in IE.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10-.
* 2. Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
*/
details, /* 1 */
menu {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Scripting
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block;
}
/**
* Add the correct display in IE.
*/
template {
display: none;
}
/* Hidden
========================================================================== */
/**
* Add the correct display in IE 10-.
*/
[hidden] {
display: none;
}

View File

@@ -0,0 +1,3 @@
.text-color-red {
color: $alert-color;
}

View File

@@ -0,0 +1,90 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
//Navbar
$nav-bar-width: 23rem;
$header-height: 5.6rem;
$woot-logo-padding: $space-large $space-two;
// Colors
$color-woot: #1f93ff;
$color-gray: #6e6f73;
$color-light-gray: #747677;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5;
$color-background: #f4f6fb;
$color-border-dark: #cad0d4;
$color-background-light: #f9fafc;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;
$masked-bg: rgba(0, 0, 0, .4);
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Thumbnail
$thumbnail-radius: 4rem;
// chat-header
$conv-header-height: 4rem;
// Inbox List
$inbox-thumb-size: 4.8rem;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Transitions
$transition-ease-in: all 0.250s ease-in;

View File

@@ -70,17 +70,12 @@ class AccountBuilder
end
def create_user
@user = build_user
@user.save!
end
def build_user
@user = User.new(email: @email,
password: user_password,
password_confirmation: user_password,
name: user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user
@user.save!
end
end

View File

@@ -67,7 +67,7 @@ class ContactInboxWithContactBuilder
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_numbers
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact
@@ -107,14 +107,9 @@ class ContactInboxWithContactBuilder
account.contacts.from_email(email)
end
def find_contact_by_phone_numbers
phone_numbers = [contact_attributes[:phone_number], *Array(contact_attributes[:phone_number_candidates])].compact_blank.uniq
def find_contact_by_phone_number(phone_number)
return if phone_number.blank?
phone_numbers.each do |phone_number|
contact = account.contacts.find_by(phone_number: phone_number)
return contact if contact
end
nil
account.contacts.find_by(phone_number: phone_number)
end
end

View File

@@ -25,13 +25,12 @@ class Email::BaseBuilder
# Friendly: <agent_name> from <business_name>
# Professional: <business_name>
if inbox.friendly?
Email::SenderNameBuilder.new(
account: account,
sender: message&.sender,
sender_email: sender_email,
I18n.t(
'conversations.reply.email.header.friendly_name',
sender_name: custom_sender_name,
business_name: business_name
).build
business_name: business_name,
from_email: sender_email
)
else
I18n.t(
'conversations.reply.email.header.professional_name',

View File

@@ -1,46 +0,0 @@
class Email::SenderNameBuilder
TRANSLATION_KEY = 'conversations.reply.email.header.friendly_name'.freeze
pattr_initialize [:account!, :sender!, :sender_email!, :sender_name!, :business_name!]
def build
address = Mail::Address.new(sender_email)
address.display_name = localized_display_name(address.address)
address.format
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
localized_header(sender_email)
end
private
def localized_display_name(email_address)
localized_header(email_address).sub(mailbox_pattern(email_address), '').strip
end
def localized_header(email_address)
I18n.t(
TRANSLATION_KEY,
sender_name: sender_name,
business_name: business_name,
from_email: email_address,
locale: sender_locale
)
end
def mailbox_pattern(email_address)
/\s*[<«]\s*(?:reply\+)?#{Regexp.escape(email_address)}\s*[>»]\s*/
end
def sender_locale
locale_candidates.find { |locale| valid_locale?(locale) && I18n.exists?(TRANSLATION_KEY, locale, fallback: false) } || I18n.default_locale
end
def locale_candidates
sender_locale = sender.ui_settings&.dig('locale') if sender.is_a?(User)
[sender_locale, account.locale, I18n.default_locale].compact.map(&:to_s).uniq
end
def valid_locale?(locale)
I18n.available_locales.map(&:to_s).include?(locale)
end
end

View File

@@ -133,10 +133,8 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def process_contact_params_result(result)
name = result['name'].presence || [result['first_name'], result['last_name']].compact_blank.join(' ').presence || 'John Doe'
{
name: name,
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
avatar_url: result['profile_pic']
}

View File

@@ -7,7 +7,7 @@ class Messages::MessageBuilder
def initialize(user, conversation, params)
@params = params
@private = ActiveModel::Type::Boolean.new.cast(params[:private]) || false
@private = params[:private] || false
@conversation = conversation
@user = user
@account = conversation.account
@@ -49,7 +49,9 @@ class Messages::MessageBuilder
end
def process_attachments
(Array(@attachments) + forwarded_attachments).each do |uploaded_attachment|
return if @attachments.blank?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.build(
account_id: @message.account_id,
file: uploaded_attachment
@@ -74,27 +76,7 @@ class Messages::MessageBuilder
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def forwarded_message_id
content_attributes[:forwarded_message_id]
end
def forwarded_attachments
return [] if forwarded_message_id.blank?
forwarded_message = @conversation.messages.find(forwarded_message_id)
forwarded_message.attachments.where(id: @params[:forwarded_attachment_ids]).map { |attachment| attachment.file.blob }
end
def validate_forward
return if forwarded_message_id.blank?
raise StandardError, 'Forwarded emails need an email inbox' unless @conversation.inbox.email?
raise StandardError, 'Forwarded emails cannot be private' if @private
raise StandardError, 'Forwarded emails need a recipient' if process_email_string(@params[:to_emails]).empty?
end
def process_emails
validate_forward
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = process_email_string(@params[:cc_emails])

View File

@@ -1,7 +1,5 @@
class Api::BaseController < ApplicationController
include AccessTokenAuthHelper
# Raw API tokens must bypass DeviseTokenAuth's encoded dashboard bearer parsing.
skip_before_action :set_current_user, if: :authenticate_by_access_token?
respond_to :json
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
@@ -9,6 +7,10 @@ class Api::BaseController < ApplicationController
private
def authenticate_by_access_token?
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def check_authorization(model = nil)
model ||= controller_name.classify.constantize

View File

@@ -2,8 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AI assignees in this payload.
@include_ai_assignees = params[:include_ai_assignees].present?
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_ai_assignees ? AgentBot.accessible_to(Current.account) : []
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -25,5 +25,3 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
params.permit(inbox_ids: [])
end
end
Api::V1::Accounts::AssignableAgentsController.prepend_mod_with('Api::V1::Accounts::AssignableAgentsController')

View File

@@ -3,7 +3,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
before_action :ensure_execution_delay_allowed, only: [:create, :update]
def index
@automation_rules = Current.account.automation_rules
@@ -48,45 +47,22 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
# Dropping the delay here would clone a paused wait into a rule that fires instantly on the
# next matching event, so refuse rather than rewrite what the rule means.
return render_delayed_automations_error if automation_rule.execution_delay.present? && !delayed_automations_enabled?
@automation_rule = automation_rule.dup
@automation_rule.save!
new_rule = automation_rule.dup
new_rule.save!
@automation_rule = new_rule
end
private
def automation_rules_permit
permitted_attributes = [:name, :description, :event_name, :active]
permitted_attributes << :execution_delay if delayed_automations_enabled?
params.permit(
*permitted_attributes,
:name, :description, :event_name, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end
def ensure_execution_delay_allowed
return if delayed_automations_enabled?
return if params[:execution_delay].blank?
render_delayed_automations_error
end
def render_delayed_automations_error
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
end
def delayed_automations_enabled?
Current.account.feature_enabled?('delayed_automations')
end
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
end
Api::V1::Accounts::AutomationRulesController.prepend_mod_with('Api::V1::Accounts::AutomationRulesController')

View File

@@ -1,10 +1,8 @@
class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
include MfaEnforcementGuard
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :check_account_mfa_enforcement, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private

View File

@@ -1,6 +1,4 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :validate_assignment_targets, only: :create, if: -> { normalized_type == 'Conversation' }
def create
case normalized_type
when 'Conversation'
@@ -21,19 +19,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
params[:type].to_s.camelize
end
def validate_assignment_targets
fields = conversation_params[:fields] || {}
assignee_id = fields[:assignee_id]
return render_could_not_create_error('Invalid assignee_id') if !assignee_id.nil? && !@current_account.users.exists?(id: assignee_id)
team_id = fields[:team_id]
# The dashboard sends 0 to remove the team assignment; accept '0' from form-encoded requests too.
return if [nil, 0, '0'].include?(team_id) || @current_account.teams.exists?(id: team_id)
render_could_not_create_error('Invalid team_id')
end
def enqueue_conversation_job
::BulkActionsJob.perform_later(
account: @current_account,

View File

@@ -13,7 +13,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token: page_access_token
)
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
sync_page_details(page_access_token, facebook_channel)
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
@@ -21,6 +21,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
# Additional log statements
log_additional_info
end
def log_additional_info
Rails.logger.debug do
"user_access_token: #{params[:user_access_token]} , page_access_token: #{params[:page_access_token]} ,
page_id: #{params[:page_id]}, inbox_name: #{params[:inbox_name]}"
end
end
def facebook_pages
@@ -34,11 +43,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
@page_details = mark_already_existing_facebook_pages(pages)
end
def sync_page_details(page_access_token, facebook_channel)
page_details = Facebook::PageDetailsService.new(access_token: page_access_token).perform
facebook_channel.update(page_details.compact)
def set_instagram_id(page_access_token, facebook_channel)
fb_object = Koala::Facebook::API.new(page_access_token)
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
return if response['instagram_business_account'].blank?
instagram_id = response['instagram_business_account']['id']
facebook_channel.update(instagram_id: instagram_id)
rescue StandardError => e
Rails.logger.error "Error in sync_page_details: #{e.message}"
Rails.logger.error "Error in set_instagram_id: #{e.message}"
end
# get params[:inbox_id], current_account. params[:omniauth_token]
@@ -66,7 +79,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
fb_page = get_fb_page(fb_page_id)
ActiveRecord::Base.transaction do
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
sync_page_details(access_token, fb_page)
set_instagram_id(access_token, fb_page)
fb_page&.reauthorized!
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception

View File

@@ -13,13 +13,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end
def update
@campaign.with_lock do
if @campaign.one_off? && !@campaign.active?
render json: { error: 'The campaign has already started' }, status: :unprocessable_entity
else
@campaign.update!(campaign_params)
end
end
@campaign.update!(campaign_params)
end
def destroy
@@ -30,7 +24,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
private
def campaign
@campaign ||= Current.account.campaigns.find_by!(display_id: params[:id])
@campaign ||= Current.account.campaigns.find_by(display_id: params[:id])
end
def campaign_params

View File

@@ -33,10 +33,9 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses
if params[:search]
search = params[:search].delete("\0")
Current.account.canned_responses
.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{search}%")
.order_by_search(search)
.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
.order_by_search(params[:search])
else
Current.account.canned_responses

View File

@@ -76,7 +76,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration')
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end

View File

@@ -31,6 +31,15 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,

View File

@@ -22,7 +22,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks
setup_webhooks if @twilio_channel.sms?
end
end
@@ -36,7 +36,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
end
def setup_webhooks
::Twilio::WebhookSetupService.new(channel: @twilio_channel).perform
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
end
def phone_number

View File

@@ -1,38 +0,0 @@
class Api::V1::Accounts::Companies::ConversationsController < Api::V1::Accounts::Companies::BaseController
before_action :authorize_company_read!
before_action :set_counts
def index
@conversations = Conversations::SortService.apply(company_conversations, params[:sort_by])
.includes(:assignee, :contact, :inbox, :taggings)
.preload(ai_assignee: { avatar_attachment: [:blob] })
.page(params[:page] || 1)
end
def filter
@conversations = Companies::ConversationFilterService.new(
params.permit!, Current.user, Current.account, company: @company
).perform[:conversations]
render :index
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
private
def company_conversations
@company_conversations ||= Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @company.contacts.select(:id)),
Current.user,
Current.account
).perform
end
def set_counts
@all_count = company_conversations.count
@open_count = company_conversations.open.count
end
end

View File

@@ -1,14 +0,0 @@
class Api::V1::Accounts::Companies::NotesController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 20
before_action :authorize_company_read!
def index
notes = Current.account.notes.where(contact_id: @company.contacts.select(:id))
notes = notes.where('notes.content ILIKE ?', "%#{Note.sanitize_sql_like(params[:q].strip)}%") if params[:q].present?
@notes = notes.latest
.includes(:contact, :user)
.page(params[:page] || 1)
.per(RESULTS_PER_PAGE)
end
end

View File

@@ -1,16 +0,0 @@
module Api::V1::Accounts::Concerns::InboxSecretManagement
extend ActiveSupport::Concern
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def rotate_hmac_token
return head :not_found unless @inbox.web_widget? || @inbox.api?
@inbox.channel.regenerate_hmac_token
render :show
end
end

View File

@@ -1,10 +1,10 @@
module Api::V1::Accounts::Concerns::InboxHealthManagement
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_health_supported_channel, only: [:health, :register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
@@ -16,22 +16,9 @@ module Api::V1::Accounts::Concerns::InboxHealthManagement
render status: :internal_server_error, json: { error: e.message }
end
def message_templates
unless whatsapp_channel?
return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' }
end
templates, last_sync_attempt_at, name_key = message_template_data
templates = templates.select { |template| template[name_key] == params[:name] } if params[:name].present?
render json: {
payload: templates,
meta: { last_sync_attempt_at: last_sync_attempt_at }
}
end
def health
render json: fetch_health_data
health_data = Whatsapp::HealthService.new(@inbox.channel).sync_health_status!
render json: health_data
rescue Whatsapp::HealthService::ApiError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: {
@@ -49,7 +36,7 @@ module Api::V1::Accounts::Concerns::InboxHealthManagement
end
def register_webhook
register_channel_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
@@ -67,26 +54,6 @@ module Api::V1::Accounts::Concerns::InboxHealthManagement
private
def fetch_health_data
return Whatsapp::HealthService.new(@inbox.channel).sync_health_status!(include_business_profile: true) if whatsapp_cloud_channel?
Twilio::HealthService.new(channel: @inbox.channel).perform
end
def register_channel_webhook
return Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback if whatsapp_cloud_channel?
Twilio::WebhookSetupService.new(channel: @inbox.channel).perform
# No-op unless voice is enabled; keeps the number's voice webhooks in sync alongside messaging.
@inbox.channel.try(:reprovision_voice_webhooks!)
end
def validate_health_supported_channel
return if whatsapp_cloud_channel? || twilio_sms_channel?
render json: { error: 'Health data only available for WhatsApp Cloud API and Twilio SMS channels' }, status: :bad_request
end
def whatsapp_channel
channel = @inbox.channel
raise ActiveRecord::RecordNotFound unless channel.is_a?(Channel::Whatsapp)
@@ -94,24 +61,16 @@ module Api::V1::Accounts::Concerns::InboxHealthManagement
channel
end
def whatsapp_cloud_channel?
@inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
def twilio_sms_channel?
@inbox.channel.is_a?(Channel::TwilioSms) && @inbox.channel.sms?
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 message_template_data
return [@inbox.channel.message_templates.presence || [], @inbox.channel.message_templates_last_updated, 'name'] unless @inbox.twilio_whatsapp?
[@inbox.channel.content_templates&.dig('templates') || [], @inbox.channel.content_templates_last_updated, 'friendly_name']
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)

View File

@@ -1,46 +1,17 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 25
# Scoped to a conversation, this returns it with the ones created around it: the in-thread
# navigation walks the whole history and never needs more than that.
def index
@conversations = if params[:conversation_id].present?
conversation_with_neighbours(params[:conversation_id])
else
permitted_conversations.order(last_activity_at: :desc, id: :desc).limit(RESULTS_PER_PAGE)
end
end
private
def permitted_conversations
# Start with all conversations for this contact
conversations = Current.account.conversations.includes(
:assignee, :contact, :taggings, :contact_inbox
).preload(
inbox: :channel,
ai_assignee: { avatar_attachment: [:blob] }
:assignee, :contact, :inbox, :taggings
).where(contact_id: @contact.id)
Conversations::PermissionFilterService.new(
# Apply permission-based filtering using the existing service
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
end
def conversation_with_neighbours(display_id)
conversation = permitted_conversations.find_by!(display_id: display_id)
[neighbour(conversation, :older), conversation, neighbour(conversation, :newer)].compact
end
def neighbour(conversation, direction)
comparison = direction == :older ? '<' : '>'
order = direction == :older ? :desc : :asc
permitted_conversations
.where("(conversations.created_at, conversations.id) #{comparison} (?, ?)", conversation.created_at, conversation.id)
.order(created_at: order, id: order)
.first
@conversations = conversations.order(last_activity_at: :desc).limit(20)
end
end

View File

@@ -84,7 +84,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url).merge(contact_type: :lead))
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save!
@contact_inbox = build_contact_inbox
process_avatar_from_url
@@ -171,12 +171,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def permitted_params
permitted_params = params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, :blocked,
additional_attributes: {}, custom_attributes: {})
return permitted_params unless Current.account.feature_enabled?('companies') && params.key?(:company_id)
company_id = params[:company_id]
permitted_params.merge(company_id: company_id.present? ? Current.account.companies.find(company_id).id : nil)
params.permit(:name, :identifier, :email, :phone_number, :avatar, :blocked, :avatar_url, additional_attributes: {}, custom_attributes: {})
end
def contact_custom_attributes

View File

@@ -34,11 +34,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
end
def set_team
team_id = params[:team_id].to_i
@team = team_id.positive? ? Current.account.teams.find(team_id) : nil
@conversation.with_lock do
@conversation.update!(team: @team)
end
@team = Current.account.teams.find_by(id: params[:team_id])
@conversation.update!(team: @team)
render json: @team
end
@@ -46,7 +43,3 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
params[:assignee_type].to_s == 'AgentBot'
end
end
Api::V1::Accounts::Conversations::AssignmentsController.prepend_mod_with(
'Api::V1::Accounts::Conversations::AssignmentsController'
)

View File

@@ -1,14 +0,0 @@
class Api::V1::Accounts::Conversations::ContactInfoRequestsController < Api::V1::Accounts::Conversations::BaseController
rescue_from CustomExceptions::WhatsappContactInfoRequestError do |error|
render json: { error: error.message }, status: error.http_status
end
def show
render json: Whatsapp::ContactInfoRequestEligibilityService.new(conversation: @conversation).availability
end
def create
@message = Whatsapp::ContactInfoRequestService.new(conversation: @conversation, sender: Current.user).perform
render 'api/v1/accounts/conversations/messages/create'
end
end

View File

@@ -3,7 +3,6 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
include RequestExceptionHandler
include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
include MfaEnforcementGuard
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
@@ -13,7 +12,6 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :check_account_mfa_enforcement, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -24,6 +22,10 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
def authenticate_by_access_token?
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?

View File

@@ -20,9 +20,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy
ActiveRecord::Base.transaction do
# keeps the forward marker so replies to a deleted forward stay out of this conversation
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text,
content_attributes: message.content_attributes.slice('forwarded_message_id').merge(deleted: true))
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
message.attachments.destroy_all
end
end
@@ -30,7 +28,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def retry
return if message.blank?
::SendReplyJob.perform_later(message.id) if claim_message_retry
service = Messages::StatusUpdateService.new(message, 'sent')
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -66,21 +67,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
@message_finder ||= MessageFinder.new(@conversation, params)
end
def claim_message_retry
message.with_lock do
next false unless message.failed?
Messages::StatusUpdateService.new(message, 'sent').perform
clear_source_id unless @conversation.inbox.api? || @conversation.inbox.web_widget?
true
end
end
def clear_source_id
Rails.logger.info "Cleared older source ID #{message.source_id} for message #{message.id}" if message.source_id.present?
message.update!(source_id: nil)
end
def permitted_params
params.permit(:id, :target_language, :status, :external_error)
end
@@ -95,5 +81,3 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
end
Api::V1::Accounts::Conversations::MessagesController.prepend_mod_with('Api::V1::Accounts::Conversations::MessagesController')

View File

@@ -1,23 +0,0 @@
class Api::V1::Accounts::Conversations::SuggestionsController < Api::V1::Accounts::Conversations::BaseController
FEATURE = 'captain_classifier'.freeze
before_action :ensure_feature_enabled
def labels
render json: { labels: classifier.labels }
end
def priority
render json: classifier.priority
end
private
def ensure_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(FEATURE)
end
def classifier
Captain::ConversationClassifierService.new(conversation: @conversation)
end
end

View File

@@ -3,10 +3,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
include HmacConcern
include ConversationCustomAttributesConcern
include ConversationAssignmentTargetsConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :validate_assignment_targets, :inbox, :contact, :contact_inbox, only: [:create]
before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100
@@ -100,7 +99,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_priority
@conversation.toggle_priority(permitted_update_params[:priority])
@conversation.toggle_priority(params[:priority])
head :ok
end
@@ -141,8 +140,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def permitted_update_params
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
raise ActionController::ParameterMissing, :priority unless params[:priority].nil? || Conversation.priorities.key?(params[:priority])
params.permit(:priority)
end
@@ -179,11 +176,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def handle_human_open
@conversation.with_lock do
@conversation.ai_assignee = nil
@conversation.assignee = Current.user if Current.user.agent?
@conversation.save!
end
@conversation.assignee_agent_bot = nil
@conversation.assignee = Current.user if Current.user.agent?
@conversation.save!
end
def conversation
@@ -210,8 +205,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# fallback for the old case where we do look up only using source id
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
# and deprecate the support of passing only source_id as the param
lookup_scope = @inbox ? @inbox.contact_inboxes : ContactInbox.joins(:inbox).where(inboxes: { account_id: Current.account.id })
@contact_inbox ||= lookup_scope.find_by!(source_id: params[:source_id])
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
rescue ActiveRecord::RecordNotUnique
render json: { error: 'source_id should be unique' }, status: :unprocessable_entity

View File

@@ -2,7 +2,6 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
before_action :check_authorization
before_action :fetch_custom_filters, only: [:index]
before_action :fetch_custom_filter, only: [:show, :update, :destroy]
before_action :validate_query_timezones, only: [:create, :update]
DEFAULT_FILTER_TYPE = 'conversation'.freeze
def index; end
@@ -48,22 +47,6 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
)
end
def validate_query_timezones
query = permitted_payload.fetch(:query, {})
return unless query.key?(:payload)
payload = query[:payload]
unless payload.is_a?(Array) && payload.all?(ActionController::Parameters)
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: 'payload')
end
payload.each do |condition|
::Filters::DateFilterHelper.timezone(condition[:timezone]) if condition.key?(:timezone)
end
rescue CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
def permitted_params
params.permit(:id, :filter_type)
end

View File

@@ -19,12 +19,14 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll
end
def validate_source
totals = source_class.credentials_validator(source_params: permitted_params.to_h, import_types: import_types).perform
totals = validate_intercom_source
render json: { valid: true, totals: totals }
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
rescue StandardError => e
render_source_client_error(e)
end
def create
@@ -34,42 +36,44 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll
return
end
enqueue_import(@data_import)
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
rescue StandardError => e
render_source_client_error(e)
end
def start
restart_service = DataImports::RestartService.new(account: Current.account, data_import: @data_import)
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
return
end
enqueue_import(@data_import) if restart_result == :enqueue
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
render_show
end
def retry_import
retry_service = DataImports::RetryService.new(account: Current.account, data_import: @data_import)
retry_service = DataImports::Intercom::RetryService.new(account: Current.account, data_import: @data_import)
retry_result = retry_service.perform
@data_import = retry_service.data_import
case retry_result
when :enqueue
enqueue_import(@data_import)
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
when :not_stalled
render json: { message: "This #{source_name} import is no longer stalled." }, status: :unprocessable_entity
render json: { message: 'This Intercom import is no longer stalled.' }, status: :unprocessable_entity
when :active_import_exists
render json: { message: "Another #{source_name} import is already in progress." }, status: :unprocessable_entity
render json: { message: 'Another Intercom import is already in progress.' }, status: :unprocessable_entity
when :access_token_missing
render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
end
end
@@ -109,11 +113,11 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll
end
def permitted_params
params.permit(:name, :source_provider, :access_token, :domain, import_types: [])
params.permit(:name, :source_provider, :access_token, import_types: [])
end
def creation_service
DataImports::CreationService.new(
DataImports::Intercom::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
@@ -121,50 +125,24 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll
end
def import_types
return DataImports::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def source_class
provider = @data_import&.source_provider || permitted_params[:source_provider]
DataImports::Source.source_class(provider)
end
def validate_intercom_source
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
def source_name
return 'Integration' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider])
source_class::DISPLAY_NAME
end
def credential_name
return 'credential' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider])
source_class.credential_name
end
def enqueue_import(data_import)
DataImports::Source.source_class(data_import.source_provider).import_job_class.perform_later(
data_import,
data_import.active_import_run_id
)
DataImports::Intercom::CredentialsValidator.new(
access_token: permitted_params[:access_token],
import_types: import_types
).perform
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_source_client_error(error)
raise error unless source_class.client_error?(error)
message = if source_class.authentication_error?(error)
"We could not validate this #{source_name} #{credential_name}. Check the key and its permissions."
else
"#{source_name} could not be reached. Please try again."
end
render_source_validation_error(message)
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)

View File

@@ -5,8 +5,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::InboxHealthManagement
include Api::V1::Accounts::Concerns::InboxSecretManagement
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes)
@@ -77,6 +76,12 @@ 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') }

View File

@@ -9,7 +9,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
redirect_uri: "#{base_url}/instagram/callback",
scope: REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_reauth: 'true',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to])
}

View File

@@ -10,17 +10,10 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select do |app|
app.active?(Current.account) ||
(app.id == 'stripe' && Current.account_user&.administrator? && Current.account.hooks.exists?(app_id: 'stripe'))
end
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
end
def fetch_app
@app = Integrations::App.find(id: params[:id])
return unless %w[shopify stripe].include?(@app&.id)
# Keep direct lookups subject to the same availability checks as the integrations list.
raise ActiveRecord::RecordNotFound unless @app.active?(Current.account)
end
end

View File

@@ -7,5 +7,3 @@ class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseC
authorize(:hook)
end
end
Api::V1::Accounts::Integrations::BaseController.prepend_mod_with('Api::V1::Accounts::Integrations::BaseController')

View File

@@ -1,8 +1,6 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, except: [:create]
before_action :check_authorization
before_action :ensure_shopify_enabled, if: :shopify_hook?
before_action :reject_stripe_hook
def create
@hook = Current.account.hooks.create!(permitted_params)
@@ -33,14 +31,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
private
def reject_stripe_hook
app_id = action_name == 'create' ? permitted_params[:app_id] : @hook.app_id
return unless app_id == 'stripe'
# Stripe credentials and connection changes are managed by its OAuth and disconnect endpoints.
head :not_found
end
def fetch_hook
@hook = Current.account.hooks.find(params[:id])
end
@@ -48,12 +38,4 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
def shopify_hook?
action_name == 'create' ? permitted_params[:app_id] == 'shopify' : @hook.app_id == 'shopify'
end
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account)
end
end

View File

@@ -29,7 +29,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Int
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user)
issue = linear_processor_service.create_issue(permitted_params, Current.user)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else

View File

@@ -1,27 +1,25 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
include Shopify::IntegrationHelper
before_action :ensure_shopify_enabled
before_action -> { Shopify::ApiContext.setup! }, only: [:orders]
before_action :fetch_hook, except: [:auth, :complete_install]
before_action :check_authorization, only: [:auth, :complete_install, :destroy]
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
unless shop_domain.is_a?(String) && Shopify::ShopDomain.valid?(shop_domain)
return render json: { error: 'Invalid Shopify shop domain' }, status: :unprocessable_entity
end
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
state = generate_shopify_token(Current.account.id)
raise 'Shopify OAuth is not configured' if client_id.blank? || state.blank?
oauth_client = OAuth2::Client.new(client_id, client_secret,
site: "https://#{Shopify::ShopDomain.normalize(shop_domain)}",
authorize_url: '/admin/oauth/authorize')
render json: { redirect_url: oauth_client.auth_code.authorize_url(
redirect_uri: "#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback",
scope: REQUIRED_SCOPES.join(','), state: state
) }
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
end
def orders
@@ -34,25 +32,6 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
render json: { error: e.message }, status: :unprocessable_entity
end
def complete_install
pending_installation = Shopify::PendingInstallation.claim(
token: params[:pending_install_token]
)
Shopify::InstallationService.new(account: Current.account, pending_installation: pending_installation).perform
head :ok
rescue Shopify::PendingInstallation::AlreadyClaimed => e
pending_installation&.release!
render json: { error: e.message }, status: :conflict
rescue Shopify::PendingInstallation::CommitOutcomeUnknown
raise
rescue Shopify::PendingInstallation::Error => e
pending_installation&.release!
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError
pending_installation&.release!
raise
end
def destroy
@hook.destroy!
head :ok
@@ -62,8 +41,8 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
private
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account)
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
end
def contact
@@ -71,9 +50,7 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
end
def fetch_hook
hooks = Integrations::Hook.where(account: Current.account, app_id: 'shopify')
hooks = hooks.enabled if action_name == 'orders'
@hook = hooks.first!
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
end
def fetch_customers
@@ -105,15 +82,25 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
end
end
def setup_shopify_context
return if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: '2025-01'.freeze,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
end
def shopify_client
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(
session: shopify_session,
api_version: Shopify::ApiContext::API_VERSION
)
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
end
def validate_contact

View File

@@ -16,8 +16,6 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
end
def update
return update_message_mode if permitted_params[:message_mode].present?
@hook = channel_builder.update(permitted_params[:reference_id])
render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank?
end
@@ -37,11 +35,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
Integrations::Slack::ChannelBuilder.new(hook: @hook)
end
def update_message_mode
@hook.update!(settings: @hook.settings.merge('message_mode' => permitted_params[:message_mode]))
end
def permitted_params
params.permit(:reference_id, :message_mode)
params.permit(:reference_id)
end
end

View File

@@ -1,58 +0,0 @@
class Api::V1::Accounts::Integrations::StripeController < Api::V1::Accounts::Integrations::BaseController
before_action :ensure_configured, only: [:auth, :customer]
before_action :check_admin_authorization?, only: [:show, :auth, :destroy]
def show
hook = Current.account.hooks.find_by!(app_id: 'stripe')
mode = Integrations::Stripe::Connection.new(hook).livemode? ? 'live' : 'sandbox'
render json: { account_id: hook.reference_id, connected_at: hook.settings.fetch('connected_at', hook.created_at), mode: mode,
reauthorization_required: hook.reauthorization_required? }
end
def auth
url = Integrations::Stripe::Oauth.authorize_url(account: Current.account, user: Current.user)
state = URI.decode_www_form(URI(url).query).to_h.fetch('state')
cookies.signed[:stripe_oauth_state] = { value: state, httponly: true, secure: request.ssl?, same_site: :lax,
expires: Integrations::Stripe::Oauth::STATE_TTL.from_now }
render json: { url: url }
end
def destroy
Current.account.hooks.find_by!(app_id: 'stripe').destroy!
head :no_content
end
def customer
conversation_id = params.require(:conversation_id)
unless conversation_id.is_a?(String) && conversation_id.match?(/\A[1-9]\d*\z/)
return render_could_not_create_error('conversation_id must be a positive integer')
end
conversation = Current.account.conversations.find_by!(display_id: conversation_id)
authorize conversation, :show?
hook = Current.account.hooks.find_by!(app_id: 'stripe', status: :enabled)
render json: Integrations::Stripe::CustomerSummary.new(connection: Integrations::Stripe::Connection.new(hook),
contact: conversation.contact).perform(customer_id: params[:customer_id])
rescue Integrations::Stripe::Connection::ReauthorizationRequired, ::Stripe::AuthenticationError
hook.prompt_reauthorization!
render json: { error: 'stripe_reauthorization_required' }, status: :unprocessable_entity
rescue OAuth2::Error => e
hook.prompt_reauthorization! if e.code == 'invalid_grant'
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
rescue ::Stripe::StripeError, Faraday::TimeoutError, Faraday::ConnectionFailed => e
render_customer_failure(e)
end
private
def render_customer_failure(error)
details = { error: error.class.name }
details.merge!(code: error.code, status: error.http_status, request_id: error.request_id) if error.is_a?(::Stripe::StripeError)
Rails.logger.warn(details.compact.to_json)
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
end
def ensure_configured
head :not_found unless Integrations::App.find(id: 'stripe').active?(Current.account)
end
end

View File

@@ -3,7 +3,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
before_action :fetch_portal, except: [:index, :create]
before_action :check_authorization
before_action :validate_analytics_params, only: [:create, :update]
before_action :set_current_page, only: [:index]
def index
@@ -24,9 +23,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
# Lock the row so concurrent saves merge onto the latest committed config
# instead of a stale snapshot, which would drop the other save's keys.
@portal.lock!
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
@@ -72,44 +68,24 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
private
def fetch_portal
@portal = Current.account.portals.find_by!(slug: permitted_params[:id])
@portal = Current.account.portals.find_by(slug: permitted_params[:id])
end
def permitted_params
params.permit(:id, :email)
end
def validate_analytics_params
analytics = params.dig(:portal, :config, :analytics)
return if analytics.blank?
valid = analytics.respond_to?(:each_pair) &&
analytics.keys.all? { |key| Portal::ANALYTICS_CONFIG_FORMATS.key?(key.to_s) } &&
analytics.values.all?(String)
return if valid
render json: { error: I18n.t('portals.analytics.invalid_configuration') }, status: :unprocessable_entity
end
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: config_param_keys }
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
)
end
def config_param_keys
keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }]
# Analytics injects tracking scripts into every public page, so keep it admin-only even though
# Enterprise lets knowledge_base_manage roles edit other portal settings.
keys << { analytics: Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym) } if Current.account_user&.administrator?
keys
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end

View File

@@ -1,8 +1,6 @@
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include Tiktok::IntegrationHelper
before_action :ensure_tiktok_enabled
def create
redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id, params[:return_to])
@@ -14,10 +12,4 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
render json: { success: false }, status: :unprocessable_entity
end
end
private
def ensure_tiktok_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_tiktok')
end
end

View File

@@ -20,7 +20,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
def ensure_embedded_signup_enabled
return if params[:inbox_id].present?
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
@@ -30,7 +29,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
params: params.permit(:code, :business_id, :waba_id, :phone_number_id, :is_coexistence).to_h.symbolize_keys,
params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
inbox_id: params[:inbox_id]
)
service.perform
@@ -52,7 +51,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def can_reconfigure_channel?
channel = @inbox.channel
channel.provider == 'whatsapp_cloud'
return false unless channel.provider == 'whatsapp_cloud'
return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
def render_success_response(inbox)
@@ -78,6 +81,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
missing_params << 'business_id' if params[:business_id].blank?
missing_params << 'waba_id' if params[:waba_id].blank?
return if missing_params.empty?

View File

@@ -1,80 +0,0 @@
class Api::V1::Accounts::Whatsapp::ManualSetupController < Api::V1::Accounts::BaseController
before_action :authorize_create, only: [:preview, :connect]
before_action :fetch_inbox, only: [:webhook_status, :setup_webhook]
def preview
render json: validation_service.perform
rescue StandardError => e
render_setup_error(e)
end
def connect
setup = Whatsapp::ManualSetupService.new(account: Current.account, **connect_params.to_h.symbolize_keys).perform
render json: connection_response(setup), status: :created
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_setup_error(e)
end
def webhook_status
render json: Whatsapp::ManualWebhookStatusService.new(@inbox.channel).perform
rescue StandardError => e
render_setup_error(e)
end
def setup_webhook
channel = @inbox.channel
webhook_setup = Whatsapp::WebhookSetupService.new(channel)
webhook_setup.perform
raise webhook_setup.registration_error if webhook_setup.registration_error
render json: Whatsapp::ManualWebhookStatusService.new(channel.reload).perform
rescue StandardError => e
render_setup_error(e)
end
private
def authorize_create
authorize ::Inbox, :create?
end
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :update?
channel = @inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.provider_config['source'] == 'manual_setup_v2'
raise ActiveRecord::RecordNotFound
end
def validation_service
Whatsapp::ManualSetupValidationService.new(**connection_params.to_h.symbolize_keys)
end
def connection_params
params.permit(:waba_id, :phone_number_id, :access_token)
end
def connect_params
params.permit(:waba_id, :phone_number_id, :access_token, :inbox_name)
end
def connection_response(setup)
channel = setup.channel.reload
{
id: channel.inbox.id,
name: channel.inbox.name,
number_access: true,
template_access: true,
webhook_setup: setup.webhook_setup?,
webhook_error: setup.webhook_error
}
end
def render_setup_error(error)
Rails.logger.error "[WHATSAPP MANUAL SETUP] account_id=#{Current.account.id} error=#{error.class}: #{error.message}"
render json: { message: error.message }, status: :unprocessable_entity
end
end

View File

@@ -1,16 +1,14 @@
class Api::V1::AccountsController < Api::BaseController
include AuthHelper
include MfaEnforcementGuard
include CacheKeysHelper
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
before_action :check_signup_enabled, only: [:create]
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :check_user_mfa_enforcement, if: :authenticate_by_access_token?, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_account_mfa_enforcement, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -18,7 +16,6 @@ class Api::V1::AccountsController < Api::BaseController
CustomExceptions::Account::UserExists,
CustomExceptions::Account::UserErrors,
with: :render_error_response
rescue_from Shopify::PendingInstallation::Error, with: :render_shopify_signup_error
def show
@latest_chatwoot_version = latest_chatwoot_version
@@ -26,8 +23,14 @@ class Api::V1::AccountsController < Api::BaseController
end
def create
builder_class = account_params[:shopify_pending_install_token].present? ? Shopify::SignupService : AccountBuilder
@user, @account = builder_class.new(**account_builder_params).perform
@user, @account = AccountBuilder.new(
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @user
# Authenticated users (dashboard "add account") and api_only signups
@@ -49,7 +52,7 @@ class Api::V1::AccountsController < Api::BaseController
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: @account.cache_keys }, status: :ok
render json: { cache_keys: cache_keys_for_account }, status: :ok
end
def update
@@ -72,20 +75,6 @@ class Api::V1::AccountsController < Api::BaseController
Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION)
end
def account_builder_params
attributes = {
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
}
pending_install_token = account_params[:shopify_pending_install_token]
attributes[:shopify_pending_install_token] = pending_install_token if pending_install_token.present?
attributes
end
def enqueue_branding_enrichment
email = account_params[:email].presence || @user&.email
return if email.blank?
@@ -108,6 +97,14 @@ class Api::V1::AccountsController < Api::BaseController
raise CustomExceptions::Account::InvalidParams.new({})
end
def cache_keys_for_account
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
@@ -120,22 +117,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def account_params
params.permit(
:account_name,
:email,
:name,
:password,
:locale,
:domain,
:support_email,
:user_full_name,
:shopify_pending_install_token
)
end
def render_shopify_signup_error(exception)
log_handled_error(exception)
render json: { message: exception.message }, status: :unprocessable_entity
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
def custom_attributes_params
@@ -147,20 +129,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def permitted_settings_attributes
attributes = [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
attributes << :enforce_mfa if Chatwoot.mfa_enabled?
attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled
return if GlobalConfigService.account_signup_enabled? || pending_shopify_signup?
raise ActionController::RoutingError, 'Not Found'
end
def pending_shopify_signup?
token = account_params[:shopify_pending_install_token]
Shopify::FeatureGate.enabled? && Shopify::PendingInstallation.pending?(token: token)
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def api_only_signup?

View File

@@ -1,6 +1,4 @@
class Api::V1::Integrations::WebhooksController < ApplicationController
prepend_before_action :verify_slack_signature!, only: [:create]
def create
builder = Integrations::Slack::IncomingMessageBuilder.new(permitted_params)
response = builder.perform
@@ -9,16 +7,6 @@ class Api::V1::Integrations::WebhooksController < ApplicationController
private
# Skip (rather than reject) when no secret is configured, so existing installs keep working.
def verify_slack_signature!
if Integrations::Slack::SignatureVerifier.signing_secret.blank?
Rails.logger.warn('[SLACK] SLACK_SIGNING_SECRET not configured; skipping webhook signature verification')
return
end
head :unauthorized unless Integrations::Slack::SignatureVerifier.valid?(request)
end
# TODO: This is a temporary solution to permit all params for slack unfurling job.
# We should only permit the params that we use. Handle all the params based on events and send it to the respective services.
def permitted_params

View File

@@ -1,8 +1,5 @@
class Api::V1::NotificationSubscriptionsController < Api::BaseController
include MfaEnforcementGuard
before_action :set_user
before_action :check_user_mfa_enforcement, if: :authenticate_by_access_token?
def create
notification_subscription = NotificationSubscriptionBuilder.new(user: @user, params: notification_subscription_params).perform

View File

@@ -1,7 +1,5 @@
class Api::V1::Profile::MfaController < Api::BaseController
before_action :ensure_interactive_session
before_action :check_mfa_feature_available
before_action :check_mfa_not_enforced, only: [:destroy]
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_password, only: [:destroy]
@@ -27,14 +25,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
private
# MFA credentials must be managed from an interactive session, never with an
# api_access_token, so a stolen token cannot enrol itself as the second factor.
def ensure_interactive_session
return unless authenticate_by_access_token?
render json: { error: I18n.t('errors.mfa.interactive_session_required') }, status: :forbidden
end
def mfa_service
@mfa_service ||= Mfa::ManagementService.new(user: current_user)
end
@@ -55,14 +45,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
end
def check_mfa_not_enforced
return unless current_user.mfa_enforced?
render json: {
error: I18n.t('errors.mfa.enforced_cannot_disable')
}, status: :forbidden
end
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,

View File

@@ -1,7 +1,4 @@
class Api::V1::Profile::SessionsController < Api::BaseController
include MfaEnforcementGuard
before_action :check_user_mfa_enforcement, if: :authenticate_by_access_token?
before_action :set_session, only: [:destroy]
def index

View File

@@ -1,14 +0,0 @@
class Api::V1::Profile::TrustedDevicesController < Api::BaseController
include MfaEnforcementGuard
before_action :check_user_mfa_enforcement, if: :authenticate_by_access_token?
# Forgetting trusted devices bumps the trust version, invalidating every
# device cookie issued so far; each device re-verifies on next sign-in.
def destroy
return head :not_found unless ChatwootApp.enterprise? && DeviceVerification.enabled?
User.update_counters(current_user.id, device_trust_version: 1) # rubocop:disable Rails/SkipsModelValidations
head :ok
end
end

View File

@@ -1,10 +1,5 @@
class Api::V1::ProfilesController < Api::BaseController
include MfaEnforcementGuard
before_action :set_user
# show leaks pubsub_token, which grants realtime access; the rest of the
# profile surface is equally off-limits to a token that MFA enforcement blocks.
before_action :check_user_mfa_enforcement, if: :authenticate_by_access_token?
def show; end

View File

@@ -29,6 +29,7 @@ class Api::V1::Widget::BaseController < ApplicationController
end
def conversation_params
# FIXME: typo referrer in additional attributes, will probably require a migration.
{
account_id: inbox.account_id,
inbox_id: inbox.id,

View File

@@ -1,5 +1,4 @@
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
skip_before_action :verify_authenticity_token
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact

View File

@@ -1,5 +1,4 @@
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
before_action :prevent_reply_to_resolved_conversation, only: [:create]
before_action :set_conversation, only: [:create]
before_action :set_message, only: [:update]
@@ -43,17 +42,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
end
# Hiding the reply box does not stop requests reaching this endpoint, so the setting is
# enforced here.
def prevent_reply_to_resolved_conversation
return unless conversation&.resolved?
return if inbox.allow_messages_after_resolved
render json: { error: I18n.t('errors.conversations.resolved') }, status: :forbidden
end
def set_conversation
return if conversation.present?
return unless conversation.nil?
@conversation = create_conversation
apply_labels if permitted_params[:labels].present?

View File

@@ -1,14 +1,11 @@
class Api::V2::AccountsController < Api::BaseController
include AuthHelper
include MfaEnforcementGuard
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
before_action :check_signup_enabled, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :check_user_mfa_enforcement, if: :authenticate_by_access_token?, only: [:create]
before_action :fetch_account, except: [:create]
before_action :check_account_mfa_enforcement, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,

View File

@@ -5,13 +5,6 @@
# 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
SHOPIFY_INSTALL_REDIRECT_PATTERN = %r{\Asettings/integrations/shopify\?shopify_pending_install=[0-9a-f]{32}\z}
SHOPIFY_BILLING_PARAMETERS = {
'shop' => Shopify::ShopDomain::FORMAT,
'plan_handle' => /\A[a-zA-Z0-9_-]+\z/
}.freeze
def create
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
@@ -19,36 +12,7 @@ class Auth::ResendConfirmationsController < ActionController::API
return head(:ok) unless email.is_a?(String)
user = User.from_email(email.strip.downcase)
send_confirmation_instructions(user) if user && !user.confirmed?
user&.send_confirmation_instructions unless user&.confirmed?
head :ok
end
private
def confirmation_redirect_url
redirect_url = params[:redirect_url].to_s
redirect_url if redirect_url.match?(SHOPIFY_INSTALL_REDIRECT_PATTERN) || shopify_billing_redirect?(redirect_url)
end
def shopify_billing_redirect?(redirect_url)
path, query = redirect_url.split('?', 2)
return false unless path == 'settings/billing' && query.present?
valid_shopify_billing_parameters?(URI.decode_www_form(query))
rescue ArgumentError
false
end
def valid_shopify_billing_parameters?(pairs)
return false if pairs.empty?
return false unless pairs.map(&:first).uniq.size == pairs.size
pairs.all? { |key, value| SHOPIFY_BILLING_PARAMETERS[key]&.match?(value) }
end
def send_confirmation_instructions(user)
return user.send_confirmation_instructions unless confirmation_redirect_url
user.send_confirmation_instructions_with_redirect(redirect_url: confirmation_redirect_url)
end
end

View File

@@ -6,38 +6,13 @@ module AccessTokenAuthHelper
'api/v1/accounts/conversations/labels' => %w[index create]
}.freeze
def authenticate_by_access_token?
legacy_access_token.present? || (!bearer_access_token.nil? && !dashboard_bearer_token?)
end
def ensure_access_token
token = bearer_access_token || legacy_access_token
token = request.headers[:api_access_token] || request.headers[:HTTP_API_ACCESS_TOKEN]
@access_token = AccessToken.find_by(token: token) if token.present?
end
def legacy_access_token
request.headers[:api_access_token] || request.headers[:HTTP_API_ACCESS_TOKEN]
end
# TODO: Use request.bearer_token once we upgrade to a Rails version that provides it.
def bearer_access_token
scheme, token = request.authorization.to_s.split(' ', 2)
# An empty credential must not fall back to another authentication method.
token.to_s if scheme&.casecmp?('Bearer')
end
# Our dashboard UI uses DeviseTokenAuth, which can send login credentials in the Bearer header.
# Let DeviseTokenAuth handle those credentials instead of treating them as API tokens.
def dashboard_bearer_token?
return false unless respond_to?(:decode_bearer_token, true)
credentials = decode_bearer_token(request.authorization)
credentials.is_a?(Hash) && credentials.values_at('uid', 'client', 'access-token').all?(&:present?)
end
def authenticate_access_token!
ensure_access_token
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
# NOTE: This ensures that current_user is set and available for the rest of the controller actions

View File

@@ -1,13 +0,0 @@
module ConversationAssignmentTargetsConcern
private
def validate_assignment_targets
assignee_id = params[:assignee_id]
return render_could_not_create_error('Invalid assignee_id') if assignee_id.present? && !Current.account.users.exists?(id: assignee_id)
team_id = params[:team_id]
return if team_id.blank? || Current.account.teams.exists?(id: team_id)
render_could_not_create_error('Invalid team_id')
end
end

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