diff --git a/.annotaterb.yml b/.annotaterb.yml index 5b79a7fba..07162a22d 100644 --- a/.annotaterb.yml +++ b/.annotaterb.yml @@ -1,45 +1,65 @@ -additional_file_patterns: [] -routes: false -models: true -position_in_routes: before -position_in_class: before -position_in_test: before -position_in_fixture: before -position_in_factory: before -position_in_serializer: before -show_foreign_keys: true -show_complete_foreign_keys: false -show_indexes: true -simple_indexes: false -model_dir: - - app/models - - enterprise/app/models -root_dir: '' -include_version: false -require: '' -exclude_tests: true -exclude_fixtures: true -exclude_factories: true -exclude_serializers: true -exclude_scaffolds: true -exclude_controllers: true -exclude_helpers: true -exclude_sti_subclasses: false -ignore_model_sub_dir: false -ignore_columns: null -ignore_routes: null -ignore_unknown_models: false -hide_limit_column_types: integer,bigint,boolean -hide_default_column_types: json,jsonb,hstore -skip_on_db_migrate: false -format_bare: true -format_rdoc: false -format_markdown: false -sort: false -force: false -frozen: false -classified_sort: true -trace: false -wrapper_open: null -wrapper_close: null -with_comment: true +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: json,jsonb,hstore +:hide_limit_column_types: integer,bigint,boolean +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +- enterprise/app/models +:require: [] +:root_dir: +- '' diff --git a/.bundler-audit.yml b/.bundler-audit.yml index afe8702ac..908d97175 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -1,3 +1,9 @@ --- ignore: - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) + - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) + # Chatwoot defaults to Active Storage redirect-style URLs, and its recommended + # storage setup uses local/cloud storage with optional direct uploads to the + # storage provider rather than Rails proxy mode. Revisit if we enable + # rails_storage_proxy or other app-served Active Storage proxy routes. + - CVE-2026-33658 diff --git a/.circleci/config.yml b/.circleci/config.yml index 99ac1c29a..59702c139 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -93,8 +93,8 @@ jobs: exit 1 fi mkdir -p ~/tmp - curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar - java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar + java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json # Bundle audit - run: diff --git a/.claude/skills/merge-upstream/SKILL.md b/.claude/skills/merge-upstream/SKILL.md new file mode 100644 index 000000000..119dc9ab5 --- /dev/null +++ b/.claude/skills/merge-upstream/SKILL.md @@ -0,0 +1,188 @@ +--- +name: merge-upstream +description: Use this skill when pulling chatwoot upstream (chatwoot/chatwoot) into the fazer-ai fork, resolving merge conflicts, and validating the result. Covers direction choice, per-file decision framework (KC/AI/CO/delete), recurring patterns (SaveBang, signature architecture, schema.rb regen, WhatsApp service, installation_config), validation flow, and pre-commit/CI pitfalls specific to this repo. Trigger when the user asks to merge develop/main from chatwoot upstream, resolve merge conflicts on a merge branch, or bump the fork to a new chatwoot version. +allowed-tools: Bash, Read, Edit, Write, Grep, Glob +--- + +# Merge upstream (chatwoot → fazer-ai fork) + +The fazer-ai fork diverges from chatwoot upstream on real features (Baileys, Zapi, per-inbox signatures, scheduled messages, group conversations, internal chat). Every few releases we pull upstream in to stay current. This skill captures the recurring patterns and footguns so the next merge doesn't rediscover them from scratch. + +## Direction of merge + +Prefer **branch from our fork's `main`, merge `upstream/develop` into it**, not the other way around. + +- Same number of conflicts either way — git is symmetric. +- What differs: the `--first-parent` chain. Merging upstream into a fork-based branch keeps our main's first-parent history "our work", with upstream as a side merge. Easier to answer "what's ours" later with `git log --first-parent`. +- If the current in-progress merge already went the other direction, finish it as-is. Standardize on next merge. + +## Pre-flight + +After `git merge upstream/develop` (or whatever ref), before touching anything: + +```bash +# list conflicted files +git diff --name-only --diff-filter=U + +# confirm direction — who is HEAD (ours) vs MERGE_HEAD (theirs) +cat .git/MERGE_HEAD +head -5 .git/MERGE_MSG +git log --oneline HEAD -3 +git log --oneline MERGE_HEAD -3 +``` + +Terminology used in this skill: +- **HEAD / current / ours** = the branch you're sitting on (the one receiving the merge). +- **MERGE_HEAD / incoming / theirs** = the branch being merged in. + +If you're on a fork-based branch pulling upstream in: `HEAD` = fork, `MERGE_HEAD` = upstream. +If you're on an upstream-based branch pulling fork in (the less-preferred direction): `HEAD` = upstream, `MERGE_HEAD` = fork. + +Read carefully which side is which before labeling decisions. + +## Per-file decision framework + +For each conflicted file, pick one of: + +| Code | Meaning | +|------|---------| +| **KC** | Keep current (HEAD) — drop the incoming side | +| **AI** | Accept incoming (MERGE_HEAD) — drop the HEAD side | +| **CO** | Combination — merge both sides manually | +| **DEL** | Accept deletion — `git rm` (modify/delete conflict where one side deleted) | + +Process: + +1. Read the conflict markers to see what each side does. +2. `git log --oneline HEAD -5 -- ` and `git log --oneline MERGE_HEAD -5 -- ` — understand WHY each side changed it. +3. For modify/delete: `git ls-files -u ` shows which stages are present (1=base, 2=ours, 3=theirs). +4. For complex hunks: `git show HEAD:` and `git show MERGE_HEAD:` to see each full file. +5. Decide KC/AI/CO/DEL based on intent, not just diff. + +## Recurring patterns in this repo + +### Style/SaveBang noise + +Our fork has `Rails/SaveBang: Enabled: true` in `.rubocop.yml`. Upstream doesn't enforce it as strictly. Consequence: when upstream touches any line near a persistence call, we see a conflict where our side says `save!`/`update!`/`destroy!`/`create!` and theirs says the non-bang version. + +The cop flags more than just `save`. Full list it tries to add `!` to: `save`, `update`, `update_attributes`, `destroy`, `create`, `create_or_find_by`, `find_or_create_by`, `find_or_initialize_by`, `first_or_create`, `first_or_initialize`. Any of these can appear in a conflict. + +- Most are **trivial** style churn from our fork's rubocop autofix, no semantic change. +- **Never blindly accept the bang rewrite (or run `rubocop -A`) without evaluating each offense individually.** The cop doesn't check the receiver's class — it matches by method name alone. Non-ActiveRecord receivers (POROs, service objects with their own `save`/`update`/`destroy` method, third-party libraries like Stripe, Kredis, OpenStruct wrappers, CSV/IO objects with `update`, filesystem objects with `destroy`) will raise `NoMethodError` at runtime. Caught by CI if there's a spec, silently broken in prod if not. +- For each SaveBang offense, read the surrounding code: what class is the receiver? If it's an ActiveRecord model, the autocorrect is safe. If it's anything else, either add the receiver to `.rubocop.yml`'s `Rails/SaveBang.AllowedReceivers` list (currently Stripe::Subscription, Stripe::Customer, Stripe::Invoice) or add a targeted `rubocop:disable Rails/SaveBang` comment. +- Safe workflow: run `bundle exec rubocop ` (without `-A`) first to see the offenses listed, evaluate each individually, then apply `-A` only once you've confirmed every receiver is an ActiveRecord object. Always review the diff before committing. + +### Signature architecture (PR #79) + +We deliberately removed upstream's editor-side signature manipulation (`addSignature`, `removeSignature`, `toggleSignatureInEditor`, signature-in-draft logic) and moved signature application to **send-time** (`getMessagePayload`). This prevents signature duplication, persistence in drafts, and position-toggle bugs. + +When upstream adds or tweaks any signature-related code in: +- `app/javascript/dashboard/components/widgets/WootWriter/Editor.vue` +- `app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue` +- `app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue` + +→ Usually **AI (accept incoming = our fork)**, preserving the send-time architecture. Upstream's "fixes" may be rebuilding exactly what we tore out. + +One exception worth porting as follow-up (NOT during merge): upstream's inline-image sanitization (`stripInlineBase64Images` + `INLINE_IMAGE_WARNING` i18n key) is orthogonal to architecture and would be a nice safety net in our send-time code. + +### WhatsApp incoming message service + +`app/services/whatsapp/incoming_message_base_service.rb` is the other frequent conflict zone. Our fork has two-layer locking (source_id lock + contact phone lock) plus a contact-level re-check for slow networks. Upstream evolves its simpler dedup logic. + +Decision: **CO (combination)**. Keep the fork's `acquire_message_processing_lock` + `with_contact_lock` + explicit `clear_message_source_id_from_redis` in `ensure`. Layer upstream's improvements in (e.g., the `@contact.blocked? && !outgoing_echo` check) at the equivalent point inside the contact lock. + +Adjacent file that may need follow-up: `app/services/whatsapp/incoming_message_service_helpers.rb` typically auto-merges to our version. That's correct. If upstream's `Whatsapp::MessageDedupLock` class becomes orphaned after a merge, `git rm` it (and its spec). + +**Known regression hiding here:** `acquire_message_processing_lock` in our fork checks `@processed_params.try(:[], :messages).blank?`, which skips `:message_echoes` payloads. Echoes from WhatsApp Cloud native-app sends were being silently dropped. Fixed in the 4.13.0 merge by changing to `messages_data.blank?` and picking `:to` vs `:from` for the contact phone based on `outgoing_echo`. Keep that fix on future merges. + +### db/schema.rb + +Always conflicts because both sides have different migration versions. Resolution is mechanical but has traps: + +1. Resolve the version-number conflict first so Ruby can parse the file (`ActiveRecord::Schema[7.1].define(version: ...)`). Pick the later timestamp. +2. Resolve every other Ruby conflict file (`installation_config.rb`, any model conflicts) so Rails can boot. +3. `bundle exec rails db:migrate` to apply pending migrations. +4. `bundle exec rails db:schema:dump` to regenerate. + +**Traps to remember:** + +- **Local dev DB may have tables from other branches** (kanban, features in progress). After `db:schema:dump`, diff against `git show HEAD:db/schema.rb` and `git show MERGE_HEAD:db/schema.rb` to find extras. Manually delete stray `create_table` blocks + any foreign-key references + column references in shared tables (`conversations.kanban_task_id`, etc.). + +- **Custom SQL functions aren't dumpable.** `db:schema:dump` strips our `execute <<~SQL CREATE OR REPLACE FUNCTION f_unaccent(text)` block. Automated re-injection is wired via the `Rakefile` + `lib/tasks/internal_chat_search.rake` (`db:internal_chat:inject_schema_functions` runs as an `enhance` hook after `db:schema:dump`). If you see the block missing after a dump, the hook didn't run — check the Rakefile wiring and the task for a warning line like `Could not find insertion point ...`. The function itself is created by migration `20260410170003_add_unaccent_search_to_internal_chat.rb`. + +- **Schema version may be stamped with a migration from another branch.** `db:schema:dump` uses `MAX(schema_migrations.version)`. If the dev DB has a kanban/other-branch migration with a higher timestamp, that version ends up in `schema.rb`. Manually set the version to the highest timestamp among migrations *present in this merge's `db/migrate/`*. + +- **Quick integrity diff** (in Python — sed-free): parse HEAD's schema + MERGE_HEAD's schema + merged schema, compare column/index sets per table. Any table with columns outside HEAD∪MERGE_HEAD is a stray from another branch. + +### annotate_rb vs auto_annotate_models + +Upstream migrated `.annotaterb.yml` + `lib/tasks/annotate_rb.rake` and deleted the old custom `lib/tasks/auto_annotate_models.rake`. Our fork did a similar migration earlier with different config style. + +- `.annotaterb.yml`: **KC** (upstream's format is more complete, symbol-key style). +- `lib/tasks/auto_annotate_models.rake`: **DEL** (`git rm`). Replacement is `lib/tasks/annotate_rb.rake` from upstream. + +### InstallationConfig serialize + +Upstream simplified to `serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess, default: {}.with_indifferent_access`. Our fork had a custom `SerializedValueCoder` handling both YAML strings and native jsonb hashes. + +Test before choosing: create a legacy `InstallationConfig` where `serialized_value` is a YAML string inside the jsonb column, then confirm upstream's simpler version can still load it. If it works (it did in 4.13.0 merge with all 3 legacy formats: tagged YAML, symbol-key YAML, native hash), go **KC**. Otherwise keep the custom coder. + +### i18n files + +`config/locales/en.yml` / `pt_BR.yml` and `app/javascript/dashboard/i18n/locale/en/settings.json` / `pt_BR/settings.json` conflict because both sides add keys. Almost always **CO**: merge both key sets under the right parent. + +When upstream only adds `en.yml` keys and not `pt_BR.yml`, match upstream's scope — do not invent pt_BR translations as part of the merge. Those come in as community PRs or a separate translation pass. + +### New features from both sides + +Controllers (`inboxes_controller`, `conversations_controller`), policies, routes, store modules, automation_rule action whitelist, spec describe-blocks — when both sides added net-new methods/endpoints/actions, the resolution is always **CO**. Keep both additions ordered sensibly. + +## Validation flow + +After staging all resolved files and before commit: + +```bash +# parse sanity +ruby -c app/models/installation_config.rb +ruby -c db/schema.rb + +# rails boots +bundle exec rails runner 'puts "ok"' + +# migrations all apply +bundle exec rails db:migrate + +# specs for each changed area at minimum +bundle exec rspec spec/models spec/policies +bundle exec rspec spec/services/whatsapp +bundle exec rspec spec/controllers/api/v1/accounts/inboxes_controller_spec.rb \ + spec/controllers/api/v1/accounts/conversations_controller_spec.rb \ + spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb + +# targeted specs we touched +bundle exec rspec spec/services/action_service_spec.rb \ + spec/services/automation_rules/action_service_spec.rb + +# smoke: load real installation configs / other records touched by the merge +bundle exec rails runner 'InstallationConfig.find_each { |c| c.value }' +``` + +## Pre-commit pitfalls + +1. **Husky rubocop check only inspects files with staged diff.** Upstream files merged as-is don't appear in the diff, so their offenses slip past the hook and blow up in CI. Before commit: + ```bash + bundle exec rubocop --parallel + ``` + Run the full thing. Fix anything that comes up (most are `Rails/SaveBang` in upstream migrations/specs — safe to `rubocop -A` after receiver check). + +2. **Frontend lint error vs warning.** `pnpm-lint-staged` eslint runs with `--max-warnings=0` in some configs; a warning appears as an error in the hook. Check the actual error line in the hook output, not the warning count. + +3. **Missing imports after removing conflict hunks.** When resolving AI (accept incoming) conflicts in JS/Vue files, you can accidentally delete imports you still need. Example from 4.13.0: `replaceVariablesInMessage` in `ReplyBox.vue` — the `replaceText` method came in from main but its import was above the conflict. After keeping `replaceText`, add the import. + +4. **Duplicate `defineExpose` / `setup()` returns.** Same category: when combining both sides of a Vue component, watch for duplicate `defineExpose({ ... })` calls or duplicate keys in the `setup()` return object. Consolidate. + +## What this skill deliberately does NOT cover + +- CI flakiness from shard redistribution (pre-existing test pollution involving `perform_enqueued_jobs` in `before_all`, test-prof `let_it_be`, and rspec-mocks interaction). Track separately. +- Frontend build pipeline issues unrelated to the merge. +- Upstream feature rollouts that need product decisions (e.g., adopting a new captain model in our UI). diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md new file mode 100644 index 000000000..d49e914a7 --- /dev/null +++ b/.claude/skills/release-notes/SKILL.md @@ -0,0 +1,170 @@ +--- +name: release-notes +description: Use this skill whenever you are about to cut, edit, or backfill a GitHub release for fazer-ai/chatwoot. Generates the bilingual user-notes blocks (pt-BR + en) embedded in the release body for non-technical end users. Trigger before calling `gh release create`, `gh release edit`, or any flow that touches a release body on this repo (including the `release` skill from fazer-ai-tools and any retroactive backfill of historical releases). +allowed-tools: Bash, Read, Edit, Write, Grep, Glob +--- + +# Release Notes (user-facing) + +Every release cut from `fazer-ai/chatwoot` must embed bilingual user-notes blocks in the release body, written for non-technical end users (operators, admins, superadmins). Do not put implementation detail in these blocks. + +## Required blocks (bilingual, both mandatory) + +The release body must contain both an English block and a Portuguese block, in this order. Use H2 headings with country flags **outside** the blocks to separate the two sections visually on GitHub. The fazer.ai page only renders the content **inside** the `` / `` markers, so the H2 headings, the flags, and any commit list above are invisible there. + +```markdown +## 🇺🇸 English + + +... markdown in english ... + + +## 🇧🇷 Português + + +... markdown em português ... + +``` + +The two versions must be **equivalent in content**, written naturally in each language. They are **not** literal translations: +- en: "Drag conversations between columns faster." +- pt-BR: "Agora você pode arrastar conversas entre colunas mais rápido." + +## Mirroring upstream releases + +Downstream forks (e.g. `fazer-ai/chatwoot-pro`) that mirror a CE release must declare it with a blockquote at the top of each user-notes block, inside the markers. List all mirrored CE versions when there's more than one. CE releases never carry this marker. + +```markdown + +> Includes changes from Chatwoot fazer.ai v4.12.0-fazer-ai.47. +... + + + +> Inclui mudanças do Chatwoot fazer.ai v4.12.0-fazer-ai.47. +... + +``` + +## Audience and tone + +Write for an **end user, not a developer**. Readers do not read code, do not know what a PR is, and do not care about refactors. + +- **Present tense, active voice.** "Agora você pode reordenar etiquetas" / "You can now reorder labels". Not "Adicionada a possibilidade de…" / "Added the ability to…". +- **Lead with benefit, not implementation.** "Carregamento mais rápido em conexões lentas" / "Faster loading on slow connections" beats "Preload de componentes de rota no módulo internal-chat". +- **Plain language.** No jargon, no internal codenames, no function/file/library/module names. +- **No PR numbers, commit hashes, `#1234` references, or links to internal issues.** +- **Group by theme**, not by PR. Use these headers (omit empty ones, but keep the same set in both locales): + +| pt-BR | en | When to use | +| ----------------- | --------------- | ---------------------------------------------------- | +| `### ✨ Novidades` | `### ✨ What's new` | New user-visible features | +| `### ⚡ Melhorias` | `### ⚡ Improvements` | Refinements to existing features (perf, UX, polish) | +| `### 🐛 Correções` | `### 🐛 Fixes` | Bugs the user might have noticed | + +## Full release body example + +The release body should preserve the auto-generated `## Changes` commit list at the top and append both locale sections after it: + +```markdown +## Changes + +- feat(internal-chat): implement internal chat system for agents (#247) +- fix(signatures): allow admins to manage inbox signatures without explicit membership (#260) + +## 🇺🇸 English + + +### ✨ What's new + +- **Internal agent chat.** Your team can now message each other right inside Chatwoot, no extra tool needed. + +### ⚡ Improvements + +- **Faster navigation on slow connections.** Switching between conversations feels more responsive. + +### 🐛 Fixes + +- **Inbox signatures.** Admins can manage signatures without having to be a member of the inbox. + + +## 🇧🇷 Português + + +### ✨ Novidades + +- **Chat interno entre agentes.** Sua equipe agora troca mensagens diretamente dentro do Chatwoot, sem precisar de outra ferramenta. + +### ⚡ Melhorias + +- **Navegação mais rápida em conexões lentas.** A troca entre conversas ficou mais responsiva. + +### 🐛 Correções + +- **Assinaturas de caixas de entrada.** Administradores conseguem gerenciar assinaturas mesmo sem participar da caixa. + +``` + +Bold the change name, then a single short sentence describing the user benefit. Keep each item to 1 or 2 lines. + +If a release has nothing user-visible, write a single generic line in both locales rather than dumping a PR list: + +```markdown +## 🇺🇸 English + + +Bug fixes and internal improvements. + + +## 🇧🇷 Português + + +Correções de bugs e melhorias internas. + +``` + +## Quality checklist (run before publishing) + +Run this checklist on **both** locale blocks: + +- [ ] Both `en` and `pt-BR` blocks are present, with the exact tag spelling shown above, and the `en` block comes first. +- [ ] Both sections are wrapped by `## 🇺🇸 English` / `## 🇧🇷 Português` H2 headings outside the markers. +- [ ] Both blocks contain equivalent content (same items, same order, same themes), written naturally in each language. Not a literal translation. +- [ ] Headers use the localized header table above. Omit empty themes consistently across locales. +- [ ] Every item leads with a user benefit, not an implementation detail. +- [ ] No PR numbers, commit hashes, file paths, function names, library names, or internal module names. +- [ ] No mention of internal initiatives, customers, deals, roadmap, or anything that would not make sense to an external operator. +- [ ] Each item is understandable by someone who has never opened the codebase. +- [ ] Items are present-tense, benefit-led, 1 to 2 lines. +- [ ] Empty release: one generic line in both locales, never an empty block, never one block missing. + +## Look at examples first + +Before drafting, read the user-notes blocks from recent releases in this repo to match tone: + +```bash +gh release list --limit 5 +gh release view --json body -q .body +``` + +The references behind this style are **Linear**, **Stripe**, **Notion**, and **Vercel** changelogs: short, benefit-led, grouped by theme, with the user as the protagonist. + +## Drafting workflow + +When invoked for a release (new or backfill): + +1. Read the current release body via `gh release view --json body -q .body` (or the source commits via `git log .. --oneline`) to understand what shipped. +2. Filter the changes through "would a non-technical operator notice or care about this?". Drop everything that fails the filter. +3. Group what survived into Novidades / Melhorias / Correções. +4. Draft the **pt-BR** block first as the source language. Write naturally, lead with benefit. +5. Draft the **en** block. Equivalent content, natural English, not a word-for-word translation. +6. Assemble the full release body: keep the `## Changes` commit list at the top, then `## 🇺🇸 English` + the `en` block, then `## 🇧🇷 Português` + the `pt-BR` block. The `en` section always comes first in the rendered release body. +7. Run the quality checklist on both blocks. +8. Show the full proposed body to the user for approval **before** editing the release. +9. Only after approval, write the body to a temp file and apply it: + - **For new releases**, pass the file via `gh release create --notes-file `. + - **For backfills / edits**, this version of `gh` does not have a `release edit` subcommand. Use the API directly: + ```bash + RELEASE_ID=$(gh api repos///releases/tags/ --jq '.id') + gh api -X PATCH "repos///releases/$RELEASE_ID" -F body=@ + ``` diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 1487d96a5..3d42496de 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -10,21 +10,21 @@ on: jobs: # Separate linting jobs for faster feedback lint-backend: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 + - uses: useblacksmith/setup-ruby@v2 with: bundler-cache: true - name: Run Rubocop run: bundle exec rubocop --parallel lint-frontend: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: useblacksmith/setup-node@v5 with: node-version: 24 cache: 'pnpm' @@ -35,11 +35,11 @@ jobs: # Frontend tests run in parallel with backend frontend-tests: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: useblacksmith/setup-node@v5 with: node-version: 24 cache: 'pnpm' @@ -50,7 +50,7 @@ jobs: # Backend tests with parallelization backend-tests: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: fail-fast: false matrix: @@ -86,11 +86,11 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} - - uses: ruby/setup-ruby@v1 + - uses: useblacksmith/setup-ruby@v2 with: bundler-cache: true - - uses: actions/setup-node@v4 + - uses: useblacksmith/setup-node@v5 with: node-version: 24 cache: 'pnpm' diff --git a/.gitignore b/.gitignore index 1ed7d578e..ea51125a9 100644 --- a/.gitignore +++ b/.gitignore @@ -92,7 +92,7 @@ yarn-debug.log* # TextEditors & AI Agents config files .vscode -.claude/settings.local.json +.claude/**/*.local.* .cursor .codex/ CLAUDE.local.md diff --git a/Gemfile b/Gemfile index db3fd7c30..b2537f3ad 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,8 @@ gem 'json_refs' gem 'rack-attack', '>= 6.7.0' # a utility tool for streaming, flexible and safe downloading of remote files gem 'down' +# SSRF-safe URL fetching +gem 'ssrf_filter', '~> 1.5' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 @@ -192,7 +194,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents' +gem 'ai-agents', '>= 0.9.1' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.8.2' @@ -271,6 +273,7 @@ group :development, :test do gem 'seed_dump' gem 'shoulda-matchers' gem 'simplecov', '>= 0.21', require: false + gem 'skooma' gem 'spring' gem 'spring-watcher-listen' end diff --git a/Gemfile.lock b/Gemfile.lock index 4f88969f1..b2f1f4007 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,7 +126,7 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.9.0) + ai-agents (0.9.1) ruby_llm (~> 1.9.1) annotaterb (4.20.0) activerecord (>= 6.0.0) @@ -166,7 +166,7 @@ GEM multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) - bcrypt (3.1.20) + bcrypt (3.1.22) benchmark (0.4.1) bigdecimal (3.2.2) bindex (0.8.1) @@ -191,7 +191,7 @@ GEM coderay (1.1.3) commonmarker (0.23.10) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.5) crack (1.0.0) bigdecimal rexml @@ -465,7 +465,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.18.1) + json (2.19.2) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -473,6 +473,12 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) + json_skooma (0.2.5) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + uri-idna (~> 0.2) + zeitwerk (~> 2.6) judoscale-rails (1.8.2) judoscale-ruby (= 1.8.2) railties @@ -583,14 +589,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.18.9) + nokogiri (1.19.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.9-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-darwin) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -736,7 +742,7 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.22.2) + redis-client (0.26.4) connection_pool redis-namespace (1.10.0) redis (>= 4) @@ -912,6 +918,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + skooma (0.3.7) + json_skooma (~> 0.2.5) + zeitwerk (~> 2.6) slack-ruby-client (2.7.0) faraday (>= 2.0.1) faraday-mashify @@ -935,6 +944,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) squasher (0.7.2) + ssrf_filter (1.5.0) stackprof (0.2.25) statsd-ruby (1.5.0) streamio-ffmpeg (3.0.2) @@ -974,6 +984,7 @@ GEM unicode-emoji (4.0.4) uniform_notifier (1.17.0) uri (1.1.1) + uri-idna (0.3.1) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) @@ -1028,7 +1039,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents + ai-agents (>= 0.9.1) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) @@ -1148,10 +1159,12 @@ DEPENDENCIES sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter + skooma slack-ruby-client (~> 2.7.0) spring spring-watcher-listen squasher + ssrf_filter (~> 1.5) stackprof streamio-ffmpeg (~> 3.0) stripe (~> 18.0) diff --git a/META-WEBHOOK-PROXY.md b/META-WEBHOOK-PROXY.md deleted file mode 100644 index 9062d14d5..000000000 --- a/META-WEBHOOK-PROXY.md +++ /dev/null @@ -1,229 +0,0 @@ -# Meta Webhook Proxy - -## Problem - -Some VPS providers silently drop inbound TCP connections from Meta's webhook servers (AS32934) due to overzealous DDoS protection. This causes 15–20% WhatsApp message loss. A reverse proxy on a clean provider (e.g., DigitalOcean) eliminates the drops completely. - -``` -Meta (WhatsApp) → proxy.example.com (clean provider) → your Chatwoot instance -``` - -## Architecture - -The proxy is a single nginx server that routes requests based on the first path segment: - -``` -https://proxy.example.com//webhooks/whatsapp/%2B -``` - -This is the URL you configure in Meta's App Dashboard as the webhook callback URL. The proxy extracts ``, checks it against an allowlist, and forwards the request to `https:///webhooks/whatsapp/%2B`. - -### Multi-tenant - -One proxy serves multiple Chatwoot instances. Each upstream is identified by its domain in the URL path — no separate config per tenant beyond adding the host to the allowlist. - -## Setup - -### Prerequisites - -- A server (Ubuntu 22.04/24.04) on a provider with clean Meta connectivity (DigitalOcean, AWS, etc.) -- A DNS A record pointing your proxy domain to the server IP (e.g., `proxy.example.com → 1.2.3.4`) -- SSH root access to the server - -### 1. Install nginx and certbot - -```bash -ssh root@proxy.example.com - -apt-get update -apt-get install -y nginx certbot python3-certbot-nginx -``` - -### 2. Create a temporary HTTP-only config - -Certbot needs nginx running to perform the ACME challenge, but the full config references SSL certs that don't exist yet. Start with an HTTP-only config: - -```bash -cat > /etc/nginx/sites-available/cw-proxy << 'EOF' -server { - listen 80; - server_name proxy.example.com; - - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - location / { - return 404; - } -} -EOF -``` - -Enable the site and reload: - -```bash -rm -f /etc/nginx/sites-enabled/default -ln -sf /etc/nginx/sites-available/cw-proxy /etc/nginx/sites-enabled/cw-proxy -nginx -t && systemctl reload nginx -``` - -### 3. Obtain the SSL certificate - -```bash -certbot certonly --webroot -w /var/www/html -d proxy.example.com \ - --non-interactive --agree-tos -m your-email@example.com -``` - -Certbot installs a systemd timer that auto-renews the certificate before it expires. - -### 4. Deploy the full proxy config - -Replace the temporary config with the full proxy configuration: - -```bash -cat > /etc/nginx/sites-available/cw-proxy << 'EOF' -# Allowlist of upstream Chatwoot hosts -# Add new hosts here to enable proxying -map $upstream_host $upstream_allowed { - default 0; - chatwoot.example.com 1; - # chatwoot.other.com 1; -} - -server { - listen 80; - server_name proxy.example.com; - - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl; - server_name proxy.example.com; - - resolver 1.1.1.1 8.8.8.8 valid=300s; - resolver_timeout 5s; - - ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem; - - # Extract upstream host from first path segment, proxy the rest - location ~ ^/([^/]+)(/.*)$ { - set $upstream_host $1; - set $upstream_path $2; - - # Reject hosts not in the allowlist - if ($upstream_allowed = 0) { - return 403; - } - - proxy_pass https://$upstream_host$upstream_path$is_args$args; - proxy_set_header Host $upstream_host; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header X-Real-IP $remote_addr; - proxy_ssl_server_name on; - } - - location / { - return 404; - } -} -EOF -``` - -Test and reload: - -```bash -nginx -t && systemctl reload nginx -``` - -### 5. Verify - -```bash -# Root path → 404 -curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/ -# Expected: 404 - -# Unknown host → 403 -curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/unknown.host/webhooks/whatsapp/test -# Expected: 403 - -# Allowed host → proxied (502 if upstream is unreachable, 200 if live) -curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/test -# Expected: 502 or 200 -``` - -### 6. Configure Meta webhook URL - -In the Meta App Dashboard, set the webhook callback URL to: - -``` -https://proxy.example.com//webhooks/whatsapp/%2B -``` - -For example, if your Chatwoot is at `chatwoot.example.com` and the phone number is `+5511999999999`: - -``` -https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/%2B5511999999999 -``` - -Meta will send both verification (GET) and delivery (POST) requests to this URL. The proxy passes them through transparently. - -## Adding a new upstream - -1. SSH into the proxy server -2. Edit `/etc/nginx/sites-available/cw-proxy` -3. Add the new host to the `map` block: - ```nginx - map $upstream_host $upstream_allowed { - default 0; - chatwoot.example.com 1; - chatwoot.newclient.com 1; # ← add this line - } - ``` -4. Test and reload: - ```bash - nginx -t && systemctl reload nginx - ``` -5. Set the Meta webhook callback URL for the new instance to: - ``` - https://proxy.example.com/chatwoot.newclient.com/webhooks/whatsapp/%2B - ``` - -## Removing an upstream - -1. Remove or comment out the host from the `map` block -2. `nginx -t && systemctl reload nginx` -3. Update the Meta webhook callback URL to point directly at the Chatwoot instance (or to a different proxy) - -## Key nginx directives - -| Directive | Purpose | -|-----------|---------| -| `map $upstream_host $upstream_allowed` | Allowlist of permitted upstream hosts. Only hosts set to `1` are proxied; all others get 403. | -| `proxy_ssl_server_name on` | Enables SNI so the TLS handshake uses the correct hostname for the upstream's certificate. | -| `resolver 1.1.1.1 8.8.8.8 valid=300s` | Required because `proxy_pass` uses a variable (`$upstream_host`), so nginx cannot resolve DNS at config load time. Uses Cloudflare and Google DNS. | -| `proxy_set_header Host $upstream_host` | Sets the Host header to the upstream domain so reverse proxies (Traefik, etc.) route correctly. | - -## Failure modes - -All recoverable — Meta retries with exponential backoff for up to 36 hours: - -| Failure | What happens | Recovery | -|---------|-------------|----------| -| Proxy down | Connection refused | Meta retries | -| Upstream down | 502 Bad Gateway | Meta retries | -| SSL expired | TLS handshake error | Meta retries | - -## Important notes - -- **Do not rate-limit.** Meta sends webhook deliveries from many IPs in AS32934. Bursts of 10+ requests per second are normal. -- **SSL auto-renewal** is handled by the certbot systemd timer. Verify with `systemctl status certbot.timer`. -- The `%2B` in the URL is the URL-encoded `+` sign for the phone number's country code. diff --git a/Makefile b/Makefile index 552ebe659..684adacc6 100644 --- a/Makefile +++ b/Makefile @@ -40,8 +40,12 @@ run: fi force_run: - rm -f ./.overmind.sock - rm -f tmp/pids/*.pid + @echo "Cleaning up Overmind processes..." + @lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true + @lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true + @rm -f ./.overmind.sock + @rm -f tmp/pids/*.pid + @echo "Cleanup complete" overmind start -f Procfile.dev force_run_tunnel: diff --git a/Rakefile b/Rakefile index 2e996417e..cf80b84af 100644 --- a/Rakefile +++ b/Rakefile @@ -7,3 +7,23 @@ enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s require enterprise_tasks_path if File.exist?(enterprise_tasks_path) Rails.application.load_tasks + +# Ensure the f_unaccent function used by internal chat search indexes is created +# before db:schema:load runs. This must happen after Rails.application.load_tasks +# so that both `db:schema:load` and `db:internal_chat:ensure_search_functions` +# are guaranteed to be defined. +if Rake::Task.task_defined?('db:schema:load') && + Rake::Task.task_defined?('db:internal_chat:ensure_search_functions') + Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions']) +end + +# Re-inject the f_unaccent `execute <<~SQL ...` block into db/schema.rb after +# db:schema:dump rewrites the file. The schema dumper can't capture CREATE +# FUNCTION statements, so without this hook every dump would silently drop the +# block and break db:schema:load downstream. +if Rake::Task.task_defined?('db:schema:dump') && + Rake::Task.task_defined?('db:internal_chat:inject_schema_functions') + Rake::Task['db:schema:dump'].enhance do + Rake::Task['db:internal_chat:inject_schema_functions'].invoke + end +end diff --git a/VERSION_CW b/VERSION_CW index a162ea75a..813b83b65 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.11.0 +4.13.0 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index d18999bc1..9109f1048 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -104,7 +104,7 @@ class ContactIdentifyAction # blank identifier or email will throw unique index error # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.discard_invalid_attrs if discard_invalid_attrs - @contact.save! + @contact.save! if @contact.changed? enqueue_avatar_job end diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 2633c907d..54825df0f 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -31,19 +31,27 @@ class ContactMergeAction end def merge_conversations - Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) + Conversation.where(contact_id: @mergee_contact.id).find_each do |conversation| + conversation.update!(contact_id: @base_contact.id) + end end def merge_contact_notes - Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id) + Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).find_each do |note| + note.update!(contact_id: @base_contact.id) + end end def merge_messages - Message.where(sender: @mergee_contact).update(sender: @base_contact) + Message.where(sender: @mergee_contact).find_each do |message| + message.update!(sender: @base_contact) + end end def merge_contact_inboxes - ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) + ContactInbox.where(contact_id: @mergee_contact.id).find_each do |contact_inbox| + contact_inbox.update!(contact_id: @base_contact.id) + end end def merge_and_remove_mergee_contact diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2c0e6087e..994b52078 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -55,7 +55,8 @@ class ContactInboxWithContactBuilder email: contact_attributes[:email], identifier: contact_attributes[:identifier], additional_attributes: contact_attributes[:additional_attributes], - custom_attributes: contact_attributes[:custom_attributes] + custom_attributes: contact_attributes[:custom_attributes], + group_type: contact_attributes[:group_type] || :individual ) end diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb index 731b1b0f5..6f79d6018 100644 --- a/app/builders/email/base_builder.rb +++ b/app/builders/email/base_builder.rb @@ -1,4 +1,6 @@ class Email::BaseBuilder + include EmailAddressParseable + pattr_initialize [:inbox!] private @@ -47,8 +49,4 @@ class Email::BaseBuilder # can save it in the format "Name " parse_email(account.support_email) end - - def parse_email(email_string) - Mail::Address.new(email_string).address - end end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 2c55922f6..1f59deadb 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end def message_params + content_attributes = { + in_reply_to_external_id: response.in_reply_to_external_id + } + content_attributes[:external_echo] = true if @outgoing_echo + { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: @message_type, + status: @outgoing_echo ? :delivered : :sent, content: response.content, source_id: response.identifier, - content_attributes: { - in_reply_to_external_id: response.in_reply_to_external_id - }, + content_attributes: content_attributes, sender: @outgoing_echo ? nil : @contact_inbox.contact } end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 8821da9d5..be1f98b43 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder include ::FileTypeHelper def process_attachment(attachment) - # This check handles very rare case if there are multiple files to attach with only one usupported file + # This check handles very rare case if there are multiple files to attach with only one unsupported file return if unsupported_file_type?(attachment['type']) - attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) + params = attachment_params(attachment) + attachment_obj = @message.attachments.new(params.except(:remote_file_url)) attachment_obj.save! - attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] + if facebook_reel?(attachment) + update_facebook_reel_content(attachment) + elsif params[:remote_file_url] + attach_file(attachment_obj, params[:remote_file_url]) + end fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' @@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder end def attachment_params(attachment) - file_type = attachment['type'].to_sym + file_type = normalize_file_type(attachment['type']) params = { file_type: file_type, account_id: @message.account_id } if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type @@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder private + # Facebook may send attachment types that don't directly match our file_type enum. + # Map known aliases to their canonical enum values. + FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze + + def normalize_file_type(type) + sym = type.to_sym + FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym) + end + + # Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than + # direct video URLs. Downloading these yields HTML, not video content. + def facebook_reel?(attachment) + attachment['type'].to_sym == :reel + end + + def update_facebook_reel_content(attachment) + url = attachment.dig('payload', 'url') + return if url.blank? + + @message.update!(content: url) if @message.content.blank? + end + def unsupported_file_type?(attachment_type) [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index c2f919659..de3d10081 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController @agent_bot.reload end + def reset_secret + @agent_bot.reset_secret! + end + private def agent_bot diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 8a6fd61f8..5e1609b64 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end def reorder - Article.update_positions(params[:positions_hash]) + Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) head :ok end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/app/controllers/api/v1/accounts/captain/tasks_controller.rb similarity index 93% rename from enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb rename to app/controllers/api/v1/accounts/captain/tasks_controller.rb index d7208d678..9ba197a3c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb +++ b/app/controllers/api/v1/accounts/captain/tasks_controller.rb @@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr if result.nil? render json: { message: nil } elsif result[:error] - render json: { error: result[:error] }, status: :unprocessable_entity + render json: { error: result[:error] }, status: :unprocessable_content else response_data = { message: result[:message] } response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context] @@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr authorize(:'captain/tasks') end end + +Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController') diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index 834b19ed9..686ffaeec 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization - before_action :fetch_category, except: [:index, :create] + before_action :fetch_category, except: [:index, :create, :reorder] before_action :set_current_page, only: [:index] def index @@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle head :ok end + def reorder + Category.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + head :ok + end + private def fetch_category @@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle end def portal - @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) end def related_categories_records diff --git a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb new file mode 100644 index 000000000..795d7f2a9 --- /dev/null +++ b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb new file mode 100644 index 000000000..79a5837c9 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb @@ -0,0 +1,56 @@ +class Api::V1::Accounts::Contacts::GroupAdminController < Api::V1::Accounts::Contacts::BaseController + VALID_PROPERTIES = %w[announce restrict join_approval_mode member_add_mode].freeze + + def leave + authorize @contact, :update? + channel.group_leave(@contact.identifier) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def update + authorize @contact, :update? + property = property_params[:property] + enabled = ActiveModel::Type::Boolean.new.cast(property_params[:enabled]) + return render json: { error: 'invalid_property' }, status: :unprocessable_entity unless property.in?(VALID_PROPERTIES) + + apply_property_change(property, enabled) + update_contact_attribute(property, enabled) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def apply_property_change(property, enabled) + case property + when 'announce', 'restrict' + channel.group_setting_update(@contact.identifier, property, enabled) + when 'join_approval_mode' + channel.group_join_approval_mode(@contact.identifier, enabled ? 'on' : 'off') + when 'member_add_mode' + channel.group_member_add_mode(@contact.identifier, enabled ? 'all_member_add' : 'admin_add') + end + end + + def property_params + params.permit(:property, :enabled) + end + + def channel + @channel ||= @contact.group_channel + end + + def resolve_group_conversations + Current.account.conversations + .where(contact_id: @contact.id, group_type: :group, status: %i[open pending]) + .find_each { |c| c.update!(status: :resolved) } + end + + def update_contact_attribute(key, value) + new_attrs = (@contact.additional_attributes || {}).merge(key => value) + @contact.update!(additional_attributes: new_attrs) + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb new file mode 100644 index 000000000..9d9c18cca --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb @@ -0,0 +1,27 @@ +class Api::V1::Accounts::Contacts::GroupInvitesController < Api::V1::Accounts::Contacts::BaseController + def show + authorize @contact, :show? + code = channel.group_invite_code(@contact.identifier) + render json: invite_response(code) + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def revoke + authorize @contact, :update? + code = channel.revoke_group_invite(@contact.identifier) + render json: invite_response(code) + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def channel + @channel ||= @contact.group_channel + end + + def invite_response(code) + { invite_code: code, invite_url: "https://chat.whatsapp.com/#{code}" } + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb new file mode 100644 index 000000000..db1caabe6 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb @@ -0,0 +1,37 @@ +class Api::V1::Accounts::Contacts::GroupJoinRequestsController < Api::V1::Accounts::Contacts::BaseController + def index + authorize @contact, :show? + requests = channel.group_join_requests(@contact.identifier) + render json: { payload: requests } + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def handle + authorize @contact, :update? + channel.handle_group_join_requests(@contact.identifier, handle_params[:participants], handle_params[:request_action]) + remove_handled_requests(handle_params[:participants]) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def handle_params + params.permit(:request_action, participants: []) + end + + def channel + @channel ||= @contact.group_channel + end + + def remove_handled_requests(participants) + return if participants.blank? + + current_requests = @contact.additional_attributes&.dig('pending_join_requests') || [] + updated_requests = current_requests.reject { |r| participants.include?(r['jid']) } + new_attrs = (@contact.additional_attributes || {}).merge('pending_join_requests' => updated_requests) + @contact.update!(additional_attributes: new_attrs) + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_members_controller.rb b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb new file mode 100644 index 000000000..8ce6ef3d2 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb @@ -0,0 +1,155 @@ +class Api::V1::Accounts::Contacts::GroupMembersController < Api::V1::Accounts::Contacts::BaseController + DEFAULT_PER_PAGE = 10 + + before_action :ensure_group_contact, only: %i[create update destroy] + + def index + authorize @contact, :show? + + base_query = GroupMember.active + .where(group_contact: @contact) + .includes(:contact) + + @total_count = base_query.count + @page = [(params[:page] || 1).to_i, 1].max + @per_page = (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100) + @inbox_phone_number = inbox_phone_number + @is_inbox_admin = inbox_admin? + + paginated = base_query.order(role: :desc, id: :asc) + .offset((@page - 1) * @per_page) + .limit(@per_page) + + @group_members = pin_own_member_on_first_page(paginated) + end + + def create + authorize @contact, :update? + participants = create_params[:participants] + return render json: { error: 'participants_required' }, status: :unprocessable_entity if participants.blank? + + channel.update_group_participants(@contact.identifier, format_participants(participants), 'add') + add_group_members(participants) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def update + authorize @contact, :update? + role = update_params[:role] + return render json: { error: 'invalid_role' }, status: :unprocessable_entity unless %w[admin member].include?(role) + + member = group_members.find(params[:member_id]) + action = role == 'admin' ? 'promote' : 'demote' + channel.update_group_participants(@contact.identifier, [jid_for_member(member)], action) + member.update!(role: role) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError + render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def destroy + authorize @contact, :update? + + member = group_members.find(params[:id]) + channel.update_group_participants(@contact.identifier, [jid_for_member(member)], 'remove') + member.update!(is_active: false) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError + render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def ensure_group_contact + return if @contact.group_type_group? && @contact.identifier.present? + + render json: { error: 'Contact is not a valid group' }, status: :unprocessable_entity + end + + def group_members + GroupMember.where(group_contact: @contact) + end + + def create_params + params.permit(participants: []) + end + + def update_params + params.permit(:role) + end + + def channel + @channel ||= @contact.group_channel + end + + def inbox_phone_number + channel&.phone_number + end + + def inbox_admin? + return false if @inbox_phone_number.blank? + + find_own_member&.role == 'admin' + end + + def pin_own_member_on_first_page(paginated) + return paginated unless @page == 1 && @inbox_phone_number.present? + + ids = paginated.pluck(:id) + own = find_own_member + return paginated if own.blank? || ids.include?(own.id) + + # Prepend own member; drop the last one so total per-page stays consistent + [own] + paginated.where.not(id: own.id).limit(@per_page - 1).to_a + end + + def find_own_member + clean = @inbox_phone_number.delete('+') + GroupMember.active + .where(group_contact: @contact) + .joins(:contact) + .where('REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)', + clean, clean) + .includes(:contact) + .first + end + + def format_participants(phone_numbers) + Array(phone_numbers).map { |phone| "#{phone.to_s.delete('+')}@s.whatsapp.net" } + end + + def jid_for_member(member) + "#{member.contact.phone_number.to_s.delete('+')}@s.whatsapp.net" + end + + def add_group_members(phone_numbers) + inbox = @contact.contact_inboxes.first&.inbox + Array(phone_numbers).each do |phone| + normalized = normalize_phone(phone) + next if normalized.blank? + + contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: normalized.delete('+'), + inbox: inbox, + contact_attributes: { name: normalized, phone_number: normalized } + ).perform + next if contact_inbox.blank? + + member = GroupMember.find_or_initialize_by(group_contact: @contact, contact: contact_inbox.contact) + member.update!(role: :member, is_active: true) unless member.persisted? && member.is_active? + end + end + + def normalize_phone(phone) + cleaned = phone.to_s.strip + return nil if cleaned.blank? + + cleaned.start_with?('+') ? cleaned : "+#{cleaned}" + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb new file mode 100644 index 000000000..889236ace --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb @@ -0,0 +1,39 @@ +class Api::V1::Accounts::Contacts::GroupMetadataController < Api::V1::Accounts::Contacts::BaseController + def update + authorize @contact, :update? + update_subject if metadata_params[:subject].present? + update_description if metadata_params[:description].present? + update_picture if metadata_params[:avatar].present? + render json: { id: @contact.id, name: @contact.name, additional_attributes: @contact.additional_attributes } + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def metadata_params + params.permit(:subject, :description, :avatar) + end + + def update_subject + channel.update_group_subject(@contact.identifier, metadata_params[:subject]) + @contact.update!(name: metadata_params[:subject]) + end + + def update_description + channel.update_group_description(@contact.identifier, metadata_params[:description]) + attrs = @contact.additional_attributes.merge('description' => metadata_params[:description]) + @contact.update!(additional_attributes: attrs) + end + + def update_picture + avatar = metadata_params[:avatar] + image_base64 = Base64.strict_encode64(avatar.read) + channel.update_group_picture(@contact.identifier, image_base64) + @contact.avatar.attach(avatar) + end + + def channel + @channel ||= @contact.group_channel + end +end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 14d4f2c89..373df2dba 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -13,7 +13,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search, :filter] - before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] + before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes, :sync_group] before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index @@ -82,6 +82,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @contact.save! end + def sync_group + authorize @contact, :sync_group? + raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if @contact.group_type_individual? + raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if @contact.identifier.blank? + + Contacts::SyncGroupJob.perform_later(@contact) + head :accepted + end + def create ActiveRecord::Base.transaction do @contact = Current.account.contacts.new(permitted_params.except(:avatar_url)) @@ -201,7 +210,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def fetch_contact - @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) + contact_scope = Current.account.contacts + contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes + @contact = contact_scope.find(params[:id]) end def process_avatar_from_url diff --git a/app/controllers/api/v1/accounts/conversations/messages/reactions_controller.rb b/app/controllers/api/v1/accounts/conversations/messages/reactions_controller.rb new file mode 100644 index 000000000..2d130c65b --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/messages/reactions_controller.rb @@ -0,0 +1,213 @@ +class Api::V1::Accounts::Conversations::Messages::ReactionsController < Api::V1::Accounts::Conversations::BaseController + before_action :ensure_channel_supports_reactions + before_action :fetch_target_message + before_action :ensure_target_is_reactable + + MAX_EMOJI_BYTES = 32 # an emoji with skin tone + ZWJ sequences fits in <=32 bytes + + # The `messages.content_attributes` column is `json` but the model writes it + # as a double-encoded JSON string (legacy `store coder: JSON`), so the `->>` + # operator can't traverse it directly. `#>>'{}'` unwraps the outer encoding + # back to a real JSON object that we can then cast to `jsonb` and query. + CONTENT_ATTRIBUTES_JSONB = "(content_attributes#>>'{}')::jsonb".freeze + + def create + # An omitted `emoji` key, or an explicit JSON `null`, would otherwise + # coerce to '' and silently wipe an active reaction. Require a String + # (explicit '' is still the intended remove signal). + return render(json: { error: 'emoji is required' }, status: :unprocessable_entity) unless params[:emoji].is_a?(String) + + emoji = reaction_params[:emoji] + return render(json: { error: 'Invalid emoji' }, status: :unprocessable_entity) unless emoji_payload_valid?(emoji) + + result = apply_toggle!(emoji) + + return render(json: { error: 'Emoji cannot be empty without an active reaction' }, status: :unprocessable_entity) if result == :invalid + + # Dispatched after the lock commits so the worker reads the post-update row + # (source_id cleared); inside the transaction it would still see the stale + # source_id and SendOnChannelService would skip the send. CREATE goes through + # Message#after_create_commit -> send_reply, which already runs post-commit, + # so we only re-dispatch for UPDATEs. + ::SendReplyJob.perform_later(result) if result.is_a?(Integer) + + # Cable broadcast so the chat list refreshes `last_non_activity_message`. + # Message#after_update_commit only sends MESSAGE_UPDATED (touches + # chat.messages on the frontend); without this, the conversation card + # snapshot stays pointed at the pre-toggle reaction state. Touch + # `updated_at` first so the frontend's out-of-order guard in + # UPDATE_CONVERSATION can drop stale cables when the user toggles fast. + @conversation.update_columns(updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + @conversation.dispatch_conversation_updated_event + + head :ok + end + + private + + # Serialize concurrent operations from the same user against the same target + # message. Without the lock, two near-simultaneous clicks would both observe + # the same state and either create duplicates or step on each other's update. + def apply_toggle!(emoji) + outcome = nil + @target_message.with_lock do + existing = current_user_reaction + if emoji.blank? && !reaction_active?(existing) + outcome = :invalid + next + end + + outcome = mutate_reaction!(emoji, existing) + end + outcome + end + + def mutate_reaction!(emoji, existing) + if existing.present? + update_existing_reaction!(existing, emoji) + existing.id + elsif emoji.present? + build_reaction_message!(emoji) + :created + end + end + + # WhatsApp allows one reaction per (message, user). We mirror that in storage: + # a single Message row holds the user's current reaction. Replacing the emoji + # updates the row in-place, removing it sets content='' + deleted=true, and a + # subsequent re-add resurrects the same row. This keeps the conversation + # history clean instead of accumulating one Message per toggle. + def update_existing_reaction!(existing, emoji) + is_removing = reaction_active?(existing) && (emoji.blank? || existing.content == emoji) + new_attrs = existing.content_attributes.dup + + if is_removing + new_content = '' + new_attrs['deleted'] = true + else + new_content = emoji + new_attrs.delete('deleted') + end + + # Reset source_id so SendOnChannelService doesn't treat this as a message + # echoed back from the provider and skip the resend. The provider assigns a + # fresh source_id on success via send_session_message. + existing.update!(content: new_content, content_attributes: new_attrs, source_id: nil) + end + + def reaction_active?(message) + return false if message.nil? + + message.content.present? && !message.content_attributes['deleted'] + end + + # An emoji payload is either empty (removal) or a single grapheme cluster + # that actually renders as an emoji. `\p{Emoji}` alone is too broad (it + # matches keycap bases like `1`, `#`, `*`), while `\p{Extended_Pictographic}` + # alone is too narrow — it only hits single codepoints, so flag sequences + # (🇧🇷 = 2 regional indicators) and keycaps (1️⃣ = digit + VS16 + U+20E3) + # would be rejected. Accept a grapheme cluster that contains at least one + # pictographic codepoint, a regional indicator, or the combining keycap. + EMOJI_PROPERTY_RE = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u{20E3}]/ + + def emoji_payload_valid?(emoji) + return true if emoji.empty? + return false if emoji.bytesize > MAX_EMOJI_BYTES + return false if emoji.each_grapheme_cluster.to_a.length != 1 + + emoji.match?(EMOJI_PROPERTY_RE) + end + + def ensure_channel_supports_reactions + channel = @conversation.inbox.channel + return if channel.respond_to?(:supports_reactions?) && channel.supports_reactions? + + render json: { error: 'Reactions are not supported on this channel' }, status: :unprocessable_entity + end + + def fetch_target_message + @target_message = @conversation.messages.find(params[:message_id]) + end + + def ensure_target_is_reactable + error = target_unreactable_error + return if error.nil? + + render(json: { error: error }, status: :unprocessable_entity) + end + + # Mirrors the client-side guard in + # app/javascript/dashboard/components-next/message/Message.vue#canShowReactionToolbar + # so a crafted POST cannot persist a reaction (and enqueue a provider send) + # against a target the UI would never let the user pick. + def target_unreactable_error + return 'Cannot react to private messages' if @target_message.private? + return 'Cannot react to a reaction' if @target_message.reaction? + return 'Cannot react to deleted messages' if @target_message.content_attributes['deleted'] + return 'Cannot react to activity messages' if @target_message.activity? + return 'Cannot react to template messages' if @target_message.template? + return 'Cannot react to failed messages' if @target_message.failed? + return 'Cannot react to unsupported messages' if @target_message.content_attributes['is_unsupported'] + return 'Target message is not deliverable to WhatsApp' if @target_message.source_id.blank? + + nil + end + + # Returns the most recent reaction Message we should mutate for the current + # user. Two sources qualify: + # - Reactions the agent created via Chatwoot UI (sender = Current.user). + # - Multi-device echoes: the agent reacted from the WhatsApp mobile app on the + # same number as the inbox, so the message comes back outgoing without an + # agent. Without this fallback, a click on such a chip would create a brand + # new Chatwoot-side reaction and the original would never be removed from + # WhatsApp. + def current_user_reaction + # Match by both the internal in_reply_to (set by Chatwoot-originated + # reactions via MessageBuilder) and the in_reply_to_external_id (set by + # WhatsApp incoming/echoed reactions via IncomingMessageBaseService). A + # multi-device echo persists with only the external id, so without this OR + # the next toggle would miss the echoed row and stack a duplicate self + # reaction. + matches = @conversation.messages + .where("#{CONTENT_ATTRIBUTES_JSONB}->>'is_reaction' = 'true'") + .where( + "(#{CONTENT_ATTRIBUTES_JSONB}->>'in_reply_to')::bigint = :message_id OR " \ + "#{CONTENT_ATTRIBUTES_JSONB}->>'in_reply_to_external_id' = :source_id", + message_id: @target_message.id, + source_id: @target_message.source_id + ) + .where( + '(sender_type = ? AND sender_id = ?) OR ' \ + '(message_type = ? AND sender_type IS NULL AND sender_id IS NULL)', + 'User', Current.user.id, Message.message_types[:outgoing] + ) + # Prefer the newest active row so a stale deleted echo can't hijack the + # toggle target and either resurrect a removed reaction or leave the + # active one untouched (creating a duplicate active state for the user). + active = matches.where.not(content: '') + .where("COALESCE(#{CONTENT_ATTRIBUTES_JSONB}->>'deleted', 'false') != 'true'") + .reorder(created_at: :desc) + .first + active || matches.reorder(created_at: :desc).first + end + + def build_reaction_message!(emoji) + Messages::MessageBuilder.new( + Current.user, + @conversation, + ActionController::Parameters.new( + message_type: 'outgoing', + content: emoji, + echo_id: reaction_params[:echo_id], + content_attributes: { + is_reaction: true, + in_reply_to: @target_message.id + } + ) + ).perform + end + + def reaction_params + params.permit(:emoji, :echo_id) + end +end diff --git a/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb new file mode 100644 index 000000000..1d325361c --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb @@ -0,0 +1,176 @@ +class Api::V1::Accounts::Conversations::RecurringScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + + before_action :set_recurring_scheduled_message, only: [:update, :destroy] + + MAX_LIMIT = 50 + + def index + authorize build_recurring_scheduled_message + @recurring_scheduled_messages = @conversation.recurring_scheduled_messages + .includes(:scheduled_messages, :author) + .order(Arel.sql('CASE status WHEN 1 THEN 0 WHEN 0 THEN 1 ELSE 2 END, created_at DESC')) + .limit(MAX_LIMIT) + end + + def create + @recurring_scheduled_message = build_recurring_scheduled_message + authorize @recurring_scheduled_message + @recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params) + + ActiveRecord::Base.transaction do + @recurring_scheduled_message.save! + create_first_occurrence if @recurring_scheduled_message.active? + end + + dispatch_event(RECURRING_SCHEDULED_MESSAGE_CREATED) + end + + def update + @recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params) + + ActiveRecord::Base.transaction do + @recurring_scheduled_message.save! + @recurring_scheduled_message.attachment.purge if params[:remove_attachment].present? && @recurring_scheduled_message.attachment.attached? + + if @recurring_scheduled_message.active? + reschedule_pending_occurrence + else + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + end + end + + dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED) + end + + def destroy + cancel_recurring_message + dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED) + end + + private + + def set_recurring_scheduled_message + @recurring_scheduled_message = @conversation.recurring_scheduled_messages.find(params[:id]) + authorize @recurring_scheduled_message + end + + def build_recurring_scheduled_message + @conversation.recurring_scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user) + end + + def recurring_scheduled_message_params + permitted = params.permit( + :content, + :status, + :attachment, + template_params: {}, + recurrence_rule: [:frequency, :interval, :end_type, :end_date, :end_count, + :monthly_type, :monthly_week, :monthly_weekday, :month_day, + :year_day, :year_month, { week_days: [] }] + ) + + permitted[:recurrence_rule] = cast_recurrence_rule(permitted[:recurrence_rule].to_h) if permitted[:recurrence_rule].present? + + permitted + end + + def cast_recurrence_rule(rule) + integer_keys = %w[interval end_count monthly_week monthly_weekday month_day year_day year_month] + rule.each_with_object({}) do |(key, value), hash| + hash[key] = if key == 'week_days' && value.is_a?(Array) + value.map(&:to_i) + elsif integer_keys.include?(key) + value.to_i + else + value + end + end + end + + def create_first_occurrence + scheduled_at = params[:scheduled_at] + return if scheduled_at.blank? + + sm = @recurring_scheduled_message.scheduled_messages.create!( + content: @recurring_scheduled_message.content, + template_params: @recurring_scheduled_message.template_params, + scheduled_at: scheduled_at, + status: :pending, + account: @recurring_scheduled_message.account, + conversation: @recurring_scheduled_message.conversation, + inbox: @recurring_scheduled_message.inbox, + author: @recurring_scheduled_message.author + ) + copy_attachment(sm) if @recurring_scheduled_message.attachment.attached? + end + + def reschedule_pending_occurrence + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + + next_scheduled_at = compute_next_valid_date + return if next_scheduled_at.blank? + + sm = @recurring_scheduled_message.scheduled_messages.create!( + content: @recurring_scheduled_message.content, + template_params: @recurring_scheduled_message.template_params, + scheduled_at: next_scheduled_at, + status: :pending, + account: @recurring_scheduled_message.account, + conversation: @recurring_scheduled_message.conversation, + inbox: @recurring_scheduled_message.inbox, + author: @recurring_scheduled_message.author + ) + copy_attachment(sm) if @recurring_scheduled_message.attachment.attached? + end + + def compute_next_valid_date + user_date = params[:scheduled_at].present? ? Time.zone.parse(params[:scheduled_at].to_s) : nil + rule = @recurring_scheduled_message.recurrence_rule + + return user_date if user_date.present? && date_matches_rule?(user_date, rule) + + base = [user_date, Time.current].compact.max + RecurringScheduledMessages::RecurrenceCalculatorService + .new(recurrence_rule: rule, last_date: base) + .next_date + end + + def date_matches_rule?(date, rule) + return true unless rule.is_a?(Hash) + + rule = rule.with_indifferent_access + return true unless rule[:frequency] == 'weekly' && rule[:week_days].present? + + rule[:week_days].map(&:to_i).include?(date.wday) + end + + def cancel_recurring_message + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + @recurring_scheduled_message.update!(status: :cancelled) + + I18n.with_locale(@recurring_scheduled_message.account.locale) do + @recurring_scheduled_message.conversation.messages.create!( + account: @recurring_scheduled_message.account, + inbox: @recurring_scheduled_message.inbox, + message_type: :activity, + content: I18n.t( + 'conversations.activity.recurring_message_cancelled', + agent: @recurring_scheduled_message.author&.name || I18n.t('conversations.activity.unknown_agent') + ) + ) + end + end + + def copy_attachment(scheduled_message) + scheduled_message.attachment.attach(@recurring_scheduled_message.attachment.blob) + end + + def dispatch_event(event_name) + Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, recurring_scheduled_message: @recurring_scheduled_message) + end +end + +Api::V1::Accounts::Conversations::RecurringScheduledMessagesController.prepend_mod_with( + 'Api::V1::Accounts::Conversations::RecurringScheduledMessagesController' +) diff --git a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb index eda5e9937..9ae9ac6bc 100644 --- a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb @@ -8,6 +8,7 @@ class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::A def index authorize build_scheduled_message @scheduled_messages = @conversation.scheduled_messages + .includes(:recurring_scheduled_message) .order(scheduled_at: :desc) .limit(MAX_LIMIT) end @@ -22,6 +23,7 @@ class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::A def update @scheduled_message.assign_attributes(scheduled_message_params) + @scheduled_message.attachment.purge if params[:remove_attachment].present? && @scheduled_message.attachment.attached? @scheduled_message.save! dispatch_event(SCHEDULED_MESSAGE_UPDATED, scheduled_message: @scheduled_message) end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index bd2186b0a..224bd876a 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro include DateRangeHelper include HmacConcern - before_action :conversation, except: [:index, :meta, :search, :create, :filter] + before_action :conversation, except: [:index, :meta, :search, :create, :filter, :presence_subscribe_bulk] before_action :inbox, :contact, :contact_inbox, only: [:create] ATTACHMENT_RESULTS_PER_PAGE = 100 @@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def meta - result = conversation_finder.perform + result = conversation_finder.perform_meta_only @conversations_count = result[:count] end @@ -34,6 +34,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro .per(ATTACHMENT_RESULTS_PER_PAGE) end + def presence_subscribe_bulk + Conversations::PresenceSubscribeService.new(Current.account, presence_subscribe_params[:conversation_ids]).perform + head :ok + end + def show; end def create @@ -107,20 +112,26 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def toggle_typing_status - typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params) + typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params) typing_status_manager.toggle_typing_status head :ok end + def presence_subscribe + Conversations::PresenceSubscribeService.new(Current.account, [@conversation.display_id]).perform + head :ok + end + def update_last_seen # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. # Always update immediately if there are unread messages to maintain accurate read/unread state. - return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? - return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? + # Visiting a conversation should clear any unread inbox notifications for this conversation. + Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform + has_unread = assignee? ? @conversation.assignee_unread_messages.any? : @conversation.unread_messages.any? # No unread messages - apply throttling to limit DB writes - return unless should_update_last_seen? + return if !has_unread && !should_update_last_seen? dispatch_messages_read_event if assignee? @@ -157,6 +168,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro params.permit(:page) end + def presence_subscribe_params + params.permit(conversation_ids: []) + end + def update_last_seen_on_conversation(last_seen_at, update_assignee) updates = { agent_last_seen_at: last_seen_at } updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present? @@ -166,7 +181,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # rubocop:enable Rails/SkipsModelValidations end + def unseen_activity? + @conversation.last_activity_at.present? && + (@conversation.agent_last_seen_at.blank? || @conversation.last_activity_at > @conversation.agent_last_seen_at) + end + def should_update_last_seen? + # Always update when there's unseen activity (e.g. soft-disabled group conversations that don't create messages) + return true if unseen_activity? + # Update if at least one relevant timestamp is older than 1 hour or not set # This prevents redundant DB writes when agents repeatedly view the same conversation agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago diff --git a/app/controllers/api/v1/accounts/groups_controller.rb b/app/controllers/api/v1/accounts/groups_controller.rb new file mode 100644 index 000000000..edad93c31 --- /dev/null +++ b/app/controllers/api/v1/accounts/groups_controller.rb @@ -0,0 +1,26 @@ +class Api::V1::Accounts::GroupsController < Api::V1::Accounts::BaseController + def create + inbox = Current.account.inboxes.find_by(id: group_params[:inbox_id]) + return render json: { error: 'Access Denied' }, status: :forbidden unless inbox_accessible?(inbox) + + result = Groups::CreateService.new( + inbox: inbox, + subject: group_params[:subject], + participants: Array(group_params[:participants]) + ).perform + + render json: result + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def group_params + params.permit(:inbox_id, :subject, participants: []) + end + + def inbox_accessible?(inbox) + inbox.present? && Current.user.assigned_inboxes.exists?(id: inbox.id) && inbox.channel.try(:allow_group_creation?) + end +end diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb index 0da05e813..051817cf3 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -1,6 +1,7 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController before_action :fetch_inbox before_action :validate_whatsapp_channel + before_action :validate_captain_enabled, only: [:analyze] def show service = CsatTemplateManagementService.new(@inbox) @@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Template parameters are required' }, status: :unprocessable_entity end + def analyze + template_params = extract_template_params + return render_missing_message_error if template_params[:message].blank? + + result = CsatTemplateUtilityAnalysisService.new( + account: Current.account, + inbox: @inbox, + message: template_params[:message], + button_text: template_params[:button_text], + language: template_params[:language] + ).perform + + render json: result + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + end + def link link_params = params.require(:template).permit(:name, :language, body_variables: {}) return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank? @@ -66,6 +84,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Message is required' }, status: :unprocessable_entity end + def validate_captain_enabled + return if Current.account.feature_enabled?('captain_integration') + + render json: { error: 'Captain is required for template analysis' }, status: :forbidden + end + def render_link_result(result) if result[:success] render json: { diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index eb6525bb1..9ca0c72fd 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas return unless @hook&.access_token begin - linear_client = Linear.new(@hook.access_token) + linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token')) linear_client.revoke_token rescue StandardError => e Rails.logger.error "Failed to revoke Linear token: #{e.message}" diff --git a/app/controllers/api/v1/accounts/internal_chat/base_controller.rb b/app/controllers/api/v1/accounts/internal_chat/base_controller.rb new file mode 100644 index 000000000..f8f928bb8 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/base_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Accounts::InternalChat::BaseController < Api::V1::Accounts::BaseController + private + + def current_channel + @current_channel ||= Current.account.internal_chat_channels.find(params[:channel_id] || params[:id]) + end + + def current_membership + @current_membership ||= current_channel.channel_members.find_by(user_id: Current.user.id) + end + + def channel_member? + current_channel.channel_type_public_channel? || current_membership.present? + end + + def render_pro_required(feature) + render json: { error: 'pro_feature_required', feature: feature }, status: :payment_required + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/categories_controller.rb b/app/controllers/api/v1/accounts/internal_chat/categories_controller.rb new file mode 100644 index 000000000..96619cb38 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/categories_controller.rb @@ -0,0 +1,49 @@ +class Api::V1::Accounts::InternalChat::CategoriesController < Api::V1::Accounts::InternalChat::BaseController + before_action :fetch_category, only: [:update, :destroy] + + def index + authorize InternalChat::Category, :index? + @categories = Current.account.internal_chat_categories.ordered.includes(:channels) + render json: @categories.map { |category| category_response(category) } + end + + def create + authorize InternalChat::Category, :create? + @category = Current.account.internal_chat_categories.create!(category_params) + render json: category_response(@category), status: :created + end + + def update + authorize @category, :update? + @category.update!(category_params) + render json: category_response(@category) + end + + def destroy + authorize @category, :destroy? + @category.destroy! + head :ok + end + + private + + def fetch_category + @category = Current.account.internal_chat_categories.find(params[:id]) + end + + def category_params + params.require(:category).permit(:name, :position) + end + + def category_response(category) + { + id: category.id, + name: category.name, + position: category.position, + account_id: category.account_id, + channels_count: category.channels.size, + created_at: category.created_at, + updated_at: category.updated_at + } + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/channel_members_controller.rb b/app/controllers/api/v1/accounts/internal_chat/channel_members_controller.rb new file mode 100644 index 000000000..7401454f3 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/channel_members_controller.rb @@ -0,0 +1,107 @@ +class Api::V1::Accounts::InternalChat::ChannelMembersController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :current_channel + before_action :fetch_member, only: [:update, :destroy] + + def index + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + @members = current_channel.channel_members.includes(user: :account_users) + render json: @members.map { |member| member_response(member) } + end + + def create + authorize current_channel, :update?, policy_class: InternalChat::ChannelPolicy + members = create_channel_members(validated_user_ids, requested_role) + dispatch_member_update + render json: members.map { |member| member_response(member) }, status: :created + end + + def update + authorize_member_update! + @member.update!(member_update_params) + render json: member_response(@member) + end + + def destroy + authorize_member_destroy! + removed_user = @member.user + @member.destroy! + dispatch_member_update(removed_user: removed_user) + head :ok + end + + private + + def validated_user_ids + user_ids = Array(params[:user_ids] || [params[:user_id]]).compact.map(&:to_i) + valid_user_ids = Current.account.users.where(id: user_ids).pluck(:id) + raise ActionController::BadRequest, 'No valid user IDs provided' if valid_user_ids.empty? + + valid_user_ids + end + + def create_channel_members(user_ids, role) + ActiveRecord::Base.transaction do + user_ids.map do |user_id| + current_channel.channel_members.find_or_create_by!(user_id: user_id) do |m| + m.role = role + end + end + end + end + + # Only account administrators can promote a new member to channel admin via params. + # Channel admins (without account-admin) always create plain members. + def requested_role + return :member unless Current.account_user&.administrator? + return :member if params[:role].blank? + + InternalChat::ChannelMember.roles.key?(params[:role].to_s) ? params[:role] : :member + end + + def fetch_member + @member = current_channel.channel_members.find(params[:id]) + end + + def authorize_member_update! + raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator? + end + + def authorize_member_destroy! + raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator? + end + + def dispatch_member_update(removed_user: nil) + # Capture tokens before the broadcast so the removed user also receives the event + tokens = current_channel.members.pluck(:pubsub_token) + tokens << removed_user.pubsub_token if removed_user.present? + + Rails.configuration.dispatcher.dispatch( + INTERNAL_CHAT_CHANNEL_UPDATED, + Time.zone.now, + channel: current_channel, + member_tokens: tokens.uniq + ) + end + + def member_update_params + params.permit(:muted, :favorited, :hidden) + end + + def member_response(member) + { + id: member.id, + user_id: member.user_id, + role: member.role, + muted: member.muted, + favorited: member.favorited, + last_read_at: member.last_read_at, + name: member.user.name, + avatar_url: member.user.avatar_url, + availability_status: member.user.availability_status, + created_at: member.created_at, + updated_at: member.updated_at + } + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/channels_controller.rb b/app/controllers/api/v1/accounts/internal_chat/channels_controller.rb new file mode 100644 index 000000000..4fca782ba --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/channels_controller.rb @@ -0,0 +1,495 @@ +class Api::V1::Accounts::InternalChat::ChannelsController < Api::V1::Accounts::InternalChat::BaseController # rubocop:disable Metrics/ClassLength + include Events::Types + + before_action :current_channel, only: [:show, :update, :destroy, :archive, :unarchive, :toggle_typing_status, :mark_read, :mark_unread] + + RECENT_MESSAGES_LIMIT = 20 + # Arbitrary 32-bit namespace for the private-channel limit advisory lock; paired with account id. + PRIVATE_CHANNEL_LOCK_KEY = 0x49434C4D # 'ICLM' + + def index + authorize InternalChat::Channel, :index? + @channels = filtered_channels + @unread_counts = compute_unread_counts(@channels) + @mention_channel_ids = compute_mention_channel_ids(@channels) + render json: @channels.map { |channel| channel_index_response(channel) } + end + + def show + authorize @current_channel, :show? + render json: channel_show_response(@current_channel) + end + + def create + @channel = build_channel + authorize @channel, :create? + created = @channel.new_record? + + if dm_params? && created + create_dm_with_lock + else + with_private_channel_limit_lock(@channel) do + return if enforce_private_channel_limit(@channel) + + ActiveRecord::Base.transaction do + @channel.save! + add_creator_as_admin + add_initial_members + add_channel_type_members + end + end + end + + dispatch_channel_event(@channel) if created + render json: channel_show_response(@channel), status: :created + end + + def update + authorize @current_channel, :update? + attrs = update_channel_params + validate_category!(attrs[:category_id]) + @current_channel.update!(attrs) + dispatch_channel_event(@current_channel) + render json: channel_show_response(@current_channel) + end + + def destroy + authorize @current_channel, :destroy? + # Capture member tokens before destroying so the listener can broadcast to them + cached_tokens = channel_member_tokens(@current_channel) + @current_channel.destroy! + Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: @current_channel, + member_tokens: cached_tokens) + head :ok + end + + def archive + authorize @current_channel, :archive? + head(:unprocessable_entity) and return if @current_channel.channel_type_dm? + + @current_channel.archived! + dispatch_channel_event(@current_channel) + render json: channel_show_response(@current_channel) + end + + def unarchive + authorize @current_channel, :unarchive? + + with_private_channel_limit_lock(@current_channel) do + return if enforce_private_channel_limit(@current_channel) + + @current_channel.active! + end + + dispatch_channel_event(@current_channel) + render json: channel_show_response(@current_channel) + end + + def toggle_typing_status + authorize @current_channel, :toggle_typing_status? + InternalChat::TypingStatusManager.new( + channel: @current_channel, user: Current.user, params: { typing_status: typing_status_param } + ).perform + head :ok + end + + def mark_read + authorize @current_channel, :mark_read? + membership = @current_channel.channel_members.find_by(user_id: Current.user.id) + membership&.update!(last_read_at: Time.current) + head :ok + end + + def mark_unread + authorize @current_channel, :mark_unread? + msg_id = mark_unread_params[:message_id] + return head(:ok) if msg_id.blank? + + membership = @current_channel.channel_members.find_by!(user_id: Current.user.id) + message = @current_channel.messages.find(msg_id) + membership.update!(last_read_at: message.created_at - 1.second) + head :ok + end + + private + + def enforce_private_channel_limit(channel) + return unless channel.channel_type_private_channel? + + max = InternalChat::Limits.max_private_channels + return if max.blank? + + count = Current.account.internal_chat_channels.where(channel_type: :private_channel).active.count + render_pro_required('private_channels') if count >= max + end + + # Postgres advisory transaction lock keyed by account so concurrent create/unarchive + # cannot bypass the private-channel limit by racing between count and save. + def with_private_channel_limit_lock(channel) + return yield unless channel.channel_type_private_channel? && InternalChat::Limits.max_private_channels.present? + + ActiveRecord::Base.transaction do + ActiveRecord::Base.connection.execute( + ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?, ?)', PRIVATE_CHANNEL_LOCK_KEY, Current.account.id]) + ) + yield + end + end + + def filtered_channels + channels = Current.account.internal_chat_channels.includes(channel_members: { user: :account_users }, category: []) + channels = apply_type_filter(channels) + channels = apply_category_filter(channels) + channels = apply_status_filter(channels) + channels = apply_visibility_filter(channels) + channels.order(last_activity_at: :desc) + end + + def apply_type_filter(channels) + case params[:type] + when 'text_channels' + channels.text_channels + when 'direct_messages' + channels.direct_messages + else + channels + end + end + + def apply_category_filter(channels) + return channels if params[:category_id].blank? + + channels.where(category_id: params[:category_id]) + end + + def apply_status_filter(channels) + case params[:status] + when 'archived' + channels.archived + else + channels.active + end + end + + def apply_visibility_filter(channels) + user_channels = channels.where(id: Current.user.internal_chat_channels.select(:id)) + + return channels.where(channel_type: %i[public_channel private_channel]).or(user_channels) if Current.account_user&.administrator? + + channels.where(channel_type: :public_channel).or(user_channels) + end + + def build_channel + if dm_params? + find_or_build_dm + else + attrs = create_channel_params.except(:member_ids, :team_ids) + validate_category!(attrs[:category_id]) + Current.account.internal_chat_channels.build(attrs.merge(created_by: Current.user)) + end + end + + def dm_params? + params[:channel_type] == 'dm' || params.dig(:channel, :channel_type) == 'dm' + end + + def find_or_build_dm + user_ids = dm_member_ids + existing_dm = find_existing_dm(user_ids) + return existing_dm if existing_dm.present? + + Current.account.internal_chat_channels.build( + channel_type: :dm, + name: nil, + created_by: Current.user + ) + end + + def find_existing_dm(user_ids) + sorted_ids = user_ids.sort + member_count = sorted_ids.size + + Current.account.internal_chat_channels + .where(channel_type: :dm) + .joins(:channel_members) + .group('internal_chat_channels.id') + .having('COUNT(internal_chat_channel_members.id) = ?', member_count) + .having( + 'ARRAY_AGG(internal_chat_channel_members.user_id ORDER BY internal_chat_channel_members.user_id) = ARRAY[?]::bigint[]', + sorted_ids + ) + .first + end + + def dm_member_ids + ids = Array(permitted_member_ids).map(&:to_i) + ids = Current.account.users.where(id: ids).pluck(:id) + ids << Current.user.id unless ids.include?(Current.user.id) + ids + end + + def add_creator_as_admin + return if @channel.channel_type_dm? + return if @channel.channel_members.exists?(user_id: Current.user.id) + + @channel.channel_members.create!(user_id: Current.user.id, role: :admin) + end + + def add_initial_members + member_ids = Array(permitted_member_ids).map(&:to_i) + member_ids = Current.account.users.where(id: member_ids).pluck(:id) + member_ids << Current.user.id if @channel.channel_type_dm? && member_ids.exclude?(Current.user.id) + + member_ids.uniq.each do |user_id| + next if @channel.channel_members.exists?(user_id: user_id) + + @channel.channel_members.create!(user_id: user_id, role: :member) + end + end + + def add_channel_type_members + return if @channel.channel_type_dm? + + if @channel.channel_type_public_channel? + add_all_agents_as_members + else + add_team_members + end + end + + def add_all_agents_as_members + agent_ids = Current.account.agents.where.not(id: Current.user.id).pluck(:id) + agent_ids.each do |uid| + @channel.channel_members.find_or_create_by!(user_id: uid) { |m| m.role = :member } + end + end + + def add_team_members + team_ids = permitted_team_ids + return if team_ids.blank? + + team_ids.each do |team_id| + team = Current.account.teams.find_by(id: team_id) + next unless team + + @channel.channel_teams.find_or_create_by!(team: team) + team.members.each do |user| + @channel.channel_members.find_or_create_by!(user_id: user.id) { |m| m.role = :member } + end + end + end + + def create_channel_params + @create_channel_params ||= params.require(:channel).permit(:name, :description, :channel_type, :category_id, member_ids: [], team_ids: []) + end + + def update_channel_params + params.require(:channel).permit(:name, :description, :category_id) + end + + def permitted_member_ids + params.permit(member_ids: [])[:member_ids] || create_channel_params[:member_ids] + end + + def permitted_team_ids + ids = params.permit(team_ids: [])[:team_ids] || create_channel_params[:team_ids] + Array(ids).map(&:to_i).compact_blank + end + + def mark_unread_params + params.permit(:message_id) + end + + def typing_status_param + params.permit(:typing_status)[:typing_status] + end + + def create_dm_with_lock + lock_key = "internal_chat_dm_#{Current.account.id}_#{dm_member_ids.sort.join('_')}" + ActiveRecord::Base.transaction do + ActiveRecord::Base.connection.execute( + ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?)', Zlib.crc32(lock_key)]) + ) + existing = find_existing_dm(dm_member_ids) + if existing + @channel = existing + else + @channel.save! + add_initial_members + end + end + end + + def compute_mention_channel_ids(channels) + user_id = Current.user.id + InternalChat::ChannelMember + .joins( + 'INNER JOIN internal_chat_messages ' \ + 'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \ + 'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at' + ) + .where(internal_chat_channel_id: channels.select(:id), user_id: user_id) + .where.not(last_read_at: nil) + .where.not('internal_chat_messages.sender_id' => user_id) + .where("internal_chat_messages.content_attributes->'mentioned_user_ids' @> ?", [user_id].to_json) + .pluck(Arel.sql('DISTINCT internal_chat_channel_members.internal_chat_channel_id')) + end + + def compute_unread_counts(channels) + InternalChat::ChannelMember + .joins( + 'INNER JOIN internal_chat_messages ' \ + 'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \ + 'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at' + ) + .where(internal_chat_channel_id: channels.select(:id), user_id: Current.user.id) + .where.not(last_read_at: nil) + .where.not('internal_chat_messages.sender_id' => Current.user.id) + .group('internal_chat_channel_members.internal_chat_channel_id') + .count('internal_chat_messages.id') + end + + def channel_base_response(channel) + { + id: channel.id, + name: channel.name, + description: channel.description, + channel_type: channel.channel_type, + status: channel.status, + category_id: channel.category_id, + last_activity_at: channel.last_activity_at, + created_at: channel.created_at, + updated_at: channel.updated_at + } + end + + def channel_index_response(channel) # rubocop:disable Metrics/AbcSize + membership = channel.channel_members.detect { |member| member.user_id == Current.user.id } + response = channel_base_response(channel).merge( + is_dm: channel.channel_type_dm?, + muted: membership&.muted || false, + favorited: membership&.favorited || false, + hidden: membership&.hidden || false, + members_count: channel.channel_members.size, + unread_count: @unread_counts&.dig(channel.id) || 0, + has_unread_mention: @mention_channel_ids&.include?(channel.id) || false + ) + if channel.channel_type_dm? + response[:members] = channel.channel_members.map do |m| + { user_id: m.user_id, name: m.user.name, avatar_url: m.user.avatar_url, availability_status: m.user.availability_status } + end + end + response + end + + def channel_show_response(channel) # rubocop:disable Metrics/AbcSize + members = channel.channel_members.includes(:user).load + membership = members.detect { |member| member.user_id == Current.user.id } + recent_messages = channel.messages + .includes(:sender, :reactions, :replies, { poll: { options: { votes: :user } } }, + attachments: { file_attachment: :blob }) + .recent.limit(RECENT_MESSAGES_LIMIT).reverse + + channel_base_response(channel).merge( + is_dm: channel.channel_type_dm?, + muted: membership&.muted || false, + favorited: membership&.favorited || false, + account_id: channel.account_id, + created_by_id: channel.created_by_id, + members_count: members.size, + unread_count: membership&.unread_messages_count || 0, + members: members.map { |m| member_response(m) }, + messages: recent_messages.map { |msg| message_response(msg) } + ) + end + + def member_response(member) + { + id: member.id, + user_id: member.user_id, + role: member.role, + muted: member.muted, + favorited: member.favorited, + name: member.user.name, + avatar_url: member.user.avatar_url + } + end + + def message_response(message) + deleted = message.content_attributes&.dig('deleted') + attrs = message.content_attributes || {} + attrs = attrs.merge(poll: poll_response_for(message.poll)) if message.poll.present? + { + id: message.id, + content: message.content, + content_type: message.content_type, + content_attributes: attrs, + sender: message.sender&.push_event_data, + parent_id: message.parent_id, + echo_id: message.echo_id, + replies_count: message.replies_count, + created_at: message.created_at, + updated_at: message.updated_at, + reactions: reaction_responses(message), + attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) } + } + end + + def poll_response_for(poll) + { + id: poll.id, + question: poll.question, + multiple_choice: poll.multiple_choice, + public_results: poll.public_results, + allow_revote: poll.allow_revote, + expires_at: poll.expires_at, + internal_chat_message_id: poll.internal_chat_message_id, + options: poll.options.ordered.includes(votes: :user).map { |opt| poll_option_response(opt, poll) }, + total_votes: poll.total_votes_count, + created_at: poll.created_at, + updated_at: poll.updated_at + } + end + + def poll_option_response(option, poll) + response = { + id: option.id, + text: option.text, + votes_count: option.votes_count, + voted: option.votes.any? { |v| v.user_id == Current.user.id } + } + response[:voters] = option.votes.map { |v| { id: v.user_id, name: v.user.name } } if poll.public_results + response + end + + def reaction_responses(message) + message.reactions.includes(:user).map do |r| + { id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } } + end + end + + def attachment_response(attachment) + { + id: attachment.id, + file_type: attachment.file_type, + external_url: attachment.external_url, + extension: attachment.extension, + file_url: attachment.file.attached? ? url_for(attachment.file) : nil + } + end + + def channel_member_tokens(channel) + users = channel.channel_type_public_channel? ? channel.account.users : channel.members + users.pluck(:pubsub_token) + end + + def validate_category!(category_id) + return if category_id.blank? + + Current.account.internal_chat_categories.find(category_id) + end + + def dispatch_channel_event(channel) + Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: channel) + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/drafts_controller.rb b/app/controllers/api/v1/accounts/internal_chat/drafts_controller.rb new file mode 100644 index 000000000..0334d6f8b --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/drafts_controller.rb @@ -0,0 +1,55 @@ +class Api::V1::Accounts::InternalChat::DraftsController < Api::V1::Accounts::InternalChat::BaseController + before_action :current_channel, only: [:update, :destroy] + + def index + accessible_channel_ids = Current.account.internal_chat_channels + .where(channel_type: :public_channel) + .or(Current.account.internal_chat_channels.where(id: Current.user.internal_chat_channels.select(:id))) + .select(:id) + @drafts = InternalChat::Draft.where(user: Current.user, account: Current.account, + internal_chat_channel_id: accessible_channel_ids).recent + render json: @drafts.map { |draft| draft_response(draft) } + end + + def update + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + + @draft = InternalChat::Draft.find_or_initialize_by( + user: Current.user, + internal_chat_channel_id: current_channel.id, + parent_id: draft_params[:parent_id] + ) + @draft.assign_attributes( + account: Current.account, + content: draft_params[:content] + ) + @draft.save! + + render json: draft_response(@draft), status: :ok + end + + def destroy + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + + @draft = InternalChat::Draft.find_by!(user: Current.user, internal_chat_channel_id: current_channel.id, parent_id: params[:parent_id]) + @draft.destroy! + head :ok + end + + private + + def draft_params + params.permit(:content, :parent_id) + end + + def draft_response(draft) + { + id: draft.id, + content: draft.content, + internal_chat_channel_id: draft.internal_chat_channel_id, + parent_id: draft.parent_id, + created_at: draft.created_at, + updated_at: draft.updated_at + } + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/messages_controller.rb b/app/controllers/api/v1/accounts/internal_chat/messages_controller.rb new file mode 100644 index 000000000..b44664e00 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/messages_controller.rb @@ -0,0 +1,191 @@ +class Api::V1::Accounts::InternalChat::MessagesController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :current_channel + before_action :fetch_message, only: [:update, :destroy, :pin, :unpin, :thread] + + MESSAGES_PER_PAGE = 50 + + def index + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + @messages = paginated_messages + render json: { + messages: @messages.map { |msg| message_response(msg) }, + meta: pagination_meta + } + end + + def create + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + @message = InternalChat::MessageCreateService.new( + channel: current_channel, + sender: Current.user, + params: message_params + ).perform + render json: message_response(@message), status: :created + end + + def update + authorize @message, :update?, policy_class: InternalChat::MessagePolicy + previous_content = @message.content + @message.update!( + content: update_params[:content], + content_attributes: (@message.content_attributes || {}).merge('edited_at' => Time.current.iso8601, 'previous_content' => previous_content) + ) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message) + render json: message_response(@message) + end + + def destroy + authorize @message, :destroy?, policy_class: InternalChat::MessagePolicy + message_data = { + id: @message.id, + internal_chat_channel_id: @message.internal_chat_channel_id, + account_id: @message.account_id + } + @message.update!(content: I18n.t('internal_chat.messages.deleted'), content_attributes: { deleted: true }) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_DELETED, message_data: message_data) + head :ok + end + + def pin + authorize @message, :pin?, policy_class: InternalChat::MessagePolicy + @message.skip_content_validation = true + @message.update!(content_attributes: (@message.content_attributes || {}).merge('pinned' => true, 'pinned_by' => Current.user.id, + 'pinned_at' => Time.current.iso8601)) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message) + render json: message_response(@message) + end + + def unpin + authorize @message, :unpin?, policy_class: InternalChat::MessagePolicy + @message.skip_content_validation = true + attrs = (@message.content_attributes || {}).except('pinned', 'pinned_by', 'pinned_at') + @message.update!(content_attributes: attrs) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message) + render json: message_response(@message) + end + + def thread + authorize @message, :thread?, policy_class: InternalChat::MessagePolicy + replies = @message.replies.includes(:sender, :reactions, :replies, :attachments, :poll).ordered + render json: { + parent: message_response(@message), + replies: replies.map { |msg| message_response(msg) } + } + end + + private + + def fetch_message + @message = current_channel.messages.find(params[:id]) + end + + def paginated_messages + return fetch_around_messages if params[:around].present? + + messages = apply_time_filters(base_messages_scope) + if params[:after].present? + messages.ordered.limit(MESSAGES_PER_PAGE) + else + messages.ordered.last(MESSAGES_PER_PAGE) + end + rescue ArgumentError + base_messages_scope.ordered.last(MESSAGES_PER_PAGE) + end + + def fetch_around_messages + target = current_channel.messages.find_by(id: params[:around]) + return base_messages_scope.ordered.last(MESSAGES_PER_PAGE) unless target + + half = MESSAGES_PER_PAGE / 2 + before_msgs = base_messages_scope.where('internal_chat_messages.created_at <= ?', target.created_at) + .ordered.last(half) + after_msgs = base_messages_scope.where('internal_chat_messages.created_at > ?', target.created_at) + .ordered.limit(half) + (before_msgs + after_msgs).uniq(&:id).sort_by(&:created_at) + end + + def base_messages_scope + current_channel.messages + .includes(:sender, :reactions, :replies, :attachments, :poll) + .where("parent_id IS NULL OR (content_attributes->>'also_send_in_channel')::boolean = true") + end + + def apply_time_filters(messages) + messages = messages.where('internal_chat_messages.created_at < ?', Time.zone.parse(params[:before])) if params[:before].present? + messages = messages.where('internal_chat_messages.created_at > ?', Time.zone.parse(params[:after])) if params[:after].present? + messages + end + + def pagination_meta + { + has_more: @messages.size >= MESSAGES_PER_PAGE + } + end + + def message_params + params.permit(:content, :content_type, :parent_id, :echo_id, :also_send_in_channel, attachments: [:file, :file_type]) + end + + def update_params + params.permit(:content) + end + + def message_response(message) # rubocop:disable Metrics/AbcSize + deleted = message.content_attributes&.dig('deleted') + response = { + id: message.id, + content: message.content, + content_type: message.content_type, + content_attributes: message.content_attributes, + internal_chat_channel_id: message.internal_chat_channel_id, + sender: message.sender&.push_event_data, + parent_id: message.parent_id, + echo_id: message.echo_id, + replies_count: message.replies_count, + created_at: message.created_at, + updated_at: message.updated_at, + reactions: message.reactions.includes(:user).map { |r| { id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } } }, + attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) } + } + response[:poll] = poll_data(message.poll) if !deleted && message.poll? + response + end + + def poll_data(poll) + return nil unless poll + + { + id: poll.id, + question: poll.question, + multiple_choice: poll.multiple_choice, + public_results: poll.public_results, + allow_revote: poll.allow_revote, + expires_at: poll.expires_at, + options: poll.options.ordered.includes(votes: :user).map { |o| poll_option_data(o, poll) }, + total_votes: poll.total_votes_count + } + end + + def poll_option_data(option, poll) + data = { id: option.id, text: option.text, emoji: option.emoji, votes_count: option.votes_count, + voted: option.votes.any? { |v| v.user_id == Current.user.id } } + data[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results + data + end + + def attachment_response(attachment) + { + id: attachment.id, + file_type: attachment.file_type, + external_url: attachment.external_url, + extension: attachment.extension, + file_url: attachment.file.attached? ? url_for(attachment.file) : nil + } + end + + def dispatch_message_event(event, data) + Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data) + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/polls_controller.rb b/app/controllers/api/v1/accounts/internal_chat/polls_controller.rb new file mode 100644 index 000000000..12849f144 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/polls_controller.rb @@ -0,0 +1,177 @@ +class Api::V1::Accounts::InternalChat::PollsController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :set_poll, only: [:vote] + before_action :set_poll_for_unvote, only: [:unvote] + + def create + return render_pro_required('polls') unless InternalChat::Limits.polls_enabled? + + @channel = Current.account.internal_chat_channels.find(params[:channel_id]) + authorize @channel, :show?, policy_class: InternalChat::ChannelPolicy + raise ActionController::BadRequest, 'Options are required' if poll_params[:options].blank? + + ActiveRecord::Base.transaction do + @message = create_poll_message + @poll = build_poll + create_poll_options + end + + dispatch_message_created_event + + render json: message_with_poll_response(@message, @poll), status: :created + end + + def vote + ActiveRecord::Base.transaction do + validate_vote! + @vote = @option.votes.create!(user: Current.user) + end + dispatch_poll_event + + render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok + end + + def unvote + raise ActionController::BadRequest, 'Poll has expired' if @poll.expired? + + @vote = if params[:option_id].present? + option = @poll.options.find(params[:option_id]) + option.votes.find_by!(user_id: Current.user.id) + else + InternalChat::PollVote.joins(:option) + .where(internal_chat_poll_options: { internal_chat_poll_id: @poll.id }, user_id: Current.user.id) + .first! + end + @vote.destroy! + dispatch_poll_event + + render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok + end + + private + + def set_poll + @poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id]) + @option = @poll.options.find(params[:option_id]) + channel = @poll.message.channel + authorize channel, :show?, policy_class: InternalChat::ChannelPolicy + end + + def set_poll_for_unvote + @poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id]) + channel = @poll.message.channel + authorize channel, :show?, policy_class: InternalChat::ChannelPolicy + end + + def create_poll_message + @channel.messages.create!( + account: Current.account, + sender: Current.user, + content: poll_params[:question], + content_type: :poll + ) + end + + def build_poll + @message.create_poll!( + question: poll_params[:question], + multiple_choice: poll_params[:multiple_choice] || false, + public_results: poll_params.fetch(:public_results, true), + allow_revote: poll_params.fetch(:allow_revote, true), + expires_at: poll_params[:expires_at] + ) + end + + def validate_vote! + raise ActionController::BadRequest, 'Poll has expired' if @poll.expired? + + existing_votes = existing_user_votes + return unless existing_votes.exists? + + raise ActionController::BadRequest, 'Revoting is not allowed' unless @poll.allow_revote + + if @poll.multiple_choice + raise ActionController::BadRequest, 'Already voted for this option' if @option.votes.exists?(user_id: Current.user.id) + else + existing_votes.destroy_all + end + end + + def existing_user_votes + InternalChat::PollVote.joins(:option).where( + internal_chat_poll_options: { internal_chat_poll_id: @poll.id }, + user_id: Current.user.id + ) + end + + def create_poll_options + poll_params[:options].each_with_index do |option_attrs, index| + @poll.options.create!( + text: option_attrs[:text], + emoji: option_attrs[:emoji], + image_url: option_attrs[:image_url], + position: index + ) + end + end + + def poll_params + params.permit(:question, :multiple_choice, :public_results, :allow_revote, :expires_at, :channel_id, + options: [:text, :emoji, :image_url]) + end + + def message_with_poll_response(message, poll) + { + id: message.id, + content: message.content, + content_type: message.content_type, + content_attributes: (message.content_attributes || {}).merge(poll: poll_response(poll)), + internal_chat_channel_id: message.internal_chat_channel_id, + sender: message.sender.push_event_data, + parent_id: message.parent_id, + created_at: message.created_at, + updated_at: message.updated_at, + attachments: [], + reactions: [] + } + end + + def poll_response(poll) + { + id: poll.id, + question: poll.question, + multiple_choice: poll.multiple_choice, + public_results: poll.public_results, + allow_revote: poll.allow_revote, + expires_at: poll.expires_at, + internal_chat_message_id: poll.internal_chat_message_id, + options: poll.options.ordered.includes(votes: :user).map { |option| option_response(option, poll) }, + total_votes: poll.total_votes_count, + created_at: poll.created_at, + updated_at: poll.updated_at + } + end + + def option_response(option, poll) + response = { + id: option.id, + text: option.text, + emoji: option.emoji, + image_url: option.image_url, + position: option.position, + votes_count: option.votes_count, + voted: option.votes.any? { |v| v.user_id == Current.user.id } + } + response[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results + response + end + + def dispatch_message_created_event + Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_MESSAGE_CREATED, Time.zone.now, message: @message) + end + + def dispatch_poll_event + Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_POLL_VOTED, Time.zone.now, poll: @poll, message: @poll.message) + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/reactions_controller.rb b/app/controllers/api/v1/accounts/internal_chat/reactions_controller.rb new file mode 100644 index 000000000..17ee5b18f --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/reactions_controller.rb @@ -0,0 +1,54 @@ +class Api::V1::Accounts::InternalChat::ReactionsController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :fetch_message + + def create + @reaction = @message.reactions.build(user: Current.user, emoji: reaction_params[:emoji]) + authorize @reaction, :create?, policy_class: InternalChat::ReactionPolicy + @reaction.save! + dispatch_reaction_event(INTERNAL_CHAT_REACTION_CREATED, reaction: @reaction) + render json: reaction_response(@reaction), status: :created + end + + def destroy + @reaction = @message.reactions.find(params[:id]) + authorize @reaction, :destroy?, policy_class: InternalChat::ReactionPolicy + reaction_data = { + id: @reaction.id, + message_id: @reaction.internal_chat_message_id, + internal_chat_channel_id: @message.internal_chat_channel_id, + account_id: @message.account_id, + user_id: @reaction.user_id, + emoji: @reaction.emoji + } + @reaction.destroy! + dispatch_reaction_event(INTERNAL_CHAT_REACTION_DELETED, reaction_data: reaction_data) + head :ok + end + + private + + def fetch_message + @message = InternalChat::Message.joins(:channel).where(internal_chat_channels: { account_id: Current.account.id }).find(params[:message_id]) + end + + def reaction_response(reaction) + { + id: reaction.id, + emoji: reaction.emoji, + user_id: reaction.user_id, + user: { name: reaction.user&.name }, + internal_chat_message_id: reaction.internal_chat_message_id, + created_at: reaction.created_at + } + end + + def reaction_params + params.permit(:emoji) + end + + def dispatch_reaction_event(event, **data) + Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data) + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/search_controller.rb b/app/controllers/api/v1/accounts/internal_chat/search_controller.rb new file mode 100644 index 000000000..aa721b3c2 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/search_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Accounts::InternalChat::SearchController < Api::V1::Accounts::BaseController + def show + authorize InternalChat::Channel, :index? + + result = InternalChat::SearchService.new( + current_user: Current.user, + current_account: Current.account, + params: search_params + ).perform + + render json: result + end + + private + + def search_params + params.permit(:q, :page) + end +end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 7f43766d2..f7eb8e4ca 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -23,7 +23,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def update ActiveRecord::Base.transaction do - @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? + @portal.update!(merged_portal_params.merge(live_chat_widget_params)) if params[:portal].present? # @portal.custom_domain = parsed_custom_domain process_attached_logo if params[:blob_id].present? rescue ActiveRecord::RecordInvalid => e @@ -79,10 +79,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( :id, :color, :custom_domain, :header_text, :homepage_link, - :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } + :name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html, + { config: [:default_locale, :show_author, { allowed_locales: [] }, { draft_locales: [] }] } ) end + def merged_portal_params + update_params = portal_params.to_h + if update_params.key?('config') + base_config = @portal.config.is_a?(Hash) ? @portal.config : {} + incoming_config = update_params['config'] + update_params['config'] = incoming_config.is_a?(Hash) ? base_config.merge(incoming_config) : base_config + end + update_params + end + def live_chat_widget_params permitted_params = params.permit(:inbox_id) return {} unless permitted_params.key?(:inbox_id) diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 479d8ae1b..bf20bc6ff 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController elsif params[:external_url].present? create_from_url else - render_error('No file or URL provided', :unprocessable_entity) + render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity) end render_success(result) if result.is_a?(ActiveStorage::Blob) @@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController end def create_from_url - uri = parse_uri(params[:external_url]) - return if performed? - - fetch_and_process_file_from_uri(uri) - end - - def parse_uri(url) - uri = URI.parse(url) - validate_uri(uri) - uri - rescue URI::InvalidURIError, SocketError - render_error('Invalid URL provided', :unprocessable_entity) - nil - end - - def validate_uri(uri) - raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - end - - def fetch_and_process_file_from_uri(uri) - uri.open do |file| - create_and_save_blob(file, File.basename(uri.path), file.content_type) + SafeFetch.fetch(params[:external_url].to_s) do |result| + create_and_save_blob(result.tempfile, result.filename, result.content_type) end - rescue OpenURI::HTTPError => e - render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity) - rescue SocketError - render_error('Invalid URL provided', :unprocessable_entity) + rescue SafeFetch::HttpError => e + render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity) + rescue SafeFetch::FetchError + render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity) + rescue SafeFetch::FileTooLargeError + render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity) + rescue SafeFetch::UnsupportedContentTypeError + render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity) + rescue SafeFetch::Error + render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity) rescue StandardError - render_error('An unexpected error occurred', :internal_server_error) + render_error(I18n.t('errors.upload.unexpected'), :internal_server_error) end def create_and_save_blob(io, filename, content_type) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index a5ef309c5..a7fe5fbff 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController locale: account_params[:locale], user: current_user ).perform + enqueue_branding_enrichment if @user - send_auth_headers(@user) - render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + # Authenticated users (dashboard "add account") and api_only signups + # need the full response with account_id. API-only deployments have no + # frontend to handle the email confirmation flow, so they need auth + # tokens to proceed. + # Unauthenticated web signup returns only the email — no session is + # created until the user confirms via the email link. + if current_user || api_only_signup? + send_auth_headers(@user) + render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + else + render json: { email: @user.email } + end else render_error_response(CustomExceptions::Account::SignupFailed.new({})) end @@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController private + def enqueue_branding_enrichment + return if account_params[:email].blank? + + Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email]) + Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30) + rescue StandardError => e + # Enrichment is optional — never let queue/Redis failures abort signup + ChatwootExceptionTracker.new(e).capture_exception + end + def ensure_account_name # ensure that account_name and user_full_name is present # this is becuase the account builder and the models validations are not triggered @@ -101,7 +122,16 @@ class Api::V1::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? + end + + def api_only_signup? + # CW_API_ONLY_SERVER is the canonical flag for API-only deployments. + # ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose. + # Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get + # typecasts it to boolean, coercing 'api_only' to true. + ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || + InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only' end def validate_captcha diff --git a/app/controllers/api/v1/profile/inbox_signatures_controller.rb b/app/controllers/api/v1/profile/inbox_signatures_controller.rb new file mode 100644 index 000000000..afe4d5b35 --- /dev/null +++ b/app/controllers/api/v1/profile/inbox_signatures_controller.rb @@ -0,0 +1,69 @@ +class Api::V1::Profile::InboxSignaturesController < Api::BaseController + before_action :set_user + before_action :set_inbox_signature, only: %i[show update destroy] + before_action :validate_inbox_access, only: %i[show update destroy] + + def index + if params[:account_id].present? + validate_account_access! + return if performed? + + @inbox_signatures = @user.inbox_signatures.joins(:inbox).where(inboxes: { account_id: params[:account_id] }) + else + @inbox_signatures = @user.inbox_signatures + end + end + + def show + head :not_found and return unless @inbox_signature + end + + def update + if @inbox_signature + @inbox_signature.update!(inbox_signature_params) + else + @inbox_signature = @user.inbox_signatures.create!( + inbox_signature_params.merge(inbox_id: params[:inbox_id]) + ) + end + end + + def destroy + @inbox_signature&.destroy! + head :no_content + end + + private + + def set_user + @user = current_user + end + + def set_inbox_signature + @inbox_signature = @user.inbox_signatures.find_by(inbox_id: params[:inbox_id]) + end + + def inbox_signature_params + params.require(:inbox_signature).permit(:message_signature, :signature_position, :signature_separator) + end + + def validate_inbox_access + inbox = Inbox.find_by(id: params[:inbox_id]) + return head :not_found unless inbox + + account_user = @user.account_users.find_by(account_id: inbox.account_id) + return head :unauthorized unless account_user + + return if account_user.administrator? + return if InboxMember.exists?(user_id: @user.id, inbox_id: inbox.id) + + head :unauthorized + end + + def validate_account_access! + account_id = params[:account_id] + return if @user.account_ids.include?(account_id.to_i) + + head :unauthorized + end +end diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 32d89f450..955c1eb2c 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController contact = @contact end - @contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac? + @contact_inbox.update!(hmac_verified: true) if should_verify_hmac? identify_contact(contact) end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2d6..83b3dc8b1 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_conversation - @conversation = create_conversation if conversation.nil? + return unless conversation.nil? + + @conversation = create_conversation + apply_labels if permitted_params[:labels].present? + end + + def apply_labels + valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title) + @conversation.update_labels(valid_labels) if valid_labels.present? end def message_finder_params @@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) + # custom_attributes and labels are applied when a new conversation is created alongside the first message + params.permit( + :id, :before, :after, :website_token, + contact: [:name, :email], + message: [:content, :referer_url, :timestamp, :echo_id, :reply_to], + custom_attributes: {}, + labels: [] + ) end def set_message diff --git a/app/controllers/api/v2/accounts_controller.rb b/app/controllers/api/v2/accounts_controller.rb index bed0a212a..5a19ddeed 100644 --- a/app/controllers/api/v2/accounts_controller.rb +++ b/app/controllers/api/v2/accounts_controller.rb @@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end def validate_captcha diff --git a/app/controllers/auth/resend_confirmations_controller.rb b/app/controllers/auth/resend_confirmations_controller.rb new file mode 100644 index 000000000..b2c778c46 --- /dev/null +++ b/app/controllers/auth/resend_confirmations_controller.rb @@ -0,0 +1,18 @@ +# Unauthenticated endpoint for resending confirmation emails during signup. +# This is a standalone controller (not on DeviseOverrides::ConfirmationsController) +# because OmniAuth middleware intercepts all POST /auth/* routes as provider +# callbacks, and Devise controller filters cause 307 redirects for custom actions. +# Inherits from ActionController::API to avoid both issues entirely. +# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha. +class Auth::ResendConfirmationsController < ActionController::API + def create + return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? + + email = params[:email] + return head(:ok) unless email.is_a?(String) + + user = User.from_email(email.strip.downcase) + user&.send_confirmation_instructions unless user&.confirmed? + head :ok + end +end diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 338b290da..b7fc14e74 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,6 +1,6 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes], + 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/assignments' => ['create'] }.freeze @@ -28,7 +28,7 @@ module AccessTokenAuthHelper def validate_bot_access_token! return if Current.user.is_a?(User) - return if agent_bot_accessible? + return if @resource.is_a?(AgentBot) && agent_bot_accessible? render_unauthorized('Access to this endpoint is not authorized for bots') end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d57ad0e53..2a3ce59b3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -10,6 +10,7 @@ class DashboardController < ActionController::Base TERMS_URL BRAND_URL BRAND_NAME + BRAND_COLOR PRIVACY_URL DISPLAY_MANIFEST CREATE_NEW_ACCOUNT_FROM_DASHBOARD @@ -78,6 +79,7 @@ class DashboardController < ActionController::Base WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, + BAILEYS_WHATSAPP_GROUPS_ENABLED: Whatsapp::Providers::WhatsappBaileysService.groups_enabled?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), GIT_SHA: GIT_HASH, ALLOWED_LOGIN_METHODS: allowed_login_methods diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index 900125670..f0f6d5394 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -51,8 +60,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def account_signup_allowed? - # set it to true by default, this is the behaviour across the app - GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false' + GlobalConfigService.account_signup_enabled? end def resource_class(_mapping = nil) @@ -82,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update!(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' end diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb index 2eea49333..cdf6f630e 100644 --- a/app/controllers/linear/callbacks_controller.rb +++ b/app/controllers/linear/callbacks_controller.rb @@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController include Linear::IntegrationHelper def show + return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank? + @response = oauth_client.auth_code.get_token( params[:code], redirect_uri: "#{base_url}/linear/callback" @@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController handle_response rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end private @@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController end def handle_response - hook = account.hooks.new( + raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank? + + hook = account.hooks.find_or_initialize_by(app_id: 'linear') + hook.assign_attributes( access_token: parsed_body['access_token'], status: 'enabled', - app_id: 'linear', - settings: { - token_type: parsed_body['token_type'], - expires_in: parsed_body['expires_in'], - scope: parsed_body['scope'] - } + settings: merged_integration_settings(hook.settings) ) - # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 hook.save! redirect_to linear_redirect_uri rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end def account @@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController end def account_id - return unless params[:state] + return @account_id if instance_variable_defined?(:@account_id) - verify_linear_token(params[:state]) + @account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil end def linear_redirect_uri "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" end + def safe_linear_redirect_uri + return base_url if account_id.blank? + + linear_redirect_uri + rescue StandardError + base_url + end + def parsed_body @parsed_body ||= @response.response.parsed end + def integration_settings + { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + expires_on: expires_on, + scope: parsed_body['scope'], + refresh_token: parsed_body['refresh_token'] + }.compact + end + + def merged_integration_settings(existing_settings) + existing_settings.to_h.with_indifferent_access.merge(integration_settings) + end + + def expires_on + return if parsed_body['expires_in'].blank? + + (Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s + end + def base_url ENV.fetch('FRONTEND_URL', 'http://localhost:3000') end diff --git a/app/controllers/manifest_controller.rb b/app/controllers/manifest_controller.rb new file mode 100644 index 000000000..31115a0c9 --- /dev/null +++ b/app/controllers/manifest_controller.rb @@ -0,0 +1,35 @@ +class ManifestController < ApplicationController + PNG_MIME = 'image/png'.freeze + SVG_MIME = 'image/svg+xml'.freeze + + def show + config = GlobalConfig.get('INSTALLATION_NAME', 'LOGO_THUMBNAIL', 'BRAND_COLOR') + installation_name = config['INSTALLATION_NAME'].presence || 'Chatwoot' + logo = config['LOGO_THUMBNAIL'].presence || '/brand-assets/logo_thumbnail.svg' + brand_color = config['BRAND_COLOR'].presence || '#1f93ff' + icon_type = svg?(logo) ? SVG_MIME : PNG_MIME + + expires_in 1.hour, public: true + render json: { + name: installation_name, + short_name: installation_name, + id: '/', + start_url: '/', + display: 'standalone', + background_color: brand_color, + theme_color: brand_color, + icons: [ + { src: logo, sizes: '192x192', type: icon_type, purpose: 'any maskable' }, + { src: logo, sizes: '512x512', type: icon_type, purpose: 'any maskable' } + ] + }, content_type: 'application/manifest+json' + end + + private + + def svg?(url) + File.extname(URI.parse(url).path).casecmp('.svg').zero? + rescue URI::InvalidURIError + false + end +end diff --git a/app/controllers/platform/api/v1/email_channel_migrations_controller.rb b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb new file mode 100644 index 000000000..3e9e8defd --- /dev/null +++ b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb @@ -0,0 +1,101 @@ +class Platform::Api::V1::EmailChannelMigrationsController < PlatformController + before_action :set_account + before_action :validate_account_permissible + before_action :validate_feature_flag + before_action :validate_params + + def create + results = migrate_email_channels + render json: { results: results }, status: :ok + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def validate_account_permissible + return if @platform_app.platform_app_permissibles.find_by(permissible: @account) + + render json: { error: 'Non permissible resource' }, status: :unauthorized + end + + def validate_feature_flag + return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false)) + + render json: { error: 'Email channel migration is not enabled' }, status: :forbidden + end + + def validate_params + return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank? + + return unless migration_params.size > MAX_MIGRATIONS + + return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" }, + status: :unprocessable_entity + end + + def migrate_email_channels + migration_params.map { |entry| migrate_single(entry) } + end + + MAX_MIGRATIONS = 25 + SUPPORTED_PROVIDERS = %w[google microsoft].freeze + + def migrate_single(entry) + validate_provider!(entry[:provider]) + + ActiveRecord::Base.transaction do + channel = create_channel(entry) + inbox = create_inbox(channel, entry) + + { email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' } + end + rescue StandardError => e + { email: entry[:email], status: 'error', message: e.message } + end + + def create_channel(entry) + Channel::Email.create!( + account_id: @account.id, + email: entry[:email], + provider: entry[:provider], + provider_config: entry[:provider_config]&.to_h, + imap_enabled: entry.fetch(:imap_enabled, true), + imap_address: entry[:imap_address] || default_imap_address(entry[:provider]), + imap_port: entry[:imap_port] || 993, + imap_login: entry[:imap_login] || entry[:email], + imap_enable_ssl: entry.fetch(:imap_enable_ssl, true) + ) + end + + def create_inbox(channel, entry) + @account.inboxes.create!( + name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}", + channel: channel + ) + end + + def validate_provider!(provider) + return if SUPPORTED_PROVIDERS.include?(provider) + + raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}" + end + + def default_imap_address(provider) + case provider + when 'google' then 'imap.gmail.com' + when 'microsoft' then 'outlook.office365.com' + else '' + end + end + + def migration_params + params.permit(migrations: [ + :email, :provider, :inbox_name, + :imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl, + { provider_config: {} } + ])[:migrations] + end +end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index e6cc2aa69..2bbfafcc7 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,11 +1,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_article, only: [:show] layout 'portal' def index + @search_query = list_params[:query] @articles = @portal.articles.published.includes(:category, :author) @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? @@ -60,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content) + @parsed_content = render_article_content(@article.content.to_s) end def set_category @@ -73,7 +75,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B end def list_params - params.permit(:query, :locale, :sort, :status, :page, :per_page) + @list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted| + permitted[:query] = permitted[:query].to_s.strip.presence + end end def permitted_params diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index ebfcb310a..3fb200269 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,6 +1,7 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :ensure_portal_feature_enabled before_action :set_category, only: [:show] layout 'portal' diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index df4552432..a187ca8a8 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,7 +1,8 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show] - before_action :portal before_action :redirect_to_portal_with_locale, only: [:show] + before_action :portal + before_action :ensure_portal_feature_enabled layout 'portal' def show @@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl def redirect_to_portal_with_locale return if params[:locale].present? + portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end end diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 3b83a2210..b266b725b 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -18,4 +18,11 @@ class PublicController < ActionController::Base Please send us an email at support@chatwoot.com with the custom domain name and account API key" }, status: :unauthorized and return end + + def ensure_portal_feature_enabled + return unless ChatwootApp.chatwoot_cloud? + return if @portal.account.feature_enabled?('help_center') + + render 'public/api/v1/portals/not_active', status: :payment_required + end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 7f45ce636..913319303 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base end def allow_iframe_requests - if @web_widget.allowed_domains.blank? + if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin? response.headers.delete('X-Frame-Options') else domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ') response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}" end end + + # Mobile WebViews (iOS/Android) load content from file:// or null origins, + # which cannot match any domain in frame-ancestors. When the per-inbox flag + # is enabled, skip frame-ancestors for these requests. + def embedded_from_non_web_origin? + return false unless @web_widget.allow_mobile_webview? + + origin = request.headers['Origin'] + origin.blank? || origin == 'null' || origin&.start_with?('file://') + end end WidgetsController.prepend_mod_with('WidgetsController') diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f11..a184a1df8 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -34,7 +34,9 @@ class AccountDashboard < Administrate::BaseDashboard locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]), account_users: Field::HasMany, - custom_attributes: Field::String + custom_attributes: Field::String, + hide_agent_unassigned_tab: Field::Boolean, + hide_agent_all_tab: HideAgentAllTabField }.merge(enterprise_attribute_types).freeze # COLLECTION_ATTRIBUTES @@ -70,6 +72,8 @@ class AccountDashboard < Administrate::BaseDashboard status conversations account_users + hide_agent_unassigned_tab + hide_agent_all_tab ] + enterprise_show_page_attributes).freeze # FORM_ATTRIBUTES @@ -87,6 +91,8 @@ class AccountDashboard < Administrate::BaseDashboard name locale status + hide_agent_unassigned_tab + hide_agent_all_tab ] + enterprise_form_attributes).freeze # COLLECTION_FILTERS diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 753b617ef..b499193cc 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard current_sign_in_ip: Field::String, last_sign_in_ip: Field::String, confirmation_token: Field::String, - confirmed_at: Field::DateTime, + confirmed_at: ConfirmedAtField, confirmation_sent_at: Field::DateTime, unconfirmed_email: Field::String, name: Field::String.with_options(searchable: true), diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index f46928d4e..12f728453 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher CsatSurveyListener.instance, HookListener.instance, InstallationWebhookListener.instance, + InternalChatListener.instance, NotificationListener.instance, ParticipationListener.instance, ReportingEventListener.instance, diff --git a/app/fields/confirmed_at_field.rb b/app/fields/confirmed_at_field.rb new file mode 100644 index 000000000..67e04c4a0 --- /dev/null +++ b/app/fields/confirmed_at_field.rb @@ -0,0 +1,4 @@ +require 'administrate/field/base' + +class ConfirmedAtField < Administrate::Field::DateTime +end diff --git a/app/fields/hide_agent_all_tab_field.rb b/app/fields/hide_agent_all_tab_field.rb new file mode 100644 index 000000000..2ac6e186a --- /dev/null +++ b/app/fields/hide_agent_all_tab_field.rb @@ -0,0 +1,4 @@ +require 'administrate/field/base' + +class HideAgentAllTabField < Administrate::Field::Boolean +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index d43ed31e7..a6d897dc0 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -11,6 +11,7 @@ class ConversationFinder 'priority_desc' => %w[sort_on_priority desc], 'waiting_since_asc' => %w[sort_on_waiting_since asc], 'waiting_since_desc' => %w[sort_on_waiting_since desc], + 'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc], # To be removed in v3.5.0 'latest' => %w[sort_on_last_activity_at desc], @@ -55,6 +56,22 @@ class ConversationFinder } end + def perform_meta_only + set_up + + mine_count, unassigned_count, all_count, = set_count_for_all_conversations + assigned_count = all_count - unassigned_count + + { + count: { + mine_count: mine_count, + assigned_count: assigned_count, + unassigned_count: unassigned_count, + all_count: all_count + } + } + end + private def set_up @@ -64,6 +81,7 @@ class ConversationFinder find_all_conversations filter_by_status unless params[:q] + filter_by_group_type filter_by_team filter_by_labels filter_by_query @@ -118,6 +136,12 @@ class ConversationFinder @conversations end + def filter_by_group_type + return unless params[:group_type].present? && params[:group_type] != 'all' + + @conversations = @conversations.where(group_type: params[:group_type]) + end + def filter_by_conversation_type case @params[:conversation_type] when 'mention' diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 8854e239a..7ef5c5c9f 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -1,4 +1,11 @@ class MessageFinder + PAGE_LIMIT = 20 + + # `messages.content_attributes` is `json` but the model stores it as a + # double-encoded string (legacy `store coder: JSON`), so `->>` can't traverse + # it directly — `#>>'{}'` unwraps the outer encoding into proper jsonb. + NON_REACTION_CLAUSE = "((content_attributes#>>'{}')::jsonb->>'is_reaction') IS DISTINCT FROM 'true'".freeze + def initialize(conversation, params) @conversation = conversation @params = params @@ -37,7 +44,7 @@ class MessageFinder end def messages_before(before_id) - messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse + page_window(messages.where('id < ?', before_id)) end def messages_between(after_id, before_id) @@ -45,6 +52,32 @@ class MessageFinder end def messages_latest - messages.reorder('created_at desc').limit(20).reverse + page_window(messages) + end + + # Reactions don't count toward the page limit — otherwise a heavily-reacted + # message can flood the latest page and hide regular messages from the UI on + # initial load. Pick the most recent non-reactions, then add only the + # reactions whose target is inside that window so chips render alongside + # their parents and orphan reactions on older messages don't bloat the page. + def page_window(scope) + # Drop `includes(:sender, ...)` for the id-only probe to avoid Rails trying + # to eager-load the polymorphic sender association (which would error). + # `minimum(:id)` would silently aggregate over the FULL relation (Rails + # drops the limit), pulling in old messages and blowing up the page. Pluck + # the limited window first and take the min in Ruby. + bare = scope.except(:includes) + window_ids = bare.where(NON_REACTION_CLAUSE).reorder('created_at desc').limit(PAGE_LIMIT).pluck(:id) + return scope.none if window_ids.empty? + + json_path = "(content_attributes#>>'{}')::jsonb" + # `Message#ensure_in_reply_to` always populates content_attributes['in_reply_to'] + # when either the internal id or external source_id resolves to a parent in the + # same conversation, so a single jsonb path scopes reactions to the windowed + # parents reliably. + reaction_in_window = "((#{json_path}->>'is_reaction') = 'true' AND " \ + "(#{json_path}->>'in_reply_to')::bigint IN (:ids))" + scope.where("id IN (:ids) OR #{reaction_in_window}", ids: window_ids) + .reorder('created_at asc') end end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index d734a346e..3d6b559c8 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -57,39 +57,35 @@ module Api::V1::InboxesHelper end def check_smtp_connection(channel_data, smtp) + smtp.open_timeout = 10 smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], channel_data[:smtp_authentication]&.to_sym || :login) smtp.finish + rescue Net::SMTPAuthenticationError + raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error') + rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout + raise StandardError, I18n.t('errors.inboxes.smtp.connection_error') + rescue OpenSSL::SSL::SSLError + raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error') + rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError + raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error') + rescue StandardError => e + raise StandardError, e.message end def set_smtp_encryption(channel_data, smtp) if channel_data[:smtp_enable_ssl_tls] - set_enable_tls(channel_data, smtp) + set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode]) elsif channel_data[:smtp_enable_starttls_auto] - set_enable_starttls_auto(channel_data, smtp) + set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode]) end end - def set_enable_starttls_auto(channel_data, smtp) - return unless smtp.respond_to?(:enable_starttls_auto) + def set_smtp_ssl_method(smtp, method, openssl_verify_mode) + return unless smtp.respond_to?(method) - if channel_data[:smtp_openssl_verify_mode] - context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode]) - smtp.enable_starttls_auto(context) - else - smtp.enable_starttls_auto - end - end - - def set_enable_tls(channel_data, smtp) - return unless smtp.respond_to?(:enable_tls) - - if channel_data[:smtp_openssl_verify_mode] - context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode]) - smtp.enable_tls(context) - else - smtp.enable_tls - end + context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode + context ? smtp.send(method, context) : smtp.send(method) end def enable_openssl_mode(smtp_openssl_verify_mode) diff --git a/app/helpers/baileys_helper.rb b/app/helpers/baileys_helper.rb index 61fcf08b1..f3b5b1b76 100644 --- a/app/helpers/baileys_helper.rb +++ b/app/helpers/baileys_helper.rb @@ -1,6 +1,6 @@ module BaileysHelper CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%s'.freeze - CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 60.seconds + CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 130.seconds def baileys_extract_message_timestamp(timestamp) # NOTE: Timestamp might be in this format {"low"=>1748003165, "high"=>0, "unsigned"=>true} @@ -35,7 +35,7 @@ module BaileysHelper yield ensure - baileys_clear_channel_lock_on_outgoing_message(channel_id) + baileys_clear_channel_lock_on_outgoing_message(channel_id) if lock_acquired end private diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index fe03dae28..da74cdd88 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -47,11 +47,15 @@ module Filters::FilterHelper def handle_additional_attributes(query_hash, filter_operator_value, data_type) if data_type == 'text_case_insensitive' - "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ - "#{filter_operator_value} #{query_hash[:query_operator]}" + ActiveRecord::Base.sanitize_sql_array( + ["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}", + query_hash[:attribute_key]] + ) else - "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \ - "#{filter_operator_value} #{query_hash[:query_operator]} " + ActiveRecord::Base.sanitize_sql_array( + ["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ", + query_hash[:attribute_key]] + ) end end @@ -70,7 +74,7 @@ module Filters::FilterHelper def date_filter(current_filter, query_hash, filter_operator_value) "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ - "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}" + "#{filter_operator_value} #{query_hash[:query_operator]}" end def text_case_insensitive_filter(query_hash, filter_operator_value) @@ -100,6 +104,10 @@ module Filters::FilterHelper values.map { |x| Conversation.priorities[x.to_sym] } end + def conversation_group_type_values(values) + values.map { |x| Conversation.group_types[x.to_sym] } + end + def message_type_values(values) values.map { |x| Message.message_types[x.to_sym] } end diff --git a/app/helpers/timezone_helper.rb b/app/helpers/timezone_helper.rb index 7b88ceae6..9e1f99f14 100644 --- a/app/helpers/timezone_helper.rb +++ b/app/helpers/timezone_helper.rb @@ -1,4 +1,10 @@ module TimezoneHelper + def timezone_name_from_params(timezone, offset) + return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present? + + timezone_name_from_offset(offset) + end + # ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset] # would return the timezone without considering day light savings. To get the correct timezone, # this method uses zone.now.utc_offset for comparison as referenced in the issues below diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 967fa667d..48dc7e74c 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -100,7 +100,9 @@ export default { mql.onchange = e => setColorTheme(e.matches); }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, async initializeAccount() { await this.$store.dispatch('accounts/get'); @@ -167,10 +169,4 @@ export default { .v-popper--theme-tooltip .v-popper__arrow-container { display: none; } - -.multiselect__input { - margin-bottom: 0px !important; -} - - diff --git a/app/javascript/dashboard/api/agentBots.js b/app/javascript/dashboard/api/agentBots.js index de887f415..a16b252de 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient { resetAccessToken(botId) { return axios.post(`${this.url}/${botId}/reset_access_token`); } + + resetSecret(botId) { + return axios.post(`${this.url}/${botId}/reset_secret`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js index d0818d941..471c2846b 100644 --- a/app/javascript/dashboard/api/captain/customTools.js +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient { delete(id) { return axios.delete(`${this.url}/${id}`); } + + test(data = {}) { + return axios.post(`${this.url}/test`, { + custom_tool: data, + }); + } } export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 1e76ac987..bae5623a7 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -57,14 +57,14 @@ class ContactAPI extends ApiClient { return axios.post(`${this.url}/${contactId}/labels`, { labels }); } - search(search = '', page = 1, sortAttr = 'name', label = '') { + search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) { let requestURL = `${this.url}/search?${buildContactParams( page, sortAttr, label, search )}`; - return axios.get(requestURL); + return axios.get(requestURL, { signal: options.signal }); } active(page = 1, sortAttr = 'name') { diff --git a/app/javascript/dashboard/api/groupMembers.js b/app/javascript/dashboard/api/groupMembers.js new file mode 100644 index 000000000..224e95eb1 --- /dev/null +++ b/app/javascript/dashboard/api/groupMembers.js @@ -0,0 +1,71 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class GroupMembersAPI extends ApiClient { + constructor() { + super('contacts', { accountScoped: true }); + } + + getGroupMembers(contactId, page = 1) { + return axios.get(`${this.url}/${contactId}/group_members`, { + params: { page }, + }); + } + + syncGroup(contactId) { + return axios.post(`${this.url}/${contactId}/sync_group`); + } + + createGroup(params) { + return axios.post(`${this.baseUrl()}/groups`, params); + } + + updateGroupMetadata(contactId, params) { + return axios.patch(`${this.url}/${contactId}/group_metadata`, params); + } + + addMembers(contactId, participants) { + return axios.post(`${this.url}/${contactId}/group_members`, { + participants, + }); + } + + removeMembers(contactId, memberId) { + return axios.delete(`${this.url}/${contactId}/group_members/${memberId}`); + } + + updateMemberRole(contactId, memberId, role) { + return axios.patch(`${this.url}/${contactId}/group_members/${memberId}`, { + role, + }); + } + + getInviteLink(contactId) { + return axios.get(`${this.url}/${contactId}/group_invite`); + } + + revokeInviteLink(contactId) { + return axios.post(`${this.url}/${contactId}/group_invite/revoke`); + } + + getPendingRequests(contactId) { + return axios.get(`${this.url}/${contactId}/group_join_requests`); + } + + handleJoinRequest(contactId, params) { + return axios.post( + `${this.url}/${contactId}/group_join_requests/handle`, + params + ); + } + + leaveGroup(contactId) { + return axios.post(`${this.url}/${contactId}/group_admin/leave`); + } + + updateGroupProperty(contactId, params) { + return axios.patch(`${this.url}/${contactId}/group_admin`, params); + } +} + +export default new GroupMembersAPI(); diff --git a/app/javascript/dashboard/api/helpCenter/categories.js b/app/javascript/dashboard/api/helpCenter/categories.js index 01658497e..eda54aadb 100644 --- a/app/javascript/dashboard/api/helpCenter/categories.js +++ b/app/javascript/dashboard/api/helpCenter/categories.js @@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI { delete({ portalSlug, categoryId }) { return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`); } + + reorder({ portalSlug, reorderedGroup }) { + return axios.post(`${this.url}/${portalSlug}/categories/reorder`, { + positions_hash: reorderedGroup, + }); + } } export default new CategoriesAPI(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index f94fca452..acbebf9eb 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -16,6 +16,7 @@ class ConversationApi extends ApiClient { conversationType, sortBy, updatedWithin, + groupType, }) { return axios.get(this.url, { params: { @@ -28,6 +29,7 @@ class ConversationApi extends ApiClient { conversation_type: conversationType, sort_by: sortBy, updated_within: updatedWithin, + group_type: groupType, }, }); } @@ -88,6 +90,16 @@ class ConversationApi extends ApiClient { }); } + presenceSubscribe(conversationId) { + return axios.post(`${this.url}/${conversationId}/presence_subscribe`); + } + + presenceSubscribeBulk(conversationIds) { + return axios.post(`${this.url}/presence_subscribe_bulk`, { + conversation_ids: conversationIds, + }); + } + mute(conversationId) { return axios.post(`${this.url}/${conversationId}/mute`); } diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 99adc0302..f1ea0bef3 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -125,6 +125,13 @@ class MessageApi extends ApiClient { } ); } + + toggleReaction(conversationId, messageId, emoji, echoId) { + return axios.post( + `${this.url}/${conversationId}/messages/${messageId}/reactions`, + { emoji, echo_id: echoId } + ); + } } export default new MessageApi(); diff --git a/app/javascript/dashboard/api/inboxHealth.js b/app/javascript/dashboard/api/inboxHealth.js index 181b041ba..b8f69fcfe 100644 --- a/app/javascript/dashboard/api/inboxHealth.js +++ b/app/javascript/dashboard/api/inboxHealth.js @@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient { getHealthStatus(inboxId) { return axios.get(`${this.url}/${inboxId}/health`); } + + registerWebhook(inboxId) { + return axios.post(`${this.url}/${inboxId}/register_webhook`); + } } export default new InboxHealthAPI(); diff --git a/app/javascript/dashboard/api/inboxSignatures.js b/app/javascript/dashboard/api/inboxSignatures.js new file mode 100644 index 000000000..6f915b1e7 --- /dev/null +++ b/app/javascript/dashboard/api/inboxSignatures.js @@ -0,0 +1,25 @@ +/* global axios */ + +const API_BASE = '/api/v1/profile/inbox_signatures'; + +export default { + getAll(accountId) { + return axios.get(API_BASE, { + params: { account_id: accountId }, + }); + }, + + get(inboxId) { + return axios.get(`${API_BASE}/${inboxId}`); + }, + + upsert(inboxId, params) { + return axios.put(`${API_BASE}/${inboxId}`, { + inbox_signature: params, + }); + }, + + delete(inboxId) { + return axios.delete(`${API_BASE}/${inboxId}`); + }, +}; diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index ef0ec7bf4..42211f58b 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -43,6 +43,16 @@ class Inboxes extends CacheEnabledApiClient { return axios.get(`${this.url}/${inboxId}/csat_template`); } + analyzeCSATTemplateUtility(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, { + template, + }); + } + + resetSecret(inboxId) { + return axios.post(`${this.url}/${inboxId}/reset_secret`); + } + linkCSATTemplate(inboxId, template) { return axios.post(`${this.url}/${inboxId}/csat_template/link`, { template, @@ -62,6 +72,13 @@ class Inboxes extends CacheEnabledApiClient { disconnectChannelProvider(inboxId) { return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`); } + + convertProvider(inboxId, { provider, providerConfig }) { + return axios.post(`${this.url}/${inboxId}/convert_provider`, { + provider, + provider_config: providerConfig, + }); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/internalChatChannels.js b/app/javascript/dashboard/api/internalChatChannels.js new file mode 100644 index 000000000..5855a4bf2 --- /dev/null +++ b/app/javascript/dashboard/api/internalChatChannels.js @@ -0,0 +1,72 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatChannelsAPI extends ApiClient { + constructor() { + super('internal_chat/channels', { accountScoped: true }); + } + + getWithParams(params) { + return axios.get(this.url, { params }); + } + + getCategories() { + return axios.get(`${this.url.replace('/channels', '/categories')}`); + } + + createCategory(data) { + return axios.post(`${this.url.replace('/channels', '/categories')}`, data); + } + + deleteCategory(categoryId) { + return axios.delete( + `${this.url.replace('/channels', '/categories')}/${categoryId}` + ); + } + + archive(channelId) { + return axios.post(`${this.url}/${channelId}/archive`); + } + + unarchive(channelId) { + return axios.post(`${this.url}/${channelId}/unarchive`); + } + + getMembers(channelId) { + return axios.get(`${this.url}/${channelId}/members`); + } + + addMember(channelId, userId) { + return axios.post(`${this.url}/${channelId}/members`, { user_id: userId }); + } + + removeMember(channelId, memberId) { + return axios.delete(`${this.url}/${channelId}/members/${memberId}`); + } + + updateMember(channelId, memberId, data) { + return axios.patch(`${this.url}/${channelId}/members/${memberId}`, data); + } + + toggleTypingStatus(channelId, typingStatus) { + return axios.post(`${this.url}/${channelId}/toggle_typing_status`, { + typing_status: typingStatus, + }); + } + + markRead(channelId) { + return axios.post(`${this.url}/${channelId}/mark_read`); + } + + markUnread(channelId, messageId) { + return axios.post(`${this.url}/${channelId}/mark_unread`, { + message_id: messageId, + }); + } + + search(params) { + return axios.get(`${this.url.replace('/channels', '/search')}`, { params }); + } +} + +export default new InternalChatChannelsAPI(); diff --git a/app/javascript/dashboard/api/internalChatDrafts.js b/app/javascript/dashboard/api/internalChatDrafts.js new file mode 100644 index 000000000..4eaffeb35 --- /dev/null +++ b/app/javascript/dashboard/api/internalChatDrafts.js @@ -0,0 +1,24 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatDraftsAPI extends ApiClient { + constructor() { + super('internal_chat', { accountScoped: true }); + } + + getDrafts() { + return axios.get(`${this.url}/drafts`); + } + + saveDraft(channelId, data) { + return axios.patch(`${this.url}/channels/${channelId}/draft`, data); + } + + deleteDraft(channelId, { parentId } = {}) { + return axios.delete(`${this.url}/channels/${channelId}/draft`, { + params: { parent_id: parentId }, + }); + } +} + +export default new InternalChatDraftsAPI(); diff --git a/app/javascript/dashboard/api/internalChatMessages.js b/app/javascript/dashboard/api/internalChatMessages.js new file mode 100644 index 000000000..efa32bc04 --- /dev/null +++ b/app/javascript/dashboard/api/internalChatMessages.js @@ -0,0 +1,62 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatMessagesAPI extends ApiClient { + constructor() { + super('internal_chat/channels', { accountScoped: true }); + } + + getMessages(channelId, params = {}) { + return axios.get(`${this.url}/${channelId}/messages`, { params }); + } + + createMessage(channelId, data, files = []) { + if (files.length === 0) { + return axios.post(`${this.url}/${channelId}/messages`, data); + } + const formData = new FormData(); + if (data.content) formData.append('content', data.content); + if (data.parent_id) formData.append('parent_id', data.parent_id); + if (data.echo_id) formData.append('echo_id', data.echo_id); + files.forEach(file => { + formData.append('attachments[][file]', file); + }); + return axios.post(`${this.url}/${channelId}/messages`, formData); + } + + updateMessage(channelId, messageId, data) { + return axios.patch(`${this.url}/${channelId}/messages/${messageId}`, data); + } + + deleteMessage(channelId, messageId) { + return axios.delete(`${this.url}/${channelId}/messages/${messageId}`); + } + + getThread(channelId, messageId) { + return axios.get(`${this.url}/${channelId}/messages/${messageId}/thread`); + } + + pinMessage(channelId, messageId) { + return axios.post(`${this.url}/${channelId}/messages/${messageId}/pin`); + } + + unpinMessage(channelId, messageId) { + return axios.delete(`${this.url}/${channelId}/messages/${messageId}/unpin`); + } + + addReaction(messageId, emoji) { + const baseUrl = this.url.replace('/channels', ''); + return axios.post(`${baseUrl}/messages/${messageId}/reactions`, { + emoji, + }); + } + + removeReaction(messageId, reactionId) { + const baseUrl = this.url.replace('/channels', ''); + return axios.delete( + `${baseUrl}/messages/${messageId}/reactions/${reactionId}` + ); + } +} + +export default new InternalChatMessagesAPI(); diff --git a/app/javascript/dashboard/api/internalChatPolls.js b/app/javascript/dashboard/api/internalChatPolls.js new file mode 100644 index 000000000..14734cbdc --- /dev/null +++ b/app/javascript/dashboard/api/internalChatPolls.js @@ -0,0 +1,24 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatPollsAPI extends ApiClient { + constructor() { + super('internal_chat/polls', { accountScoped: true }); + } + + createPoll(data) { + return axios.post(this.url, data); + } + + vote(pollId, optionId) { + return axios.post(`${this.url}/${pollId}/vote`, { option_id: optionId }); + } + + unvote(pollId, optionId) { + return axios.delete(`${this.url}/${pollId}/vote`, { + params: { option_id: optionId }, + }); + } +} + +export default new InternalChatPollsAPI(); diff --git a/app/javascript/dashboard/api/recurringScheduledMessages.js b/app/javascript/dashboard/api/recurringScheduledMessages.js new file mode 100644 index 000000000..c0af4b82d --- /dev/null +++ b/app/javascript/dashboard/api/recurringScheduledMessages.js @@ -0,0 +1,81 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildRecurringScheduledMessagePayload = ({ + content, + status, + scheduledAt, + templateParams, + attachment, + removeAttachment, + recurrenceRule, +} = {}) => { + if (!attachment) { + return { + content, + status, + scheduled_at: scheduledAt, + template_params: templateParams, + remove_attachment: removeAttachment || undefined, + recurrence_rule: recurrenceRule, + }; + } + + const payload = new FormData(); + if (content) payload.append('content', content); + if (scheduledAt) payload.append('scheduled_at', scheduledAt); + if (status) payload.append('status', status); + payload.append('attachment', attachment); + if (templateParams) { + payload.append('template_params', JSON.stringify(templateParams)); + } + if (recurrenceRule) { + Object.entries(recurrenceRule).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => + payload.append(`recurrence_rule[${key}][]`, String(v)) + ); + } else { + payload.append(`recurrence_rule[${key}]`, String(value)); + } + }); + } + + return payload; +}; + +class RecurringScheduledMessagesAPI extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + get(conversationId) { + return axios.get( + `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages` + ); + } + + create(conversationId, payload) { + return axios({ + method: 'post', + url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`, + data: buildRecurringScheduledMessagePayload(payload), + }); + } + + update(conversationId, recurringScheduledMessageId, payload) { + return axios({ + method: 'patch', + url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`, + data: buildRecurringScheduledMessagePayload(payload), + }); + } + + delete(conversationId, recurringScheduledMessageId) { + return axios.delete( + `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}` + ); + } +} + +export default new RecurringScheduledMessagesAPI(); diff --git a/app/javascript/dashboard/api/scheduledMessages.js b/app/javascript/dashboard/api/scheduledMessages.js index 052cb4f42..71465b51c 100644 --- a/app/javascript/dashboard/api/scheduledMessages.js +++ b/app/javascript/dashboard/api/scheduledMessages.js @@ -7,6 +7,7 @@ export const buildScheduledMessagePayload = ({ scheduledAt, templateParams, attachment, + removeAttachment, } = {}) => { if (!attachment) { return { @@ -14,6 +15,7 @@ export const buildScheduledMessagePayload = ({ status, scheduled_at: scheduledAt, template_params: templateParams, + remove_attachment: removeAttachment || undefined, }; } diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 0059518b0..b21aeb102 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -68,7 +68,19 @@ describe('#ContactsAPI', () => { it('#search', () => { contactAPI.search('leads', 1, 'date', 'customer-support'); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: undefined } + ); + }); + + it('#search with signal', () => { + const controller = new AbortController(); + contactAPI.search('leads', 1, 'date', 'customer-support', { + signal: controller.signal, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: controller.signal } ); }); diff --git a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js index 2c56f4e00..febf6f7a1 100644 --- a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js +++ b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js @@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => { expect(categoriesAPI).toHaveProperty('create'); expect(categoriesAPI).toHaveProperty('update'); expect(categoriesAPI).toHaveProperty('delete'); + expect(categoriesAPI).toHaveProperty('reorder'); }); }); diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss index 14ae2a9d4..108da3889 100644 --- a/app/javascript/dashboard/assets/scss/_base.scss +++ b/app/javascript/dashboard/assets/scss/_base.scss @@ -66,7 +66,7 @@ textarea { // Field base styles (Input, TextArea, Select) @layer components { .field-base { - @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; + @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; } .field-disabled { @@ -78,7 +78,7 @@ textarea { } } -$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)"; +$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)"; #{$form-input-selector} { @apply field-base h-10; @@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not } } -input[type='file']:not(.multiselect__input) { +input[type='file'] { @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm; } @@ -106,6 +106,10 @@ select { &[disabled] { @apply field-disabled; } + + option:not(:disabled) { + @apply bg-n-solid-2 text-n-slate-12; + } } // Textarea @@ -126,13 +130,6 @@ label:has(.help-text) { } } -// Error handling -.has-multi-select-error { - div.multiselect { - @apply mb-1; - } -} - // FormKit support .formkit-outer[data-invalid='true'] { #{$form-input-selector}, @@ -150,9 +147,7 @@ label:has(.help-text) { #{$form-input-selector}, input:not([type]), textarea, - select, - .multiselect > .multiselect__tags, - .multiselect:not(.no-margin) { + select { @apply field-error; } diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 40ca7b436..27764d150 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -13,7 +13,6 @@ @import 'base'; // Plugins -@import 'plugins/multiselect'; @import 'plugins/date-picker'; html, @@ -66,4 +65,84 @@ body { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } + + /** + * ============================================================================ + * TYPOGRAPHY UTILITIES + * ============================================================================ + * + * | Class | Use Case | + * |--------------------|----------------------------------------------------| + * | .text-body-main |

, , general body text | + * | .text-body-para |

for paragraphs, larger text blocks | + * | .text-heading-1 |

, page titles, panel headers | + * | .text-heading-2 |

, section headings, card titles | + * | .text-heading-3 |

, card headings, breadcrumbs, subsections | + * | .text-label |