Compare commits

..

2 Commits

Author SHA1 Message Date
Pranav
8993810af8 Merge remote-tracking branch 'origin/develop' into captain-message-generation-metadata
# Conflicts:
#	config/routes.rb
#	db/schema.rb
#	enterprise/app/models/enterprise/concerns/message.rb
2026-07-02 07:09:59 -07:00
Pranav
eb203bb144 feat(captain): capture and surface how a reply was generated
Records reasoning, citations (the FAQs the assistant found), the tool
path, and the model for each V1 Captain reply in a new
captain_message_generations table. The assistant reports the source IDs
it actually used so cited FAQs can be flagged, and handoff messages
capture the transfer reason.

Agents can expand any Captain message in the conversation (a sparkle
toggle on the timestamp line) to see this breakdown, fetched on demand
from a dedicated endpoint. Model is shown only in development.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:10:55 -07:00
4387 changed files with 30018 additions and 266164 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

@@ -77,8 +77,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -119,8 +118,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -151,8 +149,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i

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
@@ -68,16 +59,6 @@ REDIS_SENTINEL_MASTER_NAME=
# ref: https://github.com/chatwoot/chatwoot/issues/2420
# REDIS_OPENSSL_VERIFY_MODE=none
# OpenSearch / Elasticsearch-compatible search config
# Set OPENSEARCH_URL to enable advanced search. Elastic Cloud API keys can be configured with either key name below.
# API key values must be provided without the ApiKey authorization scheme prefix.
# OPENSEARCH_URL=
# OPENSEARCH_API_KEY=
# ELASTICSEARCH_API_KEY=
# OPENSEARCH_AWS_ACCESS_KEY_ID=
# OPENSEARCH_AWS_SECRET_ACCESS_KEY=
# OPENSEARCH_AWS_REGION=us-east-1
# Postgres Database config variables
# You can leave POSTGRES_DATABASE blank. The default name of
# the database in the production environment is chatwoot_production
@@ -89,13 +70,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 +132,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 +151,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 +168,6 @@ TWITTER_ENVIRONMENT=
#slack integration
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=
@@ -264,23 +230,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300
## Parent switch for all widget throttles; per-endpoint switches below only apply when this is on
# ENABLE_RACK_ATTACK_WIDGET_API=true
## Widget conversation creates, per minute, keyed on (IP, website_token)
# ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS=true
# RATE_LIMIT_WIDGET_CONVERSATIONS=30
## Widget message creates, per minute, keyed on (IP, website_token)
# ENABLE_RACK_ATTACK_WIDGET_MESSAGES=true
# RATE_LIMIT_WIDGET_MESSAGES=60
## Widget contact updates, per hour, per IP
# ENABLE_RACK_ATTACK_WIDGET_CONTACTS=true
# RATE_LIMIT_WIDGET_CONTACTS=60
## Widget page loads (new session), per hour, per IP
# ENABLE_RACK_ATTACK_WIDGET_LOAD=true
# RATE_LIMIT_WIDGET_LOAD=200
## Widget transcript requests, per hour, per IP
# ENABLE_RACK_ATTACK_WIDGET_TRANSCRIPT=true
# RATE_LIMIT_WIDGET_TRANSCRIPT=5
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
@@ -321,10 +271,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 +283,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,10 +16,8 @@ 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'
Metrics/MethodLength:
Max: 19

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.
@@ -90,10 +85,8 @@
## Project-Specific
- **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
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- 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.

