Merge branch 'main' into chore/merge-4.13.0
Resolves 26 conflicts via manual review. Key decisions: - signature: kept fork's send-time architecture (PR #79), discarded upstream's editor-manipulation functions - WhatsApp incoming: combined fork's two-layer locking (source_id + contact phone) with upstream's blocked-contact drop. Fixed pre-existing regression where echoes were silently dropped - InstallationConfig: upstream's simplified coder (validated against legacy YAML-in-jsonb data) - schema.rb: regenerated, stripped kanban tables from other branches, restored f_unaccent SQL function
This commit is contained in:
commit
112385fd9e
170
.claude/skills/release-notes/SKILL.md
Normal file
170
.claude/skills/release-notes/SKILL.md
Normal file
@ -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 `<!-- user-notes:xx:start -->` / `<!-- user-notes:xx:end -->` markers, so the H2 headings, the flags, and any commit list above are invisible there.
|
||||
|
||||
```markdown
|
||||
## 🇺🇸 English
|
||||
|
||||
<!-- user-notes:en:start -->
|
||||
... markdown in english ...
|
||||
<!-- user-notes:en:end -->
|
||||
|
||||
## 🇧🇷 Português
|
||||
|
||||
<!-- user-notes:pt-BR:start -->
|
||||
... markdown em português ...
|
||||
<!-- user-notes:pt-BR:end -->
|
||||
```
|
||||
|
||||
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
|
||||
<!-- user-notes:en:start -->
|
||||
> Includes changes from Chatwoot fazer.ai v4.12.0-fazer-ai.47.
|
||||
...
|
||||
<!-- user-notes:en:end -->
|
||||
|
||||
<!-- user-notes:pt-BR:start -->
|
||||
> Inclui mudanças do Chatwoot fazer.ai v4.12.0-fazer-ai.47.
|
||||
...
|
||||
<!-- user-notes:pt-BR:end -->
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
<!-- user-notes:en:start -->
|
||||
### ✨ 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.
|
||||
<!-- user-notes:en:end -->
|
||||
|
||||
## 🇧🇷 Português
|
||||
|
||||
<!-- user-notes:pt-BR:start -->
|
||||
### ✨ 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.
|
||||
<!-- user-notes:pt-BR:end -->
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
<!-- user-notes:en:start -->
|
||||
Bug fixes and internal improvements.
|
||||
<!-- user-notes:en:end -->
|
||||
|
||||
## 🇧🇷 Português
|
||||
|
||||
<!-- user-notes:pt-BR:start -->
|
||||
Correções de bugs e melhorias internas.
|
||||
<!-- user-notes:pt-BR:end -->
|
||||
```
|
||||
|
||||
## 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 <tag> --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 <tag> --json body -q .body` (or the source commits via `git log <prev-tag>..<tag> --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 <tag> --notes-file <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/<owner>/<repo>/releases/tags/<tag> --jq '.id')
|
||||
gh api -X PATCH "repos/<owner>/<repo>/releases/$RELEASE_ID" -F body=@<file>
|
||||
```
|
||||
20
.env.example
20
.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=
|
||||
|
||||
8
.github/copilot-instructions.md
vendored
Normal file
8
.github/copilot-instructions.md
vendored
Normal file
@ -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 -- <file>`. Just do `pnpm test <file>` directly
|
||||
8
.github/workflows/frontend-fe.yml
vendored
8
.github/workflows/frontend-fe.yml
vendored
@ -2,11 +2,9 @@ name: Frontend Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
138
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
138
.github/workflows/publish_ee_github_docker.yml
vendored
Normal file
@ -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
|
||||
139
.github/workflows/publish_github_docker.yml
vendored
Normal file
139
.github/workflows/publish_github_docker.yml
vendored
Normal file
@ -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
|
||||
139
.github/workflows/publish_github_docker_beta.yml
vendored
Normal file
139
.github/workflows/publish_github_docker_beta.yml
vendored
Normal file
@ -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
|
||||
24
.github/workflows/run_foss_spec.yml
vendored
24
.github/workflows/run_foss_spec.yml
vendored
@ -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'
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
10
.rubocop.yml
10
.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
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -2,5 +2,6 @@
|
||||
"cSpell.words": [
|
||||
"chatwoot",
|
||||
"dompurify"
|
||||
]
|
||||
],
|
||||
"css.customData": [".vscode/tailwind.json"]
|
||||
}
|
||||
|
||||
55
.vscode/tailwind.json
vendored
Normal file
55
.vscode/tailwind.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
12
AGENTS.md
12
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)
|
||||
|
||||
|
||||
73
CUSTOM_BRANDING.md
Normal file
73
CUSTOM_BRANDING.md
Normal file
@ -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).
|
||||
5
Gemfile
5
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 ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
9
Rakefile
9
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
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
)
|
||||
@ -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'
|
||||
)
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
26
app/controllers/api/v1/accounts/groups_controller.rb
Normal file
26
app/controllers/api/v1/accounts/groups_controller.rb
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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 = [])
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
52
app/helpers/baileys_helper.rb
Normal file
52
app/helpers/baileys_helper.rb
Normal file
@ -0,0 +1,52 @@
|
||||
module BaileysHelper
|
||||
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%<channel_id>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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
71
app/javascript/dashboard/api/groupMembers.js
Normal file
71
app/javascript/dashboard/api/groupMembers.js
Normal file
@ -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();
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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`
|
||||
|
||||
25
app/javascript/dashboard/api/inboxSignatures.js
Normal file
25
app/javascript/dashboard/api/inboxSignatures.js
Normal file
@ -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}`);
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
|
||||
72
app/javascript/dashboard/api/internalChatChannels.js
Normal file
72
app/javascript/dashboard/api/internalChatChannels.js
Normal file
@ -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();
|
||||
24
app/javascript/dashboard/api/internalChatDrafts.js
Normal file
24
app/javascript/dashboard/api/internalChatDrafts.js
Normal file
@ -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();
|
||||
62
app/javascript/dashboard/api/internalChatMessages.js
Normal file
62
app/javascript/dashboard/api/internalChatMessages.js
Normal file
@ -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();
|
||||
24
app/javascript/dashboard/api/internalChatPolls.js
Normal file
24
app/javascript/dashboard/api/internalChatPolls.js
Normal file
@ -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();
|
||||
81
app/javascript/dashboard/api/recurringScheduledMessages.js
Normal file
81
app/javascript/dashboard/api/recurringScheduledMessages.js
Normal file
@ -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();
|
||||
68
app/javascript/dashboard/api/scheduledMessages.js
Normal file
68
app/javascript/dashboard/api/scheduledMessages.js
Normal file
@ -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();
|
||||
77
app/javascript/dashboard/api/specs/scheduledMessages.spec.js
Normal file
77
app/javascript/dashboard/api/specs/scheduledMessages.spec.js
Normal file
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
app/javascript/dashboard/assets/images/curved-arrow.svg
Normal file
9
app/javascript/dashboard/assets/images/curved-arrow.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg fill="#2781F6"
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 302.816 302.816">
|
||||
<path d="M298.423,152.996c-5.857-5.858-15.354-5.858-21.213,0l-35.137,35.136
|
||||
c-5.871-59.78-50.15-111.403-112.001-123.706c-45.526-9.055-92.479,5.005-125.596,37.612c-5.903,5.813-5.977,15.31-0.165,21.213
|
||||
c5.813,5.903,15.31,5.977,21.212,0.164c26.029-25.628,62.923-36.679,98.695-29.565c48.865,9.72,83.772,50.677,88.07,97.978
|
||||
l-38.835-38.835c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l62.485,62.485
|
||||
c2.929,2.929,6.768,4.393,10.606,4.393s7.678-1.464,10.607-4.393l62.483-62.482C304.281,168.352,304.281,158.854,298.423,152.996z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@ -0,0 +1,526 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { buildRecurrenceDescription } from 'dashboard/helper/recurrenceHelpers';
|
||||
|
||||
const props = defineProps({
|
||||
scheduledMessage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
writtenBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
allowEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'stop']);
|
||||
const noteContentRef = useTemplateRef('noteContentRef');
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const showToggle = ref(false);
|
||||
const showHistory = ref(false);
|
||||
const showStopConfirm = ref(false);
|
||||
const { t, locale } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const normalizedLocale = computed(() => locale.value.replace('_', '-'));
|
||||
|
||||
const isRecurring = computed(() =>
|
||||
Boolean(props.scheduledMessage?.recurrence_rule)
|
||||
);
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.DRAFT',
|
||||
class: 'bg-n-slate-9/10 text-n-slate-12',
|
||||
},
|
||||
pending: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.PENDING',
|
||||
class: 'bg-n-brand/10 text-n-blue-text',
|
||||
},
|
||||
sent: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.SENT',
|
||||
class: 'bg-n-teal-9/10 text-n-teal-11',
|
||||
},
|
||||
failed: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.STATUS.FAILED',
|
||||
class: 'bg-n-ruby-9/10 text-n-ruby-11',
|
||||
},
|
||||
active: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_ACTIVE',
|
||||
class: 'bg-n-brand/10 text-n-blue-text',
|
||||
},
|
||||
completed: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_COMPLETED',
|
||||
class: 'bg-n-slate-3 text-n-slate-11',
|
||||
},
|
||||
cancelled: {
|
||||
labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_CANCELLED',
|
||||
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||
},
|
||||
};
|
||||
|
||||
const author = computed(() => props.scheduledMessage?.author || null);
|
||||
const authorType = computed(() => props.scheduledMessage?.author_type);
|
||||
const isUserAuthor = computed(
|
||||
() => authorType.value === 'User' && Boolean(author.value?.id)
|
||||
);
|
||||
const avatarSrc = computed(() => {
|
||||
if (isUserAuthor.value) {
|
||||
return author.value?.thumbnail || '';
|
||||
}
|
||||
return '/assets/images/chatwoot_bot.png';
|
||||
});
|
||||
const avatarName = computed(() => {
|
||||
if (isUserAuthor.value) {
|
||||
return author.value?.name || t('CONVERSATION.BOT');
|
||||
}
|
||||
return t('CONVERSATION.BOT');
|
||||
});
|
||||
const status = computed(() => props.scheduledMessage?.status || 'draft');
|
||||
const statusBadge = computed(() => {
|
||||
const config = statusConfig[status.value] || statusConfig.draft;
|
||||
return {
|
||||
class: config.class,
|
||||
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
|
||||
label: t(config.labelKey),
|
||||
};
|
||||
});
|
||||
|
||||
const recurrenceDescription = computed(() => {
|
||||
if (!isRecurring.value) return '';
|
||||
return buildRecurrenceDescription(
|
||||
props.scheduledMessage.recurrence_rule,
|
||||
t,
|
||||
normalizedLocale.value
|
||||
);
|
||||
});
|
||||
|
||||
const scheduledAt = computed(() => {
|
||||
if (isRecurring.value) {
|
||||
const pending =
|
||||
props.scheduledMessage.pending_scheduled_message ||
|
||||
props.scheduledMessage.scheduled_messages?.find(
|
||||
sm => sm.status === 'pending'
|
||||
);
|
||||
return pending?.scheduled_at || null;
|
||||
}
|
||||
return props.scheduledMessage?.scheduled_at;
|
||||
});
|
||||
|
||||
const formattedScheduledTime = computed(() => {
|
||||
if (!scheduledAt.value) return '';
|
||||
const date = fromUnixTime(scheduledAt.value);
|
||||
const now = new Date();
|
||||
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(normalizedLocale.value, options);
|
||||
});
|
||||
|
||||
const templateName = computed(() => {
|
||||
const templateParams = props.scheduledMessage?.template_params || {};
|
||||
return templateParams.name || templateParams.id;
|
||||
});
|
||||
|
||||
const hasTemplate = computed(() => Boolean(templateName.value));
|
||||
|
||||
const attachment = computed(() => props.scheduledMessage?.attachment);
|
||||
const attachmentName = computed(() => attachment.value?.filename);
|
||||
const attachmentUrl = computed(() => attachment.value?.file_url);
|
||||
const shouldShowAttachmentLine = computed(() => Boolean(attachmentName.value));
|
||||
|
||||
const previewContent = computed(() => {
|
||||
if (props.scheduledMessage?.content) {
|
||||
return props.scheduledMessage.content;
|
||||
}
|
||||
if (templateName.value) {
|
||||
return t('SCHEDULED_MESSAGES.ITEM.TEMPLATE_PREVIEW', {
|
||||
name: templateName.value,
|
||||
});
|
||||
}
|
||||
if (attachmentName.value) {
|
||||
return '';
|
||||
}
|
||||
return t('SCHEDULED_MESSAGES.ITEM.EMPTY_PREVIEW');
|
||||
});
|
||||
|
||||
const hasPreviewContent = computed(() => Boolean(previewContent.value));
|
||||
|
||||
const formattedContent = computed(() => formatMessage(previewContent.value));
|
||||
|
||||
// Recurring: completed children history
|
||||
const completedChildren = computed(() => {
|
||||
if (!isRecurring.value) return [];
|
||||
const children = props.scheduledMessage.scheduled_messages || [];
|
||||
return children
|
||||
.filter(m => ['sent', 'failed'].includes(m.status))
|
||||
.sort((a, b) => (b.scheduled_at || 0) - (a.scheduled_at || 0));
|
||||
});
|
||||
|
||||
const hasCompletedChildren = computed(() => completedChildren.value.length > 0);
|
||||
|
||||
const formatChildTime = childScheduledAt => {
|
||||
if (!childScheduledAt) return '';
|
||||
const date = new Date(childScheduledAt * 1000);
|
||||
const now = new Date();
|
||||
const options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
return date.toLocaleString(normalizedLocale.value, options);
|
||||
};
|
||||
|
||||
const canNavigateToChild = child =>
|
||||
child.status === 'sent' && Boolean(child.message_id);
|
||||
|
||||
const scrollToChildMessage = child => {
|
||||
if (!canNavigateToChild(child)) return;
|
||||
router.replace({
|
||||
...route,
|
||||
query: { ...route.query, messageId: child.message_id },
|
||||
});
|
||||
};
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (!props.collapsible) {
|
||||
showToggle.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = noteContentRef.value;
|
||||
if (el && !isExpanded.value) {
|
||||
showToggle.value = el.scrollHeight > el.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = () => emit('edit', props.scheduledMessage);
|
||||
const onDelete = () => {
|
||||
if (isRecurring.value) {
|
||||
showStopConfirm.value = true;
|
||||
} else {
|
||||
emit('delete', props.scheduledMessage);
|
||||
}
|
||||
};
|
||||
const confirmStop = () => {
|
||||
emit('stop', props.scheduledMessage);
|
||||
showStopConfirm.value = false;
|
||||
};
|
||||
|
||||
const canScrollToMessage = computed(
|
||||
() =>
|
||||
!isRecurring.value &&
|
||||
props.scheduledMessage?.status === 'sent' &&
|
||||
Boolean(props.scheduledMessage?.message_id)
|
||||
);
|
||||
|
||||
const scrollToMessage = () => {
|
||||
if (!canScrollToMessage.value) return;
|
||||
const messageId = props.scheduledMessage.message_id;
|
||||
router.replace({
|
||||
...route,
|
||||
query: { ...route.query, messageId },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
watch(previewContent, () => {
|
||||
nextTick(checkOverflow);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b border-n-strong py-3 group/scheduled rounded-md transition-colors"
|
||||
:class="{
|
||||
'cursor-pointer hover:bg-n-alpha-1': canScrollToMessage,
|
||||
}"
|
||||
:title="
|
||||
canScrollToMessage
|
||||
? t('SCHEDULED_MESSAGES.ITEM.GO_TO_MESSAGE')
|
||||
: undefined
|
||||
"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<!-- Recurrence description header -->
|
||||
<div
|
||||
v-if="isRecurring"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-repeat" class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ recurrenceDescription }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
:name="avatarName"
|
||||
:src="avatarSrc"
|
||||
:size="30"
|
||||
rounded-full
|
||||
class="shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium text-n-slate-12 mb-0.5 line-clamp-1"
|
||||
:title="writtenBy"
|
||||
>
|
||||
{{ writtenBy }}
|
||||
</p>
|
||||
<p
|
||||
v-if="formattedScheduledTime"
|
||||
class="flex items-center gap-1 text-xs text-n-slate-11 mb-0"
|
||||
>
|
||||
<Icon icon="i-lucide-alarm-clock" class="size-3 shrink-0" />
|
||||
{{
|
||||
isRecurring
|
||||
? t('SCHEDULED_MESSAGES.RECURRENCE.NEXT_SEND', {
|
||||
time: formattedScheduledTime,
|
||||
})
|
||||
: formattedScheduledTime
|
||||
}}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11 mb-0">
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="statusBadge.class"
|
||||
>
|
||||
{{ statusBadge.label }}
|
||||
</span>
|
||||
<div
|
||||
v-if="allowEdit || allowDelete"
|
||||
class="flex items-center gap-1 opacity-0 group-hover/scheduled:opacity-100"
|
||||
>
|
||||
<Button
|
||||
v-if="allowEdit"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click.stop="onEdit"
|
||||
/>
|
||||
<Button
|
||||
v-if="allowDelete"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
:icon="isRecurring ? 'i-lucide-square' : 'i-lucide-trash'"
|
||||
@click.stop="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hasPreviewContent"
|
||||
ref="noteContentRef"
|
||||
v-dompurify-html="formattedContent"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
:class="{
|
||||
'line-clamp-4': collapsible && !isExpanded,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div v-if="hasPreviewContent && collapsible && showToggle">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="blue"
|
||||
size="xs"
|
||||
:icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="() => toggleExpanded()"
|
||||
>
|
||||
<template v-if="isExpanded">
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.COLLAPSE') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('SCHEDULED_MESSAGES.ITEM.EXPAND') }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasTemplate"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-zap" class="size-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.TEMPLATE_LABEL', {
|
||||
name: templateName,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldShowAttachmentLine"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-paperclip" class="size-3 shrink-0" />
|
||||
<a
|
||||
v-if="attachmentUrl"
|
||||
:href="attachmentUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate hover:underline"
|
||||
>
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
|
||||
filename: attachmentName,
|
||||
})
|
||||
}}
|
||||
</a>
|
||||
<span v-else class="truncate">
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
|
||||
filename: attachmentName,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recurring: sent/failed history toggle -->
|
||||
<div v-if="isRecurring && hasCompletedChildren" class="text-xs">
|
||||
<button
|
||||
class="flex items-center gap-1 text-n-slate-10 hover:text-n-slate-12 cursor-pointer transition-colors"
|
||||
@click.stop="showHistory = !showHistory"
|
||||
>
|
||||
<Icon
|
||||
:icon="showHistory ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="size-3"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
t('SCHEDULED_MESSAGES.RECURRENCE.OCCURRENCES_SENT', {
|
||||
count: completedChildren.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recurring: expanded history list -->
|
||||
<div
|
||||
v-if="isRecurring && showHistory && hasCompletedChildren"
|
||||
class="flex flex-col gap-1 border-t border-n-weak pt-2"
|
||||
>
|
||||
<div
|
||||
v-for="child in completedChildren"
|
||||
:key="child.id"
|
||||
class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors"
|
||||
:class="{
|
||||
'cursor-pointer hover:bg-n-alpha-2': canNavigateToChild(child),
|
||||
}"
|
||||
@click.stop="scrollToChildMessage(child)"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Icon
|
||||
:icon="
|
||||
child.status === 'sent'
|
||||
? 'i-lucide-check-circle'
|
||||
: 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3 shrink-0"
|
||||
:class="
|
||||
child.status === 'sent' ? 'text-n-teal-11' : 'text-n-ruby-11'
|
||||
"
|
||||
/>
|
||||
<span class="text-n-slate-11">
|
||||
{{ formatChildTime(child.scheduled_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0"
|
||||
:class="
|
||||
child.status === 'sent'
|
||||
? 'bg-n-teal-9/10 text-n-teal-11'
|
||||
: 'bg-n-ruby-9/10 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
child.status === 'sent'
|
||||
? 'SCHEDULED_MESSAGES.STATUS.SENT'
|
||||
: 'SCHEDULED_MESSAGES.STATUS.FAILED'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stop recurrence confirmation modal -->
|
||||
<woot-modal
|
||||
v-if="isRecurring"
|
||||
v-model:show="showStopConfirm"
|
||||
size="small"
|
||||
@close="() => (showStopConfirm = false)"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-4 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-n-slate-12">
|
||||
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.MESSAGE') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CANCEL')"
|
||||
@click="showStopConfirm = false"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="ruby"
|
||||
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CONFIRM')"
|
||||
@click="confirmStop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@ -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 = () => {
|
||||
<ColorPicker v-model="state.widgetColor" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 py-2.5">
|
||||
<Switch v-model="state.showAuthor" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SHOW_AUTHOR.HELP_TEXT') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<textarea
|
||||
v-model="state.customHeadHtml"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.PLACEHOLDER')
|
||||
"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg resize-y bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-1 font-mono"
|
||||
:class="
|
||||
customHeadHtmlError
|
||||
? 'border-n-ruby-9 focus:ring-n-ruby-9'
|
||||
: 'border-n-weak focus:ring-n-brand'
|
||||
"
|
||||
@input="v$.customHeadHtml.$touch()"
|
||||
/>
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="customHeadHtmlError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{
|
||||
customHeadHtmlError ||
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_HEAD_HTML.HELP_TEXT')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
||||
>
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<textarea
|
||||
v-model="state.customBodyHtml"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.PLACEHOLDER')
|
||||
"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg resize-y bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-1 font-mono"
|
||||
:class="
|
||||
customBodyHtmlError
|
||||
? 'border-n-ruby-9 focus:ring-n-ruby-9'
|
||||
: 'border-n-weak focus:ring-n-brand'
|
||||
"
|
||||
@input="v$.customBodyHtml.$touch()"
|
||||
/>
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="customBodyHtmlError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{
|
||||
customBodyHtmlError ||
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.CUSTOM_BODY_HTML.HELP_TEXT')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end w-full gap-2">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
@ -18,9 +20,12 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { pendingGroupNavigation } from 'dashboard/helper/pendingGroupNavigation';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
import ComposeNewGroupForm from 'dashboard/components-next/NewConversation/components/ComposeNewGroupForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
alignPosition: {
|
||||
@ -42,6 +47,8 @@ const emit = defineEmits(['close']);
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
@ -59,6 +66,8 @@ const isCreatingContact = ref(false);
|
||||
const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
const composeMode = ref('conversation');
|
||||
const groupFormRef = ref(null);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
@ -84,6 +93,85 @@ const globalConfig = useMapGetter('globalConfig/get');
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
const messageSignature = useMapGetter('getMessageSignature');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const groupUiFlags = useMapGetter('groupMembers/getUIFlags');
|
||||
|
||||
const groupCreationInboxes = computed(() =>
|
||||
inboxesList.value.filter(inbox => inbox.allow_group_creation)
|
||||
);
|
||||
|
||||
const isGroupMode = computed(() => composeMode.value === 'group');
|
||||
const hasGroupInboxes = computed(() => groupCreationInboxes.value.length > 0);
|
||||
const isGroupsDisabled = computed(
|
||||
() => !globalConfig.value.baileysWhatsappGroupsEnabled
|
||||
);
|
||||
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
|
||||
|
||||
const resetContacts = () => {
|
||||
contacts.value = [];
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
composeMode.value = 'conversation';
|
||||
if (!props.contactId) {
|
||||
selectedContact.value = null;
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
groupFormRef.value?.resetForm();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const switchMode = mode => {
|
||||
if (composeMode.value === mode) return;
|
||||
composeMode.value = mode;
|
||||
selectedContact.value = null;
|
||||
targetInbox.value = null;
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
resetContacts();
|
||||
groupFormRef.value?.resetForm();
|
||||
};
|
||||
|
||||
const createGroup = async ({ inboxId, subject, participants }) => {
|
||||
try {
|
||||
const data = await store.dispatch('groupMembers/createGroup', {
|
||||
inbox_id: inboxId,
|
||||
subject,
|
||||
participants,
|
||||
});
|
||||
pendingGroupNavigation.set(data.group_jid);
|
||||
groupFormRef.value?.resetForm();
|
||||
discardCompose();
|
||||
useAlert(t('GROUP.CREATE.SUCCESS_MESSAGE'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.CREATE.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
fetchInboxSignatures,
|
||||
getSignatureForInbox,
|
||||
getSignatureSettingsForInbox,
|
||||
} = useInboxSignatures();
|
||||
|
||||
fetchInboxSignatures();
|
||||
|
||||
const resolvedMessageSignature = computed(() => {
|
||||
if (!targetInbox.value?.id) return messageSignature.value;
|
||||
return getSignatureForInbox(targetInbox.value.id);
|
||||
});
|
||||
|
||||
const resolvedSignatureSettings = computed(() => {
|
||||
if (!targetInbox.value?.id) return null;
|
||||
return getSignatureSettingsForInbox(targetInbox.value.id);
|
||||
});
|
||||
|
||||
const sendWithSignature = computed(() =>
|
||||
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
|
||||
@ -122,10 +210,6 @@ const onContactSearch = debounce(
|
||||
false
|
||||
);
|
||||
|
||||
const resetContacts = () => {
|
||||
contacts.value = [];
|
||||
};
|
||||
|
||||
const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
let contact;
|
||||
if (action === 'create') {
|
||||
@ -171,24 +255,6 @@ const clearSelectedContact = () => {
|
||||
clearFormState();
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
if (!props.contactId) {
|
||||
// If contactId is passed as prop
|
||||
// Then don't allow to remove the selected contact
|
||||
selectedContact.value = null;
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
try {
|
||||
const data = await store.dispatch('contactConversations/create', {
|
||||
@ -254,7 +320,24 @@ const onModalBackdropClick = () => {
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
onMounted(() => resetContacts());
|
||||
const navigateToGroup = ({ conversationId }) => {
|
||||
const url = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: route.params.accountId,
|
||||
id: conversationId,
|
||||
})
|
||||
);
|
||||
router.push({ path: url });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
resetContacts();
|
||||
emitter.on(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
|
||||
});
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
@ -297,30 +380,107 @@ useKeyboardEvents(keyboardEvents);
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
<div
|
||||
v-if="!isGroupMode"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
class="w-[42rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
v-if="hasGroupInboxes"
|
||||
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
!isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('conversation')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('group')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
|
||||
</button>
|
||||
</div>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="{ '!rounded-t-none !border-t-0': hasGroupInboxes }"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:message-signature="resolvedMessageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:signature-settings="resolvedSignatureSettings"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
class="w-[42rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
!isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('conversation')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('group')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
|
||||
</button>
|
||||
</div>
|
||||
<ComposeNewGroupForm
|
||||
ref="groupFormRef"
|
||||
class="!rounded-t-none !border-t-0"
|
||||
:inboxes="groupCreationInboxes"
|
||||
:is-creating="groupUiFlags.isCreating"
|
||||
:is-groups-disabled="isGroupsDisabled"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
@create-group="createGroup"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -17,6 +17,8 @@ import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isWhatsappBaileysInbox: { type: Boolean, default: false },
|
||||
isWhatsappZapiInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
@ -78,7 +80,11 @@ const shouldShowEmojiButton = computed(() => {
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
return (
|
||||
(!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox) ||
|
||||
props.isWhatsappBaileysInbox ||
|
||||
props.isWhatsappZapiInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
@ -4,8 +4,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
@ -41,6 +39,7 @@ const props = defineProps({
|
||||
contactsUiFlags: { type: Object, default: null },
|
||||
messageSignature: { type: String, default: '' },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
signatureSettings: { type: Object, default: null },
|
||||
formState: { type: Object, required: true },
|
||||
});
|
||||
|
||||
@ -77,6 +76,12 @@ const inboxTypes = computed(() => ({
|
||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
|
||||
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
|
||||
isWhatsappBaileys:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'baileys',
|
||||
isWhatsappZapi:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
props.targetInbox?.provider === 'zapi',
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isEmailOrWebWidget:
|
||||
@ -98,12 +103,6 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@ -138,6 +137,9 @@ const newMessagePayload = () => {
|
||||
currentUser: props.currentUser,
|
||||
attachedFiles,
|
||||
directUploadsEnabled: props.isDirectUploadsEnabled,
|
||||
sendWithSignature: props.sendWithSignature,
|
||||
messageSignature: props.messageSignature,
|
||||
signatureSettings: props.signatureSettings,
|
||||
});
|
||||
};
|
||||
|
||||
@ -220,22 +222,9 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
props.messageSignature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
|
||||
@ -245,7 +234,6 @@ const removeTargetInbox = value => {
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
state.attachedFiles = [];
|
||||
@ -255,22 +243,6 @@ const onClickInsertEmoji = emoji => {
|
||||
state.message += emoji;
|
||||
};
|
||||
|
||||
const handleAddSignature = signature => {
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
state.attachedFiles = files;
|
||||
};
|
||||
@ -334,7 +306,9 @@ const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
(!inboxTypes.value.isWhatsapp ||
|
||||
inboxTypes.value.isWhatsappBaileys ||
|
||||
inboxTypes.value.isWhatsappZapi) &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
@ -437,6 +411,8 @@ useKeyboardEvents({
|
||||
v-else
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"
|
||||
:is-whatsapp-zapi-inbox="inboxTypes.isWhatsappZapi"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
@ -450,8 +426,6 @@ useKeyboardEvents({
|
||||
:is-dropdown-active="isAnyDropdownActive"
|
||||
:message-signature="messageSignature"
|
||||
@insert-emoji="onClickInsertEmoji"
|
||||
@add-signature="handleAddSignature"
|
||||
@remove-signature="handleRemoveSignature"
|
||||
@attach-file="handleAttachFile"
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
|
||||
@ -0,0 +1,309 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import ContactsAPI from 'dashboard/api/contacts';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isCreating: { type: Boolean, default: false },
|
||||
isGroupsDisabled: { type: Boolean, default: false },
|
||||
isSuperAdmin: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['createGroup', 'discard']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const groupName = ref('');
|
||||
const selectedInbox = ref(null);
|
||||
const showInboxDropdown = ref(false);
|
||||
const participants = ref([]);
|
||||
const contactResults = ref([]);
|
||||
const showContactsDropdown = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const nameTouched = ref(false);
|
||||
const participantsTouched = ref(false);
|
||||
const participantsFocused = ref(false);
|
||||
|
||||
const inboxMenuItems = computed(() =>
|
||||
props.inboxes.map(inbox => ({
|
||||
label: inbox.name,
|
||||
value: inbox.id,
|
||||
action: 'select',
|
||||
}))
|
||||
);
|
||||
|
||||
const contactMenuItems = computed(() =>
|
||||
contactResults.value.map(contact => ({
|
||||
id: contact.id,
|
||||
label: contact.phone_number
|
||||
? `${contact.name} (${contact.phone_number})`
|
||||
: contact.name,
|
||||
value: contact.id,
|
||||
action: 'contact',
|
||||
thumbnail: { name: contact.name, src: contact.thumbnail },
|
||||
phoneNumber: contact.phone_number,
|
||||
name: contact.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const participantTags = computed(() =>
|
||||
participants.value.map(p => p.name || p.phone_number)
|
||||
);
|
||||
|
||||
const showNameError = computed(
|
||||
() => nameTouched.value && !groupName.value.trim()
|
||||
);
|
||||
const showParticipantsError = computed(
|
||||
() => participantsTouched.value && participants.value.length === 0
|
||||
);
|
||||
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
selectedInbox.value &&
|
||||
groupName.value.trim() &&
|
||||
participants.value.length > 0
|
||||
);
|
||||
|
||||
const searchContacts = debounce(
|
||||
async query => {
|
||||
if (!query || query.length < 2) {
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
isSearching.value = true;
|
||||
try {
|
||||
const { data } = await ContactsAPI.search(query);
|
||||
const selectedIds = participants.value.map(p => p.id);
|
||||
contactResults.value = (data.payload || []).filter(
|
||||
contact => contact.phone_number && !selectedIds.includes(contact.id)
|
||||
);
|
||||
showContactsDropdown.value = contactResults.value.length > 0;
|
||||
} catch {
|
||||
contactResults.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
},
|
||||
300,
|
||||
false
|
||||
);
|
||||
|
||||
const handleInboxAction = item => {
|
||||
const inbox = props.inboxes.find(i => i.id === item.value);
|
||||
selectedInbox.value = inbox;
|
||||
showInboxDropdown.value = false;
|
||||
};
|
||||
|
||||
const clearInbox = () => {
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
const handleAddParticipant = item => {
|
||||
const contact = contactResults.value.find(c => c.id === item.value);
|
||||
if (contact) {
|
||||
participants.value = [...participants.value, contact];
|
||||
participantsTouched.value = true;
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveParticipant = index => {
|
||||
participants.value = participants.value.filter((_, i) => i !== index);
|
||||
participantsTouched.value = true;
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
nameTouched.value = true;
|
||||
};
|
||||
|
||||
const handleParticipantsFocus = () => {
|
||||
participantsFocused.value = true;
|
||||
};
|
||||
|
||||
const handleParticipantsBlur = () => {
|
||||
showContactsDropdown.value = false;
|
||||
if (participantsFocused.value && participants.value.length === 0) {
|
||||
participantsTouched.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
groupName.value = '';
|
||||
selectedInbox.value = null;
|
||||
participants.value = [];
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
showInboxDropdown.value = false;
|
||||
nameTouched.value = false;
|
||||
participantsTouched.value = false;
|
||||
participantsFocused.value = false;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isFormValid.value) return;
|
||||
emit('createGroup', {
|
||||
inboxId: selectedInbox.value.id,
|
||||
subject: groupName.value.trim(),
|
||||
participants: participants.value.map(p => p.phone_number),
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ resetForm });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<div class="flex-1 divide-y divide-n-strong overflow-visible">
|
||||
<div
|
||||
v-if="isGroupsDisabled"
|
||||
class="flex items-center gap-2 mx-4 mt-3 px-3 py-2 rounded-lg text-sm text-n-amber-11 bg-n-amber-2"
|
||||
>
|
||||
<span class="i-lucide-triangle-alert text-base flex-shrink-0" />
|
||||
<span v-if="isSuperAdmin">
|
||||
{{ t('GROUP.CREATE.GROUPS_DISABLED') }}
|
||||
<a
|
||||
:href="wootConstants.FAZER_AI_GUIDES_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline font-medium"
|
||||
>
|
||||
{{ t('GROUP.CREATE.GROUPS_DISABLED_CTA') }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('GROUP.CREATE.GROUPS_DISABLED_NON_ADMIN') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
|
||||
>
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
{{ t('GROUP.CREATE.INBOX_LABEL') }}
|
||||
</label>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
v-if="selectedInbox"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ selectedInbox.name }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="flex-shrink-0"
|
||||
@click="clearInbox"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<Button
|
||||
:label="t('GROUP.CREATE.INBOX_PLACEHOLDER')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="hover:!no-underline"
|
||||
@click="showInboxDropdown = !showInboxDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showInboxDropdown"
|
||||
:menu-items="inboxMenuItems"
|
||||
class="z-[100] top-9 w-full max-h-48 overflow-y-auto dark:!outline-n-slate-5"
|
||||
@action="handleInboxAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
|
||||
>
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap mt-1"
|
||||
:class="showNameError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ t('GROUP.CREATE.NAME_LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<input
|
||||
v-model="groupName"
|
||||
type="text"
|
||||
class="w-full px-2 py-1 text-sm rounded-md bg-transparent text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none border"
|
||||
:class="showNameError ? 'border-n-ruby-9' : 'border-transparent'"
|
||||
:placeholder="t('GROUP.CREATE.NAME_PLACEHOLDER')"
|
||||
@blur="handleNameBlur"
|
||||
/>
|
||||
<span v-if="showNameError" class="text-xs text-n-ruby-9 mt-0.5 px-2">
|
||||
{{ t('GROUP.CREATE.NAME_REQUIRED') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-1 px-4 py-3">
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap"
|
||||
:class="showParticipantsError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ t('GROUP.CREATE.PARTICIPANTS_LABEL') }}
|
||||
</label>
|
||||
<TagInput
|
||||
:model-value="participantTags"
|
||||
:placeholder="t('GROUP.CREATE.PARTICIPANTS_PLACEHOLDER')"
|
||||
mode="multiple"
|
||||
:menu-items="contactMenuItems"
|
||||
:show-dropdown="showContactsDropdown"
|
||||
:is-loading="isSearching"
|
||||
skip-label-dedup
|
||||
:auto-open-dropdown="false"
|
||||
:class="showParticipantsError ? '!border-n-ruby-9' : ''"
|
||||
@input="searchContacts"
|
||||
@focus="handleParticipantsFocus"
|
||||
@on-click-outside="handleParticipantsBlur"
|
||||
@add="handleAddParticipant"
|
||||
@remove="handleRemoveParticipant"
|
||||
/>
|
||||
<span v-if="showParticipantsError" class="text-xs text-n-ruby-9">
|
||||
{{ t('GROUP.CREATE.PARTICIPANTS_REQUIRED') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<div />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="
|
||||
resetForm();
|
||||
emit('discard');
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="t('GROUP.CREATE.SUBMIT_BUTTON')"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:disabled="!isFormValid || isCreating || isGroupsDisabled"
|
||||
:is-loading="isCreating"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,5 +1,6 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { appendSignature } from 'dashboard/helper/editorHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
@ -129,12 +130,24 @@ export const prepareNewMessagePayload = ({
|
||||
currentUser,
|
||||
attachedFiles = [],
|
||||
directUploadsEnabled = false,
|
||||
sendWithSignature = false,
|
||||
messageSignature = '',
|
||||
signatureSettings = null,
|
||||
}) => {
|
||||
let finalMessage = message;
|
||||
if (sendWithSignature && messageSignature) {
|
||||
const settings = signatureSettings || {
|
||||
position: currentUser?.ui_settings?.signature_position || 'top',
|
||||
separator: currentUser?.ui_settings?.signature_separator || 'blank',
|
||||
};
|
||||
finalMessage = appendSignature(message, messageSignature, settings);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
inboxId: targetInbox.id,
|
||||
sourceId: targetInbox.sourceId,
|
||||
contactId: Number(selectedContact.id),
|
||||
message: { content: message },
|
||||
message: { content: finalMessage },
|
||||
assigneeId: currentUser.id,
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user