Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b65f86c9 |
@@ -77,8 +77,7 @@ jobs:
|
||||
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -119,8 +118,7 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -146,13 +144,12 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 18
|
||||
parallelism: 20
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
|
||||
+3
-3
@@ -272,9 +272,9 @@ AZURE_APP_SECRET=
|
||||
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||
|
||||
|
||||
# AI powered features (Captain)
|
||||
# The OpenAI API key and endpoint for Captain are not configured via .env.
|
||||
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
|
||||
# AI powered features
|
||||
## OpenAI key
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Housekeeping/Performance related configurations
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Build Chatwoot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- leal-cyber
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source
|
||||
run: |
|
||||
git clone --depth 1 https://github.com/chatwoot/chatwoot.git /source
|
||||
ls /source/docker/Dockerfile
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
cd /source
|
||||
docker build -t gitea.lealcyber.com/akadmin/chatwoot:test -f docker/Dockerfile --build-arg RAILS_ENV=production --build-arg BUNDLE_WITHOUT='development:test:enterprise' --build-arg NODE_OPTIONS='--max-old-space-size=1024' .
|
||||
|
||||
- name: Verify image
|
||||
run: docker images gitea.lealcyber.com/akadmin/chatwoot:test
|
||||
@@ -0,0 +1,2 @@
|
||||
## All enterprise related files should be reviewed by sojan before merging
|
||||
/enterprise/* @sojan-official
|
||||
@@ -0,0 +1,2 @@
|
||||
open_collective: chatwoot
|
||||
github: chatwoot
|
||||
@@ -0,0 +1,78 @@
|
||||
name: 🐞 Bug report
|
||||
description: Create a report to help us improve
|
||||
labels: 'Bug'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A concise description of what you expected to happen along with screenshots if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
2. With this config...
|
||||
3. Run '...'
|
||||
4. See error...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
|
||||
options:
|
||||
- app.chatwoot.com
|
||||
- Linux VM
|
||||
- Docker
|
||||
- Kubernetes
|
||||
- Heroku
|
||||
- Other [please specify in the description]
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Cloud Provider
|
||||
description:
|
||||
options:
|
||||
- AWS
|
||||
- GCP
|
||||
- Azure
|
||||
- DigitalOcean
|
||||
- Other [please specify in the description]
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Describe the platform you are using
|
||||
options:
|
||||
- Browser
|
||||
- Mobile
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: The operating system and the version you are using.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser and version
|
||||
description: The name of the browser and version you are using.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Docker (if applicable)
|
||||
description: |
|
||||
Please share the output of the following.
|
||||
- `docker version`
|
||||
- `docker info`
|
||||
- `docker-compose version`
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report a security issue
|
||||
url: https://www.chatwoot.com/docs/contributing-guide/security-reports/
|
||||
about: Guidelines and steps to report a security vulnerability. Please report security vulnerabilities here.
|
||||
- name: Product Documentation
|
||||
url: https://www.chatwoot.com/help-center
|
||||
about: If you have questions, are confused, or just want to understand our product better, please check out our documentation.
|
||||
@@ -0,0 +1,28 @@
|
||||
name: 🧙 Feature request
|
||||
description: Suggest an idea for this project
|
||||
labels: 'feature-request'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature or enhancement related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,31 @@
|
||||
# Pull Request Template
|
||||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires.
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
|
||||
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented on my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published in downstream modules
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 966 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 934 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync triage GitHub security advisories to Linear issues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
LINEAR_API = "https://api.linear.app/graphql"
|
||||
|
||||
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
|
||||
SEVERITY_COLOR = {
|
||||
"critical": 15548997,
|
||||
"high": 15105570,
|
||||
"medium": 15844367,
|
||||
"low": 3066993,
|
||||
}
|
||||
DEFAULT_COLOR = 9807270
|
||||
|
||||
|
||||
def required_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
sys.exit(f"Missing required env var: {name}")
|
||||
return value
|
||||
|
||||
|
||||
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
|
||||
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
|
||||
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
advisories: list[dict[str, Any]] = []
|
||||
while url:
|
||||
r = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
r.raise_for_status()
|
||||
advisories.extend(r.json())
|
||||
next_link = r.links.get("next")
|
||||
url = next_link["url"] if next_link else None
|
||||
params = None
|
||||
return advisories
|
||||
|
||||
|
||||
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
|
||||
r = requests.post(
|
||||
LINEAR_API,
|
||||
headers={"Authorization": api_key},
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
|
||||
query = (
|
||||
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
|
||||
"{ nodes { id } } }"
|
||||
)
|
||||
resp = linear_call(query, {"q": ghsa_id}, api_key)
|
||||
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
|
||||
|
||||
|
||||
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
|
||||
query = (
|
||||
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
|
||||
"{ success issue { identifier url } } }"
|
||||
)
|
||||
resp = linear_call(query, {"input": input_data}, api_key)
|
||||
create = resp.get("data", {}).get("issueCreate") or {}
|
||||
if not create.get("success"):
|
||||
return None
|
||||
return create.get("issue")
|
||||
|
||||
|
||||
def reporter_login(advisory: dict[str, Any]) -> str:
|
||||
for credit in advisory.get("credits") or []:
|
||||
user = (credit or {}).get("user") or {}
|
||||
if user.get("login"):
|
||||
return user["login"]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def cvss_score(advisory: dict[str, Any]) -> str:
|
||||
score = (advisory.get("cvss") or {}).get("score")
|
||||
return str(score) if score is not None else "n/a"
|
||||
|
||||
|
||||
def build_description(adv: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"**GHSA:** {adv['ghsa_id']}\n"
|
||||
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
|
||||
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
|
||||
f"**Reporter:** {reporter_login(adv)}\n"
|
||||
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
|
||||
f"**Advisory:** {adv['html_url']}\n\n"
|
||||
f"---\n\n"
|
||||
f"{adv.get('description') or 'No description provided.'}"
|
||||
)
|
||||
|
||||
|
||||
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
|
||||
severity = adv.get("severity") or "unknown"
|
||||
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
|
||||
payload = {
|
||||
"username": "GHSA Sync",
|
||||
"embeds": [
|
||||
{
|
||||
"title": title,
|
||||
"url": issue["url"],
|
||||
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
|
||||
"fields": [
|
||||
{"name": "Linear", "value": issue["identifier"], "inline": True},
|
||||
{
|
||||
"name": "Severity",
|
||||
"value": f"{severity} (CVSS {cvss_score(adv)})",
|
||||
"inline": True,
|
||||
},
|
||||
{
|
||||
"name": "Advisory",
|
||||
"value": f"[GitHub]({adv['html_url']})",
|
||||
"inline": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
try:
|
||||
requests.post(webhook_url, json=payload, timeout=10)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo = required_env("GITHUB_REPOSITORY")
|
||||
gh_token = required_env("GHSA_READ_TOKEN")
|
||||
linear_api_key = required_env("LINEAR_API_KEY")
|
||||
team_id = required_env("LINEAR_TEAM_ID")
|
||||
project_id = required_env("LINEAR_PROJECT_ID")
|
||||
label_id = required_env("LINEAR_LABEL_ID")
|
||||
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
|
||||
|
||||
advisories = fetch_triage_advisories(repo, gh_token)
|
||||
print(f"Fetched {len(advisories)} triage advisories")
|
||||
|
||||
created = skipped = failed = 0
|
||||
|
||||
for adv in advisories:
|
||||
ghsa_id = adv.get("ghsa_id")
|
||||
if not ghsa_id:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
if linear_issue_exists(ghsa_id, linear_api_key):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
severity = adv.get("severity") or "unknown"
|
||||
issue = linear_create_issue(
|
||||
{
|
||||
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
|
||||
"description": build_description(adv),
|
||||
"teamId": team_id,
|
||||
"projectId": project_id,
|
||||
"labelIds": [label_id],
|
||||
"priority": SEVERITY_PRIORITY.get(severity, 3),
|
||||
},
|
||||
linear_api_key,
|
||||
)
|
||||
except requests.RequestException:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
if not issue:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
created += 1
|
||||
if discord_webhook:
|
||||
post_discord(adv, issue, discord_webhook)
|
||||
|
||||
print(f"Created {created}, skipped {skipped}, failed {failed}")
|
||||
return 1 if failed > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Auto-assign PR to Author
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Auto-assign PR to author
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
await github.rest.issues.addAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
assignees: [author]
|
||||
});
|
||||
|
||||
console.log(`Assigned PR #${pull_number} to ${author}`);
|
||||
@@ -0,0 +1,53 @@
|
||||
## github action to check deployment success
|
||||
## curl the deployment url and check for 200 status
|
||||
## deployment url will be of the form chatwoot-pr-<pr_number>.herokuapp.com
|
||||
name: Deploy Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
- name: Print Deployment URL
|
||||
run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com"
|
||||
- name: Check Deployment Status
|
||||
run: |
|
||||
max_attempts=10
|
||||
attempt=1
|
||||
status_code=0
|
||||
echo "Waiting for review app to be deployed/redeployed, trying in 10 minutes..."
|
||||
sleep 600
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
||||
status_code=$(echo $response | head -n 1)
|
||||
if [ $status_code -eq 200 ]; then
|
||||
body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
||||
if echo "$body" | jq -e '.version and .timestamp and .queue_services == "ok" and .data_services == "ok"' > /dev/null; then
|
||||
echo "Deployment successful"
|
||||
exit 0
|
||||
else
|
||||
echo "Deployment status unknown, retrying in 3 minutes..."
|
||||
sleep 180
|
||||
fi
|
||||
else
|
||||
echo "Waiting for review app to be ready, retrying in 3 minutes..."
|
||||
sleep 180
|
||||
attempt=$((attempt + 1))
|
||||
fi
|
||||
done
|
||||
echo "Deployment failed after $max_attempts attempts"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Frontend Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run eslint
|
||||
run: pnpm run eslint
|
||||
|
||||
- name: Run frontend tests with coverage
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
pnpm run test:coverage
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Sync GHSA advisories to Linear
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # daily at 09:30 IST
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: pip install requests==2.32.3
|
||||
- name: Sync advisories
|
||||
env:
|
||||
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
|
||||
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
|
||||
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: python3 .github/scripts/ghsa_linear_sync.py
|
||||
@@ -0,0 +1,23 @@
|
||||
# ref: https://github.com/amannn/action-semantic-pull-request
|
||||
# ensure PR title is in semantic format
|
||||
|
||||
name: "Lint PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,29 @@
|
||||
# We often have cases where users would comment over stale closed Github Issues.
|
||||
# This creates unnecessary noise for the original reporter and makes it harder for triaging.
|
||||
# This action locks the closed threads once it is inactive for over a month.
|
||||
|
||||
name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: lock
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'chatwoot/chatwoot' }}
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
with:
|
||||
issue-inactive-days: '30'
|
||||
issue-lock-reason: 'resolved'
|
||||
pr-inactive-days: '30'
|
||||
pr-lock-reason: 'resolved'
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Log Lines Percentage Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for log lines and calculate percentage
|
||||
run: |
|
||||
# Define the log line pattern
|
||||
LOG_LINE_PATTERN="Rails\.logger"
|
||||
|
||||
# Get the list of changed files in the pull request
|
||||
CHANGED_FILES=$(git diff --name-only)
|
||||
|
||||
# Initialize a flag to track if any files have insufficient log lines
|
||||
INSUFFICIENT_LOGS=0
|
||||
|
||||
for file in $CHANGED_FILES; do
|
||||
if [[ $file =~ \.rb$ && ! $file =~ _spec\.rb$ ]]; then
|
||||
# Count the total number of lines in the file
|
||||
total_lines=$(wc -l < "$file")
|
||||
|
||||
# Count the number of log lines in the file
|
||||
log_lines=$(grep -c "$LOG_LINE_PATTERN" "$file")
|
||||
|
||||
# Calculate the percentage of log lines
|
||||
if [ "$total_lines" -gt 0 ]; then
|
||||
percentage=$(awk "BEGIN { pc=100*${log_lines}/${total_lines}; i=int(pc); print (pc-i<0.5)?i:i+1 }")
|
||||
else
|
||||
percentage=0
|
||||
fi
|
||||
|
||||
# Check if the percentage is less than 5%
|
||||
if [ "$percentage" -lt 5 ]; then
|
||||
echo "Error: Log lines percentage is less than 5% ($percentage%) in $file. Please add more log lines using Rails.logger statements."
|
||||
INSUFFICIENT_LOGS=1
|
||||
else
|
||||
echo "Log lines percentage is $percentage% in $file. Code looks good!"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If any files have insufficient log lines, fail the action
|
||||
if [ "$INSUFFICIENT_LOGS" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
# #
|
||||
# #
|
||||
# # Linux nightly installer action
|
||||
# # This action will try to install and setup
|
||||
# # chatwoot on an Ubuntu 22.04 machine using
|
||||
# # the linux installer script.
|
||||
# #
|
||||
# # This is set to run daily at midnight.
|
||||
# #
|
||||
|
||||
name: Run Linux nightly installer
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
- name: get installer
|
||||
run: |
|
||||
wget https://get.chatwoot.app/linux/install.sh
|
||||
chmod +x install.sh
|
||||
#fix for postgtres not starting automatically in gh action env
|
||||
sed -i '/function configure_db() {/a sudo service postgresql start' install.sh
|
||||
|
||||
- name: create input file
|
||||
run: |
|
||||
echo "no" > input
|
||||
echo "yes" >> input
|
||||
|
||||
- name: Run the installer
|
||||
run: |
|
||||
sudo ./install.sh --install < input
|
||||
|
||||
# disabling http verify for now as http
|
||||
# access to port 3000 fails in gh action env
|
||||
# - name: Verify
|
||||
# if: always()
|
||||
# run: |
|
||||
# sudo netstat -ntlp | grep 3000
|
||||
# sudo systemctl restart chatwoot.target
|
||||
# curl http://localhost:3000/api
|
||||
|
||||
- name: Upload chatwoot setup log file as an artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: chatwoot-setup-log-file
|
||||
path: /var/log/chatwoot-setup.log
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Publish Codespace Base Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish-code-space-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build the Codespace Base Image
|
||||
run: |
|
||||
docker compose -f .devcontainer/docker-compose.base.yml build base
|
||||
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||
@@ -0,0 +1,143 @@
|
||||
# #
|
||||
# # This action will publish Chatwoot EE docker image.
|
||||
# # This is set to run against merges to develop, master
|
||||
# # and when tags are created.
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot EE docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.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 DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_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.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t $TAG \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect $TAG
|
||||
@@ -0,0 +1,148 @@
|
||||
# #
|
||||
# # This action will publish Chatwoot CE docker image.
|
||||
# # This is set to run against merges to develop, master
|
||||
# # and when tags are created.
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot CE docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Set Chatwoot edition
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.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 DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_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.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t $TAG \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect $TAG
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Run Chatwoot CE spec
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Separate linting jobs for faster feedback
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
- name: Run Rubocop
|
||||
run: bundle exec rubocop --parallel
|
||||
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
- name: Run ESLint
|
||||
run: pnpm run eslint
|
||||
|
||||
# Frontend tests run in parallel with backend
|
||||
frontend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
- name: Run frontend tests
|
||||
run: pnpm run test:coverage
|
||||
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
ci_node_total: [16]
|
||||
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run backend tests (parallelized)
|
||||
run: |
|
||||
# Get all spec files and split them using round-robin distribution
|
||||
# This ensures slow tests are distributed evenly across all nodes
|
||||
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||
TESTS=""
|
||||
|
||||
for i in "${!SPEC_FILES[@]}"; do
|
||||
# Assign spec to this node if: index % total == node_index
|
||||
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
|
||||
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$TESTS" ]; then
|
||||
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
||||
fi
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: rspec-results-${{ matrix.ci_node_index }}
|
||||
path: tmp/rspec_results.json
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: rails-log-folder-${{ matrix.ci_node_index }}
|
||||
path: log
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Run MFA Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
# Only run if MFA test keys are available
|
||||
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
POSTGRES_HOST: localhost
|
||||
# Active Record encryption keys required for MFA - test keys only, not for production use
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Install pgvector extension
|
||||
run: |
|
||||
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run MFA-related backend tests
|
||||
run: |
|
||||
bundle exec rspec \
|
||||
spec/services/mfa/token_service_spec.rb \
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Run MFA-related tests in user_spec
|
||||
run: |
|
||||
# Run specific MFA-related tests from user_spec
|
||||
bundle exec rspec spec/models/user_spec.rb \
|
||||
-e "two factor" \
|
||||
-e "2FA" \
|
||||
-e "MFA" \
|
||||
-e "otp" \
|
||||
-e "backup code" \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test logs
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: mfa-test-logs
|
||||
path: |
|
||||
log/test.log
|
||||
tmp/screenshots/
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Run Size Limit Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: pnpm
|
||||
run: pnpm install
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: setup env
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Run asset compile
|
||||
run: bundle exec rake assets:precompile
|
||||
env:
|
||||
RAILS_ENV: production
|
||||
|
||||
- name: Size Check
|
||||
run: pnpm run size
|
||||
@@ -0,0 +1,28 @@
|
||||
# This workflow warns and then closes PRs that have had no activity for a specified amount of time.
|
||||
#
|
||||
# You can adjust the behavior by modifying this file.
|
||||
# For more information, see:
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '28 3 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-close: -1,
|
||||
days-before-issue-stale: -1
|
||||
days-before-pr-close: -1,
|
||||
days-before-pr-stale: 30,
|
||||
stale-pr-message: '🐢 Turtley slow progress alert! This pull request has been idle for over 30 days. Can we please speed things up and either merge it or release it back into the wild?'
|
||||
stale-pr-label: 'stale'
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Test Docker Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-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 }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
load: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
@@ -43,18 +43,13 @@
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Prefer the smallest production-ready change that solves the current problem.
|
||||
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
|
||||
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
|
||||
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
|
||||
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
|
||||
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
|
||||
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
|
||||
- MVP focus: Least code change, happy-path only
|
||||
- No unnecessary defensive programming
|
||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||
- Break down complex tasks into small, testable units
|
||||
- Iterate after confirmation
|
||||
- Avoid writing specs unless explicitly asked
|
||||
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
|
||||
- Remove dead/unreachable/unused code
|
||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||
@@ -85,8 +80,8 @@
|
||||
## Project-Specific
|
||||
|
||||
- **Translations**:
|
||||
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
|
||||
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
|
||||
- Only update `en.yml` and `en.json`
|
||||
- Other languages are handled by the community
|
||||
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
|
||||
- **Frontend**:
|
||||
- Use `components-next/` for message bubbles (the rest is being deprecated)
|
||||
|
||||
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma', '~> 7.2', '>= 7.2.1'
|
||||
gem 'puma'
|
||||
gem 'vite_rails'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.12.0'
|
||||
gem 'ai-agents', '>= 0.10.0'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.14.1'
|
||||
|
||||
+56
-71
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.12.0)
|
||||
ai-agents (0.10.0)
|
||||
ruby_llm (~> 1.14)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
@@ -136,8 +136,6 @@ GEM
|
||||
audited (5.4.1)
|
||||
activerecord (>= 5.0, < 7.7)
|
||||
activesupport (>= 5.0, < 7.7)
|
||||
auth-sanitizer (0.2.1)
|
||||
version_gem (~> 1.1, >= 1.1.10)
|
||||
aws-actionmailbox-ses (0.1.0)
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
@@ -170,7 +168,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (4.1.2)
|
||||
bigdecimal (3.2.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -186,19 +184,18 @@ GEM
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.7)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.7)
|
||||
crass (1.0.6)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
@@ -211,14 +208,13 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
datadog (2.38.0)
|
||||
cgi
|
||||
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
|
||||
libdatadog (~> 36.0.0.1.0)
|
||||
libddwaf (~> 1.30.0.0.0)
|
||||
datadog (2.19.0)
|
||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
||||
libdatadog (~> 18.1.0.1.0)
|
||||
libddwaf (~> 1.24.1.0.3)
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.5.3)
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.5.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
@@ -275,8 +271,8 @@ GEM
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
@@ -305,7 +301,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.3)
|
||||
faraday (2.14.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -316,7 +312,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.4)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
@@ -439,8 +435,7 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
hashie (5.0.0)
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http (5.1.1)
|
||||
@@ -475,7 +470,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.9)
|
||||
json (2.19.5)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -523,16 +518,15 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
libdatadog (36.0.0.1.0)
|
||||
libdatadog (36.0.0.1.0-arm64-darwin)
|
||||
libdatadog (36.0.0.1.0-x86_64-linux)
|
||||
libddwaf (1.30.0.0.2)
|
||||
libdatadog (18.1.0.1.0)
|
||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
||||
libddwaf (1.24.1.0.3)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.30.0.0.2-arm64-darwin)
|
||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.30.0.0.2-x86_64-darwin)
|
||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.30.0.0.2-x86_64-linux)
|
||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.28.0)
|
||||
lint_roller (1.1.0)
|
||||
@@ -549,7 +543,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.25.2)
|
||||
loofah (2.23.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -572,9 +566,9 @@ GEM
|
||||
minitest (5.25.5)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.8.3)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.9.1)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
@@ -584,7 +578,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.6.4.1)
|
||||
net-imap (0.4.24)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -599,37 +593,30 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4)
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
base64 (~> 0.1)
|
||||
cgi
|
||||
oauth-tty (~> 1.0, >= 1.0.8)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oauth-tty (1.0.8)
|
||||
auth-sanitizer (~> 0.1, >= 0.1.3)
|
||||
cgi
|
||||
version_gem (~> 1.1, >= 1.1.9)
|
||||
oauth2 (2.0.22)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
jwt (>= 1.0, < 4.0)
|
||||
logger (~> 1.2)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oauth-tty (1.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
oauth2 (2.0.9)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.17.3)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
@@ -676,7 +663,7 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
@@ -695,7 +682,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (7.0.5)
|
||||
puma (7.2.1)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -742,8 +729,8 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
@@ -948,9 +935,9 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.5)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
@@ -1008,14 +995,12 @@ GEM
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.11)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
version_gem (1.1.4)
|
||||
vite_rails (3.0.17)
|
||||
railties (>= 5.1, < 8)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.10.2)
|
||||
vite_ruby (3.8.0)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
logger (~> 1.6)
|
||||
mutex_m
|
||||
rack-proxy (~> 0.6, >= 0.6.1)
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
@@ -1032,7 +1017,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
websocket-driver (0.8.2)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -1060,7 +1045,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.12.0)
|
||||
ai-agents (>= 0.10.0)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -1145,7 +1130,7 @@ DEPENDENCIES
|
||||
pgvector
|
||||
procore-sift
|
||||
pry-rails
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
puma
|
||||
pundit
|
||||
rack-attack (>= 6.7.0)
|
||||
rack-cors (= 2.0.0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2017-2026 Chatwoot Inc.
|
||||
Copyright (c) 2017-2024 Chatwoot Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
|
||||
@@ -137,9 +137,3 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
# Trigger rebuild
|
||||
### Runner test
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.16.1
|
||||
4.14.1
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -22,23 +14,15 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
|
||||
|
||||
def create_contact
|
||||
account.contacts.create!(
|
||||
name: contact_name,
|
||||
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def contact_name
|
||||
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
|
||||
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
|
||||
end
|
||||
|
||||
def find_contact
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
|
||||
@@ -91,21 +91,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
|
||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url']
|
||||
}
|
||||
end
|
||||
|
||||
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
||||
# Both `share` and `post` attachment types carry a page URL rather than a media file,
|
||||
# so map them to `fallback` (which keeps the title/link without attempting a download).
|
||||
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
||||
def normalize_file_type(type)
|
||||
return :fallback if [:share, :post].include?(type.to_sym)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
|
||||
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
|
||||
file: uploaded_attachment
|
||||
)
|
||||
|
||||
attachment.file_type = attachment_file_type(uploaded_attachment)
|
||||
tag_voice_message(attachment)
|
||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(
|
||||
uploaded_attachment
|
||||
)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def attachment_file_type(uploaded_attachment)
|
||||
if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(uploaded_attachment)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
end
|
||||
|
||||
def tag_voice_message(attachment)
|
||||
return unless @is_voice_message && attachment.file_type == 'audio'
|
||||
|
||||
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
|
||||
end
|
||||
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
|
||||
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
params = attachment_params(attachment)
|
||||
# During Meta's sticker webhook transition, a sticker message carries both an `image`
|
||||
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
|
||||
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
|
||||
return if duplicate_sticker?(attachment, params[:external_url])
|
||||
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
if facebook_reel?(attachment)
|
||||
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_attachment_links(attachment_obj)
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def fetch_attachment_links(attachment_obj)
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
@@ -120,20 +111,13 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
def duplicate_sticker?(attachment, url)
|
||||
return false unless attachment['type'].to_sym == :sticker
|
||||
return false if url.blank?
|
||||
|
||||
@message.attachments.any? { |existing| existing.external_url == url }
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
class V2::Reports::DrilldownBuilder
|
||||
include DateRangeHelper
|
||||
include TimezoneHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
DEFAULT_PAGE = 1
|
||||
DEFAULT_PER_PAGE = 25
|
||||
MAX_PER_PAGE = 100
|
||||
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
|
||||
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
|
||||
MESSAGE_METRICS = {
|
||||
'incoming_messages_count' => :incoming,
|
||||
'outgoing_messages_count' => :outgoing
|
||||
}.freeze
|
||||
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
|
||||
|
||||
def build
|
||||
records = paginated_records.to_a
|
||||
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def meta
|
||||
{
|
||||
metric: metric,
|
||||
record_type: record_type,
|
||||
bucket: {
|
||||
since: bucket_range.begin.to_i,
|
||||
until: bucket_range.end.to_i
|
||||
},
|
||||
current_page: current_page,
|
||||
per_page: per_page,
|
||||
total_count: paginated_records.total_count,
|
||||
conversation_count: conversation_count
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_count
|
||||
return paginated_records.total_count if conversation_metric?
|
||||
|
||||
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
|
||||
end
|
||||
|
||||
def paginated_records
|
||||
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
|
||||
end
|
||||
|
||||
def drilldown_scope
|
||||
if message_metric?
|
||||
message_scope
|
||||
elsif conversation_metric?
|
||||
conversation_scope
|
||||
else
|
||||
reporting_event_scope
|
||||
end
|
||||
end
|
||||
|
||||
def message_scope
|
||||
scope.messages
|
||||
.where(account_id: account.id, created_at: bucket_range)
|
||||
.public_send(MESSAGE_METRICS.fetch(metric))
|
||||
.includes(:sender, conversation: [:assignee, :contact, :inbox])
|
||||
.reorder(created_at: :desc)
|
||||
end
|
||||
|
||||
def conversation_scope
|
||||
scope.conversations
|
||||
.where(account_id: account.id, created_at: bucket_range)
|
||||
.includes(:assignee, :contact, :inbox)
|
||||
.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def reporting_event_scope
|
||||
events = scope.reporting_events
|
||||
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
|
||||
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
|
||||
.order(created_at: :desc)
|
||||
|
||||
if raw_count_strategy == :exclude_bot_handoffs
|
||||
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
elsif raw_count_strategy == :distinct_conversation
|
||||
events = events.where(id: distinct_conversation_event_ids(events))
|
||||
end
|
||||
|
||||
events
|
||||
end
|
||||
|
||||
def bot_handoff_conversation_ids_subquery
|
||||
scope.reporting_events
|
||||
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
|
||||
.where.not(conversation_id: nil)
|
||||
.select(:conversation_id)
|
||||
end
|
||||
|
||||
def distinct_conversation_event_ids(events)
|
||||
events.reorder(nil)
|
||||
.where.not(conversation_id: nil)
|
||||
.select('MAX(reporting_events.id)')
|
||||
.group(:conversation_id)
|
||||
end
|
||||
|
||||
def record_serializer(records)
|
||||
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
|
||||
account,
|
||||
metric,
|
||||
use_business_hours?,
|
||||
records
|
||||
)
|
||||
end
|
||||
|
||||
def bucket_range
|
||||
@bucket_range ||= begin
|
||||
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
|
||||
bucket_end = bucket_end_for(bucket_start)
|
||||
requested_start = Time.zone.at(params[:since].to_i)
|
||||
requested_end = Time.zone.at(params[:until].to_i)
|
||||
|
||||
[bucket_start, requested_start].max...[bucket_end, requested_end].min
|
||||
end
|
||||
end
|
||||
|
||||
def bucket_end_for(bucket_start)
|
||||
{
|
||||
'hour' => bucket_start + 1.hour,
|
||||
'day' => bucket_start + 1.day,
|
||||
'week' => bucket_start + 1.week,
|
||||
'month' => bucket_start + 1.month,
|
||||
'year' => bucket_start + 1.year
|
||||
}.fetch(group_by)
|
||||
end
|
||||
|
||||
def scope
|
||||
case dimension_type
|
||||
when 'account' then account
|
||||
when 'inbox' then inbox
|
||||
when 'agent' then user
|
||||
when 'label' then label
|
||||
when 'team' then team
|
||||
else
|
||||
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
|
||||
end
|
||||
end
|
||||
|
||||
def inbox = @inbox ||= account.inboxes.find(params[:id])
|
||||
|
||||
def user = @user ||= account.users.find(params[:id])
|
||||
|
||||
def label = @label ||= account.labels.find(params[:id])
|
||||
|
||||
def team = @team ||= account.teams.find(params[:id])
|
||||
|
||||
def metric
|
||||
params[:metric].to_s
|
||||
end
|
||||
|
||||
def report_metric
|
||||
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
||||
end
|
||||
|
||||
def raw_event_name
|
||||
report_metric&.raw_event_name
|
||||
end
|
||||
|
||||
def raw_count_strategy
|
||||
report_metric&.raw_count_strategy
|
||||
end
|
||||
|
||||
def record_type
|
||||
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
|
||||
|
||||
'conversation'
|
||||
end
|
||||
|
||||
def message_metric?
|
||||
MESSAGE_METRICS.key?(metric)
|
||||
end
|
||||
|
||||
def conversation_metric?
|
||||
metric == 'conversations_count'
|
||||
end
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
end
|
||||
|
||||
def group_by
|
||||
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
|
||||
def current_page
|
||||
[params[:page].to_i, DEFAULT_PAGE].max
|
||||
end
|
||||
|
||||
def per_page
|
||||
requested_per_page = params[:per_page].to_i
|
||||
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
|
||||
|
||||
[requested_per_page, MAX_PER_PAGE].min
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
end
|
||||
end
|
||||
@@ -1,199 +0,0 @@
|
||||
class V2::Reports::DrilldownRecordSerializer
|
||||
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||
|
||||
attr_reader :account, :metric, :use_business_hours, :records
|
||||
|
||||
def initialize(account, metric, use_business_hours, records = [])
|
||||
@account = account
|
||||
@metric = metric
|
||||
@use_business_hours = use_business_hours
|
||||
@records = records
|
||||
end
|
||||
|
||||
def serialize(record)
|
||||
return serialize_message(record) if record.is_a?(Message)
|
||||
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
|
||||
|
||||
serialize_conversation(record)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_message(message, metric_value: nil, occurred_at: nil)
|
||||
{
|
||||
record_type: 'message',
|
||||
conversation: conversation_attributes(message.conversation),
|
||||
message: message_attributes(message),
|
||||
metric_value: metric_value,
|
||||
occurred_at: (occurred_at || message.created_at).to_i
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_conversation_event(event)
|
||||
inferred_message = inferred_message_for(event)
|
||||
if inferred_message.present?
|
||||
return serialize_message(
|
||||
inferred_message,
|
||||
metric_value: event_metric_value(event),
|
||||
occurred_at: event_timestamp(event)
|
||||
)
|
||||
end
|
||||
|
||||
serialize_conversation(
|
||||
event.conversation,
|
||||
metric_value: event_metric_value(event),
|
||||
occurred_at: event_timestamp(event),
|
||||
event_name: event.name
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
|
||||
serialized_record = {
|
||||
record_type: 'conversation',
|
||||
conversation: conversation_attributes(conversation),
|
||||
message: nil,
|
||||
metric_value: metric_value,
|
||||
occurred_at: (occurred_at || conversation&.created_at)&.to_i
|
||||
}
|
||||
serialized_record[:event_name] = event_name if event_name.present?
|
||||
serialized_record
|
||||
end
|
||||
|
||||
def conversation_attributes(conversation)
|
||||
return {} if conversation.blank?
|
||||
|
||||
{
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id,
|
||||
contact_id: conversation.contact_id,
|
||||
contact_name: conversation.contact&.name,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox&.name,
|
||||
assignee_id: conversation.assignee_id,
|
||||
assignee_name: conversation.assignee&.name,
|
||||
status: conversation.status,
|
||||
created_at: conversation.created_at.to_i,
|
||||
last_activity_at: conversation.last_activity_at.to_i,
|
||||
last_message: last_message_attributes(conversation)
|
||||
}
|
||||
end
|
||||
|
||||
def message_attributes(message)
|
||||
{
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
message_type: message.message_type,
|
||||
sender_name: message.sender&.try(:name),
|
||||
created_at: message.created_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def last_message_attributes(conversation)
|
||||
message = latest_messages_by_conversation_id[conversation.id]
|
||||
return if message.blank?
|
||||
|
||||
message_attributes(message)
|
||||
end
|
||||
|
||||
def inferred_message_for(event)
|
||||
return unless MESSAGE_EVENT_METRICS.include?(metric)
|
||||
return if event.conversation.blank? || event.event_end_time.blank?
|
||||
|
||||
inferred_messages_by_event_id[event.id]
|
||||
end
|
||||
|
||||
def first_response_event_with_user?(event)
|
||||
metric == 'avg_first_response_time' && event.user_id.present?
|
||||
end
|
||||
|
||||
def message_inference_range(event)
|
||||
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
|
||||
end
|
||||
|
||||
def event_metric_value(event)
|
||||
use_business_hours ? event.value_in_business_hours : event.value
|
||||
end
|
||||
|
||||
def event_timestamp(event)
|
||||
event.event_end_time || event.created_at
|
||||
end
|
||||
|
||||
def latest_messages_by_conversation_id
|
||||
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
|
||||
{}
|
||||
else
|
||||
latest_messages.index_by(&:conversation_id)
|
||||
end
|
||||
end
|
||||
|
||||
def latest_messages
|
||||
Message
|
||||
.where(account_id: account.id, conversation_id: conversation_ids)
|
||||
.where.not(message_type: :activity)
|
||||
.select('DISTINCT ON (messages.conversation_id) messages.*')
|
||||
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
|
||||
.includes(:sender)
|
||||
end
|
||||
|
||||
def inferred_messages_by_event_id
|
||||
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
|
||||
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
|
||||
message_matches_event?(message, event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def inferred_message_candidates
|
||||
@inferred_message_candidates ||= if inference_events.blank?
|
||||
[]
|
||||
else
|
||||
inferred_messages.to_a
|
||||
end
|
||||
end
|
||||
|
||||
def inferred_messages
|
||||
Message
|
||||
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
|
||||
.where(created_at: inference_time_range)
|
||||
.where(message_type: %i[outgoing template])
|
||||
.includes(:sender)
|
||||
.reorder(created_at: :desc, id: :desc)
|
||||
end
|
||||
|
||||
def message_matches_event?(message, event)
|
||||
message.conversation_id == event.conversation_id &&
|
||||
message.created_at.between?(
|
||||
message_inference_range(event).begin,
|
||||
message_inference_range(event).end
|
||||
) &&
|
||||
message_sender_matches_event?(message, event)
|
||||
end
|
||||
|
||||
def message_sender_matches_event?(message, event)
|
||||
return true unless first_response_event_with_user?(event)
|
||||
|
||||
message.sender_id == event.user_id && message.sender_type == 'User'
|
||||
end
|
||||
|
||||
def inference_time_range
|
||||
event_end_times = inference_events.map(&:event_end_time)
|
||||
|
||||
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
|
||||
end
|
||||
|
||||
def inference_events
|
||||
@inference_events ||= records.select do |record|
|
||||
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_ids
|
||||
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
|
||||
end
|
||||
|
||||
def conversation_id_for(record)
|
||||
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
|
||||
|
||||
record.id
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :check_authorization
|
||||
before_action :agent_bot, except: [:index, :create]
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
bulk_create_agents(emails)
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
end
|
||||
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
persist_article_changes if params[:article].present?
|
||||
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
|
||||
@article.update!(article_params) if params[:article].present?
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reorder
|
||||
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
render json: { positions: positions }
|
||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
@@ -67,26 +67,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
# Draft-only autosaves must not bump the public-facing updated_at, so write
|
||||
# them with update_columns (which skips the timestamp). update_columns also
|
||||
# skips validations, so assign and validate first to avoid persisting content
|
||||
# that exceeds the column length limit.
|
||||
def persist_article_changes
|
||||
keys = article_params.to_h.keys
|
||||
if keys.any? && (keys - %w[draft_title draft_content]).empty?
|
||||
@article.assign_attributes(article_params)
|
||||
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
|
||||
else
|
||||
@article.update!(article_params)
|
||||
end
|
||||
end
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||
:locale, :draft_title, :draft_content, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
:locale, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
|
||||
before_action :fetch_inboxes
|
||||
|
||||
def index
|
||||
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
|
||||
@include_agent_bots = params[:include_agent_bots].present?
|
||||
agent_ids = @inboxes.map do |inbox|
|
||||
authorize inbox, :show?
|
||||
member_ids = inbox.members.pluck(:user_id)
|
||||
@@ -12,7 +10,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
|
||||
agent_ids = agent_ids.inject(:&)
|
||||
agents = Current.account.users.where(id: agent_ids)
|
||||
@assignable_agents = (agents + Current.account.administrators).uniq
|
||||
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled,
|
||||
:exclude_older_than_hours
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,14 +2,5 @@ class Api::V1::Accounts::BaseController < Api::BaseController
|
||||
include SwitchLocale
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
around_action :switch_locale_using_account_locale
|
||||
|
||||
private
|
||||
|
||||
def validate_token_api_access
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def show
|
||||
set_branded_email_layout
|
||||
end
|
||||
|
||||
def update
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return
|
||||
end
|
||||
|
||||
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
|
||||
set_branded_email_layout
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_branded_email_layout
|
||||
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
|
||||
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
page_access_token = params[:page_access_token]
|
||||
page_id = params[:page_id]
|
||||
inbox_name = params[:inbox_name]
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
facebook_channel = Current.account.facebook_pages.create!(
|
||||
page_id: page_id, user_access_token: user_access_token,
|
||||
@@ -16,8 +15,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
set_instagram_id(page_access_token, facebook_channel)
|
||||
set_avatar(@facebook_inbox, page_id)
|
||||
end
|
||||
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
Rails.logger.error "Error in register_facebook_page: #{e.message}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :authorize_account_update, only: [:update]
|
||||
|
||||
def show
|
||||
@@ -7,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
||||
|
||||
def update
|
||||
params_to_update = captain_params
|
||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
|
||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
|
||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
|
||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
|
||||
@current_account.save!
|
||||
|
||||
render json: preferences_payload
|
||||
@@ -37,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
||||
|
||||
def merged_captain_models
|
||||
existing_models = @current_account.captain_models || {}
|
||||
existing_models.merge(permitted_captain_models).compact_blank.presence
|
||||
existing_models.merge(permitted_captain_models)
|
||||
end
|
||||
|
||||
def merged_captain_features
|
||||
@@ -46,38 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def permitted_captain_models
|
||||
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
|
||||
params.require(:captain_models).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def permitted_captain_features
|
||||
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def captain_feature_keys
|
||||
Llm::Models.feature_keys.map(&:to_sym)
|
||||
params.require(:captain_features).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def features_with_account_preferences
|
||||
preferences = Current.account.captain_preferences
|
||||
account_features = preferences[:features] || {}
|
||||
account_models = preferences[:models] || {}
|
||||
|
||||
Llm::Models.feature_keys.index_with do |feature_key|
|
||||
config = Llm::Models.feature_config(feature_key)
|
||||
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
|
||||
config.merge(
|
||||
default: default_model_for(feature_key),
|
||||
enabled: account_features[feature_key] == true,
|
||||
model: route[:model],
|
||||
selected: route[:model],
|
||||
provider: route[:provider],
|
||||
source: route[:source]
|
||||
selected: account_models[feature_key] || config[:default]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def default_model_for(feature_key)
|
||||
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
Llm::Models.default_model_for(feature_key)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(
|
||||
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
|
||||
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
|
||||
def create
|
||||
process_create
|
||||
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -214,5 +214,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
render json: error, status: error_status
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||
include RequestExceptionHandler
|
||||
include AccessTokenAuthHelper
|
||||
include EnsureCurrentAccountHelper
|
||||
|
||||
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
|
||||
|
||||
around_action :handle_with_exception
|
||||
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
|
||||
before_action :authenticate_user!, unless: :authenticate_by_access_token?
|
||||
before_action :current_account
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||
before_action :conversation
|
||||
|
||||
def create
|
||||
@@ -22,16 +11,6 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
|
||||
|
||||
private
|
||||
|
||||
def authenticate_by_access_token?
|
||||
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if Current.account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
render json: { content: translated_content }
|
||||
rescue Google::Cloud::Error => e
|
||||
# `details` carries the clean human message; `message` includes gRPC debug noise
|
||||
render_could_not_create_error(e.details.presence || e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,40 +1,27 @@
|
||||
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
|
||||
include Events::Types
|
||||
|
||||
def show
|
||||
@participants = @conversation.conversation_participants
|
||||
end
|
||||
|
||||
def create
|
||||
participant_ids_to_add = participants_to_be_added_ids
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
||||
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
||||
end
|
||||
notify_unread_count_change if participant_ids_to_add.any?
|
||||
end
|
||||
|
||||
def update
|
||||
participant_ids_to_add = participants_to_be_added_ids
|
||||
participant_ids_to_remove = participants_to_be_removed_ids
|
||||
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
||||
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
|
||||
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
||||
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
|
||||
end
|
||||
notify_unread_count_change if changed_participant_ids.any?
|
||||
@participants = @conversation.conversation_participants
|
||||
render action: 'show'
|
||||
end
|
||||
|
||||
def destroy
|
||||
participant_ids_to_remove = current_participant_ids & params[:user_ids]
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
|
||||
end
|
||||
notify_unread_count_change if participant_ids_to_remove.any?
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -51,11 +38,4 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
|
||||
def current_participant_ids
|
||||
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
|
||||
end
|
||||
|
||||
def notify_unread_count_change
|
||||
return unless Current.account.feature_enabled?('conversation_unread_counts')
|
||||
return unless Current.account.feature_enabled?('unread_count_for_filters')
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,28 +2,12 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
|
||||
before_action :ensure_unread_counts_enabled
|
||||
|
||||
def index
|
||||
counts = if filtered_unread_counts_enabled?
|
||||
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
|
||||
else
|
||||
unread_counts
|
||||
end
|
||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
render json: { payload: counts }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unread_counts
|
||||
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
end
|
||||
|
||||
def filtered_unread_counts_enabled?
|
||||
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
|
||||
end
|
||||
|
||||
def instrumentation
|
||||
::Conversations::UnreadCounts::FilteredCountInstrumentation
|
||||
end
|
||||
|
||||
def ensure_unread_counts_enabled
|
||||
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def destroy
|
||||
authorize @conversation, :destroy?
|
||||
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
|
||||
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -164,7 +164,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
||||
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_dashboard_apps, except: [:create]
|
||||
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
require 'csv'
|
||||
|
||||
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
|
||||
DATA_IMPORT_FEATURE = 'data_import'.freeze
|
||||
|
||||
before_action :ensure_data_import_feature_enabled
|
||||
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
|
||||
data_import_ids = @data_imports.map(&:id)
|
||||
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
|
||||
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
|
||||
end
|
||||
|
||||
def show
|
||||
render_show
|
||||
end
|
||||
|
||||
def validate_source
|
||||
totals = validate_intercom_source
|
||||
render json: { valid: true, totals: totals }
|
||||
rescue DataImports::Intercom::Client::AuthenticationError
|
||||
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
|
||||
rescue DataImports::Intercom::Client::Error
|
||||
render_source_validation_error('Intercom could not be reached. Please try again.')
|
||||
rescue ArgumentError => e
|
||||
render_source_validation_error(e.message)
|
||||
end
|
||||
|
||||
def create
|
||||
@data_import = creation_service.perform
|
||||
unless @data_import
|
||||
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
|
||||
render_show
|
||||
rescue DataImports::Intercom::Client::AuthenticationError
|
||||
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
|
||||
rescue DataImports::Intercom::Client::Error
|
||||
render_source_validation_error('Intercom could not be reached. Please try again.')
|
||||
rescue ArgumentError => e
|
||||
render_source_validation_error(e.message)
|
||||
end
|
||||
|
||||
def start
|
||||
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
|
||||
restart_result = restart_service.perform
|
||||
@data_import = restart_service.data_import
|
||||
if restart_result == :access_token_missing
|
||||
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
|
||||
render_show
|
||||
end
|
||||
|
||||
def abandon
|
||||
@data_import.abandon!
|
||||
render_show
|
||||
end
|
||||
|
||||
def skip_logs
|
||||
send_data(
|
||||
skip_logs_csv,
|
||||
filename: "data-import-#{@data_import.id}-skip-logs.csv",
|
||||
type: 'text/csv'
|
||||
)
|
||||
end
|
||||
|
||||
def error_logs
|
||||
send_data(
|
||||
error_logs_csv,
|
||||
filename: "data-import-#{@data_import.id}-error-logs.csv",
|
||||
type: 'text/csv'
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_data_import_feature_enabled
|
||||
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
|
||||
end
|
||||
|
||||
def set_data_import
|
||||
@data_import = Current.account.data_imports.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(@data_import || DataImport)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :source_provider, :access_token, import_types: [])
|
||||
end
|
||||
|
||||
def creation_service
|
||||
DataImports::Intercom::CreationService.new(
|
||||
account: Current.account,
|
||||
initiated_by: Current.user,
|
||||
source_params: permitted_params.to_h
|
||||
)
|
||||
end
|
||||
|
||||
def import_types
|
||||
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
|
||||
|
||||
Array(permitted_params[:import_types]).compact_blank
|
||||
end
|
||||
|
||||
def validate_intercom_source
|
||||
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
|
||||
|
||||
DataImports::Intercom::CredentialsValidator.new(
|
||||
access_token: permitted_params[:access_token],
|
||||
import_types: import_types
|
||||
).perform
|
||||
end
|
||||
|
||||
def render_source_validation_error(message)
|
||||
render json: { valid: false, message: message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def render_show
|
||||
@import_errors_finder = DataImportErrorFinder.new(@data_import)
|
||||
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
|
||||
render :show
|
||||
end
|
||||
|
||||
def skip_logs_csv
|
||||
logs_csv(@data_import.import_errors.skip_logs)
|
||||
end
|
||||
|
||||
def error_logs_csv
|
||||
logs_csv(@data_import.import_errors.non_skip_logs)
|
||||
end
|
||||
|
||||
def logs_csv(logs)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[created_at kind source_object_type source_object_id error_code message details]
|
||||
|
||||
logs.order(:created_at).find_each do |log|
|
||||
csv << [
|
||||
log.created_at.iso8601,
|
||||
log.details['kind'],
|
||||
log.source_object_type,
|
||||
log.source_object_id,
|
||||
log.error_code,
|
||||
log.message,
|
||||
log.details.to_json
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
include Api::V1::InboxesHelper
|
||||
before_action :fetch_inbox, except: [:index, :create]
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
@@ -45,20 +46,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
continue_update = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
continue_update = update_branded_email_layout
|
||||
raise ActiveRecord::Rollback unless continue_update
|
||||
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
return unless continue_update
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@@ -133,8 +125,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reauthorize_and_update_channel(channel_attributes)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
end
|
||||
|
||||
def update_channel_feature_flags
|
||||
@@ -164,34 +156,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
end
|
||||
|
||||
def update_branded_email_layout
|
||||
return true unless params.key?(:branded_email_layout)
|
||||
|
||||
branded_email_layout = normalized_branded_email_layout
|
||||
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return false
|
||||
end
|
||||
|
||||
unless @inbox.email?
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email layout is only supported for email inboxes')
|
||||
return false
|
||||
end
|
||||
|
||||
@inbox.update_branded_email_layout!(branded_email_layout)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
false
|
||||
end
|
||||
|
||||
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
|
||||
enable_fb_login: '0',
|
||||
force_authentication: '1',
|
||||
response_type: 'code',
|
||||
state: generate_instagram_token(Current.account.id, params[:return_to])
|
||||
state: generate_instagram_token(Current.account.id)
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
render_response(
|
||||
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
|
||||
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :fetch_label, except: [:index, :create]
|
||||
before_action :check_authorization
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state,
|
||||
# Force the Microsoft account picker so an already-signed-in account does not
|
||||
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
|
||||
prompt: 'select_account'
|
||||
state: state
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def state
|
||||
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
|
||||
# can route the user back to inbox setup. The purpose is part of the signed
|
||||
# payload (tamper-proof), and a non-onboarding request keeps the default
|
||||
# purpose, leaving callers like Notion byte-identical.
|
||||
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
|
||||
end
|
||||
|
||||
def state_purpose
|
||||
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
|
||||
Current.account.to_sgid(expires_in: 15.minutes).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
|
||||
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
|
||||
STEP_INBOX_SETUP = 'inbox_setup'.freeze
|
||||
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
|
||||
|
||||
def update
|
||||
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
|
||||
|
||||
@account = Current.account
|
||||
# The client declares the step it is completing; `account_details` runs
|
||||
# `complete_account_details`, and so on. The known-step guard above keeps the
|
||||
# client value from `send`-ing an arbitrary method.
|
||||
send("complete_#{params[:onboarding_step]}")
|
||||
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
def help_center_generation
|
||||
render json: help_center_generation_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def complete_account_details
|
||||
# Only act while the cursor still points here, so a stale replay after
|
||||
# onboarding finished can't re-enter it.
|
||||
return unless current_step == STEP_ACCOUNT_DETAILS
|
||||
|
||||
@account.assign_attributes(account_params)
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
|
||||
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
|
||||
# environment check); self-hosted finishes onboarding here.
|
||||
if ChatwootApp.chatwoot_cloud?
|
||||
move_to_step(STEP_INBOX_SETUP)
|
||||
create_onboarding_inboxes
|
||||
else
|
||||
finish_onboarding
|
||||
end
|
||||
end
|
||||
|
||||
def complete_inbox_setup
|
||||
# Only finalize while the cursor still points here, so a stale or out-of-order
|
||||
# request can't end onboarding early. Replays are no-ops.
|
||||
return unless current_step == STEP_INBOX_SETUP
|
||||
|
||||
finish_onboarding
|
||||
end
|
||||
|
||||
def current_step
|
||||
@account.custom_attributes[ONBOARDING_STEP_KEY]
|
||||
end
|
||||
|
||||
def move_to_step(step)
|
||||
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
|
||||
@account.save!
|
||||
end
|
||||
|
||||
def finish_onboarding
|
||||
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
|
||||
@account.save!
|
||||
end
|
||||
|
||||
def create_onboarding_inboxes
|
||||
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:name, :locale)
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
|
||||
def help_center_generation_status
|
||||
{
|
||||
generation_id: nil,
|
||||
state: nil,
|
||||
articles_count: 0,
|
||||
categories_count: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
|
||||
@@ -80,20 +80,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
|
||||
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
)
|
||||
end
|
||||
|
||||
def locale_translation_keys
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def popular_content_keys
|
||||
params.dig(:portal, :config, :popular_content)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def team_params
|
||||
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
|
||||
params.require(:team).permit(:name, :description, :allow_auto_assign)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
|
||||
|
||||
def create
|
||||
redirect_url = Tiktok::AuthClient.authorize_url(
|
||||
state: generate_tiktok_token(Current.account.id, params[:return_to])
|
||||
state: generate_tiktok_token(Current.account.id)
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_embedded_signup_enabled
|
||||
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
|
||||
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
|
||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
@@ -11,21 +8,12 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
validate_embedded_signup_params!
|
||||
channel = process_embedded_signup
|
||||
render_success_response(channel.inbox)
|
||||
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
render_embedded_signup_error(e)
|
||||
render_error_response(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_embedded_signup_enabled
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
|
||||
|
||||
raise Pundit::NotAuthorizedError
|
||||
end
|
||||
|
||||
def process_embedded_signup
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
account: Current.account,
|
||||
@@ -41,7 +29,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
end
|
||||
|
||||
def validate_reauthorization_required
|
||||
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
|
||||
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
@@ -49,11 +37,9 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def can_reconfigure_channel?
|
||||
def can_upgrade_to_embedded_signup?
|
||||
channel = @inbox.channel
|
||||
return false unless channel.provider == 'whatsapp_cloud'
|
||||
return true if ChatwootApp.chatwoot_cloud?
|
||||
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
|
||||
|
||||
true
|
||||
end
|
||||
@@ -69,7 +55,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
render json: response
|
||||
end
|
||||
|
||||
def render_embedded_signup_error(error)
|
||||
def render_error_response(error)
|
||||
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
render json: {
|
||||
|
||||
@@ -8,7 +8,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
before_action :ensure_account_name, only: [:create]
|
||||
before_action :validate_captcha, only: [:create]
|
||||
before_action :fetch_account, except: [:create]
|
||||
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
|
||||
before_action :check_authorization, except: [:create]
|
||||
|
||||
rescue_from CustomExceptions::Account::InvalidEmail,
|
||||
@@ -59,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
@@ -106,12 +106,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
|
||||
def validate_token_api_access
|
||||
return if @account.api_and_webhooks_enabled?
|
||||
|
||||
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||
end
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
class Api::V1::Profile::SessionsController < Api::BaseController
|
||||
before_action :set_session, only: [:destroy]
|
||||
|
||||
def index
|
||||
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
|
||||
@current_client_id = request.headers['client']
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @session.current?(request.headers['client'])
|
||||
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
revoke_token!(@session.client_id)
|
||||
@session.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = current_user.user_sessions.find(params[:id])
|
||||
end
|
||||
|
||||
def revoke_token!(client_id)
|
||||
tokens = current_user.tokens
|
||||
tokens.delete(client_id)
|
||||
current_user.update!(tokens: tokens)
|
||||
end
|
||||
|
||||
def active_token_client_ids
|
||||
now = Time.current.to_i
|
||||
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
|
||||
end
|
||||
end
|
||||
@@ -59,10 +59,6 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
permitted_params.dig(:contact, :phone_number)
|
||||
end
|
||||
|
||||
def contact_custom_attributes
|
||||
permitted_params.dig(:contact, :custom_attributes)&.to_h
|
||||
end
|
||||
|
||||
def browser_params
|
||||
{
|
||||
browser_name: browser.name,
|
||||
|
||||
@@ -2,7 +2,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
include WidgetHelper
|
||||
|
||||
before_action :validate_hmac, only: [:set_user]
|
||||
before_action :validate_hmac_for_identified_update, only: [:update]
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -47,16 +46,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
|
||||
end
|
||||
|
||||
# The plain update endpoint is also used for anonymous prechat updates
|
||||
# (name/email/phone/custom_attributes with no identifier), which must keep
|
||||
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
|
||||
# identifier is supplied and the contact can be rebound, requires HMAC.
|
||||
def validate_hmac_for_identified_update
|
||||
return if params[:identifier].blank?
|
||||
|
||||
validate_hmac
|
||||
end
|
||||
|
||||
def validate_hmac
|
||||
return unless should_verify_hmac?
|
||||
|
||||
@@ -73,15 +62,11 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
expected_hash = OpenSSL::HMAC.hexdigest(
|
||||
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
|
||||
'sha256',
|
||||
@web_widget.hmac_token,
|
||||
params[:identifier].to_s
|
||||
)
|
||||
identifier_hash = params[:identifier_hash].to_s
|
||||
return false unless identifier_hash.bytesize == expected_hash.bytesize
|
||||
|
||||
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
def process_update_contact
|
||||
@contact = ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
|
||||
retain_original_contact_name: true,
|
||||
discard_invalid_attrs: true
|
||||
).perform
|
||||
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||
custom_attributes: {})
|
||||
end
|
||||
|
||||
@@ -10,8 +10,7 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
|
||||
|
||||
response = dyte_processor_service.add_participant_to_meeting(
|
||||
@message.content_attributes['data']['meeting_id'],
|
||||
@conversation.contact,
|
||||
@message
|
||||
@conversation.contact
|
||||
)
|
||||
render_response(response)
|
||||
end
|
||||
|
||||
@@ -51,13 +51,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
|
||||
end
|
||||
|
||||
def drilldown
|
||||
return head :unauthorized unless Current.account_user.administrator?
|
||||
return head :unprocessable_entity unless valid_drilldown_params?
|
||||
|
||||
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
|
||||
end
|
||||
|
||||
def conversations
|
||||
return head :unprocessable_entity if params[:type].blank?
|
||||
|
||||
@@ -140,22 +133,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
})
|
||||
end
|
||||
|
||||
def drilldown_params
|
||||
permitted_params = params.permit(
|
||||
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
|
||||
).to_h.symbolize_keys
|
||||
permitted_params.merge(
|
||||
type: (params[:type].presence || 'account').to_sym,
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
)
|
||||
end
|
||||
|
||||
def valid_drilldown_params?
|
||||
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
|
||||
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
|
||||
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
|
||||
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
|
||||
include RequestExceptionHandler
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
include TrackSessionActivity
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create'],
|
||||
'api/v1/accounts/conversations/labels' => %w[index create]
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
|
||||
def ensure_access_token
|
||||
|
||||
@@ -14,8 +14,6 @@ module EnsureCurrentAccountHelper
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
else
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized'))
|
||||
end
|
||||
account
|
||||
end
|
||||
@@ -23,7 +21,7 @@ module EnsureCurrentAccountHelper
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
end
|
||||
|
||||
def account_accessible_for_bot?(account)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
module PortalHomeData
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def load_home_data
|
||||
load_recommended_content
|
||||
# The classic hero only needs the recommendations above; the rest is
|
||||
# documentation-layout home data (also used on custom-domain home pages).
|
||||
return unless @portal.layout == 'documentation'
|
||||
|
||||
@visible_categories = @portal.categories
|
||||
.where(locale: @locale)
|
||||
.joins(:articles).where(articles: { status: :published })
|
||||
.order(position: :asc)
|
||||
.group('categories.id')
|
||||
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
|
||||
@featured = base_articles.order_by_views.limit(6)
|
||||
@category_contributors = build_category_contributors(@visible_categories)
|
||||
end
|
||||
|
||||
def load_recommended_content
|
||||
@recommended_categories = recommended_categories
|
||||
@recommended_articles = recommended_articles
|
||||
end
|
||||
|
||||
def base_articles
|
||||
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
end
|
||||
|
||||
# Admin-recommended categories for the locale, in the chosen order. Unlike the
|
||||
# position-based fallback, published articles aren't required: the admin's pick wins.
|
||||
def recommended_categories
|
||||
ids = @portal.popular_category_ids(@locale)
|
||||
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
|
||||
end
|
||||
|
||||
# Admin-recommended articles for the locale, in the chosen order, limited to
|
||||
# published articles that still exist.
|
||||
def recommended_articles
|
||||
ids = @portal.popular_article_ids(@locale)
|
||||
ordered_by_ids(base_articles.where(id: ids), ids)
|
||||
end
|
||||
|
||||
# Loads the scope and returns its records ordered to match `ids`, dropping any
|
||||
# that no longer exist. Skips the query entirely when `ids` is blank.
|
||||
def ordered_by_ids(scope, ids)
|
||||
return [] if ids.blank?
|
||||
|
||||
by_id = scope.index_by(&:id)
|
||||
ids.filter_map { |id| by_id[id] }
|
||||
end
|
||||
|
||||
def build_category_contributors(categories)
|
||||
category_ids = categories.map(&:id)
|
||||
return {} if category_ids.empty?
|
||||
|
||||
@portal.articles
|
||||
.published
|
||||
.where(locale: @locale, category_id: category_ids)
|
||||
.includes(:author)
|
||||
.group_by(&:category_id)
|
||||
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
|
||||
end
|
||||
end
|
||||
@@ -1,15 +1,8 @@
|
||||
module RequestExceptionHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
|
||||
'ActiveRecord::QueryCanceled',
|
||||
'PG::QueryCanceled',
|
||||
'canceling statement due to statement timeout'
|
||||
].freeze
|
||||
|
||||
included do
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
|
||||
end
|
||||
|
||||
private
|
||||
@@ -25,9 +18,6 @@ module RequestExceptionHandler
|
||||
rescue ActionController::ParameterMissing => e
|
||||
log_handled_error(e)
|
||||
render_could_not_create_error(e.message)
|
||||
rescue ActiveRecord::QueryCanceled => e
|
||||
log_handled_error(e)
|
||||
render_could_not_create_error(database_query_canceled_message)
|
||||
ensure
|
||||
# to address the thread variable leak issues in Puma/Thin webserver
|
||||
Current.reset
|
||||
@@ -41,8 +31,8 @@ module RequestExceptionHandler
|
||||
render json: { error: message }, status: :not_found
|
||||
end
|
||||
|
||||
def render_could_not_create_error(error)
|
||||
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
|
||||
def render_could_not_create_error(message)
|
||||
render json: { error: message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def render_payment_required(message)
|
||||
@@ -69,19 +59,4 @@ module RequestExceptionHandler
|
||||
def log_handled_error(exception)
|
||||
logger.info("Handled error: #{exception.inspect}")
|
||||
end
|
||||
|
||||
def sanitized_error_message(message)
|
||||
return database_query_canceled_message if database_query_canceled_message?(message)
|
||||
|
||||
message
|
||||
end
|
||||
|
||||
def database_query_canceled_message?(message)
|
||||
error_message = message.to_s
|
||||
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
|
||||
end
|
||||
|
||||
def database_query_canceled_message
|
||||
I18n.t('errors.database.query_canceled')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
module TrackSessionActivity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :update_session_activity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_session_activity
|
||||
return unless current_user
|
||||
return if request.headers['client'].blank?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: current_user,
|
||||
request: request,
|
||||
client_id: request.headers['client']
|
||||
).update_activity!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session activity update failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,5 @@
|
||||
class DashboardController < ActionController::Base
|
||||
include SwitchLocale
|
||||
include PortalHomeData
|
||||
|
||||
GLOBAL_CONFIG_KEYS = %w[
|
||||
LOGO
|
||||
@@ -64,8 +63,6 @@ class DashboardController < ActionController::Base
|
||||
return unless @portal
|
||||
|
||||
@locale = @portal.default_locale
|
||||
request.variant = :documentation if @portal.layout == 'documentation'
|
||||
load_home_data
|
||||
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
|
||||
|
||||
# Prevent session parameter from being passed
|
||||
# Unpermitted parameter: session
|
||||
wrap_parameters format: []
|
||||
@@ -16,14 +14,12 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
user = find_user_for_authentication
|
||||
return handle_mfa_required(user) if user&.mfa_enabled?
|
||||
return if user && enforce_session_limit_for_password_login(user)
|
||||
|
||||
# Only proceed with standard authentication if no MFA is required
|
||||
super
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
track_user_session unless @impersonation
|
||||
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
||||
end
|
||||
|
||||
@@ -57,8 +53,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def handle_sso_authentication
|
||||
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
|
||||
|
||||
authenticate_resource_with_sso_token
|
||||
yield @resource if block_given?
|
||||
render_create_success
|
||||
@@ -71,10 +65,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def authenticate_resource_with_sso_token
|
||||
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
|
||||
# The short-lived impersonation token would always be that one, so pre-evict to make room.
|
||||
make_room_for_impersonation_token if @impersonation
|
||||
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
|
||||
sign_in(:user, @resource, store: false, bypass: false)
|
||||
@@ -82,21 +73,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
|
||||
end
|
||||
|
||||
def make_room_for_impersonation_token
|
||||
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
|
||||
|
||||
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
||||
@resource.tokens.delete(oldest_client_id) if oldest_client_id
|
||||
end
|
||||
|
||||
def process_sso_auth_token
|
||||
return if params[:email].blank?
|
||||
|
||||
user = User.from_email(params[:email])
|
||||
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
|
||||
@resource = user
|
||||
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
end
|
||||
|
||||
def handle_mfa_required(user)
|
||||
@@ -122,7 +103,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def sign_in_mfa_user(user)
|
||||
evict_oldest_session(user) if sessions_limit_reached?(user)
|
||||
@resource = user
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
@@ -134,103 +114,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
def render_mfa_error(message_key, status = :bad_request)
|
||||
render json: { error: I18n.t(message_key) }, status: status
|
||||
end
|
||||
|
||||
def sessions_limit_reached?(user)
|
||||
active_token_count(user) >= MAX_SESSIONS
|
||||
end
|
||||
|
||||
def active_token_count(user)
|
||||
now = Time.current.to_i
|
||||
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
|
||||
end
|
||||
|
||||
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
|
||||
# auto-evict instead of getting stuck on a UI they can't render.
|
||||
def enforce_session_limit_for_password_login(user)
|
||||
if revoking_sessions?
|
||||
revoke_sessions_for_login(user)
|
||||
return false
|
||||
end
|
||||
|
||||
return false unless sessions_limit_reached?(user)
|
||||
|
||||
# Picker only when every token has a tracked session; partial tracking would
|
||||
# show a misleading count, so fall through to silent eviction instead.
|
||||
if browser_request? && user.user_sessions.count >= user.tokens.size
|
||||
handle_sessions_limit_for_login(user)
|
||||
true
|
||||
else
|
||||
evict_oldest_session(user)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def browser_request?
|
||||
request.user_agent.to_s.include?('Mozilla')
|
||||
end
|
||||
|
||||
def revoking_sessions?
|
||||
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
|
||||
end
|
||||
|
||||
def revoke_sessions_for_login(user)
|
||||
if params[:revoke_all_sessions].present?
|
||||
user.tokens = {}
|
||||
user.save!
|
||||
user.user_sessions.destroy_all
|
||||
elsif params[:revoke_session_id].present?
|
||||
session = user.user_sessions.find_by(id: params[:revoke_session_id])
|
||||
return unless session
|
||||
|
||||
user.tokens.delete(session.client_id)
|
||||
user.save!
|
||||
session.destroy!
|
||||
end
|
||||
end
|
||||
|
||||
def evict_oldest_session(user)
|
||||
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
|
||||
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
|
||||
|
||||
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
|
||||
return evict_oldest_token(user) unless oldest_session
|
||||
|
||||
user.tokens.delete(oldest_session.client_id)
|
||||
user.save!
|
||||
oldest_session.destroy!
|
||||
end
|
||||
|
||||
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
|
||||
def evict_oldest_token(user)
|
||||
return if user.tokens.blank?
|
||||
|
||||
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
||||
return unless oldest_client_id
|
||||
|
||||
user.tokens.delete(oldest_client_id)
|
||||
user.save!
|
||||
end
|
||||
|
||||
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
|
||||
ip_address city country last_activity_at created_at].freeze
|
||||
|
||||
def handle_sessions_limit_for_login(user)
|
||||
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
|
||||
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
|
||||
end
|
||||
|
||||
def track_user_session
|
||||
client_id = @token&.try(:client) || response.headers['client']
|
||||
return unless client_id.present? && @resource.present?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: @resource,
|
||||
request: request,
|
||||
client_id: client_id
|
||||
).create_or_update!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session tracking failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -11,8 +11,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
process_successful_authorization
|
||||
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||
handle_limit_error(e)
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
@@ -30,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -49,14 +45,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
redirect_to_error_page(error_info)
|
||||
end
|
||||
|
||||
def handle_limit_error(error)
|
||||
redirect_to_error_page(
|
||||
'error_type' => error.class.name,
|
||||
'code' => Rack::Utils.status_code(error.http_status),
|
||||
'error_message' => error.message
|
||||
)
|
||||
end
|
||||
|
||||
# Extract error details from the exception
|
||||
def extract_error_info(error)
|
||||
if error.is_a?(OAuth2::Error)
|
||||
@@ -161,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
verify_instagram_token(params[:state])
|
||||
end
|
||||
|
||||
def return_to
|
||||
instagram_token_return_to(params[:state])
|
||||
end
|
||||
|
||||
def oauth_code
|
||||
params[:code]
|
||||
end
|
||||
|
||||
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
|
||||
def imap_address
|
||||
'outlook.office365.com'
|
||||
end
|
||||
|
||||
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
|
||||
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
|
||||
# `upn` is the v1.0 fallback.
|
||||
def imap_login_identity
|
||||
users_data['preferred_username'] || users_data['upn'] || super
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
|
||||
def handle_response
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
|
||||
|
||||
def update_channel(channel_email)
|
||||
channel_email.update!({
|
||||
imap_login: imap_login_identity, imap_address: imap_address,
|
||||
imap_login: users_data['email'], imap_address: imap_address,
|
||||
imap_port: '993', imap_enabled: true,
|
||||
provider: provider_name,
|
||||
provider_config: {
|
||||
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
|
||||
})
|
||||
end
|
||||
|
||||
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
|
||||
# id_token's email claim; providers override when their server requires a different
|
||||
# claim (e.g. Microsoft SMTP requires UPN).
|
||||
def imap_login_identity
|
||||
users_data['email']
|
||||
end
|
||||
|
||||
def provider_name
|
||||
raise NotImplementedError
|
||||
end
|
||||
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
|
||||
decoded_token[0]
|
||||
end
|
||||
|
||||
# The sgid purpose carries the onboarding return hint (see
|
||||
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
|
||||
# both resolves the account and records the return target — then fall back to the
|
||||
# default purpose used by every other caller.
|
||||
def account_from_signed_id
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
|
||||
@return_to = 'onboarding'
|
||||
else
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
end
|
||||
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
raise 'Invalid or expired state' if account.nil?
|
||||
|
||||
account
|
||||
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
|
||||
@account ||= account_from_signed_id
|
||||
end
|
||||
|
||||
def return_to
|
||||
account # resolving the sgid records which purpose matched
|
||||
@return_to
|
||||
end
|
||||
|
||||
# Fallback name, for when name field is missing from users_data
|
||||
def fallback_name
|
||||
users_data['email'].split('@').first.parameterize.titleize
|
||||
|
||||
@@ -18,8 +18,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
contact: @contact_inbox.contact,
|
||||
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
|
||||
)
|
||||
contact_identify_action.perform
|
||||
@contact_inbox.reload
|
||||
render json: contact_identify_action.perform
|
||||
end
|
||||
|
||||
private
|
||||
@@ -36,15 +35,11 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
expected_hash = OpenSSL::HMAC.hexdigest(
|
||||
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
|
||||
'sha256',
|
||||
@inbox_channel.hmac_token,
|
||||
params[:identifier].to_s
|
||||
)
|
||||
identifier_hash = params[:identifier_hash].to_s
|
||||
return false unless identifier_hash.bytesize == expected_hash.bytesize
|
||||
|
||||
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -24,11 +24,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
end
|
||||
|
||||
def set_view_variant
|
||||
request.variant = if @is_plain_layout_enabled
|
||||
:plain
|
||||
elsif @portal_layout == 'documentation'
|
||||
:documentation
|
||||
end
|
||||
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
|
||||
end
|
||||
|
||||
def portal
|
||||
@@ -43,11 +39,9 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
end
|
||||
|
||||
def switch_locale_with_portal(&)
|
||||
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
|
||||
# while UI translations fall back to an available I18n locale (e.g. th).
|
||||
@locale = params[:locale]
|
||||
@locale = validate_and_get_locale(params[:locale])
|
||||
|
||||
I18n.with_locale(validate_and_get_locale(@locale), &)
|
||||
I18n.with_locale(@locale, &)
|
||||
end
|
||||
|
||||
def switch_locale_with_article(&)
|
||||
@@ -55,12 +49,13 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
|
||||
render_404 && return if article.blank?
|
||||
|
||||
@locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
article.locale
|
||||
end
|
||||
I18n.with_locale(validate_and_get_locale(@locale), &)
|
||||
article_locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
article.locale
|
||||
end
|
||||
@locale = validate_and_get_locale(article_locale)
|
||||
I18n.with_locale(@locale, &)
|
||||
end
|
||||
|
||||
def allow_iframe_requests
|
||||
@@ -69,8 +64,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
|
||||
def render_404
|
||||
portal
|
||||
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
|
||||
set_view_variant
|
||||
render 'public/api/v1/portals/error/404', status: :not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:index]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@query = params[:query].to_s.strip
|
||||
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
|
||||
|
||||
search_articles
|
||||
|
||||
@articles = @articles.page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_articles
|
||||
@articles = @query.present? ? @articles.search(search_params) : @articles.none
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
|
||||
permitted[:query] = @query
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
|
||||
@@ -1,17 +1,15 @@
|
||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||
include PortalHomeData
|
||||
|
||||
before_action :ensure_custom_domain_request, only: [:show]
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
|
||||
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
|
||||
end
|
||||
|
||||
def sitemap
|
||||
@@ -33,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
portal
|
||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||
end
|
||||
|
||||
def load_home_data
|
||||
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
@visible_categories = @portal.categories
|
||||
.where(locale: @locale)
|
||||
.joins(:articles).where(articles: { status: :published })
|
||||
.order(position: :asc)
|
||||
.group('categories.id')
|
||||
@popular_topics = @visible_categories.first(3)
|
||||
@featured = base_articles.order_by_views.limit(6)
|
||||
@category_contributors = build_category_contributors(@visible_categories)
|
||||
end
|
||||
|
||||
def build_category_contributors(categories)
|
||||
category_ids = categories.map(&:id)
|
||||
return {} if category_ids.empty?
|
||||
|
||||
@portal.articles
|
||||
.published
|
||||
.where(locale: @locale, category_id: category_ids)
|
||||
.includes(:author)
|
||||
.group_by(&:category_id)
|
||||
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user