18
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'
@@ -84,8 +83,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 +95,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 +133,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
@@ -193,18 +211,17 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.38.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
libdatadog (~> 36.0.0.1.0)
libddwaf (~> 1.30.0.0.0)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
logger
msgpack
datadog-ruby_core_source (3.5.3)
datadog-ruby_core_source (3.4.1)
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 +229,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)
@@ -275,8 +289,7 @@ GEM
ruby2_keywords
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 +308,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 +317,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 +431,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 +456,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)
@@ -507,16 +522,15 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (36.0.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin)
libdatadog (36.0.0.1.0-x86_64-linux)
libddwaf (1.30.0.0.2)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-arm64-darwin)
libddwaf (1.24.1.0.3-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-darwin)
libddwaf (1.24.1.0.3-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-linux)
libddwaf (1.24.1.0.3-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -533,11 +547,10 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.2)
loofah (2.23.1)
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 +567,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 +580,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 +684,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 +706,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,54 +722,43 @@ 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)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.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 +766,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 +861,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 +911,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 +950,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 +967,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 +979,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 +987,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 +1003,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 +1010,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)
@@ -1035,7 +1030,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.8.2)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -1062,15 +1057,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,7 +1080,7 @@ 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)
@@ -1155,7 +1149,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 +1175,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 @@
Copyright (c) 2017-2026 Chatwoot Inc.
Copyright (c) 2017-2024 Chatwoot Inc.
Portions of this software are licensed as follows:

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.15.1

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

@@ -2,14 +2,6 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -22,51 +14,30 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction(requires_new: true) do
@user = find_or_create_user
create_account_user
reserve_invitation_email_capacity if user_needs_confirmation?
end
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
@user.send_confirmation_instructions if user_needs_confirmation?
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
@new_user = false
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.new(email: email, name: @name, password: temp_password, password_confirmation: temp_password).tap do |new_user|
new_user.skip_confirmation_notification!
new_user.save!
@new_user = true
end
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
def user_needs_confirmation?
@new_user && @user.persisted? && !@user.confirmed?
end
def reserve_invitation_email_capacity
return if account.reserve_email_send_capacity
raise CustomExceptions::Account::EmailLimitExceeded.new({})
@user.persisted? && !@user.confirmed?
end
# Creates an account user linking the user to the current account.

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

@@ -1,4 +1,5 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]

View File

@@ -1,6 +1,8 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
def update
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
emails = params[:emails]
bulk_create_agents(emails)
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
private
@@ -75,41 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def bulk_create_agents(emails)
email_limit_error = nil
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
Current.account.with_lock do
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
emails.each do |email|
create_agent_from_email(email)
rescue CustomExceptions::Account::EmailLimitExceeded => e
email_limit_error = e
end
end
raise email_limit_error if email_limit_error
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
def create_agent_from_email(email)
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
def clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
end
def available_agent_count
Current.account.usage_limits[:agents] - Current.account.account_users.count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end
def delete_user_record(agent)

View File

@@ -3,8 +3,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_article, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
before_action :validate_author, only: [:create]
before_action :discard_invalid_author, only: [:update]
def index
@portal_articles = @portal.articles
@@ -32,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -65,52 +63,16 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@article = @portal.articles.find(params[:id])
end
def validate_author
author_id = params.dig(:article, :author_id)
return if author_id.blank?
return if author_belongs_to_account?(author_id)
render json: { error: 'Invalid author ID' }, status: :unprocessable_entity
end
def discard_invalid_author
submitted_article_params = params[:article]
return unless submitted_article_params&.key?(:author_id)
author_id = submitted_article_params[:author_id]
return if author_belongs_to_account?(author_id)
submitted_article_params.delete(:author_id)
end
def author_belongs_to_account?(author_id)
author_id.to_s.match?(/\A\d+\z/) && Current.account.users.exists?(id: author_id.to_i)
end
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
:locale, meta: [:title,
:description,
{ tags: [] }]
)
end

View File

@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AI assignees in this payload.
@include_ai_assignees = params[:include_ai_assignees].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +10,6 @@ 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) : []
end
private
@@ -25,5 +22,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,17 +1,6 @@
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
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end

View File

@@ -1,28 +0,0 @@
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def show
set_branded_email_layout
end
def update
unless Current.account.feature_enabled?(:branded_email_templates)
render_could_not_create_error('Branded email templates feature is not enabled')
return
end
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
set_branded_email_layout
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
private
def set_branded_email_layout
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
end
end
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')

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

