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/.env.example b/.env.example index bc7380a29..ca4faf88b 100644 --- a/.env.example +++ b/.env.example @@ -137,6 +137,17 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= +# S3-compatible storage (e.g., Cloudflare R2, MinIO, DigitalOcean Spaces) +# Set ACTIVE_STORAGE_SERVICE=s3_compatible to use this +# STORAGE_ACCESS_KEY_ID= +# STORAGE_SECRET_ACCESS_KEY= +# STORAGE_REGION= +# STORAGE_BUCKET_NAME= +# STORAGE_ENDPOINT= +# STORAGE_FORCE_PATH_STYLE=true +# STORAGE_REQUEST_CHECKSUM_CALCULATION=when_required +# STORAGE_RESPONSE_CHECKSUM_VALIDATION=when_required + # Log settings # Disable if you want to write logs to a file RAILS_LOG_TO_STDOUT=true @@ -277,3 +288,12 @@ AZURE_APP_SECRET= # REDIS_ALFRED_SIZE=10 # REDIS_VELMA_SIZE=10 + +# Baileys API Whatsapp provider +BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot +BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025 +BAILEYS_PROVIDER_DEFAULT_API_KEY= +# Enable WhatsApp group conversations for Baileys provider (default: false) +BAILEYS_WHATSAPP_GROUPS_ENABLED=false + +RESEND_API_KEY= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..5da1bca2e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,8 @@ +# GitHub Copilot Instructions + +- Always include pt-BR translations for any new text added to the project. +- fazer.ai is always styled as-is, with a dot and lowercase letters. Never use Fazer.ai +- Always check if adding specs is necessary when modifying code. +- Evaluate if specs added are actually needed and not redundant. Specs should not be for documentation purposes only, they should cover expected behavior. +- Always evaluate if frontend changes are needed when modifying backend code, and vice versa. +- NEVER use `--` in `pnpm test -- `. Just do `pnpm test ` directly diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 1d1116d0c..a7b19cd7a 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -2,11 +2,9 @@ name: Frontend Lint & Test on: push: - branches: - - develop - pull_request: - branches: - - develop + tags: + - '*' + workflow_dispatch: jobs: test: diff --git a/.github/workflows/publish_ee_github_docker.yml b/.github/workflows/publish_ee_github_docker.yml new file mode 100644 index 000000000..4bf5d9fce --- /dev/null +++ b/.github/workflows/publish_ee_github_docker.yml @@ -0,0 +1,138 @@ +name: Publish Chatwoot Enterprise docker images to GitHub + +permissions: + contents: read + packages: write + +on: + release: + types: [released] + workflow_dispatch: + +env: + GITHUB_REPO: ghcr.io/${{ github.repository }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile + + - name: Update version in app.yml + run: | + if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "Updating version to: $VERSION" + sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml + else + echo "No version tag found, keeping existing version" + fi + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GitHub Container Registry + id: build-ghcr + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} + tags: | + ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-ee + ${{ env.GITHUB_REPO }}:latest-ee + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build-ghcr.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:${SANITIZED_REF}-ee \ + -t ghcr.io/${{ github.repository }}:latest-ee \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + REPO="ghcr.io/${{ github.repository }}" + docker buildx imagetools inspect ${REPO}:${SANITIZED_REF}-ee + docker buildx imagetools inspect ${REPO}:latest-ee diff --git a/.github/workflows/publish_github_docker.yml b/.github/workflows/publish_github_docker.yml new file mode 100644 index 000000000..2aeaf6366 --- /dev/null +++ b/.github/workflows/publish_github_docker.yml @@ -0,0 +1,139 @@ +name: Publish Chatwoot docker images to GitHub + +permissions: + contents: read + packages: write + +on: + release: + types: [released] + workflow_dispatch: + +env: + GITHUB_REPO: ghcr.io/${{ github.repository }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Update version in app.yml + run: | + if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "Updating version to: $VERSION" + sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml + else + echo "No version tag found, keeping existing version" + fi + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GitHub Container Registry + id: build-ghcr + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} + tags: | + ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }} + ${{ env.GITHUB_REPO }}:latest + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build-ghcr.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \ + -t ghcr.io/${{ github.repository }}:latest \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + REPO="ghcr.io/${{ github.repository }}" + docker buildx imagetools inspect ${REPO}:${SANITIZED_REF} + docker buildx imagetools inspect ${REPO}:latest diff --git a/.github/workflows/publish_github_docker_beta.yml b/.github/workflows/publish_github_docker_beta.yml new file mode 100644 index 000000000..7bc537ed2 --- /dev/null +++ b/.github/workflows/publish_github_docker_beta.yml @@ -0,0 +1,139 @@ +name: Publish Chatwoot beta docker images to GitHub + +permissions: + contents: read + packages: write + +on: + release: + types: [prereleased] + workflow_dispatch: + +env: + GITHUB_REPO: ghcr.io/${{ github.repository }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Update version in app.yml + run: | + if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "Updating version to: $VERSION" + sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml + else + echo "No version tag found, keeping existing version" + fi + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GitHub Container Registry + id: build-ghcr + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} + tags: | + ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }} + ${{ env.GITHUB_REPO }}:beta + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build-ghcr.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \ + -t ghcr.io/${{ github.repository }}:beta \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + REPO="ghcr.io/${{ github.repository }}" + docker buildx imagetools inspect ${REPO}:${SANITIZED_REF} + docker buildx imagetools inspect ${REPO}:beta diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index c2a626388..3d42496de 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -3,30 +3,28 @@ permissions: contents: read on: push: - branches: - - develop - - master - pull_request: + tags: + - '*' workflow_dispatch: 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' @@ -37,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' @@ -52,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: @@ -88,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 017c5c224..e3b18e413 100644 --- a/.gitignore +++ b/.gitignore @@ -92,10 +92,9 @@ yarn-debug.log* # TextEditors & AI Agents config files .vscode -.claude/settings.local.json +.claude/**/*.local.* .cursor .codex/ -.claude/ CLAUDE.local.md # Histoire deployment diff --git a/.husky/pre-commit b/.husky/pre-commit index b3aceacd6..9c350db88 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,8 +4,8 @@ # lint js and vue files npx --no-install lint-staged -# lint only staged ruby files that still exist (not deleted) -git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true +# lint only staged ruby files +git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion # stage rubocop changes to files -git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true +# git diff --name-only --cached | xargs git add diff --git a/.rubocop.yml b/.rubocop.yml index d87f08bfd..246f503bb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -218,6 +218,7 @@ Style/OneClassPerFile: AllCops: NewCops: enable + SuggestExtensions: false Exclude: - 'bin/**/*' - 'db/schema.rb' @@ -348,3 +349,12 @@ FactoryBot/RedundantFactoryOption: FactoryBot/FactoryAssociationWithStrategy: Enabled: false + +Rails/SaveBang: + Enabled: true + AllowedReceivers: + - Stripe::Subscription + - Stripe::Customer + - Stripe::Invoice + - Stripe::InvoiceItem + - FactoryBot diff --git a/.vscode/settings.json b/.vscode/settings.json index b3cfaee50..b663561bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "cSpell.words": [ "chatwoot", "dompurify" - ] + ], + "css.customData": [".vscode/tailwind.json"] } diff --git a/.vscode/tailwind.json b/.vscode/tailwind.json new file mode 100644 index 000000000..e47372fec --- /dev/null +++ b/.vscode/tailwind.json @@ -0,0 +1,55 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@tailwind", + "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" + } + ] + }, + { + "name": "@apply", + "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#apply" + } + ] + }, + { + "name": "@responsive", + "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" + } + ] + }, + { + "name": "@screen", + "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#screen" + } + ] + }, + { + "name": "@variants", + "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#variants" + } + ] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 2ab6373b7..97a940410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,7 @@ - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Run Project**: `overmind start -f Procfile.dev` -- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) -- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used +- **Ruby Version**: Manage Ruby via `rvm` - Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) ## Code Style @@ -62,6 +61,11 @@ - The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions. - Start each worktree with its own Overmind socket/title so multiple instances can run at the same time. +## Release Notes + +- Every GitHub release cut from this repo must include the bilingual `user-notes` blocks (pt-BR + en) in the release body, written for non-technical end users. +- Before running `gh release create`, `gh release edit`, the `release` skill from `fazer-ai-tools`, or any flow that touches a release body (including retroactive backfills), invoke the `release-notes` skill at `.claude/skills/release-notes/SKILL.md` to draft and validate the blocks. + ## Commit Messages - Prefer Conventional Commits: `type(scope): subject` (scope optional) @@ -80,9 +84,9 @@ ## Project-Specific - **Translations**: - - Only update `en.yml` and `en.json` + - Update `en.yml`/`en.json` and `pt_BR.yml`/`pt_BR.json` - Other languages are handled by the community - - Backend i18n → `en.yml`, Frontend i18n → `en.json` + - Backend i18n → `.yml`, Frontend i18n → `.json` - **Frontend**: - Use `components-next/` for message bubbles (the rest is being deprecated) diff --git a/CUSTOM_BRANDING.md b/CUSTOM_BRANDING.md new file mode 100644 index 000000000..2359bf342 --- /dev/null +++ b/CUSTOM_BRANDING.md @@ -0,0 +1,73 @@ +# Custom branding + +## Brand configuration + +Export environment variables and run rake task with `bundle exec rails branding:update`. + +> [!IMPORTANT] +> Unset environment variables are reset to default values. + +```bash +INSTALLATION_NAME="Chatwoot fazer.ai" \ +BRAND_NAME="My Company" \ +LOGO_THUMBNAIL="https://fazer.ai/logo-thumbnail.svg" \ +LOGO="https://fazer.ai/logo.svg" \ +bundle exec rails branding:update +``` + +| Environment variable | Default Value | Description | +| :--------------------| :------------------------------------------ | :-------------------------------------------------------------------- | +| `INSTALLATION_NAME` | `Chatwoot` | The installation-wide name used in the dashboard, title, etc. | +| `LOGO_THUMBNAIL` | `/brand-assets/logo_thumbnail.svg` | The thumbnail used for favicon (512px X 512px). | +| `LOGO` | `/brand-assets/logo.svg` | The logo used on the dashboard, login page, etc. | +| `LOGO_DARK` | `/brand-assets/logo_dark.svg` | The logo used on the dashboard, login page, etc. for dark mode. | +| `BRAND_URL` | `https://www.chatwoot.com` | The URL used in emails under the section “Powered By”. | +| `WIDGET_BRAND_URL` | `https://www.chatwoot.com` | The URL used in the widget under the section “Powered By”. | +| `BRAND_NAME` | `Chatwoot` | The name used in emails and the widget. | +| `TERMS_URL` | `https://www.chatwoot.com/terms-of-service` | The terms of service URL displayed on the Signup Page. | +| `PRIVACY_URL` | `https://www.chatwoot.com/privacy-policy` | The privacy policy URL displayed in the app. | +| `DISPLAY_MANIFEST` | `true` | Display default Chatwoot metadata like favicons and upgrade warnings. | + +## Favicon and other assets + +Update the favicon files in the [`public/`](public/) folder. + +Can also be done by creating a zip file with relevant files, and running [`deployment/extract_brand_assets.sh`](deployment/extract_brand_assets.sh) to override the existing favicons with your own. +In this case, the zip file should be a flat archive containing the following files: + +``` +android-icon-36x36.png +android-icon-48x48.png +android-icon-72x72.png +android-icon-96x96.png +android-icon-144x144.png +android-icon-192x192.png +apple-icon-57x57.png +apple-icon-60x60.png +apple-icon-72x72.png +apple-icon-76x76.png +apple-icon-114x114.png +apple-icon-120x120.png +apple-icon-144x144.png +apple-icon-152x152.png +apple-icon-180x180.png +apple-icon.png +apple-icon-precomposed.png +apple-touch-icon.png +apple-touch-icon-precomposed.png +favicon-16x16.png +favicon-32x32.png +favicon-96x96.png +favicon-512x512.png +favicon-badge-16x16.png +favicon-badge-32x32.png +favicon-badge-96x96.png +ms-icon-70x70.png +ms-icon-144x144.png +ms-icon-150x150.png +ms-icon-310x310.png +``` + +> [!NOTE] +> You can include other assets in the zip file, and use them when running the rake task for `LOGO_THUMBNAIL`, `LOGO`, and `LOGO_DARK`. +> See [Brand configuration](#brand-configuration). diff --git a/Gemfile b/Gemfile index a5068e765..69d6f6ed2 100644 --- a/Gemfile +++ b/Gemfile @@ -45,7 +45,7 @@ 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 -gem 'net-smtp', '~> 0.3.4' +gem 'net-smtp', '~> 0.3.4' # Prevent CSV injection gem 'csv-safe' @@ -56,6 +56,7 @@ gem 'aws-sdk-s3', require: false gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'image_processing' +gem 'streamio-ffmpeg', '~> 3.0' ##-- for actionmailbox --## gem 'aws-actionmailbox-ses', '~> 0' @@ -207,6 +208,8 @@ gem 'opentelemetry-exporter-otlp' gem 'shopify_api' +gem 'resend', '~> 0.19.0' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index b77e5880f..b2f1f4007 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -755,6 +755,8 @@ GEM uber (< 0.2.0) request_store (1.5.1) rack (>= 1.4) + resend (0.19.0) + httparty (>= 0.21.0) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) @@ -945,6 +947,8 @@ GEM ssrf_filter (1.5.0) stackprof (0.2.25) statsd-ruby (1.5.0) + streamio-ffmpeg (3.0.2) + multi_json (~> 1.8) stripe (18.0.1) telephone_number (1.4.20) test-prof (1.2.1) @@ -1127,6 +1131,7 @@ DEPENDENCIES rails (~> 7.1) redis redis-namespace + resend (~> 0.19.0) responders (>= 3.1.1) rest-client reverse_markdown @@ -1161,6 +1166,7 @@ DEPENDENCIES squasher ssrf_filter (~> 1.5) stackprof + streamio-ffmpeg (~> 3.0) stripe (~> 18.0) telephone_number test-prof diff --git a/Rakefile b/Rakefile index 2e996417e..99dd20aa6 100644 --- a/Rakefile +++ b/Rakefile @@ -7,3 +7,12 @@ 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 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index 9afa5d019..9109f1048 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -70,7 +70,7 @@ class ContactIdentifyAction end def merge_contacts?(existing_contact, key) - return if existing_contact.blank? + return false if existing_contact.blank? return true if params[:identifier].blank? 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/conversation_builder.rb b/app/builders/conversation_builder.rb index 6a995b188..b9d9b8148 100644 --- a/app/builders/conversation_builder.rb +++ b/app/builders/conversation_builder.rb @@ -10,7 +10,7 @@ class ConversationBuilder def look_up_exising_conversation return unless @contact_inbox.inbox.lock_to_single_conversation? - @contact_inbox.conversations.last + @contact_inbox.inbox.conversations.where(contact_id: @contact_inbox.contact_id).last end def create_new_conversation diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 8045e84c9..09176f499 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -183,7 +183,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil end def all_unsupported_files? - return if attachments.empty? + return false if attachments.empty? attachments_type = attachments.pluck(:type).uniq.first unsupported_file_type?(attachments_type) diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 4e7150894..e306c7b70 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -30,7 +30,7 @@ class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuil # https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 if error_code == 1_609_005 @message.attachments.destroy_all - @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) + @message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content')) end Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}") diff --git a/app/builders/messages/instagram/messenger/message_builder.rb b/app/builders/messages/instagram/messenger/message_builder.rb index 1263dee90..41ba59f2c 100644 --- a/app/builders/messages/instagram/messenger/message_builder.rb +++ b/app/builders/messages/instagram/messenger/message_builder.rb @@ -14,7 +14,7 @@ class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::Base rescue Koala::Facebook::ClientError => e # The exception occurs when we are trying fetch the deleted story or blocked story. @message.attachments.destroy_all - @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) + @message.update!(content: I18n.t('conversations.messages.instagram_deleted_story_content')) Rails.logger.error e {} rescue StandardError => e diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 7df72e14a..91d4df118 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -1,11 +1,11 @@ -class Messages::MessageBuilder +class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength include ::FileTypeHelper include ::EmailHelper include ::DataHelper attr_reader :message - def initialize(user, conversation, params) + def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity @params = params @private = params[:private] || false @conversation = conversation @@ -13,11 +13,16 @@ class Messages::MessageBuilder @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] + @is_recorded_audio = params[:is_recorded_audio] + @transcode_audio = params[:transcode_audio] + @attachments_metadata = normalize_attachments_metadata(params[:attachments_metadata]) @automation_rule = content_attributes&.dig(:automation_rule_id) return unless params.instance_of?(ActionController::Parameters) @in_reply_to = content_attributes&.dig(:in_reply_to) + @is_reaction = content_attributes&.dig(:is_reaction) @items = content_attributes&.dig(:items) + @zapi_args = content_attributes&.dig(:zapi_args) end def perform @@ -55,7 +60,7 @@ class Messages::MessageBuilder account_id: @message.account_id, file: uploaded_attachment ) - + attachment.meta = process_metadata(uploaded_attachment) attachment.file_type = if uploaded_attachment.is_a?(String) file_type_by_signed_id( uploaded_attachment @@ -63,9 +68,71 @@ class Messages::MessageBuilder else file_type(uploaded_attachment&.content_type) end + transcode_attachment(attachment, file_like_source(uploaded_attachment)) if should_transcode?(attachment) end end + def process_metadata(attachment) + meta = {} + meta.merge!(recorded_audio_metadata(attachment) || {}) + meta.merge!(custom_attachment_metadata(attachment) || {}) + meta.presence + end + + def recorded_audio_metadata(attachment) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity + # NOTE: `is_recorded_audio` can be either a boolean, the string "true", or an array of file names. + return unless @is_recorded_audio + return { is_recorded_audio: true } if @is_recorded_audio == true || @is_recorded_audio == 'true' + + return { is_recorded_audio: true } if @is_recorded_audio.is_a?(Array) && attachment.original_filename.in?(@is_recorded_audio) + + # FIXME: Remove backwards compatibility with old format. + if @is_recorded_audio.is_a?(String) + parsed = JSON.parse(@is_recorded_audio) + { is_recorded_audio: true } if parsed.is_a?(Array) && attachment.original_filename.in?(parsed) + end + rescue JSON::ParserError + nil + end + + def custom_attachment_metadata(attachment) + return unless @attachments_metadata.is_a?(Hash) + + filename = attachment.respond_to?(:original_filename) ? attachment.original_filename : nil + return unless filename + + metadata = @attachments_metadata[filename] + metadata.to_h if metadata.present? + end + + def normalize_attachments_metadata(metadata) + return if metadata.blank? + + metadata = metadata.to_unsafe_h if metadata.respond_to?(:to_unsafe_h) + metadata.deep_stringify_keys + end + + def should_transcode?(attachment) + @transcode_audio.present? && attachment.file_type == 'audio' + end + + # Returns the uploaded file only when it's a real file-like object (ActionDispatch::Http::UploadedFile, + # Tempfile, etc.). Direct-upload signed-ID Strings are not usable as source files for transcoding; + # TranscodeService falls back to downloading from the blob in that case. + def file_like_source(uploaded_attachment) + return uploaded_attachment if uploaded_attachment.respond_to?(:path) || uploaded_attachment.respond_to?(:tempfile) + end + + def transcode_attachment(attachment, uploaded_file = nil) + Audio::TranscodeService.new(attachment, @transcode_audio, source_file: uploaded_file).perform + attachment.meta ||= {} + attachment.meta['is_recorded_audio'] = true + rescue CustomExceptions::Audio::UnsupportedFormatError, CustomExceptions::Audio::TranscodingError => e + Rails.logger.error("Audio transcoding failed, keeping original attachment: #{e.message}") + attachment.meta ||= {} + attachment.meta['audio_transcoding_failed'] = true + end + def process_emails return unless @conversation.inbox&.inbox_type == 'Email' @@ -123,12 +190,32 @@ class Messages::MessageBuilder @params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {} end + def scheduled_message_metadata + return {} if @params[:scheduled_message].blank? + + sm = @params[:scheduled_message] + scheduled_by = { 'id' => sm.author_id, 'type' => sm.author_type } + scheduled_by['name'] = sm.author.name if sm.author.respond_to?(:name) + + { + additional_attributes: { + scheduled_message_id: sm.id, + scheduled_by: scheduled_by, + scheduled_at: sm.updated_at.to_i + } + } + end + def message_sender return if @params[:sender_type] != 'AgentBot' AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id]) end + def zapi_args + @zapi_args.present? ? { zapi_args: @zapi_args } : {} + end + def message_params { account_id: @conversation.account_id, @@ -141,9 +228,11 @@ class Messages::MessageBuilder content_attributes: content_attributes.presence, items: @items, in_reply_to: @in_reply_to, + is_reaction: @is_reaction, echo_id: @params[:echo_id], source_id: @params[:source_id] - }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) + }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id) + .deep_merge(template_params).merge(zapi_args).deep_merge(scheduled_message_metadata) end def email_inbox? diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 90cdf2418..69a7f31da 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -46,7 +46,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController return if response['instagram_business_account'].blank? instagram_id = response['instagram_business_account']['id'] - facebook_channel.update(instagram_id: instagram_id) + facebook_channel.update!(instagram_id: instagram_id) rescue StandardError => e Rails.logger.error "Error in set_instagram_id: #{e.message}" 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 dd5346bd6..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)) diff --git a/app/controllers/api/v1/accounts/conversations/attachments_controller.rb b/app/controllers/api/v1/accounts/conversations/attachments_controller.rb new file mode 100644 index 000000000..0c2dc9977 --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/attachments_controller.rb @@ -0,0 +1,34 @@ +class Api::V1::Accounts::Conversations::AttachmentsController < Api::V1::Accounts::Conversations::BaseController + before_action :set_message + before_action :set_attachment + before_action :validate_meta_size, only: [:update] + + MAX_META_SIZE = 16.kilobytes + + def update + @attachment.update!(permitted_params) + @attachment.message.send_update_event + end + + private + + def set_message + @message = @conversation.messages.find(params[:message_id]) + end + + def set_attachment + @attachment = @message.attachments.find(params[:id]) + end + + def permitted_params + params.permit(meta: {}) + end + + def validate_meta_size + return if params[:meta].blank? + + return unless params[:meta].to_json.bytesize > MAX_META_SIZE + + render json: { error: "Metadata size exceeds maximum allowed (#{MAX_META_SIZE / 1024}KB)" }, status: :unprocessable_entity + end +end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a715..1675e4b43 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + before_action :ensure_api_inbox, only: :update def index @@ -9,6 +11,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: user = Current.user || @resource mb = Messages::MessageBuilder.new(user, @conversation, params) @message = mb.perform + + trigger_typing_event(CONVERSATION_TYPING_OFF) rescue StandardError => e render_could_not_create_error(e.message) end @@ -23,14 +27,16 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true }) message.attachments.destroy_all end + delete_message_on_channel end def retry return if message.blank? + return head :unprocessable_entity unless message.failed? && (message.outgoing? || message.template?) service = Messages::StatusUpdateService.new(message, 'sent') service.perform - message.update!(content_attributes: {}) + message.update!(content_attributes: {}, source_id: nil) ::SendReplyJob.perform_later(message.id) rescue StandardError => e render_could_not_create_error(e.message) @@ -54,6 +60,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: render json: { content: translated_content } end + def edit_content + new_content = params[:content] + return render json: { error: 'Content is required' }, status: :unprocessable_entity if new_content.blank? + return render json: { error: 'Content exceeds maximum length' }, status: :unprocessable_entity if new_content.length > 150_000 + return render json: { error: 'Only outgoing messages can be edited' }, status: :forbidden unless message.outgoing? + + original_content = message.content + # Only save previous_content on first edit to preserve the original message + previous_content_to_save = message.is_edited ? message.previous_content : original_content + message.update!(content: new_content, is_edited: true, previous_content: previous_content_to_save) + + edit_message_on_channel(new_content, original_content) + + @message = message.reload + end + private def message @@ -65,16 +87,48 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end def permitted_params - params.permit(:id, :target_language, :status, :external_error) + params.permit(:id, :target_language, :status, :external_error, :content) end def already_translated_content_available? message.translations.present? && message.translations[permitted_params[:target_language]].present? end + def delete_message_on_channel + return unless @conversation.inbox.channel.respond_to?(:delete_message) + return if message.source_id.blank? + + @conversation.inbox.channel.delete_message(message, conversation: @conversation) + rescue StandardError => e + Rails.logger.error "Failed to delete message on channel: #{e.message}" + end + + def edit_message_on_channel(new_content, original_content) + return unless @conversation.inbox.channel.respond_to?(:edit_message) + return if message.source_id.blank? + + @conversation.inbox.channel.edit_message(message, new_content, conversation: @conversation) + rescue StandardError => e + Rails.logger.error "Failed to edit message on channel: #{e.message}" + was_already_edited = message.previous_content != original_content + if was_already_edited + message.update!(content: original_content) + else + message.update!(content: original_content, is_edited: false, previous_content: nil) + end + raise e + end + # API inbox check def ensure_api_inbox # Only API inboxes can update messages render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api? end + + def trigger_typing_event(event) + user = Current.user || @resource + return unless user.is_a?(User) + + Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: params[:private]) + 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 new file mode 100644 index 000000000..9ae9ac6bc --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb @@ -0,0 +1,69 @@ +class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + + before_action :scheduled_message, only: [:update, :destroy] + + MAX_LIMIT = 100 + + def index + authorize build_scheduled_message + @scheduled_messages = @conversation.scheduled_messages + .includes(:recurring_scheduled_message) + .order(scheduled_at: :desc) + .limit(MAX_LIMIT) + end + + def create + @scheduled_message = build_scheduled_message + authorize @scheduled_message + @scheduled_message.assign_attributes(scheduled_message_params) + @scheduled_message.save! + dispatch_event(SCHEDULED_MESSAGE_CREATED, scheduled_message: @scheduled_message) + end + + 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 + + def destroy + if @scheduled_message.sent? || @scheduled_message.failed? + return render json: { error: I18n.t('errors.scheduled_messages.cannot_delete_processed') }, status: :unprocessable_entity + end + + scheduled_message = @scheduled_message + scheduled_message.destroy! + dispatch_event(SCHEDULED_MESSAGE_DELETED, scheduled_message: scheduled_message) + end + + private + + def scheduled_message + @scheduled_message ||= @conversation.scheduled_messages.find(params[:id]) + authorize @scheduled_message + end + + def build_scheduled_message + @conversation.scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user) + end + + def scheduled_message_params + params.permit( + :content, + :scheduled_at, + :status, + :attachment, + template_params: {} + ) + end + + def dispatch_event(event_name, data) + Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, data) + end +end + +Api::V1::Accounts::Conversations::ScheduledMessagesController.prepend_mod_with( + 'Api::V1::Accounts::Conversations::ScheduledMessagesController' +) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 6159f804d..224bd876a 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -1,9 +1,9 @@ -class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength include Events::Types 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 @@ -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 @@ -112,22 +117,30 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro 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. # Visiting a conversation should clear any unread inbox notifications for this conversation. Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform - return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? - return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? + 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? update_last_seen_on_conversation(DateTime.now.utc, assignee?) end def unread + Rails.configuration.dispatcher.dispatch(Events::Types::CONVERSATION_UNREAD, Time.zone.now, conversation: @conversation) + last_incoming_message = @conversation.messages.incoming.last last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present? update_last_seen_on_conversation(last_seen_at, true) @@ -155,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? @@ -164,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 @@ -233,6 +258,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def assignee? @conversation.assignee_id? && Current.user == @conversation.assignee end + + def dispatch_messages_read_event + # NOTE: Use old `agent_last_seen_at`, so we reference messages received after that + Rails.configuration.dispatcher.dispatch(Events::Types::MESSAGES_READ, Time.zone.now, conversation: @conversation, + last_seen_at: @conversation.agent_last_seen_at) + end end Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController') diff --git a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb index a8d7ebcb9..fce80036b 100644 --- a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -34,6 +34,7 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseContro def permitted_payload params.require(:dashboard_app).permit( :title, + :show_on_sidebar, content: [:url, :type] ) end 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 d7244616d..051817cf3 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -42,6 +42,26 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC 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? + + service = CsatTemplateManagementService.new(@inbox) + result = service.link_existing_template( + link_params[:name], link_params[:language], body_variables: link_params[:body_variables].to_h + ) + + render_link_result(result) + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + end + + def available_templates + service = CsatTemplateManagementService.new(@inbox) + templates = service.available_templates + render json: { templates: templates } + end + private def fetch_inbox @@ -70,6 +90,22 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Captain is required for template analysis' }, status: :forbidden end + def render_link_result(result) + if result[:success] + render json: { + template: { + name: result[:template_name], template_id: result[:template_id], + status: result[:status], language: result[:language], source: result[:source], + linked_at: result[:linked_at] + } + }, status: :ok + elsif result[:error] + render json: { error: result[:error] }, status: :unprocessable_entity + else + render json: { error: result[:service_error] || 'An unexpected error occurred' }, status: :internal_server_error + end + end + def render_template_creation_result(result) if result[:success] render_successful_template_creation(result) diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index c7d3e2737..979108307 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,11 +1,13 @@ -class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox - before_action :check_authorization, except: [:show] - + # rubocop:disable Rails/LexicallyScopedActionFilter -- health is defined in WhatsappHealthManagement concern + before_action :check_authorization, except: [:show, :health, :setup_channel_provider] + before_action :validate_whatsapp_cloud_channel, only: [:health] + # rubocop:enable Rails/LexicallyScopedActionFilter include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index @@ -72,11 +74,49 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @inbox.channel.reset_secret! end + def setup_channel_provider + channel = @inbox.channel + + unless channel.respond_to?(:setup_channel_provider) + render json: { error: 'Channel does not support setup' }, status: :unprocessable_entity and return + end + + channel.setup_channel_provider + head :ok + end + + def disconnect_channel_provider + channel = @inbox.channel + + unless channel.respond_to?(:disconnect_channel_provider) + render json: { error: 'Channel does not support disconnect' }, status: :unprocessable_entity and return + end + + channel.disconnect_channel_provider + head :ok + ensure + channel.update_provider_connection!(connection: 'close') if channel.respond_to?(:update_provider_connection!) + end + def destroy ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end + def on_whatsapp + params.require(:phone_number) + phone_number = params[:phone_number] + channel = @inbox.channel + + unless channel.respond_to?(:on_whatsapp) + render json: { error: 'Channel does not support whatsapp check' }, status: :unprocessable_entity and return + end + + response = channel.on_whatsapp(phone_number) + + render json: response, status: :ok + end + private def fetch_inbox @@ -160,7 +200,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, { csat_config: [:display_type, :message, :button_text, :language, { survey_rules: [:operator, { values: [] }], - template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }] + template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, + :created_at, :linked_at, :language, :source, :status, { body_variables: {} }] }] }] end def permitted_params(channel_attributes = []) diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb index eaa18c2de..eadd46e4a 100644 --- a/app/controllers/api/v1/accounts/integrations/slack_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -16,7 +16,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base end def update - @hook = channel_builder.update(permitted_params[:reference_id]) + @hook = channel_builder.update_reference_id(permitted_params[:reference_id]) render json: { error: I18n.t('errors.slack.invalid_channel_id') }, status: :unprocessable_entity if @hook.blank? end 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..2deddaba7 --- /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, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + 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, Metrics/CyclomaticComplexity + 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..9f0495177 --- /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, + 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, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + 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..ef8c95ec7 --- /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, + 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/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 52035ce64..874fb67a4 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -25,17 +25,17 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro end def update - @notification.update(read_at: DateTime.now.utc) + @notification.update!(read_at: DateTime.now.utc) render json: @notification end def unread - @notification.update(read_at: nil) + @notification.update!(read_at: nil) render json: @notification end def destroy - @notification.destroy + @notification.destroy! head :ok end @@ -55,7 +55,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro def snooze updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil) - @notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until] + @notification.update!(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until] render json: @notification end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 972b244fa..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 @@ -37,7 +37,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def archive - @portal.update(archive: true) + @portal.update!(archive: true) head :ok end @@ -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: [] }, { draft_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/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 9f8e94821..a32f1aa3c 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -7,12 +7,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController end def create - @webhook = Current.account.webhooks.new(webhook_params) + @webhook = Current.account.webhooks.new(webhook_create_params) @webhook.save! end def update - @webhook.update!(webhook_params) + @webhook.update!(webhook_update_params) end def destroy @@ -22,10 +22,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController private - def webhook_params + def webhook_create_params params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: []) end + def webhook_update_params + params.require(:webhook).permit(:name, subscriptions: []) + end + def fetch_webhook @webhook = Current.account.webhooks.find(params[:id]) end 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/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index 141253d0d..a99f6ced6 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -26,10 +26,14 @@ class Api::V1::ProfilesController < Api::BaseController def availability @user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability]) + + Rails.configuration.dispatcher.dispatch(Events::Types::ACCOUNT_PRESENCE_UPDATED, Time.zone.now, account_id: availability_params[:account_id], + user_id: @current_user.id, + status: availability_params[:availability]) end def set_active_account - @user.account_users.find_by(account_id: profile_params[:account_id]).update(active_at: Time.now.utc) + @user.account_users.find_by(account_id: profile_params[:account_id]).update!(active_at: Time.now.utc) head :ok end diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 6c595ab59..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? + @contact_inbox.update!(hmac_verified: true) if should_verify_hmac? identify_contact(contact) end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 00e718614..276d92c74 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -47,6 +47,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController case permitted_params[:typing_status] when 'on' trigger_typing_event(CONVERSATION_TYPING_ON) + when 'recording' + trigger_typing_event(CONVERSATION_RECORDING) when 'off' trigger_typing_event(CONVERSATION_TYPING_OFF) end @@ -91,7 +93,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def render_not_found_if_empty - return head :not_found if conversation.nil? + head :not_found if conversation.nil? end def permitted_params diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb index 7946614bb..d17b5dc13 100644 --- a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb +++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb @@ -18,7 +18,7 @@ class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseContro ui_settings = Current.user.ui_settings || {} ui_settings[cache_key] = data - Current.user.update(ui_settings: ui_settings) + Current.user.update!(ui_settings: ui_settings) render json: data end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index eb6f776f1..769bb1b18 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -12,7 +12,7 @@ class ApiController < ApplicationController def redis_status r = Redis.new(Redis::Config.app) - return 'ok' if r.ping + 'ok' if r.ping rescue Redis::CannotConnectError 'failing' end diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb index 2652f04be..6eae2d6f5 100644 --- a/app/controllers/concerns/attachment_concern.rb +++ b/app/controllers/concerns/attachment_concern.rb @@ -6,7 +6,7 @@ module AttachmentConcern return [blobs, actions, nil] if actions.blank? sanitized = actions.map do |action| - next action unless action[:action_name] == 'send_attachment' + next action unless attachment_action?(action) result = process_attachment_action(action, record, blobs) return [nil, nil, I18n.t('errors.attachments.invalid')] unless result @@ -20,15 +20,39 @@ module AttachmentConcern private def process_attachment_action(action, record, blobs) - blob_id = action[:action_params].first + blob_id = attachment_blob_id(action) + return action if action[:action_name] == 'create_scheduled_message' && blob_id.blank? + blob = ActiveStorage::Blob.find_signed(blob_id.to_s) - return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present? + return action.merge(action_params: attachment_action_params(action, blob.id)).tap { blobs << blob } if blob.present? return action if blob_already_attached?(record, blob_id) nil end + def attachment_action?(action) + %w[send_attachment create_scheduled_message].include?(action[:action_name]) + end + + def attachment_blob_id(action) + return action[:action_params].first unless action[:action_name] == 'create_scheduled_message' + + params = action[:action_params].first + params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h) + params&.with_indifferent_access&.dig(:blob_id) + end + + def attachment_action_params(action, blob_id) + return [blob_id] unless action[:action_name] == 'create_scheduled_message' + + params = action[:action_params].first + params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h) + params = params.with_indifferent_access + params[:blob_id] = blob_id + [params] + end + def blob_already_attached?(record, blob_id) record&.files&.any? { |f| f.blob_id == blob_id.to_i } end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d57ad0e53..5b18d3030 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -78,6 +78,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/health_controller.rb b/app/controllers/health_controller.rb index fdf969a39..6f948cb57 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -2,6 +2,6 @@ # authentication, and callbacks. Used for health checks class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController def show - render json: { status: 'woot' } + render json: { status: 'woot', platform: 'fazer.ai', version: Chatwoot.config[:version] } end end diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index 4521930a6..903e09832 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -12,7 +12,7 @@ class Platform::Api::V1::AccountsController < PlatformController @resource = Account.create!(account_params) update_resource_features @resource.save! - @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) + @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource) end def update diff --git a/app/controllers/platform/api/v1/agent_bots_controller.rb b/app/controllers/platform/api/v1/agent_bots_controller.rb index dd70a1ba5..a3ed639fd 100644 --- a/app/controllers/platform/api/v1/agent_bots_controller.rb +++ b/app/controllers/platform/api/v1/agent_bots_controller.rb @@ -12,7 +12,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController @resource = AgentBot.new(agent_bot_params.except(:avatar_url)) @resource.save! process_avatar_from_url - @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) + @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource) end def update diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 835c2596b..246c95d25 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -31,7 +31,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac? - @contact_inbox.update(hmac_verified: true) if @contact_inbox.present? + @contact_inbox.update!(hmac_verified: true) if @contact_inbox.present? end def valid_hmac? diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index 242dcde77..ec8475fc5 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -30,6 +30,8 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox case params[:typing_status] when 'on' trigger_typing_event(CONVERSATION_TYPING_ON) + when 'recording' + trigger_typing_event(CONVERSATION_RECORDING) when 'off' trigger_typing_event(CONVERSATION_TYPING_OFF) end diff --git a/app/controllers/super_admin/account_users_controller.rb b/app/controllers/super_admin/account_users_controller.rb index d665b5684..04092b2d9 100644 --- a/app/controllers/super_admin/account_users_controller.rb +++ b/app/controllers/super_admin/account_users_controller.rb @@ -13,7 +13,7 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController resource = resource_class.new(resource_params) authorize_resource(resource) - notice = resource.save ? translate_with_resource('create.success') : resource.errors.full_messages.first + notice = resource.save ? translate_with_resource('create.success') : resource.errors.full_messages.first redirect_back(fallback_location: [namespace, resource.account], notice: notice) end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef1..b48cf5cd9 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -19,7 +19,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController params['app_config'].each do |key, value| next unless @allowed_configs.include?(key) - i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) + i = InstallationConfig.where(name: key).first_or_create!(value: value, locked: false) i.value = value errors.concat(i.errors.full_messages) unless i.save end diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index b0e97b95d..15a930c3e 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -7,6 +7,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController redis_metrics chatwoot_edition instance_meta + baileys_api_version end def chatwoot_edition @@ -56,4 +57,10 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController rescue Redis::CannotConnectError @metrics['Redis alive'] = false end + + def baileys_api_version + @metrics['Baileys API version'] = Whatsapp::Providers::WhatsappBaileysService.status[:packageInfo][:version] + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + @metrics['Baileys API version'] = e.message + end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c..088677556 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -8,11 +8,26 @@ class Webhooks::WhatsappController < ActionController::API return end + perform_whatsapp_events_job + end + + private + + def perform_whatsapp_events_job + perform_sync if params[:awaitResponse].present? + return if performed? + Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) head :ok end - private + def perform_sync + Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash) + rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken + head :unauthorized + rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError + head :not_found + end def valid_token?(token) channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 7416b7861..12f728453 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -15,10 +15,12 @@ class AsyncDispatcher < BaseDispatcher CsatSurveyListener.instance, HookListener.instance, InstallationWebhookListener.instance, + InternalChatListener.instance, NotificationListener.instance, ParticipationListener.instance, ReportingEventListener.instance, - WebhookListener.instance + WebhookListener.instance, + ChannelListener.instance ] end end diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb index d62642885..b8f8a9e06 100644 --- a/app/drops/conversation_drop.rb +++ b/app/drops/conversation_drop.rb @@ -24,8 +24,33 @@ class ConversationDrop < BaseDrop custom_attributes.transform_keys(&:to_s) end + def first_reply_created_at + format_datetime(@obj.try(:first_reply_created_at)) + end + + def first_reply_created_at_time + format_datetime(@obj.try(:first_reply_created_at), include_time: true) + end + + def last_activity_at + format_datetime(@obj.try(:last_activity_at)) + end + + def last_activity_at_time + format_datetime(@obj.try(:last_activity_at), include_time: true) + end + private + def format_datetime(datetime, include_time: false) + return '' if datetime.blank? + + locale = @obj.try(:account)&.locale || 'en' + date_format = locale == 'pt_BR' ? '%d/%m/%Y' : '%b %d, %Y' + date_format += ' %H:%M' if include_time + datetime.strftime(date_format) + end + def message_sender_name(sender) return 'Bot' if sender.blank? return contact_name if sender.instance_of?(Contact) diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index fa437327d..a6d897dc0 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -81,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 @@ -135,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/helpers/baileys_helper.rb b/app/helpers/baileys_helper.rb new file mode 100644 index 000000000..f3b5b1b76 --- /dev/null +++ b/app/helpers/baileys_helper.rb @@ -0,0 +1,52 @@ +module BaileysHelper + CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%s'.freeze + 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} + if timestamp.is_a?(Hash) && timestamp.key?('low') + low = timestamp['low'].to_i + high = timestamp.fetch('high', 0).to_i + return (high << 32) | low + end + + # NOTE: Timestamp might be a string or a number + timestamp.to_i + end + + def with_baileys_channel_lock_on_outgoing_message(channel_id, timeout: CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT) + raise ArgumentError, 'A block is required for with_baileys_channel_lock_on_outgoing_message' unless block_given? + + start_time = Time.now.to_i + lock_acquired = false + + # NOTE: On timeout, we log a warning and proceed with the block execution. + # The re-check inside the contact lock handles potential duplicates. + while (Time.now.to_i - start_time) < timeout + if baileys_lock_channel_on_outgoing_message(channel_id, timeout) + lock_acquired = true + break + end + + sleep(0.1) + end + + Rails.logger.warn "Baileys channel lock timeout for channel #{channel_id} after #{timeout}s - proceeding anyway" unless lock_acquired + + yield + ensure + baileys_clear_channel_lock_on_outgoing_message(channel_id) if lock_acquired + end + + private + + def baileys_lock_channel_on_outgoing_message(channel_id, timeout) + key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id) + Redis::Alfred.set(key, 1, nx: true, ex: timeout) + end + + def baileys_clear_channel_lock_on_outgoing_message(channel_id) + key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id) + Redis::Alfred.delete(key) + end +end diff --git a/app/helpers/cache_keys_helper.rb b/app/helpers/cache_keys_helper.rb index aab33e44c..366c87383 100644 --- a/app/helpers/cache_keys_helper.rb +++ b/app/helpers/cache_keys_helper.rb @@ -10,6 +10,6 @@ module CacheKeysHelper return value_from_cache if value_from_cache.present? # zero epoch time: 1970-01-01 00:00:00 UTC - '0000000000' + '0000000000000' end end diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index 4f345676e..da74cdd88 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -104,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/frontend_urls_helper.rb b/app/helpers/frontend_urls_helper.rb index 1867c77ea..967483c7b 100644 --- a/app/helpers/frontend_urls_helper.rb +++ b/app/helpers/frontend_urls_helper.rb @@ -1,6 +1,8 @@ module FrontendUrlsHelper def frontend_url(path, **query_params) url_params = query_params.blank? ? '' : "?#{query_params.to_query}" - "#{root_url}app/#{path}#{url_params}" + host = ENV.fetch('FRONTEND_URL', root_url) + host = "#{host}/" unless host.end_with?('/') + "#{host}app/#{path}#{url_params}" end end diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb index f0a419cc8..7da3ed5b3 100644 --- a/app/helpers/reporting_event_helper.rb +++ b/app/helpers/reporting_event_helper.rb @@ -65,8 +65,8 @@ module ReportingEventHelper end def format_time(hour, minute) - hour = hour < 10 ? "0#{hour}" : hour - minute = minute < 10 ? "0#{minute}" : minute + hour = "0#{hour}" if hour < 10 + minute = "0#{minute}" if minute < 10 "#{hour}:#{minute}" end end diff --git a/app/helpers/timezone_helper.rb b/app/helpers/timezone_helper.rb index d4a05f054..9e1f99f14 100644 --- a/app/helpers/timezone_helper.rb +++ b/app/helpers/timezone_helper.rb @@ -20,6 +20,6 @@ module TimezoneHelper zone.now.utc_offset == offset_in_seconds end - return matching_zone.name if matching_zone + matching_zone&.name end end 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/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 8f294a0ee..99adc0302 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -8,6 +8,7 @@ export const buildCreatePayload = ({ contentAttributes, echoId, files, + isRecordedAudio, ccEmails = '', bccEmails = '', toEmails = '', @@ -22,6 +23,13 @@ export const buildCreatePayload = ({ files.forEach(file => { payload.append('attachments[]', file); }); + if (isRecordedAudio === true) { + payload.append('is_recorded_audio', true); + } else if (Array.isArray(isRecordedAudio)) { + isRecordedAudio.forEach(filename => { + payload.append('is_recorded_audio[]', filename); + }); + } payload.append('private', isPrivate); payload.append('echo_id', echoId); payload.append('cc_emails', ccEmails); @@ -60,6 +68,7 @@ class MessageApi extends ApiClient { contentAttributes, echo_id: echoId, files, + isRecordedAudio, ccEmails = '', bccEmails = '', toEmails = '', @@ -74,6 +83,7 @@ class MessageApi extends ApiClient { contentAttributes, echoId, files, + isRecordedAudio, ccEmails, bccEmails, toEmails, @@ -86,6 +96,13 @@ class MessageApi extends ApiClient { return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`); } + editContent(conversationID, messageId, content) { + return axios.patch( + `${this.url}/${conversationID}/messages/${messageId}/edit_content`, + { content } + ); + } + retry(conversationID, messageId) { return axios.post( `${this.url}/${conversationID}/messages/${messageId}/retry` 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 cc564fe96..522324cb2 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,26 @@ class Inboxes extends CacheEnabledApiClient { resetSecret(inboxId) { return axios.post(`${this.url}/${inboxId}/reset_secret`); } + + linkCSATTemplate(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template/link`, { + template, + }); + } + + getAvailableCSATTemplates(inboxId) { + return axios.get( + `${this.url}/${inboxId}/csat_template/available_templates` + ); + } + + setupChannelProvider(inboxId) { + return axios.post(`${this.url}/${inboxId}/setup_channel_provider`); + } + + disconnectChannelProvider(inboxId) { + return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`); + } } 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 new file mode 100644 index 000000000..71465b51c --- /dev/null +++ b/app/javascript/dashboard/api/scheduledMessages.js @@ -0,0 +1,68 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildScheduledMessagePayload = ({ + content, + status, + scheduledAt, + templateParams, + attachment, + removeAttachment, +} = {}) => { + if (!attachment) { + return { + content, + status, + scheduled_at: scheduledAt, + template_params: templateParams, + remove_attachment: removeAttachment || undefined, + }; + } + + 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)); + } + + return payload; +}; + +class ScheduledMessagesAPI extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + get(conversationId) { + return axios.get( + `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages` + ); + } + + create(conversationId, payload) { + return axios({ + method: 'post', + url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`, + data: buildScheduledMessagePayload(payload), + }); + } + + update(conversationId, scheduledMessageId, payload) { + return axios({ + method: 'patch', + url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`, + data: buildScheduledMessagePayload(payload), + }); + } + + delete(conversationId, scheduledMessageId) { + return axios.delete( + `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}` + ); + } +} + +export default new ScheduledMessagesAPI(); diff --git a/app/javascript/dashboard/api/specs/scheduledMessages.spec.js b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js new file mode 100644 index 000000000..6358b478f --- /dev/null +++ b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js @@ -0,0 +1,77 @@ +import ScheduledMessagesAPI, { + buildScheduledMessagePayload, +} from '../scheduledMessages'; + +describe('#ScheduledMessagesAPI', () => { + describe('#buildScheduledMessagePayload', () => { + it('builds object payload without attachment or FormData with attachment', () => { + const objectPayload = buildScheduledMessagePayload({ + content: 'Hello', + scheduledAt: '2025-01-01T10:00:00Z', + status: 'pending', + }); + + expect(objectPayload).toEqual({ + content: 'Hello', + scheduled_at: '2025-01-01T10:00:00Z', + status: 'pending', + private: undefined, + template_params: undefined, + content_attributes: undefined, + additional_attributes: undefined, + }); + + const formPayload = buildScheduledMessagePayload({ + content: 'Hello', + attachment: new Blob(['test'], { type: 'text/plain' }), + }); + + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('content')).toEqual('Hello'); + }); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = Object.assign( + vi.fn(() => Promise.resolve()), + { delete: vi.fn(() => Promise.resolve()) } + ); + + beforeEach(() => { + axiosMock.mockClear(); + axiosMock.delete.mockClear(); + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/inbox'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('calls correct endpoints for create, update, and delete', () => { + ScheduledMessagesAPI.create(12, { content: 'Hello' }); + expect(axiosMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/api/v1/accounts/1/conversations/12/scheduled_messages', + }) + ); + + ScheduledMessagesAPI.update(12, 7, { status: 'pending' }); + expect(axiosMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'patch', + url: '/api/v1/accounts/1/conversations/12/scheduled_messages/7', + }) + ); + + ScheduledMessagesAPI.delete(12, 7); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/conversations/12/scheduled_messages/7' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/assets/images/curved-arrow.svg b/app/javascript/dashboard/assets/images/curved-arrow.svg new file mode 100644 index 000000000..f021b57ba --- /dev/null +++ b/app/javascript/dashboard/assets/images/curved-arrow.svg @@ -0,0 +1,9 @@ + + + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue new file mode 100644 index 000000000..f9ce8680e --- /dev/null +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue @@ -0,0 +1,526 @@ + + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue index b99f08a29..f396baaed 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue @@ -7,7 +7,13 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store'; import { uploadFile } from 'dashboard/helper/uploadHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { useVuelidate } from '@vuelidate/core'; -import { required, minLength, helpers, url } from '@vuelidate/validators'; +import { + required, + minLength, + maxLength, + helpers, + url, +} from '@vuelidate/validators'; import { isValidSlug } from 'shared/helpers/Validators'; import Button from 'dashboard/components-next/button/Button.vue'; @@ -15,6 +21,7 @@ import Input from 'dashboard/components-next/input/Input.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue'; +import Switch from 'dashboard/components-next/switch/Switch.vue'; const props = defineProps({ activePortal: { @@ -34,6 +41,7 @@ const store = useStore(); const getters = useStoreGetters(); const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB +const CUSTOM_HTML_MAX_LENGTH = 15_000; const state = reactive({ name: '', @@ -45,6 +53,9 @@ const state = reactive({ liveChatWidgetInboxId: '', logoUrl: '', avatarBlobId: '', + showAuthor: true, + customHeadHtml: '', + customBodyHtml: '', }); const originalState = reactive({ ...state }); @@ -80,6 +91,8 @@ const rules = { ), }, homePageLink: { url }, + customHeadHtml: { maxLength: maxLength(CUSTOM_HTML_MAX_LENGTH) }, + customBodyHtml: { maxLength: maxLength(CUSTOM_HTML_MAX_LENGTH) }, }; const v$ = useVuelidate(rules, state); @@ -98,6 +111,18 @@ const homePageLinkError = computed(() => : '' ); +const customHeadHtmlError = computed(() => + v$.value.customHeadHtml.$error + ? t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.ERROR') + : '' +); + +const customBodyHtmlError = computed(() => + v$.value.customBodyHtml.$error + ? t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.ERROR') + : '' +); + const isUpdatingPortal = computed(() => { const slug = props.activePortal?.slug; if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating; @@ -117,6 +142,9 @@ watch( homePageLink: newVal.homepage_link, slug: newVal.slug, liveChatWidgetInboxId: newVal.inbox?.id || '', + showAuthor: newVal.config?.show_author !== false, + customHeadHtml: newVal.custom_head_html || '', + customBodyHtml: newVal.custom_body_html || '', }); if (newVal.logo) { const { @@ -149,6 +177,9 @@ const handleUpdatePortal = () => { homepage_link: state.homePageLink, blob_id: state.avatarBlobId, inbox_id: state.liveChatWidgetInboxId, + config: { show_author: state.showAuthor }, + custom_head_html: state.customHeadHtml, + custom_body_html: state.customBodyHtml, }; emit('updatePortal', portal); }; @@ -335,6 +366,89 @@ const handleAvatarDelete = () => { +
+ +
+ + + {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.HELP_TEXT') }} + +
+
+
+ +
+