Compare commits
3 Commits
develop
...
codex/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b165b624 | ||
|
|
ca9bedfba1 | ||
|
|
4edf626af2 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -146,13 +144,12 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 18
|
||||
parallelism: 20
|
||||
steps:
|
||||
- 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
|
||||
|
||||
72
.env.example
72
.env.example
@@ -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,14 +271,10 @@ 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.
|
||||
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
|
||||
# AI powered features
|
||||
## OpenAI key
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Housekeeping/Performance related configurations
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
@@ -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
|
||||
|
||||
@@ -247,7 +247,6 @@ module.exports = {
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
node: true,
|
||||
},
|
||||
globals: {
|
||||
|
||||
16
.github/scripts/ghsa_linear_sync.py
vendored
16
.github/scripts/ghsa_linear_sync.py
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/ghsa-linear-sync.yml
vendored
1
.github/workflows/ghsa-linear-sync.yml
vendored
@@ -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
|
||||
|
||||
41
.github/workflows/migration_version_check.yml
vendored
41
.github/workflows/migration_version_check.yml
vendored
@@ -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
|
||||
18
.github/workflows/run_foss_spec.yml
vendored
18
.github/workflows/run_foss_spec.yml
vendored
@@ -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:
|
||||
|
||||
18
.github/workflows/run_mfa_spec.yml
vendored
18
.github/workflows/run_mfa_spec.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -104,4 +104,3 @@ CLAUDE.local.md
|
||||
.pnpm-store/*
|
||||
local/
|
||||
Procfile.worktree
|
||||
.nodeterm/project.json
|
||||
|
||||
@@ -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
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -43,23 +43,13 @@
|
||||
|
||||
## 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.
|
||||
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
|
||||
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
|
||||
- MVP focus: Least code change, happy-path only
|
||||
- No unnecessary defensive programming
|
||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||
- Break down complex tasks into small, testable units
|
||||
- Iterate after confirmation
|
||||
- Avoid writing specs unless explicitly asked
|
||||
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
|
||||
- Remove dead/unreachable/unused code
|
||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||
@@ -90,10 +80,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 +90,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.
|
||||
|
||||
20
Gemfile
20
Gemfile
@@ -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'
|
||||
@@ -199,7 +195,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.12.0'
|
||||
gem 'ai-agents', '>= 0.10.0'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.14.1'
|
||||
|
||||
327
Gemfile.lock
327
Gemfile.lock
@@ -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,12 +121,12 @@ 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)
|
||||
ai-agents (0.10.0)
|
||||
ruby_llm (~> 1.14)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
@@ -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,19 +186,19 @@ 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
|
||||
rexml
|
||||
crass (1.0.7)
|
||||
crass (1.0.6)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
@@ -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,10 +567,10 @@ 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)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.9.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
@@ -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)
|
||||
ai-agents (>= 0.12.0)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.10.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
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2017-2026 Chatwoot Inc.
|
||||
Copyright (c) 2017-2024 Chatwoot Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.18.0
|
||||
4.15.1
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
//= link administrate/application.css
|
||||
//= link administrate/application.js
|
||||
//= link administrate-field-active_storage/application.css
|
||||
//= link secretField.js
|
||||
|
||||
45
app/assets/javascripts/secretField.js
Normal file
45
app/assets/javascripts/secretField.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
96
app/assets/stylesheets/administrate/base/_forms.scss
Normal file
96
app/assets/stylesheets/administrate/base/_forms.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
22
app/assets/stylesheets/administrate/base/_layout.scss
Normal file
22
app/assets/stylesheets/administrate/base/_layout.scss
Normal 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%;
|
||||
}
|
||||
19
app/assets/stylesheets/administrate/base/_lists.scss
Normal file
19
app/assets/stylesheets/administrate/base/_lists.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
71
app/assets/stylesheets/administrate/base/_tables.scss
Normal file
71
app/assets/stylesheets/administrate/base/_tables.scss
Normal 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;
|
||||
}
|
||||
44
app/assets/stylesheets/administrate/base/_typography.scss
Normal file
44
app/assets/stylesheets/administrate/base/_typography.scss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.app-container {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
50
app/assets/stylesheets/administrate/components/_buttons.scss
Normal file
50
app/assets/stylesheets/administrate/components/_buttons.scss
Normal 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;
|
||||
}
|
||||
70
app/assets/stylesheets/administrate/components/_cells.scss
Normal file
70
app/assets/stylesheets/administrate/components/_cells.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
28
app/assets/stylesheets/administrate/components/_flashes.scss
Normal file
28
app/assets/stylesheets/administrate/components/_flashes.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.form-actions {
|
||||
margin-left: calc(15% + 1.25rem);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
15
app/assets/stylesheets/administrate/components/_reports.scss
Normal file
15
app/assets/stylesheets/administrate/components/_reports.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
44
app/assets/stylesheets/administrate/components/_search.scss
Normal file
44
app/assets/stylesheets/administrate/components/_search.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
23
app/assets/stylesheets/administrate/custom_styles.scss
Normal file
23
app/assets/stylesheets/administrate/custom_styles.scss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@mixin administrate-clearfix {
|
||||
&::after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
61
app/assets/stylesheets/administrate/library/_variables.scss
Normal file
61
app/assets/stylesheets/administrate/library/_variables.scss
Normal 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;
|
||||
448
app/assets/stylesheets/administrate/reset/_normalize.scss
Normal file
448
app/assets/stylesheets/administrate/reset/_normalize.scss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.text-color-red {
|
||||
color: $alert-color;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -91,17 +91,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||
}
|
||||
end
|
||||
|
||||
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
||||
# Both `share` and `post` attachment types carry a page URL rather than a media file,
|
||||
# so map them to `fallback` (which keeps the title/link without attempting a download).
|
||||
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
||||
def normalize_file_type(type)
|
||||
return :fallback if [:share, :post].include?(type.to_sym)
|
||||
return :fallback if type.to_sym == :share
|
||||
|
||||
super
|
||||
end
|
||||
@@ -133,10 +131,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']
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
class V2::Reports::DrilldownBuilder
|
||||
include DateRangeHelper
|
||||
include TimezoneHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
DEFAULT_PAGE = 1
|
||||
DEFAULT_PER_PAGE = 25
|
||||
MAX_PER_PAGE = 100
|
||||
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
|
||||
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
|
||||
MESSAGE_METRICS = {
|
||||
'incoming_messages_count' => :incoming,
|
||||
'outgoing_messages_count' => :outgoing
|
||||
}.freeze
|
||||
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
|
||||
|
||||
def build
|
||||
records = paginated_records.to_a
|
||||
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def meta
|
||||
{
|
||||
metric: metric,
|
||||
record_type: record_type,
|
||||
bucket: {
|
||||
since: bucket_range.begin.to_i,
|
||||
until: bucket_range.end.to_i
|
||||
},
|
||||
current_page: current_page,
|
||||
per_page: per_page,
|
||||
total_count: paginated_records.total_count,
|
||||
conversation_count: conversation_count
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_count
|
||||
return paginated_records.total_count if conversation_metric?
|
||||
|
||||
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
|
||||
end
|
||||
|
||||
def paginated_records
|
||||
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
|
||||
end
|
||||
|
||||
def drilldown_scope
|
||||
if message_metric?
|
||||
message_scope
|
||||
elsif conversation_metric?
|
||||
conversation_scope
|
||||
else
|
||||
reporting_event_scope
|
||||
end
|
||||
end
|
||||
|
||||
def message_scope
|
||||
scope.messages
|
||||
.where(account_id: account.id, created_at: bucket_range)
|
||||
.public_send(MESSAGE_METRICS.fetch(metric))
|
||||
.includes(:sender, conversation: [:assignee, :contact, :inbox])
|
||||
.reorder(created_at: :desc)
|
||||
end
|
||||
|
||||
def conversation_scope
|
||||
scope.conversations
|
||||
.where(account_id: account.id, created_at: bucket_range)
|
||||
.includes(:assignee, :contact, :inbox)
|
||||
.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def reporting_event_scope
|
||||
events = scope.reporting_events
|
||||
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
|
||||
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
|
||||
.order(created_at: :desc)
|
||||
|
||||
if raw_count_strategy == :exclude_bot_handoffs
|
||||
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
elsif raw_count_strategy == :distinct_conversation
|
||||
events = events.where(id: distinct_conversation_event_ids(events))
|
||||
end
|
||||
|
||||
events
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
scope.reporting_events
|
||||
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
|
||||
.where.not(conversation_id: nil)
|
||||
.select(:conversation_id)
|
||||
end
|
||||
|
||||
def distinct_conversation_event_ids(events)
|
||||
events.reorder(nil)
|
||||
.where.not(conversation_id: nil)
|
||||
.select('MAX(reporting_events.id)')
|
||||
.group(:conversation_id)
|
||||
end
|
||||
|
||||
def record_serializer(records)
|
||||
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
|
||||
account,
|
||||
metric,
|
||||
use_business_hours?,
|
||||
records
|
||||
)
|
||||
end
|
||||
|
||||
def bucket_range
|
||||
@bucket_range ||= begin
|
||||
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
|
||||
bucket_end = bucket_end_for(bucket_start)
|
||||
requested_start = Time.zone.at(params[:since].to_i)
|
||||
requested_end = Time.zone.at(params[:until].to_i)
|
||||
|
||||
[bucket_start, requested_start].max...[bucket_end, requested_end].min
|
||||
end
|
||||
end
|
||||
|
||||
def bucket_end_for(bucket_start)
|
||||
{
|
||||
'hour' => bucket_start + 1.hour,
|
||||
'day' => bucket_start + 1.day,
|
||||
'week' => bucket_start + 1.week,
|
||||
'month' => bucket_start + 1.month,
|
||||
'year' => bucket_start + 1.year
|
||||
}.fetch(group_by)
|
||||
end
|
||||
|
||||
def scope
|
||||
case dimension_type
|
||||
when 'account' then account
|
||||
when 'inbox' then inbox
|
||||
when 'agent' then user
|
||||
when 'label' then label
|
||||
when 'team' then team
|
||||
else
|
||||
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
|
||||
end
|
||||
end
|
||||
|
||||
def inbox = @inbox ||= account.inboxes.find(params[:id])
|
||||
|
||||
def user = @user ||= account.users.find(params[:id])
|
||||
|
||||
def label = @label ||= account.labels.find(params[:id])
|
||||
|
||||
def team = @team ||= account.teams.find(params[:id])
|
||||
|
||||
def metric
|
||||
params[:metric].to_s
|
||||
end
|
||||
|
||||
def report_metric
|
||||
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
||||
end
|
||||
|
||||
def raw_event_name
|
||||
report_metric&.raw_event_name
|
||||
end
|
||||
|
||||
def raw_count_strategy
|
||||
report_metric&.raw_count_strategy
|
||||
end
|
||||
|
||||
def record_type
|
||||
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
|
||||
|
||||
'conversation'
|
||||
end
|
||||
|
||||
def message_metric?
|
||||
MESSAGE_METRICS.key?(metric)
|
||||
end
|
||||
|
||||
def conversation_metric?
|
||||
metric == 'conversations_count'
|
||||
end
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
end
|
||||
|
||||
def group_by
|
||||
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
|
||||
def current_page
|
||||
[params[:page].to_i, DEFAULT_PAGE].max
|
||||
end
|
||||
|
||||
def per_page
|
||||
requested_per_page = params[:per_page].to_i
|
||||
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
|
||||
|
||||
[requested_per_page, MAX_PER_PAGE].min
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
end
|
||||
end
|
||||
@@ -1,199 +0,0 @@
|
||||
class V2::Reports::DrilldownRecordSerializer
|
||||
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||
|
||||
attr_reader :account, :metric, :use_business_hours, :records
|
||||
|
||||
def initialize(account, metric, use_business_hours, records = [])
|
||||
@account = account
|
||||
@metric = metric
|
||||
@use_business_hours = use_business_hours
|
||||
@records = records
|
||||
end
|
||||
|
||||
def serialize(record)
|
||||
return serialize_message(record) if record.is_a?(Message)
|
||||
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
|
||||
|
||||
serialize_conversation(record)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_message(message, metric_value: nil, occurred_at: nil)
|
||||
{
|
||||
record_type: 'message',
|
||||
conversation: conversation_attributes(message.conversation),
|
||||
message: message_attributes(message),
|
||||
metric_value: metric_value,
|
||||
occurred_at: (occurred_at || message.created_at).to_i
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_conversation_event(event)
|
||||
inferred_message = inferred_message_for(event)
|
||||
if inferred_message.present?
|
||||
return serialize_message(
|
||||
inferred_message,
|
||||
metric_value: event_metric_value(event),
|
||||
occurred_at: event_timestamp(event)
|
||||
)
|
||||
end
|
||||
|
||||
serialize_conversation(
|
||||
event.conversation,
|
||||
metric_value: event_metric_value(event),
|
||||
occurred_at: event_timestamp(event),
|
||||
event_name: event.name
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
|
||||
serialized_record = {
|
||||
record_type: 'conversation',
|
||||
conversation: conversation_attributes(conversation),
|
||||
message: nil,
|
||||
metric_value: metric_value,
|
||||
occurred_at: (occurred_at || conversation&.created_at)&.to_i
|
||||
}
|
||||
serialized_record[:event_name] = event_name if event_name.present?
|
||||
serialized_record
|
||||
end
|
||||
|
||||
def conversation_attributes(conversation)
|
||||
return {} if conversation.blank?
|
||||
|
||||
{
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id,
|
||||
contact_id: conversation.contact_id,
|
||||
contact_name: conversation.contact&.name,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox&.name,
|
||||
assignee_id: conversation.assignee_id,
|
||||
assignee_name: conversation.assignee&.name,
|
||||
status: conversation.status,
|
||||
created_at: conversation.created_at.to_i,
|
||||
last_activity_at: conversation.last_activity_at.to_i,
|
||||
last_message: last_message_attributes(conversation)
|
||||
}
|
||||
end
|
||||
|
||||
def message_attributes(message)
|
||||
{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
message_type: message.message_type,
|
||||
sender_name: message.sender&.try(:name),
|
||||
created_at: message.created_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def last_message_attributes(conversation)
|
||||
message = latest_messages_by_conversation_id[conversation.id]
|
||||
return if message.blank?
|
||||
|
||||
message_attributes(message)
|
||||
end
|
||||
|
||||
def inferred_message_for(event)
|
||||
return unless MESSAGE_EVENT_METRICS.include?(metric)
|
||||
return if event.conversation.blank? || event.event_end_time.blank?
|
||||
|
||||
inferred_messages_by_event_id[event.id]
|
||||
end
|
||||
|
||||
def first_response_event_with_user?(event)
|
||||
metric == 'avg_first_response_time' && event.user_id.present?
|
||||
end
|
||||
|
||||
def message_inference_range(event)
|
||||
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
|
||||
end
|
||||
|
||||
def event_metric_value(event)
|
||||
use_business_hours ? event.value_in_business_hours : event.value
|
||||
end
|
||||
|
||||
def event_timestamp(event)
|
||||
event.event_end_time || event.created_at
|
||||
end
|
||||
|
||||
def latest_messages_by_conversation_id
|
||||
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
|
||||
{}
|
||||
else
|
||||
latest_messages.index_by(&:conversation_id)
|
||||
end
|
||||
end
|
||||
|
||||
def latest_messages
|
||||
Message
|
||||
.where(account_id: account.id, conversation_id: conversation_ids)
|
||||
.where.not(message_type: :activity)
|
||||
.select('DISTINCT ON (messages.conversation_id) messages.*')
|
||||
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
|
||||
.includes(:sender)
|
||||
end
|
||||
|
||||
def inferred_messages_by_event_id
|
||||
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
|
||||
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
|
||||
message_matches_event?(message, event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def inferred_message_candidates
|
||||
@inferred_message_candidates ||= if inference_events.blank?
|
||||
[]
|
||||
else
|
||||
inferred_messages.to_a
|
||||
end
|
||||
end
|
||||
|
||||
def inferred_messages
|
||||
Message
|
||||
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
|
||||
.where(created_at: inference_time_range)
|
||||
.where(message_type: %i[outgoing template])
|
||||
.includes(:sender)
|
||||
.reorder(created_at: :desc, id: :desc)
|
||||
end
|
||||
|
||||
def message_matches_event?(message, event)
|
||||
message.conversation_id == event.conversation_id &&
|
||||
message.created_at.between?(
|
||||
message_inference_range(event).begin,
|
||||
message_inference_range(event).end
|
||||
) &&
|
||||
message_sender_matches_event?(message, event)
|
||||
end
|
||||
|
||||
def message_sender_matches_event?(message, event)
|
||||
return true unless first_response_event_with_user?(event)
|
||||
|
||||
message.sender_id == event.user_id && message.sender_type == 'User'
|
||||
end
|
||||
|
||||
def inference_time_range
|
||||
event_end_times = inference_events.map(&:event_end_time)
|
||||
|
||||
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
|
||||
end
|
||||
|
||||
def inference_events
|
||||
@inference_events ||= records.select do |record|
|
||||
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_ids
|
||||
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
|
||||
end
|
||||
|
||||
def conversation_id_for(record)
|
||||
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
|
||||
|
||||
record.id
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -42,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reorder
|
||||
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
render json: { positions: positions }
|
||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled,
|
||||
:exclude_older_than_hours
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -7,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
||||
|
||||
def update
|
||||
params_to_update = captain_params
|
||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
|
||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
|
||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
|
||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
|
||||
@current_account.save!
|
||||
|
||||
render json: preferences_payload
|
||||
@@ -37,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
||||
|
||||
def merged_captain_models
|
||||
existing_models = @current_account.captain_models || {}
|
||||
existing_models.merge(permitted_captain_models).compact_blank.presence
|
||||
existing_models.merge(permitted_captain_models)
|
||||
end
|
||||
|
||||
def merged_captain_features
|
||||
@@ -46,38 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def permitted_captain_models
|
||||
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
|
||||
params.require(:captain_models).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def permitted_captain_features
|
||||
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def captain_feature_keys
|
||||
Llm::Models.feature_keys.map(&:to_sym)
|
||||
params.require(:captain_features).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def features_with_account_preferences
|
||||
preferences = Current.account.captain_preferences
|
||||
account_features = preferences[:features] || {}
|
||||
account_models = preferences[:models] || {}
|
||||
|
||||
Llm::Models.feature_keys.index_with do |feature_key|
|
||||
config = Llm::Models.feature_config(feature_key)
|
||||
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
|
||||
config.merge(
|
||||
default: default_model_for(feature_key),
|
||||
enabled: account_features[feature_key] == true,
|
||||
model: route[:model],
|
||||
selected: route[:model],
|
||||
provider: route[:provider],
|
||||
source: route[:source]
|
||||
selected: account_models[feature_key] || config[:default]
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -51,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
render json: { content: translated_content }
|
||||
rescue Google::Cloud::Error => e
|
||||
# `details` carries the clean human message; `message` includes gRPC debug noise
|
||||
render_could_not_create_error(e.details.presence || e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -66,21 +64,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 +78,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -21,6 +21,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@assignable_agents = @inbox.assignable_agents
|
||||
end
|
||||
|
||||
def assignable_owners
|
||||
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
|
||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||
end
|
||||
|
||||
def campaigns
|
||||
@campaigns = @inbox.campaigns
|
||||
end
|
||||
@@ -46,20 +51,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 +73,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 +130,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 +161,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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
render_response(
|
||||
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
|
||||
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user