@@ -6,21 +6,27 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
page_access_token: page_access_token
)
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
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
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
# Additional log statements
log_additional_info
end
def log_additional_info
Rails.logger.debug do
"user_access_token: #{params[:user_access_token]} , page_access_token: #{params[:page_access_token]} ,
page_id: #{params[:page_id]}, inbox_name: #{params[:inbox_name]}"
end
end
def facebook_pages
@@ -34,11 +40,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 +76,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

@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -65,7 +66,6 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -74,10 +74,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration')
Llm::Models.default_model_for(feature_key)
end
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

@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -22,7 +20,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 +34,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,122 +0,0 @@
module Api::V1::Accounts::Concerns::InboxHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_health_supported_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def message_templates
unless whatsapp_channel?
return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' }
end
templates, last_sync_attempt_at, name_key = message_template_data
templates = templates.select { |template| template[name_key] == params[:name] } if params[:name].present?
render json: {
payload: templates,
meta: { last_sync_attempt_at: last_sync_attempt_at }
}
end
def health
render json: fetch_health_data
rescue Whatsapp::HealthService::ApiError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: {
error: {
type: e.authorization_error? ? 'authorization' : 'api',
message: e.message,
http_status: e.http_status,
code: e.code,
subcode: e.subcode
}.compact
}, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
register_channel_webhook
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def whatsapp_business_management_token
Whatsapp::BusinessManagementTokenService.new(whatsapp_channel).update!(params.require(:business_management_token))
head :no_content
rescue ArgumentError, ActiveRecord::RecordInvalid => e
render json: { error: e.message, message: e.message }, status: :unprocessable_entity
end
private
def fetch_health_data
return Whatsapp::HealthService.new(@inbox.channel).sync_health_status!(include_business_profile: true) if whatsapp_cloud_channel?
Twilio::HealthService.new(channel: @inbox.channel).perform
end
def register_channel_webhook
return Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback if whatsapp_cloud_channel?
Twilio::WebhookSetupService.new(channel: @inbox.channel).perform
# No-op unless voice is enabled; keeps the number's voice webhooks in sync alongside messaging.
@inbox.channel.try(:reprovision_voice_webhooks!)
end
def validate_health_supported_channel
return if whatsapp_cloud_channel? || twilio_sms_channel?
render json: { error: 'Health data only available for WhatsApp Cloud API and Twilio SMS channels' }, status: :bad_request
end
def whatsapp_channel
channel = @inbox.channel
raise ActiveRecord::RecordNotFound unless channel.is_a?(Channel::Whatsapp)
channel
end
def whatsapp_cloud_channel?
@inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
end
def twilio_sms_channel?
@inbox.channel.is_a?(Channel::TwilioSms) && @inbox.channel.sms?
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def message_template_data
return [@inbox.channel.message_templates.presence || [], @inbox.channel.message_templates_last_updated, 'name'] unless @inbox.twilio_whatsapp?
[@inbox.channel.content_templates&.dig('templates') || [], @inbox.channel.content_templates_last_updated, 'friendly_name']
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

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

@@ -0,0 +1,55 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

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

@@ -1,19 +1,6 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
include MfaEnforcementGuard
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
around_action :handle_with_exception
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :check_account_mfa_enforcement, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -24,12 +11,6 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end

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,74 +1,41 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
before_action :validate_participant_ids, only: [:create, :update]
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by!(user_id: user_id) }
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by!(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants.reload
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
private
def participants_to_be_added_ids
participant_ids - current_participant_ids
params[:user_ids] - current_participant_ids
end
def participants_to_be_removed_ids
current_participant_ids - participant_ids
current_participant_ids - params[:user_ids]
end
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def participant_ids
@participant_ids ||= params[:user_ids].map(&:to_i)
end
def validate_participant_ids
invalid_ids = participants_to_be_added_ids - @conversation.inbox.assignable_agents.map(&:id)
return if invalid_ids.empty?
render json: { error: 'Invalid participant IDs' }, status: :unprocessable_entity
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end

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

@@ -2,28 +2,12 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')

View File

@@ -2,11 +2,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include Events::Types
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
@@ -82,7 +80,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_status
# FIXME: move this logic into a service object
if bot_handoff?
if pending_to_open_by_bot?
@conversation.bot_handoff!
elsif params[:status].present?
set_conversation_status
@@ -90,17 +88,21 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
else
@status = @conversation.toggle_status
end
handle_human_open if @conversation.open? && Current.user.is_a?(User)
assign_conversation if should_assign_conversation?
end
def bot_handoff?
def pending_to_open_by_bot?
return false unless Current.user.is_a?(AgentBot)
@conversation.status == 'pending' && params[:status] == 'open'
end
def should_assign_conversation?
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def toggle_priority
@conversation.toggle_priority(permitted_update_params[:priority])
@conversation.toggle_priority(params[:priority])
head :ok
end
@@ -131,6 +133,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
update_last_seen_on_conversation(last_seen_at, true)
end
def custom_attributes
@conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes]
@conversation.save!
end
def destroy
authorize @conversation, :destroy?
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
@@ -141,8 +148,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
@@ -159,7 +164,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -178,12 +182,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def handle_human_open
@conversation.with_lock do
@conversation.ai_assignee = nil
@conversation.assignee = Current.user if Current.user.agent?
@conversation.save!
end
def assign_conversation
@conversation.assignee = current_user
@conversation.save!
end
def conversation
@@ -210,8 +211,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

@@ -1,5 +1,4 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]

View File

@@ -1,199 +0,0 @@
require 'csv'
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :retry_import, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
data_import_ids = @data_imports.map(&:id)
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
end
def show
render_show
end
def validate_source
totals = source_class.credentials_validator(source_params: permitted_params.to_h, import_types: import_types).perform
render json: { valid: true, totals: totals }
rescue ArgumentError => e
render_source_validation_error(e.message)
rescue StandardError => e
render_source_client_error(e)
end
def create
@data_import = creation_service.perform
unless @data_import
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
return
end
enqueue_import(@data_import)
render_show
rescue ArgumentError => e
render_source_validation_error(e.message)
rescue StandardError => e
render_source_client_error(e)
end
def start
restart_service = DataImports::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity
return
end
enqueue_import(@data_import) if restart_result == :enqueue
render_show
end
def retry_import
retry_service = DataImports::RetryService.new(account: Current.account, data_import: @data_import)
retry_result = retry_service.perform
@data_import = retry_service.data_import
case retry_result
when :enqueue
enqueue_import(@data_import)
render_show
when :not_stalled
render json: { message: "This #{source_name} import is no longer stalled." }, status: :unprocessable_entity
when :active_import_exists
render json: { message: "Another #{source_name} import is already in progress." }, status: :unprocessable_entity
when :access_token_missing
render json: { message: "The #{source_name} #{credential_name} for this import is unavailable." }, status: :unprocessable_entity
end
end
def abandon
@data_import.abandon!
render_show
end
def skip_logs
send_data(
skip_logs_csv,
filename: "data-import-#{@data_import.id}-skip-logs.csv",
type: 'text/csv'
)
end
def error_logs
send_data(
error_logs_csv,
filename: "data-import-#{@data_import.id}-error-logs.csv",
type: 'text/csv'
)
end
private
def ensure_data_import_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
end
def set_data_import
@data_import = Current.account.data_imports.find(params[:id])
end
def check_authorization
authorize(@data_import || DataImport)
end
def permitted_params
params.permit(:name, :source_provider, :access_token, :domain, import_types: [])
end
def creation_service
DataImports::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
)
end
def import_types
return DataImports::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def source_class
provider = @data_import&.source_provider || permitted_params[:source_provider]
DataImports::Source.source_class(provider)
end
def source_name
return 'Integration' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider])
source_class::DISPLAY_NAME
end
def credential_name
return 'credential' unless DataImports::Source.supported?(@data_import&.source_provider || permitted_params[:source_provider])
source_class.credential_name
end
def enqueue_import(data_import)
DataImports::Source.source_class(data_import.source_provider).import_job_class.perform_later(
data_import,
data_import.active_import_run_id
)
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_source_client_error(error)
raise error unless source_class.client_error?(error)
message = if source_class.authentication_error?(error)
"We could not validate this #{source_name} #{credential_name}. Check the key and its permissions."
else
"#{source_name} could not be reached. Please try again."
end
render_source_validation_error(message)
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
render :show
end
def skip_logs_csv
logs_csv(@data_import.import_errors.skip_logs)
end
def error_logs_csv
logs_csv(@data_import.import_errors.non_skip_logs)
end
def logs_csv(logs)
CSV.generate(headers: true) do |csv|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
logs.order(:created_at).find_each do |log|
csv << [
log.created_at.iso8601,
log.details['kind'],
log.source_object_type,
log.source_object_id,
log.error_code,
log.message,
log.details.to_json
]
end
end
end
end

View File

@@ -2,11 +2,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::InboxHealthManagement
include Api::V1::Accounts::Concerns::InboxSecretManagement
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes)
@@ -46,20 +46,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
continue_update = false
ActiveRecord::Base.transaction do
continue_update = update_branded_email_layout
raise ActiveRecord::Rollback unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
return unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
def agent_bot
@@ -77,6 +68,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') }
@@ -128,8 +125,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
@@ -159,34 +156,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present?
end
def update_branded_email_layout
return true unless params.key?(:branded_email_layout)
branded_email_layout = normalized_branded_email_layout
unless Current.account.feature_enabled?(:branded_email_templates)
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email templates feature is not enabled')
return false
end
unless @inbox.email?
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email layout is only supported for email inboxes')
return false
end
@inbox.update_branded_email_layout!(branded_email_layout)
true
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
false
end
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,

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

@@ -1,11 +0,0 @@
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
private
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
def check_authorization
authorize(:hook)
end
end
Api::V1::Accounts::Integrations::BaseController.prepend_mod_with('Api::V1::Accounts::Integrations::BaseController')

View File

@@ -1,8 +1,6 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::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,27 +31,15 @@ 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
def check_authorization
authorize(:hook)
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
def shopify_hook?
action_name == 'create' ? permitted_params[:app_id] == 'shopify' : @hook.app_id == 'shopify'
end
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account)
end
end

View File

@@ -1,7 +1,6 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
@@ -29,7 +28,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,6 +1,5 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
@hook.destroy!

View File

@@ -1,27 +1,24 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::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 :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 +31,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 +40,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 +49,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 +81,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

@@ -1,4 +1,5 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization

View File

@@ -6,10 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
state: state
}
)
if redirect_url

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,52 +68,27 @@ 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] } }] }
)
end
def config_param_keys
keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }]
# Analytics injects tracking scripts into every public page, so keep it admin-only even though
# Enterprise lets knowledge_base_manage roles edit other portal settings.
keys << { analytics: Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym) } if Current.account_user&.administrator?
keys
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def popular_content_keys
params.dig(:portal, :config, :popular_content)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)

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

@@ -1,7 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -11,26 +8,16 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
render_error_response(e)
end
private
def ensure_embedded_signup_enabled
return if params[:inbox_id].present?
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
raise Pundit::NotAuthorizedError
end
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
params: params.permit(:code, :business_id, :waba_id, :phone_number_id, :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
@@ -42,7 +29,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
render json: {
success: false,
@@ -50,9 +37,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def can_reconfigure_channel?
def can_upgrade_to_embedded_signup?
channel = @inbox.channel
channel.provider == 'whatsapp_cloud'
return false unless channel.provider == 'whatsapp_cloud'
true
end
def render_success_response(inbox)
@@ -66,7 +55,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_embedded_signup_error(error)
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -78,6 +67,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

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