Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge
This commit is contained in:
@@ -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())
|
||||
@@ -5,93 +5,25 @@ on:
|
||||
- cron: '0 4 * * *' # daily at 09:30 IST
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: read
|
||||
steps:
|
||||
- name: Fetch triage advisories
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \
|
||||
| jq -cs 'add | [.[] | {
|
||||
ghsa_id, cve_id, summary, severity, state, html_url,
|
||||
description, created_at,
|
||||
cvss_score: .cvss.score,
|
||||
reporter: ([.credits[]?.user.login] | first // "unknown")
|
||||
}]' > advisories.json
|
||||
echo "Fetched $(jq 'length' advisories.json) triage advisories"
|
||||
|
||||
- name: Create Linear issues for new advisories
|
||||
- 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 }}
|
||||
run: |
|
||||
created_count=0
|
||||
skipped_count=0
|
||||
failed_count=0
|
||||
while read -r advisory; do
|
||||
ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id')
|
||||
summary=$(printf '%s' "$advisory" | jq -r '.summary')
|
||||
severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"')
|
||||
cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"')
|
||||
cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"')
|
||||
reporter=$(printf '%s' "$advisory" | jq -r '.reporter')
|
||||
html_url=$(printf '%s' "$advisory" | jq -r '.html_url')
|
||||
created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1)
|
||||
description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."')
|
||||
|
||||
existing=$(curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \
|
||||
| jq '.data.issues.nodes | length')
|
||||
|
||||
if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then
|
||||
skipped_count=$((skipped_count+1))
|
||||
continue
|
||||
fi
|
||||
|
||||
priority=3
|
||||
case "$severity" in
|
||||
critical) priority=1 ;;
|
||||
high) priority=2 ;;
|
||||
medium) priority=3 ;;
|
||||
low) priority=4 ;;
|
||||
esac
|
||||
|
||||
title="[$ghsa_id] $summary"
|
||||
body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \
|
||||
"$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description")
|
||||
|
||||
success=$(curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-d "$(jq -n \
|
||||
--arg title "$title" \
|
||||
--arg body "$body" \
|
||||
--arg teamId "$LINEAR_TEAM_ID" \
|
||||
--arg projectId "$LINEAR_PROJECT_ID" \
|
||||
--arg labelId "$LINEAR_LABEL_ID" \
|
||||
--argjson priority "$priority" \
|
||||
'{
|
||||
query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }",
|
||||
variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}}
|
||||
}')" | jq -r '.data.issueCreate.success // false')
|
||||
|
||||
if [ "$success" = "true" ]; then
|
||||
created_count=$((created_count+1))
|
||||
else
|
||||
failed_count=$((failed_count+1))
|
||||
fi
|
||||
done < <(jq -c '.[]' advisories.json)
|
||||
echo "Created $created_count, skipped $skipped_count, failed $failed_count"
|
||||
if [ "$failed_count" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: python3 .github/scripts/ghsa_linear_sync.py
|
||||
|
||||
+2
-2
@@ -684,7 +684,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.5)
|
||||
rack (3.2.6)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -699,7 +699,7 @@ GEM
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.1)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
export const buildCompanyParams = (page, sort) => {
|
||||
let params = `page=${page}`;
|
||||
if (sort) {
|
||||
params = `${params}&sort=${sort}`;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const buildSearchParams = (query, page, sort) => {
|
||||
let params = `q=${encodeURIComponent(query)}&page=${page}`;
|
||||
if (sort) {
|
||||
params = `${params}&sort=${sort}`;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
const buildParams = params =>
|
||||
new URLSearchParams(
|
||||
Object.entries(params).filter(
|
||||
([key, value]) => value !== undefined && (value !== '' || key === 'q')
|
||||
)
|
||||
).toString();
|
||||
|
||||
class CompanyAPI extends ApiClient {
|
||||
constructor() {
|
||||
@@ -24,14 +15,41 @@ class CompanyAPI extends ApiClient {
|
||||
|
||||
get(params = {}) {
|
||||
const { page = 1, sort = 'name' } = params;
|
||||
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
|
||||
const requestURL = `${this.url}?${buildParams({ page, sort })}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
search(query = '', page = 1, sort = 'name') {
|
||||
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
|
||||
const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
listContacts(id, page = 1) {
|
||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
||||
}
|
||||
|
||||
searchContacts(id, query = '', page = 1) {
|
||||
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
createContact(id, payload) {
|
||||
return axios.post(`${this.url}/${id}/contacts`, payload);
|
||||
}
|
||||
|
||||
removeContact(id, contactId) {
|
||||
return axios.delete(`${this.url}/${id}/contacts/${contactId}`);
|
||||
}
|
||||
|
||||
destroyCustomAttributes(id, customAttributes) {
|
||||
return axios.post(`${this.url}/${id}/destroy_custom_attributes`, {
|
||||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
destroyAvatar(id) {
|
||||
return axios.delete(`${this.url}/${id}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CompanyAPI();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import companyAPI, {
|
||||
buildCompanyParams,
|
||||
buildSearchParams,
|
||||
} from '../companies';
|
||||
import companyAPI from '../companies';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#CompanyAPI', () => {
|
||||
@@ -9,7 +6,6 @@ describe('#CompanyAPI', () => {
|
||||
expect(companyAPI).toBeInstanceOf(ApiClient);
|
||||
expect(companyAPI).toHaveProperty('get');
|
||||
expect(companyAPI).toHaveProperty('show');
|
||||
expect(companyAPI).toHaveProperty('create');
|
||||
expect(companyAPI).toHaveProperty('update');
|
||||
expect(companyAPI).toHaveProperty('delete');
|
||||
expect(companyAPI).toHaveProperty('search');
|
||||
@@ -32,111 +28,69 @@ describe('#CompanyAPI', () => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#get with default params', () => {
|
||||
it('#get includes pagination and sorting params', () => {
|
||||
companyAPI.get({});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies?page=1&sort=name'
|
||||
);
|
||||
});
|
||||
|
||||
it('#get with page and sort params', () => {
|
||||
companyAPI.get({ page: 2, sort: 'domain' });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies?page=2&sort=domain'
|
||||
);
|
||||
});
|
||||
|
||||
it('#get with descending sort', () => {
|
||||
companyAPI.get({ page: 1, sort: '-created_at' });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies?page=1&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with query', () => {
|
||||
companyAPI.search('acme', 1, 'name');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=acme&page=1&sort=name'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with special characters in query', () => {
|
||||
it('#search encodes query params', () => {
|
||||
companyAPI.search('acme & co', 2, 'domain');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
|
||||
'/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with descending sort', () => {
|
||||
companyAPI.search('test', 1, '-created_at');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with empty query', () => {
|
||||
it('#search keeps empty query param for backend validation', () => {
|
||||
companyAPI.search('', 1, 'name');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/search?q=&page=1&sort=name'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#buildCompanyParams', () => {
|
||||
it('returns correct string with page only', () => {
|
||||
expect(buildCompanyParams(1)).toBe('page=1');
|
||||
});
|
||||
|
||||
it('returns correct string with page and sort', () => {
|
||||
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
|
||||
});
|
||||
|
||||
it('returns correct string with different page', () => {
|
||||
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
|
||||
});
|
||||
|
||||
it('returns correct string with descending sort', () => {
|
||||
expect(buildCompanyParams(1, '-created_at')).toBe(
|
||||
'page=1&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct string without sort parameter', () => {
|
||||
expect(buildCompanyParams(2, '')).toBe('page=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#buildSearchParams', () => {
|
||||
it('returns correct string with all parameters', () => {
|
||||
expect(buildSearchParams('acme', 1, 'name')).toBe(
|
||||
'q=acme&page=1&sort=name'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct string with special characters', () => {
|
||||
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
|
||||
'q=acme%20%26%20co&page=2&sort=domain'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns correct string with empty query', () => {
|
||||
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
|
||||
});
|
||||
|
||||
it('returns correct string without sort parameter', () => {
|
||||
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
|
||||
});
|
||||
|
||||
it('returns correct string with descending sort', () => {
|
||||
expect(buildSearchParams('company', 3, '-created_at')).toBe(
|
||||
'q=company&page=3&sort=-created_at'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes special characters correctly', () => {
|
||||
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
|
||||
'q=test%40example.com&page=1&sort=name'
|
||||
);
|
||||
it('#destroyAvatar deletes the company avatar endpoint', () => {
|
||||
companyAPI.destroyAvatar(1);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/avatar'
|
||||
);
|
||||
});
|
||||
|
||||
it('#listContacts fetches company contacts', () => {
|
||||
companyAPI.listContacts(1, 2);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts?page=2'
|
||||
);
|
||||
});
|
||||
|
||||
it('#searchContacts encodes contact search params', () => {
|
||||
companyAPI.searchContacts(1, 'jane & co', 3);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3'
|
||||
);
|
||||
});
|
||||
|
||||
it('#createContact links a contact to the company', () => {
|
||||
companyAPI.createContact(1, { contact_id: 2 });
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts',
|
||||
{ contact_id: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('#removeContact unlinks a contact from the company', () => {
|
||||
companyAPI.removeContact(1, 2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/contacts/2'
|
||||
);
|
||||
});
|
||||
|
||||
it('#destroyCustomAttributes removes company custom attributes', () => {
|
||||
companyAPI.destroyCustomAttributes(1, ['plan']);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/companies/1/destroy_custom_attributes',
|
||||
{ custom_attributes: ['plan'] }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+22
-30
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
@@ -12,9 +12,8 @@ const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
domain: { type: String, default: '' },
|
||||
contactsCount: { type: Number, default: 0 },
|
||||
description: { type: String, default: '' },
|
||||
avatarUrl: { type: String, default: '' },
|
||||
updatedAt: { type: [String, Number], default: null },
|
||||
lastActivityAt: { type: [String, Number], default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['showCompany']);
|
||||
@@ -27,15 +26,21 @@ const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
|
||||
|
||||
const avatarSource = computed(() => props.avatarUrl || null);
|
||||
|
||||
const formattedUpdatedAt = computed(() => {
|
||||
if (!props.updatedAt) return '';
|
||||
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
|
||||
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0);
|
||||
|
||||
const contactsCountLabel = computed(() =>
|
||||
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
|
||||
);
|
||||
|
||||
const formattedLastActivityAt = computed(() => {
|
||||
if (!props.lastActivityAt) return '';
|
||||
return dynamicTime(props.lastActivityAt);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout layout="row" @click="onClickViewDetails">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
|
||||
<Avatar
|
||||
:username="displayName"
|
||||
:src="avatarSource"
|
||||
@@ -51,42 +56,29 @@ const formattedUpdatedAt = computed(() => {
|
||||
{{ displayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="domain && description"
|
||||
v-if="hasContacts"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||
>
|
||||
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||
<span class="truncate">{{ domain }}</span>
|
||||
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
||||
{{ contactsCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center min-w-0">
|
||||
<span
|
||||
v-if="domain && !description"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||
v-if="domain"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
|
||||
@click.stop
|
||||
>
|
||||
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||
<span class="truncate">{{ domain }}</span>
|
||||
</span>
|
||||
<span v-if="description" class="text-sm text-n-slate-11 truncate">
|
||||
{{ description }}
|
||||
</span>
|
||||
<div
|
||||
v-if="(description || domain) && contactsCount"
|
||||
class="w-px h-3 bg-n-slate-6"
|
||||
/>
|
||||
<span
|
||||
v-if="contactsCount"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||
>
|
||||
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
||||
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="updatedAt"
|
||||
v-if="lastActivityAt"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ formattedUpdatedAt }}
|
||||
{{ formattedLastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { ref, useSlots } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
breadcrumbItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back']);
|
||||
|
||||
const slots = useSlots();
|
||||
const isSidebarOpen = ref(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isSidebarOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||
:class="isSidebarOpen ? 'w-full' : 'w-16'"
|
||||
>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#details-sidebar-content'] },
|
||||
]"
|
||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||
:class="[
|
||||
isSidebarOpen
|
||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full rtl:rotate-180"
|
||||
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
|
||||
:icon="
|
||||
isSidebarOpen
|
||||
? 'i-lucide-panel-right-close'
|
||||
: 'i-lucide-panel-right-open'
|
||||
"
|
||||
data-details-sidebar-toggle
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
id="details-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+10
-10
@@ -19,22 +19,15 @@ const emit = defineEmits(['search', 'update:sort']);
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
>
|
||||
<span class="text-heading-1 truncate text-n-slate-12">
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
||||
<div class="flex items-center">
|
||||
<CompanySortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
|
||||
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
|
||||
:custom-input-class="[
|
||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||
]"
|
||||
@@ -49,6 +42,13 @@ const emit = defineEmits(['search', 'update:sort']);
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center flex-shrink-0 gap-2">
|
||||
<CompanySortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+6
@@ -35,6 +35,10 @@ const sortMenus = [
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.LAST_ACTIVITY_AT'),
|
||||
value: 'last_activity_at',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
|
||||
value: 'contacts_count',
|
||||
@@ -101,6 +105,7 @@ const handleOrderChange = value => {
|
||||
:model-value="activeSort"
|
||||
:options="sortMenus"
|
||||
:label="activeSortLabel"
|
||||
sub-menu-position="left"
|
||||
@update:model-value="handleSortChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,6 +117,7 @@ const handleOrderChange = value => {
|
||||
:model-value="activeOrdering"
|
||||
:options="orderingMenus"
|
||||
:label="activeOrderingLabel"
|
||||
sub-menu-position="left"
|
||||
@update:model-value="handleOrderChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
|
||||
const props = defineProps({
|
||||
company: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
contacts: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
meta: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isBusy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'cancelContactSelection',
|
||||
'confirmContactSelection',
|
||||
'removeContact',
|
||||
'search',
|
||||
'selectContact',
|
||||
'update:currentPage',
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedContactId = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const hasContacts = computed(() => props.contacts.length > 0);
|
||||
const currentPage = computed(() => Number(props.meta?.page || 1));
|
||||
const totalContacts = computed(() => Number(props.meta?.totalCount || 0));
|
||||
const linkedContactIds = computed(
|
||||
() => new Set(props.contacts.map(contact => Number(contact.id)))
|
||||
);
|
||||
const showPaginationFooter = computed(
|
||||
() => hasContacts.value && totalContacts.value > props.contacts.length
|
||||
);
|
||||
|
||||
const openContact = contactId => {
|
||||
router.push({
|
||||
name: 'contacts_edit',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const contactMeta = contact =>
|
||||
[contact.email, contact.phoneNumber].filter(Boolean).join(' • ');
|
||||
|
||||
const contactName = contact =>
|
||||
contact.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
|
||||
|
||||
const contactOptions = computed(() =>
|
||||
props.searchResults
|
||||
.filter(
|
||||
contact =>
|
||||
!contact.linkedToCurrentCompany &&
|
||||
!linkedContactIds.value.has(Number(contact.id))
|
||||
)
|
||||
.map(contact => ({
|
||||
value: contact.id,
|
||||
label: [contactName(contact), contact.email, contact.phoneNumber]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}))
|
||||
);
|
||||
|
||||
const emptyState = computed(() => {
|
||||
if (props.isSearching) {
|
||||
return t('COMPANIES.DETAIL.CONTACTS.LOADING');
|
||||
}
|
||||
|
||||
return searchQuery.value.trim()
|
||||
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.EMPTY')
|
||||
: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.INITIAL');
|
||||
});
|
||||
|
||||
const selectedContactName = computed(() =>
|
||||
props.selectedContact ? contactName(props.selectedContact) : ''
|
||||
);
|
||||
|
||||
const selectedContactMeta = computed(() =>
|
||||
props.selectedContact ? contactMeta(props.selectedContact) : ''
|
||||
);
|
||||
|
||||
const selectedContactCompanyName = computed(
|
||||
() => props.selectedContact?.company?.name || ''
|
||||
);
|
||||
|
||||
const summaryRows = computed(() => [
|
||||
{
|
||||
key: 'company',
|
||||
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.COMPANY_LABEL'),
|
||||
avatarName: props.company.name || t('COMPANIES.UNNAMED'),
|
||||
avatarSrc: props.company.avatarUrl,
|
||||
primary: props.company.name || t('COMPANIES.UNNAMED'),
|
||||
secondary: props.company.domain,
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONTACT_LABEL'),
|
||||
badge: selectedContactCompanyName.value
|
||||
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CURRENT_COMPANY', {
|
||||
companyName: selectedContactCompanyName.value,
|
||||
})
|
||||
: '',
|
||||
avatarName: selectedContactName.value,
|
||||
avatarSrc: props.selectedContact?.thumbnail,
|
||||
primary: selectedContactName.value,
|
||||
secondary: selectedContactMeta.value,
|
||||
},
|
||||
]);
|
||||
|
||||
const debouncedSearch = debounce(query => {
|
||||
emit('search', query);
|
||||
}, 300);
|
||||
|
||||
const handleSearch = query => {
|
||||
searchQuery.value = query;
|
||||
debouncedSearch(query.trim());
|
||||
};
|
||||
|
||||
const handleContactSelect = contactId => {
|
||||
const selectedContact = props.searchResults.find(
|
||||
contact => contact.id === Number(contactId)
|
||||
);
|
||||
|
||||
selectedContactId.value = null;
|
||||
if (selectedContact) {
|
||||
emit('selectContact', selectedContact);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-6 pb-6 pt-1">
|
||||
<div v-if="!selectedContact" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-base text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<ComboBox
|
||||
use-api-results
|
||||
:model-value="selectedContactId"
|
||||
:options="contactOptions"
|
||||
:disabled="isBusy"
|
||||
:empty-state="emptyState"
|
||||
:search-placeholder="
|
||||
t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
:placeholder="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD')"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleContactSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-base text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_TITLE') }}
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="row in summaryRows"
|
||||
:key="row.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex items-center justify-between h-5 gap-2">
|
||||
<label class="text-sm text-n-slate-12">
|
||||
{{ row.label }}
|
||||
</label>
|
||||
<span
|
||||
v-if="row.badge"
|
||||
class="px-2 py-0.5 text-xs rounded-md text-n-amber-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ row.badge }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
|
||||
>
|
||||
<Avatar
|
||||
:name="row.avatarName"
|
||||
:src="row.avatarSrc"
|
||||
:size="32"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<div class="flex flex-col w-full min-w-0 gap-1">
|
||||
<span
|
||||
class="text-sm leading-4 font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ row.primary }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.secondary"
|
||||
class="text-sm leading-4 truncate text-n-slate-11"
|
||||
>
|
||||
{{ row.secondary }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 mt-2">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
:disabled="isBusy"
|
||||
@click="emit('cancelContactSelection')"
|
||||
/>
|
||||
<Button
|
||||
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.ADD')"
|
||||
class="w-full"
|
||||
:is-loading="isBusy"
|
||||
:disabled="isBusy"
|
||||
@click="emit('confirmContactSelection')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
|
||||
</h4>
|
||||
<span v-if="hasContacts" class="text-xs tabular-nums text-n-slate-11">
|
||||
{{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading && !hasContacts"
|
||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasContacts"
|
||||
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col divide-y divide-n-weak">
|
||||
<div
|
||||
v-for="contact in contacts"
|
||||
:key="contact.id"
|
||||
class="flex items-center gap-2 py-3 group/contact"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center flex-1 min-w-0 !p-0 gap-3 text-start rounded-lg transition-colors text-n-slate-12 hover:text-n-blue-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-n-brand focus-visible:ring-offset-2 focus-visible:ring-offset-n-background"
|
||||
@click="openContact(contact.id)"
|
||||
>
|
||||
<Avatar
|
||||
:name="contactName(contact)"
|
||||
:src="contact.thumbnail"
|
||||
:size="32"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
/>
|
||||
<div class="min-w-0 space-y-0.5">
|
||||
<span
|
||||
class="text-sm font-medium leading-5 truncate text-n-slate-12"
|
||||
>
|
||||
{{ contactName(contact) }}
|
||||
</span>
|
||||
<p
|
||||
v-if="contactMeta(contact)"
|
||||
class="text-sm leading-5 truncate text-n-slate-11"
|
||||
>
|
||||
{{ contactMeta(contact) }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-unlink"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="shrink-0 opacity-70 transition-opacity sm:opacity-0 sm:group-hover/contact:opacity-100 sm:focus-visible:opacity-100"
|
||||
:disabled="isBusy"
|
||||
:title="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
|
||||
:aria-label="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
|
||||
@click.stop="emit('removeContact', contact.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationFooter
|
||||
v-if="showPaginationFooter"
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalContacts"
|
||||
:items-per-page="15"
|
||||
class="px-0 before:hidden"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
|
||||
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
|
||||
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
|
||||
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
|
||||
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await companiesStore.deleteCustomAttributes({
|
||||
id: props.companyId,
|
||||
customAttributes: [props.attribute.attributeKey],
|
||||
});
|
||||
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async value => {
|
||||
try {
|
||||
await companiesStore.update({
|
||||
id: props.companyId,
|
||||
customAttributes: {
|
||||
[props.attribute.attributeKey]: value,
|
||||
},
|
||||
});
|
||||
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const componentMap = {
|
||||
list: ListAttribute,
|
||||
checkbox: CheckboxAttribute,
|
||||
date: DateAttribute,
|
||||
default: OtherAttribute,
|
||||
};
|
||||
|
||||
const CurrentAttributeComponent = computed(
|
||||
() =>
|
||||
componentMap[props.attribute.attributeDisplayType] || componentMap.default
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
|
||||
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
|
||||
>
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="CurrentAttributeComponent"
|
||||
:attribute="attribute"
|
||||
:is-editing-view="isEditingView"
|
||||
@update="handleUpdate"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import CompanyCustomAttributeItem from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
company: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
|
||||
|
||||
const customAttributes = computed(() => props.company?.customAttributes || {});
|
||||
const hasCompanyAttributes = computed(
|
||||
() => companyAttributes.value?.length > 0
|
||||
);
|
||||
|
||||
const processCompanyAttributes = (
|
||||
attributes,
|
||||
attributeValues,
|
||||
filterCondition
|
||||
) => {
|
||||
if (!attributes.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return attributes.reduce((result, attribute) => {
|
||||
const { attributeKey } = attribute;
|
||||
|
||||
if (filterCondition(attributeKey, attributeValues)) {
|
||||
result.push({
|
||||
...attribute,
|
||||
value: attributeValues[attributeKey] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const usedAttributes = computed(() =>
|
||||
processCompanyAttributes(
|
||||
companyAttributes.value,
|
||||
customAttributes.value,
|
||||
(key, values) => key in values
|
||||
)
|
||||
);
|
||||
|
||||
const unusedAttributes = computed(() =>
|
||||
processCompanyAttributes(
|
||||
companyAttributes.value,
|
||||
customAttributes.value,
|
||||
(key, values) => !(key in values)
|
||||
)
|
||||
);
|
||||
|
||||
const filteredUnusedAttributes = computed(() =>
|
||||
unusedAttributes.value.filter(attribute =>
|
||||
attribute.attributeDisplayName
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const unusedAttributesCount = computed(() => unusedAttributes.value.length);
|
||||
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
|
||||
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<CompanyCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
:key="`${company.id}-${attribute.id}`"
|
||||
is-editing-view
|
||||
:company-id="company.id"
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-n-slate-5" />
|
||||
<span class="text-sm font-medium text-n-slate-10">
|
||||
{{
|
||||
t('COMPANIES.DETAIL.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
||||
count: unusedAttributesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-n-slate-5" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="!hasNoUnusedAttributes" class="relative">
|
||||
<span
|
||||
class="absolute i-lucide-search size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
|
||||
class="flex items-center justify-start h-11"
|
||||
>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.ATTRIBUTES.NO_ATTRIBUTES') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
|
||||
<CompanyCustomAttributeItem
|
||||
v-for="attribute in filteredUnusedAttributes"
|
||||
:key="`${company.id}-${attribute.id}`"
|
||||
:company-id="company.id"
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
const props = defineProps({
|
||||
company: { type: Object, default: () => ({}) },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const form = reactive({ name: '', domain: '', description: '' });
|
||||
const avatarPreviewUrl = ref('');
|
||||
const isUploadingAvatar = ref(false);
|
||||
|
||||
const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
const isUpdating = computed(() => uiFlags.value.updatingItem);
|
||||
const isAvatarBusy = computed(
|
||||
() =>
|
||||
isUploadingAvatar.value || uiFlags.value.deletingAvatar || isUpdating.value
|
||||
);
|
||||
|
||||
const displayName = computed(
|
||||
() => props.company?.name || t('COMPANIES.UNNAMED')
|
||||
);
|
||||
const avatarSource = computed(
|
||||
() => avatarPreviewUrl.value || props.company?.avatarUrl || ''
|
||||
);
|
||||
const isFormInvalid = computed(() => !form.name.trim());
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
form.name.trim() !== (props.company?.name || '').trim() ||
|
||||
form.domain.trim() !== (props.company?.domain || '').trim() ||
|
||||
form.description.trim() !== (props.company?.description || '').trim()
|
||||
);
|
||||
|
||||
const summary = computed(() => {
|
||||
const { createdAt, lastActivityAt } = props.company || {};
|
||||
return [
|
||||
createdAt &&
|
||||
t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
|
||||
date: dynamicTime(createdAt),
|
||||
}),
|
||||
lastActivityAt &&
|
||||
t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
|
||||
date: dynamicTime(lastActivityAt),
|
||||
}),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
});
|
||||
|
||||
const syncForm = company => {
|
||||
form.name = company?.name || '';
|
||||
form.domain = company?.domain || '';
|
||||
form.description = company?.description || '';
|
||||
};
|
||||
|
||||
const isCurrentCompany = companyId => Number(props.company?.id) === companyId;
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.company?.id,
|
||||
props.company?.name,
|
||||
props.company?.domain,
|
||||
props.company?.description,
|
||||
props.company?.avatarUrl,
|
||||
],
|
||||
() => {
|
||||
avatarPreviewUrl.value = '';
|
||||
syncForm(props.company);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleAvatarUpload = async ({ file, url }) => {
|
||||
avatarPreviewUrl.value = url;
|
||||
isUploadingAvatar.value = true;
|
||||
try {
|
||||
await companiesStore.update({ id: props.company.id, avatar: file });
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
|
||||
} catch {
|
||||
avatarPreviewUrl.value = '';
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
|
||||
} finally {
|
||||
isUploadingAvatar.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
try {
|
||||
await companiesStore.deleteCompanyAvatar(props.company.id);
|
||||
avatarPreviewUrl.value = '';
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCompany = async () => {
|
||||
const companyId = Number(props.company.id);
|
||||
|
||||
try {
|
||||
const updated = await companiesStore.update({
|
||||
id: companyId,
|
||||
name: form.name.trim(),
|
||||
domain: form.domain.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
if (!isCurrentCompany(companyId)) return;
|
||||
|
||||
syncForm(updated);
|
||||
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
|
||||
} catch {
|
||||
if (!isCurrentCompany(companyId)) return;
|
||||
|
||||
syncForm(props.company);
|
||||
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isLoading && !company?.id" class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.LOADING') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="company?.id" class="flex flex-col items-start gap-8 pb-6">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Avatar
|
||||
:name="displayName"
|
||||
:src="avatarSource"
|
||||
:size="72"
|
||||
:allow-upload="!isAvatarBusy"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
<span class="text-sm leading-6 text-n-slate-11">{{ summary }}</span>
|
||||
<p
|
||||
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
|
||||
class="text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start w-full gap-6">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
|
||||
</span>
|
||||
|
||||
<div class="grid w-full gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
v-model="form.name"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
|
||||
:disabled="isUpdating"
|
||||
custom-input-class="h-8 !pt-1 !pb-1"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.domain"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
|
||||
:disabled="isUpdating"
|
||||
custom-input-class="h-8 !pt-1 !pb-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
|
||||
:disabled="isUpdating"
|
||||
:max-length="280"
|
||||
class="w-full"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
|
||||
<Button
|
||||
:label="t('COMPANIES.DETAIL.PROFILE.ACTIONS.SAVE')"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid || !hasChanges"
|
||||
@click="handleUpdateCompany"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
company: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const description = computed(() =>
|
||||
props.company?.name
|
||||
? t('COMPANIES.DETAIL.DELETE.DESCRIPTION_WITH_NAME', {
|
||||
companyName: props.company.name,
|
||||
})
|
||||
: t('COMPANIES.DETAIL.DELETE.DESCRIPTION')
|
||||
);
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('COMPANIES.DETAIL.DELETE.TITLE')"
|
||||
:description="description"
|
||||
:confirm-button-label="t('COMPANIES.DETAIL.DELETE.CONFIRM')"
|
||||
:is-loading="isLoading"
|
||||
@confirm="emit('confirm')"
|
||||
/>
|
||||
</template>
|
||||
@@ -134,7 +134,7 @@ const handleInputUpdate = async () => {
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
autofocus
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
@enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
|
||||
@@ -191,7 +191,7 @@ const handleInputUpdate = async () => {
|
||||
:message="attributeErrorMessage"
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
@enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
|
||||
@@ -457,7 +457,7 @@ const menuItems = computed(() => {
|
||||
{},
|
||||
{ page: 1, search: undefined }
|
||||
),
|
||||
activeOn: ['companies_dashboard_index'],
|
||||
activeOn: ['companies_dashboard_index', 'companies_dashboard_show'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
@@ -18,7 +20,13 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isBeta: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,9 +45,18 @@ defineProps({
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<Label
|
||||
v-if="isBeta && !isComingSoon"
|
||||
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
|
||||
:label="t('GENERAL.BETA')"
|
||||
color="blue"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-start text-sm">
|
||||
{{ description }}
|
||||
</p>
|
||||
@@ -50,7 +67,7 @@ defineProps({
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
|
||||
>
|
||||
<span class="text-n-slate-12 font-medium text-sm">
|
||||
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||
{{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -77,6 +77,10 @@ const isComingSoon = computed(() => {
|
||||
return ['voice'].includes(key) && !isActive.value;
|
||||
});
|
||||
|
||||
const isBeta = computed(() => {
|
||||
return ['tiktok', 'voice'].includes(props.channel.key);
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
if (isActive.value) {
|
||||
emit('channelItemClick', props.channel.key);
|
||||
@@ -90,6 +94,7 @@ const onItemClick = () => {
|
||||
:description="channel.description"
|
||||
:icon="channel.icon"
|
||||
:is-coming-soon="isComingSoon"
|
||||
:is-beta="isBeta"
|
||||
:disabled="!isActive"
|
||||
@click="onItemClick"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -91,6 +93,15 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const copyConversationId = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(String(props.chat.id));
|
||||
useAlert(t('CONVERSATION.HEADER.COPY_ID_SUCCESS'));
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -133,9 +144,18 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||
class="flex items-center gap-1 overflow-hidden text-xs conversation--header--actions text-n-slate-11 text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="truncate text-label-small text-n-slate-11 hover:text-n-slate-12 !p-0 cucursor-pointer"
|
||||
@click="copyConversationId"
|
||||
>
|
||||
{{ `#${chat.id}` }}
|
||||
</button>
|
||||
<span v-if="hasMultipleInboxes">•</span>
|
||||
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
|
||||
<span v-if="isSnoozed">•</span>
|
||||
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
||||
{{ snoozedDisplayText }}
|
||||
</span>
|
||||
|
||||
@@ -273,6 +273,10 @@ export default {
|
||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||
},
|
||||
showFileUpload() {
|
||||
const { image_send: imageSend } =
|
||||
this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
|
||||
const tiktokAttachmentSupported = imageSend ?? true;
|
||||
|
||||
return (
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isAFacebookInbox ||
|
||||
@@ -283,7 +287,7 @@ export default {
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAnInstagramChannel ||
|
||||
this.isATiktokChannel
|
||||
(this.isATiktokChannel && tiktokAttachmentSupported)
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -706,6 +710,7 @@ export default {
|
||||
|
||||
// Don't handle paste if editor is disabled
|
||||
if (this.isEditorDisabled) return;
|
||||
if (!this.showFileUpload && !this.isOnPrivateNote) return;
|
||||
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(e.clipboardData.files)
|
||||
@@ -1025,6 +1030,8 @@ export default {
|
||||
});
|
||||
},
|
||||
attachFile({ blob, file }) {
|
||||
if (!this.showFileUpload && !this.isOnPrivateNote) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
reader.onloadend = () => {
|
||||
|
||||
@@ -22,6 +22,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
autoPlay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
@@ -309,6 +313,7 @@ onMounted(() => {
|
||||
:src="activeAttachment.data_url"
|
||||
controls
|
||||
playsInline
|
||||
:autoplay="autoPlay"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
@click.stop
|
||||
/>
|
||||
@@ -317,6 +322,7 @@ onMounted(() => {
|
||||
v-if="isAudio"
|
||||
:key="activeAttachment.message_id"
|
||||
controls
|
||||
:autoplay="autoPlay"
|
||||
class="w-full max-w-md"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'conversation_info' },
|
||||
{ name: 'contact_attributes' },
|
||||
{ name: 'contact_notes' },
|
||||
{ name: 'shared_files' },
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'linear_issues' },
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
"HEADER": "Custom Attributes",
|
||||
"HEADER_BTN_TXT": "Add Custom Attribute",
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts, companies, or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"COUNT": "{n} attribute | {n} attributes",
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "Company"
|
||||
},
|
||||
"ATTRIBUTE_TYPES": {
|
||||
"TEXT": "Text",
|
||||
@@ -108,7 +109,8 @@
|
||||
"TABS": {
|
||||
"HEADER": "Custom Attributes",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "Company"
|
||||
},
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY_AT": "Last activity",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
@@ -21,6 +22,100 @@
|
||||
"LOADING": "Loading companies...",
|
||||
"UNNAMED": "Unnamed Company",
|
||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
||||
"DETAIL": {
|
||||
"LOADING": "Loading company details...",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "Company not found",
|
||||
"SUBTITLE": "This company may have been removed or is no longer available in this account."
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"TABS": {
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"CONTACTS": "Contacts"
|
||||
}
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"EMPTY_STATE": "There are no company custom attributes configured yet.",
|
||||
"NO_ATTRIBUTES": "No matching attributes found.",
|
||||
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
|
||||
"MESSAGES": {
|
||||
"UPDATE_SUCCESS": "Company attribute updated.",
|
||||
"UPDATE_ERROR": "Could not update company attribute.",
|
||||
"DELETE_SUCCESS": "Company attribute removed.",
|
||||
"DELETE_ERROR": "Could not remove company attribute."
|
||||
}
|
||||
},
|
||||
"CONTACTS": {
|
||||
"LOADING": "Loading contacts...",
|
||||
"EMPTY": "No contacts are linked to this company yet.",
|
||||
"UNNAMED_CONTACT": "Unnamed contact",
|
||||
"ACTIONS": {
|
||||
"ADD": "Add contact",
|
||||
"REMOVE": "Remove contact"
|
||||
},
|
||||
"DIALOGS": {
|
||||
"ADD": {
|
||||
"DESCRIPTION": "Search for an existing contact and link it to this company.",
|
||||
"SEARCH_PLACEHOLDER": "Search contacts...",
|
||||
"INITIAL": "Start typing to search contacts.",
|
||||
"EMPTY": "No contacts found.",
|
||||
"CONFIRM_TITLE": "Link contact",
|
||||
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
|
||||
"COMPANY_LABEL": "Company",
|
||||
"CONTACT_LABEL": "Contact",
|
||||
"CURRENT_COMPANY": "Currently linked to {companyName}",
|
||||
"ADD": "Link contact",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"MESSAGES": {
|
||||
"ADD_SUCCESS": "Contact linked to company.",
|
||||
"ADD_ERROR": "Could not link contact to company.",
|
||||
"REASSIGN_SUCCESS": "Contact reassigned to company.",
|
||||
"REASSIGN_ERROR": "Could not reassign contact to company.",
|
||||
"REMOVE_SUCCESS": "Contact removed from company.",
|
||||
"REMOVE_ERROR": "Could not remove contact from company."
|
||||
}
|
||||
},
|
||||
"AVATAR": {
|
||||
"UPDATING": "Updating company avatar...",
|
||||
"UPLOAD_SUCCESS": "Company avatar updated.",
|
||||
"UPLOAD_ERROR": "Could not update the company avatar.",
|
||||
"DELETE_SUCCESS": "Company avatar removed.",
|
||||
"DELETE_ERROR": "Could not remove the company avatar."
|
||||
},
|
||||
"PROFILE": {
|
||||
"TITLE": "Edit company details",
|
||||
"CREATED_AT": "Created {date}",
|
||||
"LAST_ACTIVE": "Last active {date}",
|
||||
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
|
||||
"ACTIONS": {
|
||||
"SAVE": "Update company"
|
||||
},
|
||||
"MESSAGES": {
|
||||
"UPDATE_SUCCESS": "Company updated.",
|
||||
"UPDATE_ERROR": "Could not update the company."
|
||||
},
|
||||
"FIELDS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"SECTION_TITLE": "Danger zone",
|
||||
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
|
||||
"BUTTON": "Delete company",
|
||||
"TITLE": "Delete company?",
|
||||
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
|
||||
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
|
||||
"CONFIRM": "Delete company",
|
||||
"MESSAGES": {
|
||||
"SUCCESS": "Company deleted.",
|
||||
"ERROR": "Could not delete the company."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No companies found"
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"OPEN": "More",
|
||||
"CLOSE": "Close",
|
||||
"DETAILS": "details",
|
||||
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
|
||||
"SNOOZED_UNTIL": "Snoozed until",
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
@@ -365,7 +366,19 @@
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"MACROS": "Macros",
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
"SHOPIFY_ORDERS": "Shopify Orders",
|
||||
"SHARED_FILES": "Attachments"
|
||||
},
|
||||
"SHARED_FILES": {
|
||||
"EMPTY": "No attachments yet",
|
||||
"DOWNLOAD": "Download file",
|
||||
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
|
||||
"MEDIA_HEADING": "Media",
|
||||
"FILES_HEADING": "Files",
|
||||
"VIEW_ALL": "View all",
|
||||
"SHOW_LESS": "Show less",
|
||||
"MORE_COUNT": "+{count}",
|
||||
"UNTITLED_FILE": "Untitled file"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
|
||||
}
|
||||
},
|
||||
"VIEW": {
|
||||
"TOOLTIP": "View macro"
|
||||
},
|
||||
"EDIT": {
|
||||
"TOOLTIP": "Edit macro",
|
||||
"API": {
|
||||
@@ -66,7 +69,9 @@
|
||||
"LABEL": "Macro Visibility",
|
||||
"GLOBAL": {
|
||||
"LABEL": "Public",
|
||||
"DESCRIPTION": "This macro is available publicly for all agents in this account."
|
||||
"DESCRIPTION": "This macro is available publicly for all agents in this account.",
|
||||
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
|
||||
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
|
||||
},
|
||||
"PERSONAL": {
|
||||
"LABEL": "Private",
|
||||
|
||||
@@ -111,6 +111,16 @@ const onPageChange = page => {
|
||||
fetchCompanies(page, searchValue.value, sortParam.value);
|
||||
};
|
||||
|
||||
const showCompany = companyId => {
|
||||
router.push({
|
||||
name: 'companies_dashboard_show',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
companyId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = async ({ sort, order }) => {
|
||||
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||
|
||||
@@ -123,6 +133,11 @@ const handleSort = async ({ sort, order }) => {
|
||||
|
||||
onMounted(() => {
|
||||
searchValue.value = searchQuery.value;
|
||||
|
||||
if (!route.query.sort && sortParam.value !== DEFAULT_SORT_FIELD) {
|
||||
updateURLParams(pageNumber.value, searchQuery.value, sortParam.value);
|
||||
}
|
||||
|
||||
fetchCompanies();
|
||||
});
|
||||
</script>
|
||||
@@ -162,9 +177,9 @@ onMounted(() => {
|
||||
:name="company.name"
|
||||
:domain="company.domain"
|
||||
:contacts-count="company.contactsCount || 0"
|
||||
:description="company.description"
|
||||
:avatar-url="company.avatarUrl"
|
||||
:updated-at="company.updatedAt"
|
||||
:last-activity-at="company.lastActivityAt"
|
||||
@show-company="showCompany"
|
||||
/>
|
||||
</div>
|
||||
</CompaniesListLayout>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CompaniesDetailsLayout from 'dashboard/components-next/Companies/CompaniesDetailsLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CompanyContactsSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue';
|
||||
import CompanyProfileCard from 'dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue';
|
||||
import ConfirmCompanyDeleteDialog from 'dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const companiesStore = useCompaniesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const confirmDeleteDialogRef = ref(null);
|
||||
const selectedCandidate = ref(null);
|
||||
|
||||
const companyId = computed(() => Number(route.params.companyId));
|
||||
const company = computed(() => companiesStore.getRecord(companyId.value));
|
||||
const companyContacts = computed(() => companiesStore.companyContacts);
|
||||
const companyContactsMeta = computed(() => companiesStore.companyContactsMeta);
|
||||
const contactSearchResults = computed(
|
||||
() => companiesStore.contactSearchResults
|
||||
);
|
||||
const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const isFetchingCompany = computed(() => uiFlags.value.fetchingItem);
|
||||
const isFetchingContacts = computed(() => uiFlags.value.fetchingContacts);
|
||||
const isSearchingContacts = computed(() => uiFlags.value.searchingContacts);
|
||||
const isManagingContacts = computed(
|
||||
() => uiFlags.value.creatingContact || uiFlags.value.removingContact
|
||||
);
|
||||
const isDeletingCompany = computed(() => uiFlags.value.deletingItem);
|
||||
const hasCompany = computed(() => Boolean(company.value?.id));
|
||||
const showInitialLoadingState = computed(
|
||||
() =>
|
||||
!hasCompany.value && (isFetchingCompany.value || isFetchingContacts.value)
|
||||
);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{ label: t('COMPANIES.HEADER') },
|
||||
...(hasCompany.value
|
||||
? [{ label: company.value?.name || t('COMPANIES.UNNAMED') }]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const goToCompaniesIndex = () => {
|
||||
router.push({
|
||||
name: 'companies_dashboard_index',
|
||||
params: { accountId: route.params.accountId },
|
||||
query: { page: '1' },
|
||||
});
|
||||
};
|
||||
|
||||
const goToCompaniesList = () => {
|
||||
if (window.history.state?.back) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
goToCompaniesIndex();
|
||||
};
|
||||
|
||||
const loadCompanyContactsPage = async page => {
|
||||
if (!companyId.value) return;
|
||||
await companiesStore.getCompanyContacts(companyId.value, page);
|
||||
};
|
||||
|
||||
const openDeleteCompanyDialog = () => {
|
||||
confirmDeleteDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
|
||||
const clearSelectedCandidate = () => {
|
||||
selectedCandidate.value = null;
|
||||
};
|
||||
|
||||
const handleContactSearch = async query => {
|
||||
await companiesStore.searchCompanyContactCandidates({
|
||||
companyId: companyId.value,
|
||||
search: query,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmContactSelection = async () => {
|
||||
const candidate = selectedCandidate.value;
|
||||
if (!candidate) return;
|
||||
|
||||
const isReassigning =
|
||||
candidate.company?.id && candidate.company.id !== companyId.value;
|
||||
const message = isReassigning
|
||||
? t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REASSIGN_SUCCESS')
|
||||
: t('COMPANIES.DETAIL.CONTACTS.MESSAGES.ADD_SUCCESS');
|
||||
|
||||
try {
|
||||
await companiesStore.attachContactToCompany(companyId.value, candidate.id);
|
||||
useAlert(message);
|
||||
clearSelectedCandidate();
|
||||
} catch {
|
||||
const errorMessage = isReassigning
|
||||
? t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REASSIGN_ERROR')
|
||||
: t('COMPANIES.DETAIL.CONTACTS.MESSAGES.ADD_ERROR');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveContact = async contactId => {
|
||||
const currentPage = Number(companyContactsMeta.value.page || 1);
|
||||
const nextPage =
|
||||
currentPage > 1 && companyContacts.value.length === 1
|
||||
? currentPage - 1
|
||||
: currentPage;
|
||||
|
||||
try {
|
||||
await companiesStore.removeContactFromCompany(
|
||||
companyId.value,
|
||||
contactId,
|
||||
nextPage
|
||||
);
|
||||
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async () => {
|
||||
try {
|
||||
await companiesStore.delete(companyId.value);
|
||||
useAlert(t('COMPANIES.DETAIL.DELETE.MESSAGES.SUCCESS'));
|
||||
confirmDeleteDialogRef.value?.dialogRef.close();
|
||||
goToCompaniesIndex();
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.DETAIL.DELETE.MESSAGES.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
companyId,
|
||||
async id => {
|
||||
companiesStore.resetCompanyDetailState();
|
||||
clearSelectedCandidate();
|
||||
if (!id) return;
|
||||
await Promise.allSettled([
|
||||
companiesStore.show(id),
|
||||
companiesStore.getCompanyContacts(id),
|
||||
]);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
companiesStore.resetCompanyDetailState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CompaniesDetailsLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
@back="goToCompaniesList"
|
||||
>
|
||||
<div
|
||||
v-if="showInitialLoadingState"
|
||||
class="flex flex-col items-center justify-center gap-3 py-24 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
<span class="text-sm">{{ t('COMPANIES.DETAIL.LOADING') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasCompany"
|
||||
class="flex flex-col items-center justify-center gap-3 px-6 py-24 text-center rounded-2xl border border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<span class="text-lg font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.EMPTY_STATE.TITLE') }}
|
||||
</span>
|
||||
<p class="max-w-md text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.EMPTY_STATE.SUBTITLE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-6">
|
||||
<CompanyProfileCard :company="company" :is-loading="isFetchingCompany" />
|
||||
|
||||
<Policy :permissions="['administrator']">
|
||||
<section
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.DELETE.SECTION_TITLE') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.DELETE.SECTION_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('COMPANIES.DETAIL.DELETE.BUTTON')"
|
||||
color="ruby"
|
||||
:disabled="isDeletingCompany"
|
||||
@click="openDeleteCompanyDialog"
|
||||
/>
|
||||
</section>
|
||||
</Policy>
|
||||
</div>
|
||||
|
||||
<template v-if="hasCompany" #sidebar>
|
||||
<CompanyContactsSidebar
|
||||
:company="company"
|
||||
:contacts="companyContacts"
|
||||
:meta="companyContactsMeta"
|
||||
:is-loading="isFetchingContacts"
|
||||
:is-busy="isManagingContacts"
|
||||
:search-results="contactSearchResults"
|
||||
:is-searching="isSearchingContacts"
|
||||
:selected-contact="selectedCandidate"
|
||||
@cancel-contact-selection="clearSelectedCandidate"
|
||||
@confirm-contact-selection="handleConfirmContactSelection"
|
||||
@search="handleContactSearch"
|
||||
@select-contact="contact => (selectedCandidate = contact)"
|
||||
@remove-contact="handleRemoveContact"
|
||||
@update:current-page="loadCompanyContactsPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ConfirmCompanyDeleteDialog
|
||||
ref="confirmDeleteDialogRef"
|
||||
:company="company"
|
||||
:is-loading="isDeletingCompany"
|
||||
@confirm="handleDeleteCompany"
|
||||
/>
|
||||
</CompaniesDetailsLayout>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import CompaniesIndex from './pages/CompaniesIndex.vue';
|
||||
import CompanyDetailView from './pages/CompanyDetailView.vue';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
|
||||
@@ -23,4 +24,17 @@ export const routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/companies/:companyId'),
|
||||
component: CompanyDetailView,
|
||||
meta: commonMeta,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'companies_dashboard_show',
|
||||
component: CompanyDetailView,
|
||||
meta: commonMeta,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ import ContactInfo from './contact/ContactInfo.vue';
|
||||
import ContactNotes from './contact/ContactNotes.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import SharedFiles from './SharedFiles.vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import MacrosList from './Macros/List.vue';
|
||||
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
|
||||
@@ -297,6 +298,18 @@ onMounted(() => {
|
||||
<ContactNotes :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'shared_files'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHARED_FILES')"
|
||||
:is-open="isContactSidebarItemOpen('is_shared_files_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_shared_files_open', value)
|
||||
"
|
||||
>
|
||||
<SharedFiles />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||
import {
|
||||
dynamicTime,
|
||||
formatDuration,
|
||||
shortTimestamp,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import {
|
||||
ATTACHMENT_TYPES,
|
||||
MEDIA_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const MEDIA_PEEK_LIMIT = 6;
|
||||
const FILES_PEEK_LIMIT = 3;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const allAttachments = useMapGetter('getSelectedChatAttachments');
|
||||
const attachmentsLoaded = useMapGetter('getSelectedChatAttachmentsLoaded');
|
||||
|
||||
const sortedAttachments = computed(() =>
|
||||
[...allAttachments.value].sort(
|
||||
(a, b) => (b.created_at || 0) - (a.created_at || 0)
|
||||
)
|
||||
);
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
sortedAttachments.value.filter(a => MEDIA_TYPES.includes(a.file_type))
|
||||
);
|
||||
|
||||
const fileAttachments = computed(() =>
|
||||
sortedAttachments.value.filter(
|
||||
a => !MEDIA_TYPES.includes(a.file_type) && a.data_url
|
||||
)
|
||||
);
|
||||
|
||||
const showAllMedia = ref(false);
|
||||
const showAllFiles = ref(false);
|
||||
|
||||
const visibleMedia = computed(() =>
|
||||
showAllMedia.value
|
||||
? mediaAttachments.value
|
||||
: mediaAttachments.value.slice(0, MEDIA_PEEK_LIMIT)
|
||||
);
|
||||
|
||||
const visibleFiles = computed(() =>
|
||||
showAllFiles.value
|
||||
? fileAttachments.value
|
||||
: fileAttachments.value.slice(0, FILES_PEEK_LIMIT)
|
||||
);
|
||||
|
||||
const mediaOverflow = computed(() => {
|
||||
const total = mediaAttachments.value.length;
|
||||
return total > MEDIA_PEEK_LIMIT ? total - (MEDIA_PEEK_LIMIT - 1) : 0;
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
const selectedAttachment = ref(null);
|
||||
const downloadingId = ref(null);
|
||||
|
||||
const fileNameFromUrl = url => {
|
||||
if (!url) return '';
|
||||
const name = url.split('/').pop();
|
||||
return name ? decodeURIComponent(name) : '';
|
||||
};
|
||||
|
||||
const onDownloadFile = async attachment => {
|
||||
const { id, file_type: type, data_url: url, extension } = attachment;
|
||||
try {
|
||||
downloadingId.value = id;
|
||||
await downloadFile({ url, type, extension });
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
|
||||
} finally {
|
||||
downloadingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isVideoType = type =>
|
||||
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
|
||||
|
||||
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
|
||||
const isPlayableType = type => isVideoType(type) || isAudioType(type);
|
||||
|
||||
const durations = ref({});
|
||||
|
||||
const onLoadedMetadata = (attachment, event) => {
|
||||
const seconds = event.target?.duration;
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
durations.value[attachment.id] = seconds;
|
||||
}
|
||||
};
|
||||
|
||||
const displayDuration = attachment => {
|
||||
const seconds = durations.value[attachment.id];
|
||||
return seconds ? formatDuration(Math.round(seconds)) : '';
|
||||
};
|
||||
|
||||
const isOverflowTile = index =>
|
||||
!showAllMedia.value &&
|
||||
mediaOverflow.value > 0 &&
|
||||
index === MEDIA_PEEK_LIMIT - 1;
|
||||
|
||||
const onTileActivate = (attachment, index) => {
|
||||
if (isOverflowTile(index)) {
|
||||
showAllMedia.value = true;
|
||||
return;
|
||||
}
|
||||
selectedAttachment.value = attachment;
|
||||
showGallery.value = true;
|
||||
};
|
||||
|
||||
const failedThumbs = ref(new Set());
|
||||
const failedPreviews = ref(new Set());
|
||||
|
||||
const imagePreviewSrc = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
|
||||
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
|
||||
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPreviewError = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
|
||||
if (
|
||||
canRetryWithFull &&
|
||||
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
|
||||
) {
|
||||
failedThumbs.value.add(id);
|
||||
return;
|
||||
}
|
||||
failedPreviews.value.add(id);
|
||||
};
|
||||
|
||||
const hasPreview = attachment =>
|
||||
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
|
||||
const hasVideoPreview = attachment =>
|
||||
isVideoType(attachment.file_type) &&
|
||||
attachment.data_url &&
|
||||
!failedPreviews.value.has(attachment.id);
|
||||
|
||||
const fallbackIcon = type => {
|
||||
if (type === ATTACHMENT_TYPES.AUDIO) return 'i-lucide-music';
|
||||
if (isVideoType(type)) return 'i-lucide-video';
|
||||
return 'i-lucide-image';
|
||||
};
|
||||
|
||||
const displayName = attachment =>
|
||||
fileNameFromUrl(attachment.data_url) ||
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
|
||||
|
||||
const displaySize = attachment => {
|
||||
if (attachment.file_size) return formatBytes(attachment.file_size);
|
||||
if (attachment.extension) return attachment.extension.toUpperCase();
|
||||
return '—';
|
||||
};
|
||||
|
||||
const displayTime = attachment => {
|
||||
if (!attachment.created_at) return '';
|
||||
return shortTimestamp(dynamicTime(attachment.created_at), true);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 p-2">
|
||||
<div v-if="!attachmentsLoaded" class="flex justify-center p-3">
|
||||
<Spinner class="size-5" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="!mediaAttachments.length && !fileAttachments.length"
|
||||
class="p-3 text-sm text-center text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
||||
</p>
|
||||
|
||||
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ mediaAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="mediaOverflow > 0"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="
|
||||
showAllMedia ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
|
||||
"
|
||||
:label="
|
||||
showAllMedia
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAllMedia = !showAllMedia"
|
||||
/>
|
||||
</header>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(attachment, index) in visibleMedia"
|
||||
:key="attachment.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
|
||||
@click="onTileActivate(attachment, index)"
|
||||
@keydown.enter="onTileActivate(attachment, index)"
|
||||
@keydown.space.prevent="onTileActivate(attachment, index)"
|
||||
>
|
||||
<template v-if="!isOverflowTile(index)">
|
||||
<img
|
||||
v-if="hasPreview(attachment)"
|
||||
:src="imagePreviewSrc(attachment)"
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
:alt="fileNameFromUrl(attachment.data_url)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<video
|
||||
v-else-if="hasVideoPreview(attachment)"
|
||||
:src="`${attachment.data_url}#t=0.1`"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="fallbackIcon(attachment.file_type)"
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
v-if="isAudioType(attachment.file_type) && attachment.data_url"
|
||||
:src="attachment.data_url"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasVideoPreview(attachment)"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-play"
|
||||
class="ms-0.5 size-3.5 text-n-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="
|
||||
isPlayableType(attachment.file_type) &&
|
||||
displayDuration(attachment)
|
||||
"
|
||||
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
|
||||
>
|
||||
{{ displayDuration(attachment) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="displayTime(attachment)"
|
||||
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
|
||||
>
|
||||
{{ displayTime(attachment) }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
|
||||
:disabled="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click.stop="onDownloadFile(attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isOverflowTile(index)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
|
||||
>
|
||||
<span class="text-base font-semibold text-n-slate-12">
|
||||
{{
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
|
||||
count: mediaOverflow,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ fileAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="fileAttachments.length > FILES_PEEK_LIMIT"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="
|
||||
showAllFiles ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
|
||||
"
|
||||
:label="
|
||||
showAllFiles
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAllFiles = !showAllFiles"
|
||||
/>
|
||||
</header>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
<li
|
||||
v-for="attachment in visibleFiles"
|
||||
:key="attachment.id"
|
||||
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg hover:bg-n-slate-3 group"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
|
||||
>
|
||||
<FileIcon
|
||||
:file-type="attachment.extension?.toLowerCase() || ''"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
:href="attachment.data_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-1 min-w-0"
|
||||
:title="displayName(attachment)"
|
||||
>
|
||||
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
|
||||
{{ displayName(attachment) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ displaySize(attachment) }}
|
||||
<template v-if="displayTime(attachment)">
|
||||
· {{ displayTime(attachment) }}
|
||||
</template>
|
||||
</p>
|
||||
</a>
|
||||
<NextButton
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-download"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:is-loading="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click="onDownloadFile(attachment)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<GalleryView
|
||||
v-if="showGallery && selectedAttachment"
|
||||
v-model:show="showGallery"
|
||||
:attachment="selectedAttachment"
|
||||
:all-attachments="mediaAttachments"
|
||||
auto-play
|
||||
@close="showGallery = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,6 +32,7 @@ const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
const [showEditPopup, toggleEditPopup] = useToggle(false);
|
||||
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
|
||||
const selectedAttribute = ref({});
|
||||
const attributeModels = ['conversation_attribute', 'contact_attribute'];
|
||||
|
||||
const openAddPopup = () => {
|
||||
toggleAddPopup(true);
|
||||
@@ -69,8 +70,8 @@ onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
});
|
||||
|
||||
const attributeModel = computed(() =>
|
||||
selectedTabIndex.value ? 'contact_attribute' : 'conversation_attribute'
|
||||
const attributeModel = computed(
|
||||
() => attributeModels[selectedTabIndex.value] || 'conversation_attribute'
|
||||
);
|
||||
|
||||
const attributes = computed(() =>
|
||||
|
||||
@@ -9,10 +9,12 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { BaseTable } from 'dashboard/components-next/table';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const showDeleteConfirmationPopup = ref(false);
|
||||
const selectedMacro = ref({});
|
||||
@@ -109,6 +111,7 @@ const tableHeaders = computed(() => {
|
||||
v-for="macro in items"
|
||||
:key="macro.id"
|
||||
:macro="macro"
|
||||
:can-manage-public-macros="isAdmin"
|
||||
@delete="openDeletePopup(macro)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MACRO_ACTION_TYPES } from './constants';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
|
||||
import { useMacros } from 'dashboard/composables/useMacros';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
@@ -18,6 +19,7 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const macro = ref(null);
|
||||
const mode = ref('CREATE');
|
||||
@@ -33,6 +35,9 @@ provide('macroActionTypes', macroActionTypes);
|
||||
|
||||
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
|
||||
const macroId = computed(() => route.params.macroId);
|
||||
const isPublicMacroReadOnly = computed(
|
||||
() => macro.value?.visibility === 'global' && !isAdmin.value
|
||||
);
|
||||
|
||||
const fetchDropdownData = () => {
|
||||
store.dispatch('agents/get');
|
||||
@@ -92,7 +97,7 @@ const initNewMacro = () => {
|
||||
action_params: [],
|
||||
},
|
||||
],
|
||||
visibility: 'global',
|
||||
visibility: isAdmin.value ? 'global' : 'personal',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -110,6 +115,8 @@ watch(
|
||||
);
|
||||
|
||||
const saveMacro = async macroData => {
|
||||
if (isPublicMacroReadOnly.value) return;
|
||||
|
||||
try {
|
||||
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
|
||||
const successMessage =
|
||||
@@ -136,6 +143,8 @@ const saveMacro = async macroData => {
|
||||
<MacroForm
|
||||
v-if="macro && !uiFlags.isFetchingItem"
|
||||
:macro-data="macro"
|
||||
:can-manage-public-macros="isAdmin"
|
||||
:read-only="isPublicMacroReadOnly"
|
||||
@update:macro-data="macro = $event"
|
||||
@submit="saveMacro"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,14 @@ export default {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
canManagePublicMacros: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup() {
|
||||
@@ -112,19 +120,23 @@ export default {
|
||||
<div
|
||||
class="flex-1 w-full h-full max-h-full ltr:pl-12 ltr:pr-6 rtl:pl-6 rtl:pr-12 py-4 overflow-y-auto lg:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size"
|
||||
>
|
||||
<MacroNodes
|
||||
v-model="macro.actions"
|
||||
:files="files"
|
||||
:errors="errors"
|
||||
@add-new-node="appendNode"
|
||||
@delete-node="deleteNode"
|
||||
@reset-action="resetNode"
|
||||
/>
|
||||
<div :inert="readOnly" :class="{ 'opacity-75': readOnly }">
|
||||
<MacroNodes
|
||||
v-model="macro.actions"
|
||||
:files="files"
|
||||
:errors="errors"
|
||||
@add-new-node="appendNode"
|
||||
@delete-node="deleteNode"
|
||||
@reset-action="resetNode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/3 pb-4">
|
||||
<MacroProperties
|
||||
:macro-name="macro.name"
|
||||
:macro-visibility="macro.visibility"
|
||||
:can-manage-public-macros="canManagePublicMacros"
|
||||
:read-only="readOnly"
|
||||
@update:name="updateName"
|
||||
@update:visibility="updateVisibility"
|
||||
@submit="submit"
|
||||
|
||||
@@ -17,8 +17,36 @@ export default {
|
||||
type: String,
|
||||
default: 'global',
|
||||
},
|
||||
canManagePublicMacros: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:name', 'update:visibility', 'submit'],
|
||||
computed: {
|
||||
isPublicVisibilityDisabled() {
|
||||
return !this.canManagePublicMacros;
|
||||
},
|
||||
publicVisibilityDescription() {
|
||||
if (this.readOnly) {
|
||||
return this.$t(
|
||||
'MACROS.EDITOR.VISIBILITY.GLOBAL.EDIT_DISABLED_DESCRIPTION'
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isPublicVisibilityDisabled) {
|
||||
return this.$t(
|
||||
'MACROS.EDITOR.VISIBILITY.GLOBAL.CREATE_DISABLED_DESCRIPTION'
|
||||
);
|
||||
}
|
||||
|
||||
return this.$t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isActive(key) {
|
||||
return this.macroVisibility === key
|
||||
@@ -26,9 +54,14 @@ export default {
|
||||
: 'bg-white dark:bg-n-solid-2 border-n-weak dark:border-n-strong';
|
||||
},
|
||||
onUpdateName(value) {
|
||||
if (this.readOnly) return;
|
||||
|
||||
this.$emit('update:name', value);
|
||||
},
|
||||
onUpdateVisibility(value) {
|
||||
if (this.readOnly) return;
|
||||
if (value === 'global' && this.isPublicVisibilityDisabled) return;
|
||||
|
||||
this.$emit('update:visibility', value);
|
||||
},
|
||||
},
|
||||
@@ -46,6 +79,7 @@ export default {
|
||||
:placeholder="$t('MACROS.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
:error="v$.macro.name.$error ? $t('MACROS.ADD.FORM.NAME.ERROR') : null"
|
||||
:class="{ error: v$.macro.name.$error }"
|
||||
:readonly="readOnly"
|
||||
@update:model-value="onUpdateName"
|
||||
/>
|
||||
</div>
|
||||
@@ -55,8 +89,13 @@ export default {
|
||||
</p>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<button
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
|
||||
type="button"
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start"
|
||||
:class="isActive('global')"
|
||||
:disabled="isPublicVisibilityDisabled || readOnly"
|
||||
:aria-describedby="
|
||||
isPublicVisibilityDisabled ? 'macro-public-visibility-help' : null
|
||||
"
|
||||
@click="onUpdateVisibility('global')"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
|
||||
@@ -69,13 +108,18 @@ export default {
|
||||
class="text-n-brand size-4"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-label-small">
|
||||
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
|
||||
<p
|
||||
id="macro-public-visibility-help"
|
||||
class="text-n-slate-11 text-label-small"
|
||||
>
|
||||
{{ publicVisibilityDescription }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
|
||||
type="button"
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start"
|
||||
:class="isActive('personal')"
|
||||
:disabled="readOnly"
|
||||
@click="onUpdateVisibility('personal')"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
|
||||
@@ -111,6 +155,7 @@ export default {
|
||||
solid
|
||||
:label="$t('MACROS.HEADER_BTN_TXT_SAVE')"
|
||||
class="w-full"
|
||||
:disabled="readOnly"
|
||||
@click="$emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
canManagePublicMacros: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
defineEmits(['delete']);
|
||||
const { t } = useI18n();
|
||||
@@ -32,6 +36,14 @@ const visibilityLabel = computed(() => {
|
||||
: 'MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL';
|
||||
return t(i18nKey);
|
||||
});
|
||||
|
||||
const canManageMacro = computed(
|
||||
() => props.canManagePublicMacros || props.macro.visibility !== 'global'
|
||||
);
|
||||
|
||||
const editTooltip = computed(() =>
|
||||
canManageMacro.value ? t('MACROS.EDIT.TOOLTIP') : t('MACROS.VIEW.TOOLTIP')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,13 +97,14 @@ const visibilityLabel = computed(() => {
|
||||
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
|
||||
v-tooltip.top="editTooltip"
|
||||
icon="i-woot-edit-pen"
|
||||
slate
|
||||
sm
|
||||
/>
|
||||
</router-link>
|
||||
<Button
|
||||
v-if="canManageMacro"
|
||||
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import MacroProperties from '../MacroProperties.vue';
|
||||
|
||||
const mountComponent = props =>
|
||||
shallowMount(MacroProperties, {
|
||||
props: {
|
||||
macroName: 'Close conversation',
|
||||
macroVisibility: 'personal',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
v$: {
|
||||
macro: {
|
||||
name: {
|
||||
$error: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
WootInput: true,
|
||||
NextButton: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('MacroProperties.vue', () => {
|
||||
it('allows administrators to select public visibility', async () => {
|
||||
const wrapper = mountComponent({ canManagePublicMacros: true });
|
||||
const publicButton = wrapper.findAll('button')[0];
|
||||
|
||||
await publicButton.trigger('click');
|
||||
|
||||
expect(publicButton.attributes('disabled')).toBeUndefined();
|
||||
expect(wrapper.emitted('update:visibility')?.[0]).toEqual(['global']);
|
||||
});
|
||||
|
||||
it('disables public visibility for agents with helper copy', async () => {
|
||||
const wrapper = mountComponent({ canManagePublicMacros: false });
|
||||
const publicButton = wrapper.findAll('button')[0];
|
||||
|
||||
await publicButton.trigger('click');
|
||||
|
||||
expect(publicButton.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.emitted('update:visibility')).toBeUndefined();
|
||||
expect(wrapper.text()).toContain(
|
||||
'Only administrators can create public macros.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps existing public macros visibly selected when public is disabled', () => {
|
||||
const wrapper = mountComponent({
|
||||
canManagePublicMacros: false,
|
||||
macroVisibility: 'global',
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows existing public macros as read-only for agents', async () => {
|
||||
const wrapper = mountComponent({
|
||||
canManagePublicMacros: false,
|
||||
macroVisibility: 'global',
|
||||
readOnly: true,
|
||||
});
|
||||
const [publicButton, privateButton] = wrapper.findAll('button');
|
||||
|
||||
await privateButton.trigger('click');
|
||||
|
||||
expect(publicButton.attributes('disabled')).toBeDefined();
|
||||
expect(privateButton.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.emitted('update:visibility')).toBeUndefined();
|
||||
expect(wrapper.text()).toContain(
|
||||
'Only administrators can edit public macros.'
|
||||
);
|
||||
});
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import MacrosTableRow from '../MacrosTableRow.vue';
|
||||
|
||||
const macro = visibility => ({
|
||||
id: 1,
|
||||
name: 'Close conversation',
|
||||
visibility,
|
||||
created_by: {
|
||||
available_name: 'Maya Chen',
|
||||
email: 'maya.chen@example.com',
|
||||
},
|
||||
updated_by: {
|
||||
available_name: 'Maya Chen',
|
||||
email: 'maya.chen@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
const mountComponent = props =>
|
||||
shallowMount(MacrosTableRow, {
|
||||
props: {
|
||||
macro: macro('global'),
|
||||
canManagePublicMacros: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Avatar: true,
|
||||
BaseTableRow: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
BaseTableCell: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
Button: true,
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('MacrosTableRow.vue', () => {
|
||||
it('shows actions for public macros when public macros can be managed', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps public macros viewable without delete actions when public macros cannot be managed', () => {
|
||||
const wrapper = mountComponent({ canManagePublicMacros: false });
|
||||
|
||||
expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps actions available for personal macros when public macros cannot be managed', () => {
|
||||
const wrapper = mountComponent({
|
||||
macro: macro('personal'),
|
||||
canManagePublicMacros: false,
|
||||
});
|
||||
|
||||
expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,11 @@ export const getters = {
|
||||
.filter(record => record.attribute_model === 'contact_attribute')
|
||||
.map(camelcaseKeys);
|
||||
},
|
||||
getCompanyAttributes: _state => {
|
||||
return _state.records
|
||||
.filter(record => record.attribute_model === 'company_attribute')
|
||||
.map(camelcaseKeys);
|
||||
},
|
||||
getAttributesByModel: _state => attributeModel => {
|
||||
return _state.records.filter(
|
||||
record => record.attribute_model === attributeModel
|
||||
|
||||
@@ -57,6 +57,8 @@ const getters = {
|
||||
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
|
||||
return attachments[selectedChatId] || [];
|
||||
},
|
||||
getSelectedChatAttachmentsLoaded: ({ selectedChatId, attachments }) =>
|
||||
selectedChatId !== null && attachments[selectedChatId] !== undefined,
|
||||
getChatListFilters: ({ conversationFilters }) => conversationFilters,
|
||||
getLastEmailInSelectedChat: (stage, _getters) => {
|
||||
const selectedChat = _getters.getSelectedChat;
|
||||
|
||||
@@ -35,6 +35,36 @@ describe('#getters', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('getCompanyAttributes', () => {
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
attribute_display_name: 'Industry',
|
||||
attribute_display_type: 0,
|
||||
attribute_description: 'Company industry',
|
||||
attribute_key: 'industry',
|
||||
attribute_model: 'company_attribute',
|
||||
},
|
||||
{
|
||||
attribute_display_name: 'Language',
|
||||
attribute_display_type: 1,
|
||||
attribute_description: 'Conversation language',
|
||||
attribute_key: 'language',
|
||||
attribute_model: 'conversation_attribute',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(getters.getCompanyAttributes(state)).toEqual([
|
||||
{
|
||||
attributeDisplayName: 'Industry',
|
||||
attributeDisplayType: 0,
|
||||
attributeDescription: 'Company industry',
|
||||
attributeKey: 'industry',
|
||||
attributeModel: 'company_attribute',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
|
||||
@@ -328,6 +328,31 @@ describe('#getters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSelectedChatAttachmentsLoaded', () => {
|
||||
it('returns true when attachments have been fetched for the selected chat', () => {
|
||||
const state = { selectedChatId: 1, attachments: { 1: [] } };
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the fetched attachment list is non-empty', () => {
|
||||
const state = {
|
||||
selectedChatId: 1,
|
||||
attachments: { 1: [{ id: 1, file_name: 'test' }] },
|
||||
};
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when attachments have not been fetched yet', () => {
|
||||
const state = { selectedChatId: 1, attachments: {} };
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no chat is selected', () => {
|
||||
const state = { selectedChatId: null, attachments: {} };
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getContextMenuChatId', () => {
|
||||
it('returns the context menu chat id', () => {
|
||||
const state = { contextMenuChatId: 1 };
|
||||
|
||||
@@ -1,31 +1,375 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { createStore } from 'dashboard/store/storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
||||
const createInitialUIFlags = () => ({
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
deletingAvatar: false,
|
||||
deletingCustomAttributes: false,
|
||||
fetchingContacts: false,
|
||||
searchingContacts: false,
|
||||
creatingContact: false,
|
||||
removingContact: false,
|
||||
});
|
||||
|
||||
const camelizeCompany = data =>
|
||||
camelcaseKeys(data || {}, { deep: true, stopPaths: ['custom_attributes'] });
|
||||
|
||||
const camelizeContact = data =>
|
||||
camelcaseKeys(data || {}, {
|
||||
deep: true,
|
||||
stopPaths: ['custom_attributes', 'additional_attributes'],
|
||||
});
|
||||
|
||||
const normalizeMeta = meta => ({
|
||||
...camelcaseKeys(meta || {}),
|
||||
totalCount: Number(meta?.total_count || meta?.totalCount || meta?.count || 0),
|
||||
page: Number(meta?.page || meta?.current_page || 1),
|
||||
});
|
||||
|
||||
const appendFormData = (formData, key, value) => {
|
||||
if (value === undefined || value == null || value === '') return;
|
||||
if (
|
||||
value instanceof File ||
|
||||
value instanceof Blob ||
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
formData.append(key, value);
|
||||
return;
|
||||
}
|
||||
Object.entries(value).forEach(([k, v]) =>
|
||||
appendFormData(formData, `${key}[${k}]`, v)
|
||||
);
|
||||
};
|
||||
|
||||
const buildCompanyRequestPayload = ({ avatar, customAttributes, ...rest }) => {
|
||||
const payload = {
|
||||
...snakecaseKeys(rest, { deep: true }),
|
||||
...(customAttributes && { custom_attributes: customAttributes }),
|
||||
...(avatar && { avatar }),
|
||||
};
|
||||
if (!avatar) return { company: payload };
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([k, v]) =>
|
||||
appendFormData(formData, `company[${k}]`, v)
|
||||
);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const useCompaniesStore = createStore({
|
||||
name: 'companies',
|
||||
type: 'pinia',
|
||||
API: CompanyAPI,
|
||||
|
||||
getters: {
|
||||
getCompaniesList: state => {
|
||||
return camelcaseKeys(state.records, { deep: true });
|
||||
},
|
||||
getCompaniesList: state => state.records,
|
||||
},
|
||||
|
||||
actions: () => ({
|
||||
async search({ search, page, sort }) {
|
||||
setMeta(meta) {
|
||||
this.meta = normalizeMeta(meta);
|
||||
},
|
||||
|
||||
setActiveCompanyId(companyId) {
|
||||
this.activeCompanyId = Number(companyId);
|
||||
},
|
||||
|
||||
ensureActiveCompanyContext(companyId) {
|
||||
if (this.activeCompanyId === null) {
|
||||
this.setActiveCompanyId(companyId);
|
||||
}
|
||||
},
|
||||
|
||||
upsertCompanyRecord(record) {
|
||||
const index = this.records.findIndex(r => r.id === record.id);
|
||||
if (index === -1) this.records.push(record);
|
||||
else this.records[index] = record;
|
||||
},
|
||||
|
||||
updateCompanyContactsCount(companyId, contactsCount) {
|
||||
const company = this.getRecord(companyId);
|
||||
if (!company.id) return;
|
||||
this.upsertCompanyRecord({ ...company, contactsCount });
|
||||
},
|
||||
|
||||
clearContactSearchResults() {
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
},
|
||||
|
||||
async get({ page = 1, sort = 'name' } = {}) {
|
||||
this.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.get({ page, sort });
|
||||
this.records = camelizeCompany(payload);
|
||||
this.setMeta(meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
},
|
||||
|
||||
async show(id) {
|
||||
this.setUIFlag({ fetchingItem: true });
|
||||
this.setActiveCompanyId(id);
|
||||
const activeCompanyId = Number(id);
|
||||
const requestToken = (this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyDetailRequestToken = requestToken;
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.show(id);
|
||||
const company = camelizeCompany(payload);
|
||||
|
||||
if (
|
||||
this.companyDetailRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return company;
|
||||
}
|
||||
|
||||
this.upsertCompanyRecord(company);
|
||||
this.setActiveCompanyId(company.id);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.companyDetailRequestToken === requestToken) {
|
||||
this.setUIFlag({ fetchingItem: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async update({ id, ...companyAttrs }) {
|
||||
this.setUIFlag({ updatingItem: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.update(
|
||||
id,
|
||||
buildCompanyRequestPayload(companyAttrs)
|
||||
);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ updatingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
this.setUIFlag({ deletingItem: true });
|
||||
try {
|
||||
await CompanyAPI.delete(id);
|
||||
this.records = this.records.filter(r => r.id !== Number(id));
|
||||
if (this.activeCompanyId === Number(id)) this.resetCompanyDetailState();
|
||||
return Number(id);
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async search({ search, page = 1, sort = 'name' }) {
|
||||
this.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.search(search, page, sort);
|
||||
this.records = payload;
|
||||
this.records = camelizeCompany(payload);
|
||||
this.setMeta(meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCompanyAvatar(companyId) {
|
||||
this.setUIFlag({ deletingAvatar: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.destroyAvatar(companyId);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingAvatar: false });
|
||||
}
|
||||
},
|
||||
|
||||
async getCompanyContacts(companyId, page = 1) {
|
||||
this.setUIFlag({ fetchingContacts: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.companyContactsRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.listContacts(companyId, page);
|
||||
const contacts = camelizeContact(payload);
|
||||
const normalizedMeta = normalizeMeta(meta);
|
||||
|
||||
if (
|
||||
this.companyContactsRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
this.companyContacts = contacts;
|
||||
this.companyContactsMeta = normalizedMeta;
|
||||
this.updateCompanyContactsCount(companyId, normalizedMeta.totalCount);
|
||||
return contacts;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.companyContactsRequestToken === requestToken) {
|
||||
this.setUIFlag({ fetchingContacts: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async searchCompanyContactCandidates({ companyId, search, page = 1 }) {
|
||||
const query = search?.trim() || '';
|
||||
if (!query) {
|
||||
this.clearContactSearchResults();
|
||||
return [];
|
||||
}
|
||||
|
||||
this.setUIFlag({ searchingContacts: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
this.activeContactSearchQuery = query;
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.contactSearchRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.searchContacts(companyId, query, page);
|
||||
const contacts = camelizeContact(payload);
|
||||
const normalizedMeta = normalizeMeta(meta);
|
||||
|
||||
if (
|
||||
this.contactSearchRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId ||
|
||||
this.activeContactSearchQuery !== query
|
||||
) {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
this.contactSearchResults = contacts;
|
||||
this.contactSearchMeta = normalizedMeta;
|
||||
return contacts;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.contactSearchRequestToken === requestToken) {
|
||||
this.setUIFlag({ searchingContacts: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async attachContactToCompany(companyId, contactId) {
|
||||
this.setUIFlag({ creatingContact: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.createContact(companyId, {
|
||||
contact_id: contactId,
|
||||
});
|
||||
const contact = camelizeContact(payload);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
await this.getCompanyContacts(companyId, 1);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
this.clearContactSearchResults();
|
||||
}
|
||||
}
|
||||
return contact;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ creatingContact: false });
|
||||
}
|
||||
},
|
||||
|
||||
async removeContactFromCompany(companyId, contactId, page = null) {
|
||||
this.setUIFlag({ removingContact: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
try {
|
||||
await CompanyAPI.removeContact(companyId, contactId);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
await this.getCompanyContacts(
|
||||
companyId,
|
||||
page || this.companyContactsMeta?.page || 1
|
||||
);
|
||||
}
|
||||
return Number(contactId);
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ removingContact: false });
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomAttributes({ id, customAttributes }) {
|
||||
this.setUIFlag({ deletingCustomAttributes: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.destroyCustomAttributes(id, customAttributes);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingCustomAttributes: false });
|
||||
}
|
||||
},
|
||||
|
||||
resetCompanyDetailState() {
|
||||
const { fetchingList } = this.uiFlags;
|
||||
this.activeCompanyId = null;
|
||||
this.companyDetailRequestToken =
|
||||
(this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken =
|
||||
(this.companyContactsRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
this.companyContacts = [];
|
||||
this.companyContactsMeta = {};
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
this.uiFlags = { ...createInitialUIFlags(), fetchingList };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { useCompaniesStore } from './companies';
|
||||
|
||||
vi.mock('dashboard/api/companies', () => ({
|
||||
default: {
|
||||
show: vi.fn(),
|
||||
update: vi.fn(),
|
||||
destroyAvatar: vi.fn(),
|
||||
listContacts: vi.fn(),
|
||||
searchContacts: vi.fn(),
|
||||
createContact: vi.fn(),
|
||||
removeContact: vi.fn(),
|
||||
destroyCustomAttributes: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(error => error),
|
||||
}));
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(res => {
|
||||
resolve = res;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('companies store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the latest active company when show requests resolve out of order', async () => {
|
||||
const firstRequest = createDeferred();
|
||||
const secondRequest = createDeferred();
|
||||
|
||||
CompanyAPI.show
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockImplementationOnce(() => secondRequest.promise);
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const staleRequest = companiesStore.show(1);
|
||||
const currentRequest = companiesStore.show(2);
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
payload: {
|
||||
id: 2,
|
||||
name: 'Beta Company',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await currentRequest;
|
||||
|
||||
expect(companiesStore.activeCompanyId).toBe(2);
|
||||
expect(companiesStore.getUIFlags.fetchingItem).toBe(false);
|
||||
|
||||
firstRequest.resolve({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Alpha Company',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await staleRequest;
|
||||
|
||||
expect(companiesStore.activeCompanyId).toBe(2);
|
||||
expect(companiesStore.getRecord(1)).toEqual({});
|
||||
expect(companiesStore.getRecord(2)).toEqual(
|
||||
expect.objectContaining({ id: 2, name: 'Beta Company' })
|
||||
);
|
||||
expect(companiesStore.getUIFlags.fetchingItem).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps avatar files intact when building multipart update params', async () => {
|
||||
CompanyAPI.update.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' });
|
||||
|
||||
await companiesStore.update({
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
avatar,
|
||||
});
|
||||
|
||||
const formData = CompanyAPI.update.mock.calls[0][1];
|
||||
expect(formData.get('company[avatar]')).toBe(avatar);
|
||||
expect(formData.get('company[name]')).toBe('Acme');
|
||||
});
|
||||
|
||||
it('preserves custom attribute keys when building update params', async () => {
|
||||
CompanyAPI.update.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
custom_attributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
await companiesStore.update({
|
||||
id: 1,
|
||||
customAttributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
});
|
||||
|
||||
expect(CompanyAPI.update).toHaveBeenCalledWith(1, {
|
||||
company: {
|
||||
custom_attributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('links an existing contact and refreshes company contacts', async () => {
|
||||
CompanyAPI.createContact.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 2,
|
||||
name: 'Jane Contact',
|
||||
company_id: 1,
|
||||
linked_to_current_company: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
CompanyAPI.listContacts.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Contact',
|
||||
company_id: 1,
|
||||
linked_to_current_company: true,
|
||||
},
|
||||
],
|
||||
meta: { total_count: 1, page: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
companiesStore.setActiveCompanyId(1);
|
||||
|
||||
await companiesStore.attachContactToCompany(1, 2);
|
||||
|
||||
expect(CompanyAPI.createContact).toHaveBeenCalledWith(1, {
|
||||
contact_id: 2,
|
||||
});
|
||||
expect(CompanyAPI.listContacts).toHaveBeenCalledWith(1, 1);
|
||||
expect(companiesStore.companyContacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
companyId: 1,
|
||||
linkedToCurrentCompany: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a company custom attribute and updates the company record', async () => {
|
||||
CompanyAPI.destroyCustomAttributes.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
custom_attributes: {
|
||||
region: 'us',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
await companiesStore.deleteCustomAttributes({
|
||||
id: 1,
|
||||
customAttributes: ['plan'],
|
||||
});
|
||||
|
||||
expect(CompanyAPI.destroyCustomAttributes).toHaveBeenCalledWith(1, [
|
||||
'plan',
|
||||
]);
|
||||
expect(companiesStore.getRecord(1)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
customAttributes: {
|
||||
region: 'us',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
class Account::ContactsExportJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
LABELS_COLUMN = 'labels'.freeze
|
||||
LABELS_DELIMITER = ','.freeze
|
||||
|
||||
def perform(account_id, user_id, column_names, params)
|
||||
@account = Account.find(account_id)
|
||||
@params = params
|
||||
@@ -14,16 +17,45 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
private
|
||||
|
||||
def generate_csv(headers)
|
||||
contacts_to_export = contacts.to_a
|
||||
preload_contact_labels(contacts_to_export) if headers.include?(LABELS_COLUMN)
|
||||
|
||||
csv_data = CSV.generate do |csv|
|
||||
csv << headers
|
||||
contacts.each do |contact|
|
||||
csv << headers.map { |header| contact.send(header) }
|
||||
contacts_to_export.each do |contact|
|
||||
csv << headers.map { |header| value_for_header(contact, header) }
|
||||
end
|
||||
end
|
||||
|
||||
attach_export_file(csv_data)
|
||||
end
|
||||
|
||||
def value_for_header(contact, header)
|
||||
return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN
|
||||
|
||||
contact.send(header)
|
||||
end
|
||||
|
||||
def approved_labels
|
||||
@approved_labels ||= @account.labels.pluck(:title)
|
||||
end
|
||||
|
||||
def preload_contact_labels(contacts_to_export)
|
||||
contact_ids = contacts_to_export.map(&:id)
|
||||
return if contact_ids.blank?
|
||||
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins(:tag)
|
||||
.where(context: LABELS_COLUMN, taggable_type: 'Contact', taggable_id: contact_ids)
|
||||
.where(tags: { name: approved_labels })
|
||||
.pluck(:taggable_id, 'tags.name')
|
||||
.each { |contact_id, label| contact_labels_by_id[contact_id] << label }
|
||||
end
|
||||
|
||||
def contact_labels_by_id
|
||||
@contact_labels_by_id ||= Hash.new { |hash, contact_id| hash[contact_id] = [] }
|
||||
end
|
||||
|
||||
def contacts
|
||||
if @params.present? && @params[:payload].present? && @params[:payload].any?
|
||||
result = ::Contacts::FilterService.new(@account, @account_user, @params).perform
|
||||
@@ -36,7 +68,12 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
end
|
||||
|
||||
def valid_headers(column_names)
|
||||
(column_names.presence || default_columns) & Contact.column_names
|
||||
requested_headers = column_names.presence || default_columns
|
||||
|
||||
# Keep requested header order while allowing the virtual labels column.
|
||||
requested_headers.select do |header|
|
||||
header == LABELS_COLUMN || Contact.column_names.include?(header)
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def attach_export_file(csv_data)
|
||||
@@ -65,6 +102,6 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
end
|
||||
|
||||
def default_columns
|
||||
%w[id name email phone_number]
|
||||
%w[id name email phone_number labels]
|
||||
end
|
||||
end
|
||||
|
||||
+102
-7
@@ -5,6 +5,10 @@ class DataImportJob < ApplicationJob
|
||||
queue_as :low
|
||||
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
|
||||
|
||||
LABELS_DELIMITER = ','.freeze
|
||||
LABELS_CONTEXT = 'labels'.freeze
|
||||
CONTACT_TAGGABLE_TYPE = 'Contact'.freeze
|
||||
|
||||
def perform(data_import)
|
||||
@data_import = data_import
|
||||
@contact_manager = DataImport::ContactManager.new(@data_import.account)
|
||||
@@ -33,26 +37,117 @@ class DataImportJob < ApplicationJob
|
||||
|
||||
with_import_file do |file|
|
||||
csv_reader(file).each do |row|
|
||||
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
|
||||
if current_contact.valid?
|
||||
contacts << current_contact
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
end
|
||||
build_contact_from_row(row, contacts, rejected_contacts)
|
||||
end
|
||||
end
|
||||
|
||||
[contacts, rejected_contacts]
|
||||
end
|
||||
|
||||
def build_contact_from_row(row, contacts, rejected_contacts)
|
||||
row_hash = row.to_h.with_indifferent_access
|
||||
labels = extract_labels(row_hash)
|
||||
invalid_labels = labels.map(&:downcase) - approved_labels
|
||||
|
||||
if invalid_labels.present?
|
||||
append_label_error(row, invalid_labels, rejected_contacts)
|
||||
return
|
||||
end
|
||||
|
||||
current_contact = @contact_manager.build_contact(row_hash.except(:labels))
|
||||
if current_contact.valid?
|
||||
contacts << { contact: current_contact, labels: labels }
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
end
|
||||
end
|
||||
|
||||
def extract_labels(row_hash)
|
||||
row_hash[:labels].to_s.split(LABELS_DELIMITER).map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
def append_rejected_contact(row, contact, rejected_contacts)
|
||||
row['errors'] = contact.errors.full_messages.join(', ')
|
||||
rejected_contacts << row
|
||||
end
|
||||
|
||||
def import_contacts(contacts)
|
||||
def import_contacts(contacts_with_labels)
|
||||
contacts = contacts_with_labels.pluck(:contact)
|
||||
# <struct ActiveRecord::Import::Result failed_instances=[], num_inserts=1, ids=[444, 445], results=[]>
|
||||
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
|
||||
apply_labels_to_contacts(contacts_with_labels)
|
||||
end
|
||||
|
||||
def apply_labels_to_contacts(contacts_with_labels)
|
||||
taggings = taggings_for_contacts(contacts_with_labels)
|
||||
return if taggings.blank?
|
||||
|
||||
ActsAsTaggableOn::Tagging.import(%i[tag_id taggable_type taggable_id context created_at],
|
||||
taggings, on_duplicate_key_ignore: true, validate: false, batch_size: 1000)
|
||||
end
|
||||
|
||||
def taggings_for_contacts(contacts_with_labels)
|
||||
tag_lookup = tags_by_label_name(contacts_with_labels)
|
||||
taggings = contacts_with_labels.flat_map do |item|
|
||||
contact = contact_for_label_import(item[:contact])
|
||||
labels = item[:labels].map(&:downcase).uniq
|
||||
next [] if contact&.id.blank?
|
||||
|
||||
labels.map do |label|
|
||||
[tag_lookup[label].id, CONTACT_TAGGABLE_TYPE, contact.id, LABELS_CONTEXT]
|
||||
end
|
||||
end.uniq
|
||||
|
||||
reject_existing_taggings(taggings).map { |tagging| tagging + [Time.zone.now] }
|
||||
end
|
||||
|
||||
def reject_existing_taggings(taggings)
|
||||
tag_ids = taggings.map { |tag_id, _taggable_type, _taggable_id, _context| tag_id }
|
||||
taggable_ids = taggings.map { |_tag_id, _taggable_type, taggable_id, _context| taggable_id }
|
||||
existing_taggings = ActsAsTaggableOn::Tagging
|
||||
.where(context: LABELS_CONTEXT, taggable_type: CONTACT_TAGGABLE_TYPE,
|
||||
taggable_id: taggable_ids, tag_id: tag_ids)
|
||||
.pluck(:tag_id, :taggable_id)
|
||||
.index_with(true)
|
||||
|
||||
taggings.reject do |tag_id, _taggable_type, taggable_id, _context|
|
||||
existing_taggings[[tag_id, taggable_id]]
|
||||
end
|
||||
end
|
||||
|
||||
def contact_for_label_import(contact)
|
||||
return contact if contact.id.present?
|
||||
|
||||
key = contact_identity_key(contact)
|
||||
return if key.blank?
|
||||
|
||||
imported_contact(contact)
|
||||
end
|
||||
|
||||
def contact_identity_key(contact)
|
||||
contact.identifier.presence || contact.email.presence || contact.phone_number.presence
|
||||
end
|
||||
|
||||
def imported_contact(contact)
|
||||
return @data_import.account.contacts.find_by(identifier: contact.identifier) if contact.identifier.present?
|
||||
return @data_import.account.contacts.from_email(contact.email) if contact.email.present?
|
||||
|
||||
@data_import.account.contacts.find_by(phone_number: contact.phone_number) if contact.phone_number.present?
|
||||
end
|
||||
|
||||
def tags_by_label_name(contacts_with_labels)
|
||||
labels = contacts_with_labels.flat_map { |item| item[:labels] }.map(&:downcase).uniq
|
||||
|
||||
ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(labels).index_by { |tag| tag.name.downcase }
|
||||
end
|
||||
|
||||
def approved_labels
|
||||
@approved_labels ||= @data_import.account.labels.pluck(:title)
|
||||
end
|
||||
|
||||
def append_label_error(row, labels, rejected_contacts)
|
||||
row['errors'] = "Unknown labels: #{labels.join(', ')}"
|
||||
rejected_contacts << row
|
||||
end
|
||||
|
||||
def update_data_import_status(processed_records, rejected_records)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module MailboxHelper
|
||||
include MailboxInlineAttachmentHelper
|
||||
|
||||
private
|
||||
|
||||
def create_message
|
||||
@@ -24,6 +26,9 @@ module MailboxHelper
|
||||
def add_attachments_to_message
|
||||
return if @message.blank?
|
||||
|
||||
# Load email content once for all attachment processing
|
||||
load_email_content
|
||||
|
||||
# ensure we don't add more than the permitted number of attachments
|
||||
all_attachments = processed_mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS)
|
||||
grouped_attachments = group_attachments(all_attachments)
|
||||
@@ -38,7 +43,7 @@ module MailboxHelper
|
||||
# If the email lacks a text body or if inline attachments aren't images,
|
||||
# treat them as standard attachments for processing.
|
||||
inline_attachments = attachments.select do |attachment|
|
||||
mail_content.present? && attachment[:original].inline? && attachment[:original].content_type.to_s.start_with?('image/')
|
||||
inline_attachment?(attachment)
|
||||
end
|
||||
|
||||
regular_attachments = attachments - inline_attachments
|
||||
@@ -59,11 +64,6 @@ module MailboxHelper
|
||||
def process_inline_attachments(attachments)
|
||||
Rails.logger.info "[MailboxHelper] Processing inline attachments for message with ID: #{processed_mail.message_id}"
|
||||
|
||||
# create an instance variable here, the `embed_inline_image_source`
|
||||
# updates them directly. And then the value is eventaully used to update the message content
|
||||
@html_content = processed_mail.serialized_data[:html_content][:full]
|
||||
@text_content = processed_mail.serialized_data[:text_content][:reply]
|
||||
|
||||
attachments.each do |mail_attachment|
|
||||
embed_inline_image_source(mail_attachment)
|
||||
end
|
||||
@@ -81,12 +81,6 @@ module MailboxHelper
|
||||
end
|
||||
end
|
||||
|
||||
def upload_inline_image(mail_attachment)
|
||||
content_id = mail_attachment[:original].cid
|
||||
|
||||
@html_content = @html_content.gsub("cid:#{content_id}", inline_image_url(mail_attachment[:blob]).to_s)
|
||||
end
|
||||
|
||||
def embed_plain_text_email_with_inline_image(mail_attachment)
|
||||
attachment_name = mail_attachment[:original].filename
|
||||
img_tag = "<img src=\"#{inline_image_url(mail_attachment[:blob])}\" alt=\"#{attachment_name}\">"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
module MailboxInlineAttachmentHelper
|
||||
private
|
||||
|
||||
def load_email_content
|
||||
@html_content = processed_mail.serialized_data[:html_content][:full]
|
||||
@text_content = processed_mail.serialized_data[:text_content][:reply]
|
||||
end
|
||||
|
||||
def inline_attachment?(attachment)
|
||||
# Only process images as potential inline attachments
|
||||
return false unless mail_content.present? && attachment[:original].content_type.to_s.start_with?('image/')
|
||||
|
||||
# Check if attachment is explicitly marked as inline
|
||||
return true if attachment[:original].inline?
|
||||
|
||||
# For Outlook compatibility: if not marked as inline but has CID and is referenced in body
|
||||
cid = attachment[:original].cid
|
||||
cid.present? && body_references_cid?(cid)
|
||||
end
|
||||
|
||||
def body_references_cid?(cid)
|
||||
# Check if CID is referenced in HTML content
|
||||
return false if @html_content.blank?
|
||||
|
||||
cid_urls_for(cid).any? { |cid_url| @html_content.include?(cid_url) }
|
||||
end
|
||||
|
||||
def upload_inline_image(mail_attachment)
|
||||
content_id = mail_attachment[:original].cid
|
||||
image_url = inline_image_url(mail_attachment[:blob]).to_s
|
||||
|
||||
cid_urls_for(content_id).each do |cid_url|
|
||||
@html_content = @html_content.gsub(cid_url, image_url)
|
||||
end
|
||||
end
|
||||
|
||||
def cid_urls_for(cid)
|
||||
# RFC 2392 cid URLs can contain URL-encoded Content-ID values.
|
||||
# Check both raw and encoded variants so clients using either form render inline images.
|
||||
encoded_cid = ERB::Util.url_encode(cid)
|
||||
lowercase_encoded_cid = encoded_cid.gsub(/%[0-9A-F]{2}/, &:downcase)
|
||||
|
||||
["cid:#{cid}", "cid:#{encoded_cid}", "cid:#{lowercase_encoded_cid}"].uniq
|
||||
end
|
||||
end
|
||||
@@ -25,7 +25,8 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
STANDARD_ATTRIBUTES = {
|
||||
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
|
||||
last_activity_at],
|
||||
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked]
|
||||
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked],
|
||||
:company => %w[name domain description contacts_count created_at updated_at last_activity_at]
|
||||
}.freeze
|
||||
|
||||
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
|
||||
@@ -41,12 +42,12 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
validates :attribute_model, presence: true
|
||||
validate :attribute_must_not_conflict, on: :create
|
||||
|
||||
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 }
|
||||
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1, company_attribute: 2 }
|
||||
enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5, list: 6, checkbox: 7 }
|
||||
|
||||
belongs_to :account
|
||||
after_update :update_widget_pre_chat_custom_fields
|
||||
after_destroy :sync_widget_pre_chat_custom_fields
|
||||
after_update :update_widget_pre_chat_custom_fields, unless: :company_attribute?
|
||||
after_destroy :sync_widget_pre_chat_custom_fields, unless: :company_attribute?
|
||||
|
||||
private
|
||||
|
||||
@@ -64,8 +65,10 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
end
|
||||
|
||||
def attribute_must_not_conflict
|
||||
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
|
||||
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
|
||||
model_keys = attribute_model.to_s.delete_suffix('_attribute').to_sym
|
||||
standard_attributes = STANDARD_ATTRIBUTES[model_keys]
|
||||
return if standard_attributes.blank?
|
||||
return unless attribute_key.in?(standard_attributes)
|
||||
|
||||
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
|
||||
end
|
||||
|
||||
@@ -12,11 +12,15 @@ class MacroPolicy < ApplicationPolicy
|
||||
end
|
||||
|
||||
def update?
|
||||
author? || (@account_user.administrator? && @record.global?)
|
||||
return @account_user.administrator? if @record.global?
|
||||
|
||||
author?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
author? || orphan_record?
|
||||
return @account_user.administrator? if @record.global?
|
||||
|
||||
author?
|
||||
end
|
||||
|
||||
def execute?
|
||||
@@ -28,10 +32,4 @@ class MacroPolicy < ApplicationPolicy
|
||||
def author?
|
||||
@record.created_by == @account_user.user
|
||||
end
|
||||
|
||||
def orphan_record?
|
||||
return @account_user.administrator? if @record.created_by.nil? && @record.global?
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
require 'faraday'
|
||||
require 'faraday/multipart'
|
||||
|
||||
class Tiktok::Client
|
||||
# Always use Tiktok::TokenService to get a valid access token
|
||||
pattr_initialize [:business_id!, :access_token!]
|
||||
@@ -31,14 +34,32 @@ class Tiktok::Client
|
||||
json['data']['download_url']
|
||||
end
|
||||
|
||||
def image_send_capable?(conversation_id, conversation_type: 'SINGLE')
|
||||
endpoint = "#{api_base_url}/business/message/capabilities/get/"
|
||||
headers = { 'Access-Token': access_token }
|
||||
query = {
|
||||
business_id: business_id,
|
||||
conversation_id: conversation_id,
|
||||
conversation_type: conversation_type,
|
||||
capability_types: ['IMAGE_SEND'].to_json
|
||||
}
|
||||
|
||||
response = HTTParty.get(endpoint, query: query, headers: headers)
|
||||
json = process_json_response(response, 'Failed to fetch TikTok message capabilities')
|
||||
capabilities = json.dig('data', 'capability_infos') || []
|
||||
image_send = capabilities.find { |capability| capability['capability_type'] == 'IMAGE_SEND' }
|
||||
|
||||
image_send&.[]('capability_result') == true
|
||||
end
|
||||
|
||||
def send_text_message(conversation_id, text, referenced_message_id: nil)
|
||||
send_message(conversation_id, 'TEXT', text, referenced_message_id: referenced_message_id)
|
||||
end
|
||||
|
||||
def send_media_message(conversation_id, attachment, referenced_message_id: nil)
|
||||
def send_media_message(conversation_id, attachment)
|
||||
# As of now, only IMAGE media type is supported
|
||||
media_id = upload_media(attachment.file, 'IMAGE')
|
||||
send_message(conversation_id, 'IMAGE', media_id, referenced_message_id: referenced_message_id)
|
||||
media_id = upload_media(attachment.file.blob, 'IMAGE')
|
||||
send_message(conversation_id, 'IMAGE', media_id)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -69,31 +90,39 @@ class Tiktok::Client
|
||||
json['data']['message']['message_id']
|
||||
end
|
||||
|
||||
def upload_media(file, media_type = 'IMAGE')
|
||||
def upload_media(blob, media_type = 'IMAGE')
|
||||
endpoint = "#{api_base_url}/business/message/media/upload/"
|
||||
headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' }
|
||||
|
||||
file.open do |temp_file|
|
||||
body = {
|
||||
blob.open do |temp_file|
|
||||
temp_file.rewind
|
||||
payload = {
|
||||
business_id: business_id,
|
||||
media_type: media_type,
|
||||
file: temp_file
|
||||
file: Faraday::Multipart::FilePart.new(temp_file, blob.content_type || 'application/octet-stream', blob.filename.to_s)
|
||||
}
|
||||
|
||||
response = HTTParty.post(endpoint, body: body, headers: headers)
|
||||
response = multipart_connection.post(endpoint, payload) do |request|
|
||||
request.headers['Access-Token'] = access_token
|
||||
end
|
||||
json = process_json_response(response, 'Failed to upload TikTok media')
|
||||
json['data']['media_id']
|
||||
end
|
||||
end
|
||||
|
||||
def multipart_connection
|
||||
@multipart_connection ||= Faraday.new do |faraday|
|
||||
faraday.request :multipart
|
||||
end
|
||||
end
|
||||
|
||||
def api_base_url
|
||||
"https://business-api.tiktok.com/open_api/#{GlobalConfigService.load('TIKTOK_API_VERSION', 'v1.3')}"
|
||||
end
|
||||
|
||||
def process_json_response(response, error_prefix)
|
||||
unless response.success?
|
||||
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
|
||||
raise "#{response.code}: #{response.body}"
|
||||
Rails.logger.error "#{error_prefix}. Status: #{response_status(response)}, Body: #{response.body}"
|
||||
raise "#{response_status(response)}: #{response.body}"
|
||||
end
|
||||
|
||||
res = JSON.parse(response.body)
|
||||
@@ -101,4 +130,8 @@ class Tiktok::Client
|
||||
|
||||
res
|
||||
end
|
||||
|
||||
def response_status(response)
|
||||
response.respond_to?(:code) ? response.code : response.status
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,14 +48,26 @@ module Tiktok::MessagingHelpers
|
||||
inbox_id: channel.inbox.id,
|
||||
contact_id: contact_inbox.contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
additional_attributes: conversation_additional_attributes(tt_conversation_id)
|
||||
additional_attributes: conversation_additional_attributes(channel, tt_conversation_id)
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_additional_attributes(tt_conversation_id)
|
||||
{
|
||||
conversation_id: tt_conversation_id
|
||||
}
|
||||
def conversation_additional_attributes(channel, tt_conversation_id)
|
||||
attributes = { conversation_id: tt_conversation_id }
|
||||
capabilities = tiktok_conversation_capabilities(channel, tt_conversation_id)
|
||||
attributes[:tiktok_capabilities] = capabilities if capabilities.present?
|
||||
attributes
|
||||
end
|
||||
|
||||
def tiktok_conversation_capabilities(channel, tt_conversation_id)
|
||||
image_send = tiktok_client(channel).image_send_capable?(tt_conversation_id)
|
||||
{ image_send: image_send, updated_at: Time.current.iso8601 }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(
|
||||
'Failed to fetch TikTok conversation capabilities ' \
|
||||
"for tt_conversation_id=#{tt_conversation_id}, business_id=#{channel.business_id}: #{e.class}: #{e.message}"
|
||||
)
|
||||
{}
|
||||
end
|
||||
|
||||
def find_message(tt_conversation_id, tt_message_id)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class Tiktok::SendOnTiktokService < Base::SendOnChannelService
|
||||
SUPPORTED_IMAGE_CONTENT_TYPES = %w[image/jpeg image/png].freeze
|
||||
MAX_IMAGE_SIZE = 3.megabytes
|
||||
|
||||
private
|
||||
|
||||
def channel_class
|
||||
@@ -18,8 +21,22 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService
|
||||
|
||||
def validate_message_support!
|
||||
return unless message.attachments.any?
|
||||
|
||||
raise 'Sending attachments with text is not supported on TikTok.' if message.outgoing_content.present?
|
||||
raise 'Sending multiple attachments in a single TikTok message is not supported.' unless message.attachments.one?
|
||||
|
||||
validate_attachment_support!(message.attachments.first)
|
||||
end
|
||||
|
||||
def validate_attachment_support!(attachment)
|
||||
raise 'Sending image attachments is not supported for this TikTok conversation.' unless image_send_capable?
|
||||
raise 'Only image attachments are supported on TikTok.' unless attachment.image?
|
||||
raise 'TikTok supports only JPG and PNG images.' unless SUPPORTED_IMAGE_CONTENT_TYPES.include?(attachment.file.content_type)
|
||||
raise 'TikTok image attachments must be smaller than 3 MB.' if attachment.file.byte_size > MAX_IMAGE_SIZE
|
||||
end
|
||||
|
||||
def image_send_capable?
|
||||
message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send') != false
|
||||
end
|
||||
|
||||
def send_message
|
||||
@@ -27,7 +44,7 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService
|
||||
tt_referenced_message_id = message.content_attributes['in_reply_to_external_id']
|
||||
|
||||
if message.attachments.any?
|
||||
tiktok_client.send_media_message(tt_conversation_id, message.attachments.first, referenced_message_id: tt_referenced_message_id)
|
||||
tiktok_client.send_media_message(tt_conversation_id, message.attachments.first)
|
||||
else
|
||||
tiktok_client.send_text_message(tt_conversation_id, message.outgoing_content, referenced_message_id: tt_referenced_message_id)
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ json.meta do
|
||||
end
|
||||
|
||||
json.payload @attachments do |attachment|
|
||||
json.id attachment.push_event_data[:id]
|
||||
json.message_id attachment.push_event_data[:message_id]
|
||||
json.thumb_url attachment.push_event_data[:thumb_url]
|
||||
json.data_url attachment.push_event_data[:data_url]
|
||||
|
||||
@@ -177,6 +177,17 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
get :search
|
||||
end
|
||||
member do
|
||||
post :destroy_custom_attributes
|
||||
delete :avatar
|
||||
end
|
||||
scope module: :companies do
|
||||
resources :contacts, only: [:index, :create, :destroy] do
|
||||
collection do
|
||||
get :search
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
|
||||
collection do
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddAdditionalAttributesToCompanies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
change_table :companies, bulk: true do |t|
|
||||
t.jsonb :additional_attributes, default: {}
|
||||
t.jsonb :custom_attributes, default: {}
|
||||
t.datetime :last_activity_at, precision: nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -612,6 +612,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contacts_count"
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
|
||||
t.index ["account_id"], name: "index_companies_on_account_id"
|
||||
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
RESULTS_PER_PAGE = 15
|
||||
CONTACT_SEARCH_QUERY = [
|
||||
'contacts.name ILIKE :search',
|
||||
'contacts.email ILIKE :search',
|
||||
'contacts.phone_number ILIKE :search',
|
||||
'contacts.identifier ILIKE :search'
|
||||
].join(' OR ')
|
||||
|
||||
before_action :ensure_companies_enabled!
|
||||
before_action :fetch_company
|
||||
before_action :authorize_company_read!, only: [:index, :search]
|
||||
before_action :authorize_company_update!, only: [:create, :destroy]
|
||||
before_action :set_current_page, only: [:index, :search]
|
||||
before_action :fetch_contact, only: [:destroy]
|
||||
|
||||
def index
|
||||
@contacts = fetch_contacts(@company.contacts.order(:name, :id))
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def search
|
||||
if params[:q].blank?
|
||||
return render json: { error: 'Specify search string with parameter q' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@contacts = fetch_contacts(contact_search_scope)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def create
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
membership_service.assign(contact: @contact)
|
||||
end
|
||||
|
||||
def destroy
|
||||
membership_service.remove(contact: @contact)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
|
||||
def fetch_company
|
||||
@company = Current.account.companies.find(params[:company_id])
|
||||
end
|
||||
|
||||
def fetch_contact
|
||||
@contact = @company.contacts.find(params[:id])
|
||||
end
|
||||
|
||||
def fetch_contacts(contacts)
|
||||
contacts
|
||||
.includes({ avatar_attachment: [:blob] }, :company)
|
||||
.page(@current_page)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def contact_search_scope
|
||||
Current.account.contacts
|
||||
.where('contacts.company_id IS NULL OR contacts.company_id != ?', @company.id)
|
||||
.where(CONTACT_SEARCH_QUERY, search: "%#{params[:q].strip}%")
|
||||
.order(:name, :id)
|
||||
end
|
||||
|
||||
def membership_service
|
||||
@membership_service ||= Companies::ContactMembershipService.new(company: @company)
|
||||
end
|
||||
|
||||
def ensure_companies_enabled!
|
||||
return if Current.account.feature_enabled?('companies')
|
||||
|
||||
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def authorize_company_read!
|
||||
authorize(@company, :show?)
|
||||
end
|
||||
|
||||
def authorize_company_update!
|
||||
authorize(@company, :update?)
|
||||
end
|
||||
end
|
||||
@@ -3,13 +3,15 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
sort_on :name, type: :string
|
||||
sort_on :domain, type: :string
|
||||
sort_on :created_at, type: :datetime
|
||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :contacts_count, internal_name: :order_on_contacts_count, type: :scope, scope_params: [:direction]
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
before_action :ensure_companies_enabled!
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :search]
|
||||
before_action :fetch_company, only: [:show, :update, :destroy]
|
||||
before_action :fetch_company, only: [:show, :update, :destroy, :avatar, :destroy_custom_attributes]
|
||||
|
||||
def index
|
||||
@companies = fetch_companies(resolved_companies)
|
||||
@@ -35,7 +37,15 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
end
|
||||
|
||||
def update
|
||||
@company.update!(company_params)
|
||||
@company.update!(company_update_params)
|
||||
end
|
||||
|
||||
def destroy_custom_attributes
|
||||
custom_attributes = custom_attributes_to_destroy
|
||||
return if performed?
|
||||
|
||||
@company.custom_attributes = @company.custom_attributes.excluding(*custom_attributes)
|
||||
@company.save!
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -43,6 +53,10 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
head :ok
|
||||
end
|
||||
|
||||
def avatar
|
||||
@company.avatar.purge if @company.avatar.attached?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolved_companies
|
||||
@@ -59,10 +73,10 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
|
||||
def ensure_companies_enabled!
|
||||
return if Current.account.feature_enabled?('companies')
|
||||
|
||||
authorize(Company)
|
||||
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def fetch_company
|
||||
@@ -70,6 +84,31 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
end
|
||||
|
||||
def company_params
|
||||
params.require(:company).permit(:name, :domain, :description, :avatar)
|
||||
params.require(:company).permit(
|
||||
:name,
|
||||
:domain,
|
||||
:description,
|
||||
:avatar,
|
||||
additional_attributes: {},
|
||||
custom_attributes: {}
|
||||
)
|
||||
end
|
||||
|
||||
def company_custom_attributes
|
||||
custom_attributes = company_params[:custom_attributes]
|
||||
return @company.custom_attributes.merge(custom_attributes.to_h) if custom_attributes.present?
|
||||
|
||||
@company.custom_attributes
|
||||
end
|
||||
|
||||
def company_update_params
|
||||
company_params.except(:custom_attributes).merge(custom_attributes: company_custom_attributes)
|
||||
end
|
||||
|
||||
def custom_attributes_to_destroy
|
||||
custom_attributes = params.permit(custom_attributes: [])[:custom_attributes]
|
||||
return custom_attributes if custom_attributes.present? || params[:custom_attributes].is_a?(Array)
|
||||
|
||||
render json: { error: 'custom_attributes must be an array' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,12 +3,13 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
|
||||
PER_ACCOUNT_HOURLY_CAP = 50
|
||||
GLOBAL_HOURLY_CAP = 1000
|
||||
DUE_DOCUMENT_BATCH_SIZE = PER_ACCOUNT_HOURLY_CAP * 2 # Inspite of skipping, we should at least reach the hourly cap
|
||||
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
|
||||
|
||||
def perform
|
||||
@remaining_global_capacity = GLOBAL_HOURLY_CAP
|
||||
sync_intervals = Enterprise::Account.captain_document_sync_intervals
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0, documents_skipped: 0 }
|
||||
|
||||
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
|
||||
break if @remaining_global_capacity <= 0
|
||||
@@ -21,7 +22,9 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
next unless interval
|
||||
|
||||
stats[:accounts_scheduled] += 1
|
||||
stats[:documents_enqueued] += enqueue_due_documents(account, interval)
|
||||
result = enqueue_due_documents(account, interval)
|
||||
stats[:documents_enqueued] += result[:enqueued]
|
||||
stats[:documents_skipped] += result[:skipped]
|
||||
end
|
||||
|
||||
log_scheduler_summary(stats)
|
||||
@@ -30,28 +33,74 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
private
|
||||
|
||||
def enqueue_due_documents(account, interval)
|
||||
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
|
||||
result = { enqueued: 0, skipped: 0 }
|
||||
skipped_document_ids = []
|
||||
|
||||
while result[:enqueued] < per_account_limit
|
||||
|
||||
documents = due_documents(account, interval, skipped_document_ids).limit(DUE_DOCUMENT_BATCH_SIZE).to_a
|
||||
break if documents.empty?
|
||||
|
||||
documents.each do |document|
|
||||
break if result[:enqueued] >= per_account_limit
|
||||
|
||||
process_due_document(document, result, skipped_document_ids)
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def process_due_document(document, result, skipped_document_ids)
|
||||
return unless document.syncable?
|
||||
|
||||
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
|
||||
unless reserve_sync_slot(document)
|
||||
result[:skipped] += 1
|
||||
skipped_document_ids << document.id
|
||||
return
|
||||
end
|
||||
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
@remaining_global_capacity -= 1
|
||||
result[:enqueued] += 1
|
||||
end
|
||||
|
||||
def due_documents(account, interval, skipped_document_ids)
|
||||
syncing = Captain::Document.sync_statuses[:syncing]
|
||||
synced = Captain::Document.sync_statuses[:synced]
|
||||
failed = Captain::Document.sync_statuses[:failed]
|
||||
stale_cutoff = SYNC_STALE_TIMEOUT.ago
|
||||
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
|
||||
enqueued_count = 0
|
||||
|
||||
account.captain_documents.syncable.where(status: :available).where(
|
||||
documents = account.captain_documents.syncable.where(status: :available).where(
|
||||
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
|
||||
'(sync_status = ? AND last_sync_attempted_at < ?)',
|
||||
synced, interval.ago, failed, interval.ago, syncing, stale_cutoff
|
||||
).order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id).limit(per_account_limit).each do |document|
|
||||
next unless document.syncable?
|
||||
synced, interval.ago, failed, interval.ago, syncing, SYNC_STALE_TIMEOUT.ago
|
||||
)
|
||||
documents = documents.where.not(id: skipped_document_ids) if skipped_document_ids.present?
|
||||
documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id)
|
||||
end
|
||||
|
||||
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
@remaining_global_capacity -= 1
|
||||
enqueued_count += 1
|
||||
end
|
||||
def reserve_sync_slot(document)
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
log_document_skip(document, e)
|
||||
false
|
||||
end
|
||||
|
||||
enqueued_count
|
||||
def log_document_skip(document, error)
|
||||
payload = {
|
||||
event: 'document_skipped',
|
||||
document_id: document.id,
|
||||
account_id: document.account_id,
|
||||
assistant_id: document.assistant_id,
|
||||
error_class: error.class.name,
|
||||
error_message: error.message,
|
||||
validation_errors: document.errors.full_messages
|
||||
}
|
||||
|
||||
Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
|
||||
end
|
||||
|
||||
def log_scheduler_summary(stats)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
#
|
||||
# Table name: companies
|
||||
#
|
||||
# additional_attributes :jsonb
|
||||
# custom_attributes :jsonb
|
||||
# last_activity_at :datetime
|
||||
# id :bigint not null, primary key
|
||||
# contacts_count :integer
|
||||
# description :text
|
||||
@@ -19,6 +22,7 @@
|
||||
#
|
||||
class Company < ApplicationRecord
|
||||
include Avatarable
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
|
||||
validates :domain, allow_blank: true, format: {
|
||||
@@ -27,9 +31,11 @@ class Company < ApplicationRecord
|
||||
}
|
||||
validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? }
|
||||
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :custom_attributes, jsonb_attributes_length: true
|
||||
|
||||
belongs_to :account
|
||||
has_many :contacts, dependent: :nullify
|
||||
before_validation :prepare_jsonb_attributes
|
||||
after_create_commit :fetch_favicon, if: -> { domain.present? }
|
||||
|
||||
scope :ordered_by_name, -> { order(:name) }
|
||||
@@ -44,9 +50,21 @@ class Company < ApplicationRecord
|
||||
)
|
||||
)
|
||||
}
|
||||
scope :order_on_last_activity_at, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
sanitize_sql_for_order("\"companies\".\"last_activity_at\" #{direction} NULLS LAST")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private
|
||||
|
||||
def prepare_jsonb_attributes
|
||||
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
|
||||
self.custom_attributes = {} unless custom_attributes.is_a?(Hash)
|
||||
end
|
||||
|
||||
def fetch_favicon
|
||||
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
||||
end
|
||||
|
||||
@@ -19,6 +19,14 @@ class CompanyPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def avatar?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy_custom_attributes?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Companies::ContactMembershipService
|
||||
attr_reader :company
|
||||
|
||||
def initialize(company:)
|
||||
@company = company
|
||||
end
|
||||
|
||||
def assign(contact:)
|
||||
contact.update!(company: company)
|
||||
end
|
||||
|
||||
def remove(contact:)
|
||||
contact.update!(company: nil)
|
||||
end
|
||||
end
|
||||
@@ -46,12 +46,16 @@ class Voice::InboundCallBuilder
|
||||
# Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
|
||||
# creating with a colliding source_id under a different contact would raise
|
||||
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
|
||||
# A concurrent message webhook for the same wa_id can win the (inbox_id, source_id)
|
||||
# race; rescue and re-find so the call path doesn't drop the connect.
|
||||
def ensure_contact_inbox!
|
||||
sid = source_id_for_provider
|
||||
existing = inbox.contact_inboxes.find_by(source_id: sid)
|
||||
return existing if existing
|
||||
|
||||
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
inbox.contact_inboxes.find_by!(source_id: sid)
|
||||
end
|
||||
|
||||
def ensure_contact!
|
||||
|
||||
@@ -46,12 +46,24 @@ class Whatsapp::IncomingCallService
|
||||
|
||||
def handle_connect(payload)
|
||||
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||
return create_inbound_call(payload) if call.nil?
|
||||
if call.nil?
|
||||
# Only an `offer` payload is a real inbound caller. An `answer` with no
|
||||
# local row means Meta beat our outbound `Call.create!` (tiny window
|
||||
# between initiate API response and DB insert) — do not mint an inbound
|
||||
# row for it; the next status webhook (or a retry) will find it.
|
||||
return create_inbound_call(payload) if inbound_offer?(payload)
|
||||
|
||||
Rails.logger.warn "[WHATSAPP CALL] Outbound connect for unknown call #{payload[:id]}; skipping"
|
||||
return
|
||||
end
|
||||
|
||||
return accept_outbound_call(call, payload) if call.outgoing?
|
||||
|
||||
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{payload[:id]}"
|
||||
end
|
||||
|
||||
def inbound_offer?(payload)
|
||||
payload.dig(:session, :sdp_type).to_s.downcase == 'offer'
|
||||
end
|
||||
|
||||
def create_inbound_call(payload)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
json.id company.id
|
||||
json.name company.name
|
||||
json.contacts_count company.contacts_count
|
||||
json.domain company.domain
|
||||
json.description company.description
|
||||
json.avatar_url company.avatar_url
|
||||
json.created_at company.created_at
|
||||
json.updated_at company.updated_at
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.partial! 'api/v1/models/contact', formats: [:json], resource: contact, with_contact_inboxes: false
|
||||
json.company_id contact.company_id
|
||||
json.linked_to_current_company contact.company_id == @company.id
|
||||
if contact.company.present?
|
||||
json.company do
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: contact.company
|
||||
end
|
||||
else
|
||||
json.company nil
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/accounts/companies/contacts/contact', formats: [:json], contact: @contact
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.meta do
|
||||
json.total_count @contacts_count
|
||||
json.page @current_page
|
||||
end
|
||||
|
||||
json.payload do
|
||||
json.array! @contacts do |contact|
|
||||
json.partial! 'api/v1/accounts/companies/contacts/contact', formats: [:json], contact: contact
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.meta do
|
||||
json.total_count @contacts_count
|
||||
json.page @current_page
|
||||
end
|
||||
|
||||
json.payload do
|
||||
json.array! @contacts do |contact|
|
||||
json.partial! 'api/v1/accounts/companies/contacts/contact', formats: [:json], contact: contact
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
@@ -5,6 +5,6 @@ end
|
||||
|
||||
json.payload do
|
||||
json.array! @companies do |company|
|
||||
json.partial! 'company', company: company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: company
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,6 @@ end
|
||||
|
||||
json.payload do
|
||||
json.array! @companies do |company|
|
||||
json.partial! 'company', company: company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: company
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
json.partial! 'api/v1/models/company', formats: [:json], resource: @company
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.contacts_count resource.contacts_count
|
||||
json.domain resource.domain
|
||||
json.description resource.description
|
||||
json.custom_attributes resource.custom_attributes
|
||||
json.avatar_url resource.avatar_url
|
||||
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
|
||||
json.created_at resource.created_at.to_i if resource[:created_at].present?
|
||||
json.updated_at resource.updated_at.to_i if resource[:updated_at].present?
|
||||
@@ -77,9 +77,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
return nil unless embed_config
|
||||
|
||||
template = embed_config['template']
|
||||
# Use Ruby's built-in named captures with gsub to handle CSS % values
|
||||
# Use gsub (not format) so CSS `%` values in templates don't need escaping.
|
||||
# Captured values are HTML-escaped since they land inside HTML attribute contexts.
|
||||
match_data.named_captures.each do |var_name, value|
|
||||
template = template.gsub("%{#{var_name}}", value)
|
||||
template = template.gsub("%{#{var_name}}", CGI.escapeHTML(value))
|
||||
end
|
||||
template
|
||||
end
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"date-fns": "2.21.1",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"dompurify": "3.3.2",
|
||||
"dompurify": "3.4.0",
|
||||
"flag-icons": "^7.2.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
@@ -94,7 +94,7 @@
|
||||
"tinykeys": "^3.0.0",
|
||||
"turbolinks": "^5.2.0",
|
||||
"urlpattern-polyfill": "^10.0.0",
|
||||
"video.js": "7.18.1",
|
||||
"video.js": "7.21.1",
|
||||
"videojs-record": "4.5.0",
|
||||
"videojs-wavesurfer": "3.8.0",
|
||||
"virtua": "^0.48.6",
|
||||
|
||||
Generated
+40
-58
@@ -128,8 +128,8 @@ importers:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.8(date-fns@2.21.1)
|
||||
dompurify:
|
||||
specifier: 3.3.2
|
||||
version: 3.3.2
|
||||
specifier: 3.4.0
|
||||
version: 3.4.0
|
||||
flag-icons:
|
||||
specifier: ^7.2.3
|
||||
version: 7.2.3
|
||||
@@ -206,8 +206,8 @@ importers:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
video.js:
|
||||
specifier: 7.18.1
|
||||
version: 7.18.1
|
||||
specifier: 7.21.1
|
||||
version: 7.21.1
|
||||
videojs-record:
|
||||
specifier: 4.5.0
|
||||
version: 4.5.0
|
||||
@@ -441,10 +441,6 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.25.6':
|
||||
resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.26.7':
|
||||
resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1386,17 +1382,14 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.2.0':
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@videojs/http-streaming@2.13.1':
|
||||
resolution: {integrity: sha512-1x3fkGSPyL0+iaS3/lTvfnPTtfqzfgG+ELQtPPtTvDwqGol9Mx3TNyZwtSTdIufBrqYRn7XybB/3QNMsyjq13A==}
|
||||
'@videojs/http-streaming@2.15.1':
|
||||
resolution: {integrity: sha512-/uuN3bVkEeJAdrhu5Hyb19JoUo3CMys7yf2C1vUjeL1wQaZ4Oe8JrZzRrnWZ0rjvPgKfNLPXQomsRtgrMoRMJQ==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
peerDependencies:
|
||||
video.js: ^6 || ^7
|
||||
|
||||
'@videojs/vhs-utils@3.0.4':
|
||||
resolution: {integrity: sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
|
||||
'@videojs/vhs-utils@3.0.5':
|
||||
resolution: {integrity: sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
@@ -1570,8 +1563,8 @@ packages:
|
||||
'@vueuse/shared@12.0.0':
|
||||
resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==}
|
||||
|
||||
'@xmldom/xmldom@0.7.13':
|
||||
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
|
||||
'@xmldom/xmldom@0.8.13':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
@@ -1618,8 +1611,8 @@ packages:
|
||||
activestorage@5.2.8:
|
||||
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
|
||||
|
||||
aes-decrypter@3.1.2:
|
||||
resolution: {integrity: sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A==}
|
||||
aes-decrypter@3.1.3:
|
||||
resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
@@ -2194,9 +2187,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.3.2:
|
||||
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
|
||||
engines: {node: '>=20'}
|
||||
dompurify@3.4.0:
|
||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||
|
||||
domutils@3.1.0:
|
||||
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
||||
@@ -3198,8 +3190,8 @@ packages:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
m3u8-parser@4.7.0:
|
||||
resolution: {integrity: sha512-48l/OwRyjBm+QhNNigEEcRcgbRvnUjL7rxs597HmW9QSNbyNvt+RcZ9T/d9vxi9A9z7EZrB1POtZYhdRlwYQkQ==}
|
||||
m3u8-parser@4.8.0:
|
||||
resolution: {integrity: sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==}
|
||||
|
||||
magic-string@0.30.11:
|
||||
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
|
||||
@@ -3310,8 +3302,8 @@ packages:
|
||||
mlly@1.8.1:
|
||||
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
||||
mpd-parser@0.22.1:
|
||||
resolution: {integrity: sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==}
|
||||
hasBin: true
|
||||
|
||||
mri@1.2.0:
|
||||
@@ -4498,8 +4490,8 @@ packages:
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
video.js@7.18.1:
|
||||
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
|
||||
video.js@7.21.1:
|
||||
resolution: {integrity: sha512-AvHfr14ePDHCfW5Lx35BvXk7oIonxF6VGhSxocmTyqotkQpxwYdmt4tnQSV7MYzNrYHb0GI8tJMt20NDkCQrxg==}
|
||||
|
||||
videojs-font@3.2.0:
|
||||
resolution: {integrity: sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==}
|
||||
@@ -4982,10 +4974,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.26.0
|
||||
|
||||
'@babel/runtime@7.25.6':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/runtime@7.26.7':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
@@ -5920,22 +5908,16 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.2.0': {}
|
||||
|
||||
'@videojs/http-streaming@2.13.1(video.js@7.18.1)':
|
||||
'@videojs/http-streaming@2.15.1(video.js@7.21.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.4
|
||||
aes-decrypter: 3.1.2
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
aes-decrypter: 3.1.3
|
||||
global: 4.4.0
|
||||
m3u8-parser: 4.7.0
|
||||
mpd-parser: 0.21.0
|
||||
m3u8-parser: 4.8.0
|
||||
mpd-parser: 0.22.1
|
||||
mux.js: 6.0.1
|
||||
video.js: 7.18.1
|
||||
|
||||
'@videojs/vhs-utils@3.0.4':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
global: 4.4.0
|
||||
url-toolkit: 2.2.5
|
||||
video.js: 7.21.1
|
||||
|
||||
'@videojs/vhs-utils@3.0.5':
|
||||
dependencies:
|
||||
@@ -6214,7 +6196,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@xmldom/xmldom@0.7.13': {}
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
abab@2.0.6: {}
|
||||
|
||||
@@ -6249,7 +6231,7 @@ snapshots:
|
||||
dependencies:
|
||||
spark-md5: 3.0.2
|
||||
|
||||
aes-decrypter@3.1.2:
|
||||
aes-decrypter@3.1.3:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
@@ -6861,7 +6843,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.3.2:
|
||||
dompurify@3.4.0:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -8107,7 +8089,7 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
m3u8-parser@4.7.0:
|
||||
m3u8-parser@4.8.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
@@ -8221,11 +8203,11 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.3
|
||||
|
||||
mpd-parser@0.21.0:
|
||||
mpd-parser@0.22.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
'@xmldom/xmldom': 0.7.13
|
||||
'@xmldom/xmldom': 0.8.13
|
||||
global: 4.4.0
|
||||
|
||||
mri@1.2.0: {}
|
||||
@@ -9527,17 +9509,17 @@ snapshots:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
|
||||
video.js@7.18.1:
|
||||
video.js@7.21.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.6
|
||||
'@videojs/http-streaming': 2.13.1(video.js@7.18.1)
|
||||
'@babel/runtime': 7.26.7
|
||||
'@videojs/http-streaming': 2.15.1(video.js@7.21.1)
|
||||
'@videojs/vhs-utils': 3.0.5
|
||||
'@videojs/xhr': 2.6.0
|
||||
aes-decrypter: 3.1.2
|
||||
aes-decrypter: 3.1.3
|
||||
global: 4.4.0
|
||||
keycode: 2.2.1
|
||||
m3u8-parser: 4.7.0
|
||||
mpd-parser: 0.21.0
|
||||
m3u8-parser: 4.8.0
|
||||
mpd-parser: 0.22.1
|
||||
mux.js: 6.0.1
|
||||
safe-json-parse: 4.0.0
|
||||
videojs-font: 3.2.0
|
||||
@@ -9548,7 +9530,7 @@ snapshots:
|
||||
videojs-record@4.5.0:
|
||||
dependencies:
|
||||
recordrtc: 5.6.2
|
||||
video.js: 7.18.1
|
||||
video.js: 7.21.1
|
||||
videojs-wavesurfer: 3.8.0
|
||||
webrtc-adapter: 9.0.1
|
||||
|
||||
@@ -9558,7 +9540,7 @@ snapshots:
|
||||
|
||||
videojs-wavesurfer@3.8.0:
|
||||
dependencies:
|
||||
video.js: 7.18.1
|
||||
video.js: 7.21.1
|
||||
wavesurfer.js: 7.8.6
|
||||
|
||||
virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
|
||||
@@ -9656,7 +9638,7 @@ snapshots:
|
||||
|
||||
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
dompurify: 3.3.2
|
||||
dompurify: 3.4.0
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.0):
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
id,name,email,identifier,phone_number,ip_address,company_name,custom_attribute_1,custom_attribute_2
|
||||
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,70.61.11.201,Acme Inc,Random-value-1,Random-value-1
|
||||
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,168.186.4.241,Acme Inc,Random-value0,Random-value0
|
||||
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,73.44.41.59,Acme Inc,Random-value1,Random-value1
|
||||
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,115.249.27.155,Acme Inc,Random-value2,Random-value2
|
||||
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,219.181.212.8,Acme Inc,Random-value3,Random-value3
|
||||
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,231.210.115.166,Acme Inc,Random-value4,Random-value4
|
||||
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,160.189.41.11,Acme Inc,Random-value5,Random-value5
|
||||
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,53.94.18.201,Acme Inc,Random-value6,Random-value6
|
||||
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,18.87.247.249,Acme Inc,Random-value7,Random-value7
|
||||
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,25.191.96.124,Acme Inc,Random-value8,Random-value8
|
||||
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,11.211.174.93,Acme Inc,Random-value9,Random-value9
|
||||
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,47.26.205.153,Acme Inc,Random-value10,Random-value10
|
||||
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,157.196.34.166,Acme Inc,Random-value11,Random-value11
|
||||
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,109.231.152.148,Acme Inc,Random-value12,Random-value12
|
||||
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,20.43.146.179,Acme Inc,Random-value13,Random-value13
|
||||
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,179.76.226.171,Acme Inc,Random-value14,Random-value14
|
||||
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,92.243.194.160,Acme Inc,Random-value15,Random-value15
|
||||
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,25.22.86.101,Acme Inc,Random-value16,Random-value16
|
||||
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,150.249.116.118,Acme Inc,Random-value17,Random-value17
|
||||
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,237.167.197.197,Acme Inc,Random-value18,Random-value18
|
||||
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,88.102.64.113,Acme Inc,Random-value19,Random-value19
|
||||
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,141.248.89.29,Acme Inc,Random-value20,Random-value20
|
||||
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,118.123.138.115,Acme Inc,Random-value21,Random-value21
|
||||
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,157.45.102.235,Acme Inc,Random-value22,Random-value22
|
||||
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,170.73.198.47,Acme Inc,Random-value23,Random-value23
|
||||
id,name,email,identifier,phone_number,labels,ip_address,company_name,custom_attribute_1,custom_attribute_2
|
||||
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,,70.61.11.201,Acme Inc,Random-value-1,Random-value-1
|
||||
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,,168.186.4.241,Acme Inc,Random-value0,Random-value0
|
||||
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,,73.44.41.59,Acme Inc,Random-value1,Random-value1
|
||||
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,,115.249.27.155,Acme Inc,Random-value2,Random-value2
|
||||
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,,219.181.212.8,Acme Inc,Random-value3,Random-value3
|
||||
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,,231.210.115.166,Acme Inc,Random-value4,Random-value4
|
||||
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,,160.189.41.11,Acme Inc,Random-value5,Random-value5
|
||||
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,,53.94.18.201,Acme Inc,Random-value6,Random-value6
|
||||
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,,18.87.247.249,Acme Inc,Random-value7,Random-value7
|
||||
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,,25.191.96.124,Acme Inc,Random-value8,Random-value8
|
||||
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,,11.211.174.93,Acme Inc,Random-value9,Random-value9
|
||||
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,,47.26.205.153,Acme Inc,Random-value10,Random-value10
|
||||
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,,157.196.34.166,Acme Inc,Random-value11,Random-value11
|
||||
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,,109.231.152.148,Acme Inc,Random-value12,Random-value12
|
||||
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,,20.43.146.179,Acme Inc,Random-value13,Random-value13
|
||||
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,,179.76.226.171,Acme Inc,Random-value14,Random-value14
|
||||
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,,92.243.194.160,Acme Inc,Random-value15,Random-value15
|
||||
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,,25.22.86.101,Acme Inc,Random-value16,Random-value16
|
||||
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,,150.249.116.118,Acme Inc,Random-value17,Random-value17
|
||||
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,,237.167.197.197,Acme Inc,Random-value18,Random-value18
|
||||
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,,88.102.64.113,Acme Inc,Random-value19,Random-value19
|
||||
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,,141.248.89.29,Acme Inc,Random-value20,Random-value20
|
||||
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,,118.123.138.115,Acme Inc,Random-value21,Random-value21
|
||||
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,,157.45.102.235,Acme Inc,Random-value22,Random-value22
|
||||
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,,170.73.198.47,Acme Inc,Random-value23,Random-value23
|
||||
|
||||
|
@@ -1039,6 +1039,8 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
attachment = conversation.messages.last.attachments.first
|
||||
expect(response_body['payload'].first['id']).to eq(attachment.id)
|
||||
expect(response_body['payload'].first['file_type']).to eq('image')
|
||||
expect(response_body['payload'].first['sender']['id']).to eq(conversation.messages.last.sender.id)
|
||||
end
|
||||
|
||||
@@ -239,6 +239,22 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
|
||||
# A public macro can still point to an agent when an admin who authored it
|
||||
# is later changed to the agent role. Public macros should remain
|
||||
# admin-managed even when the original author is no longer an admin.
|
||||
it 'does not allow agents to update public macros they created' do
|
||||
macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
|
||||
it 'allows update with existing blob_id' do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||
@@ -551,6 +567,21 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
|
||||
# A public macro can still point to an agent when an admin who authored it
|
||||
# is later changed to the agent role. Public macros should remain
|
||||
# admin-managed even when the original author is no longer an admin.
|
||||
it 'does not allow agents to delete public macros they created' do
|
||||
macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
|
||||
it 'Unauthorize to delete the macro' do
|
||||
macro = create(:macro, account: account, created_by: agent, updated_by: agent)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Company contacts API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, name: 'Acme', account: account) }
|
||||
|
||||
before { account.enable_features!(:companies) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies/{company.id}/contacts' do
|
||||
it 'returns contacts linked to the company' do
|
||||
linked_contact = create(:contact, name: 'Linked Contact', company: company, account: account)
|
||||
create(:contact, name: 'Other Contact', account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].pluck('id')).to eq([linked_contact.id])
|
||||
expect(response_body['payload'].first['company_id']).to eq(company.id)
|
||||
expect(response_body['payload'].first['linked_to_current_company']).to be true
|
||||
expect(response_body['meta']['total_count']).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies/{company.id}/contacts/search' do
|
||||
it 'returns matching contacts that are not already linked to the company' do
|
||||
other_company = create(:company, name: 'Other Company', account: account)
|
||||
linked_contact = create(:contact, name: 'Jane Current', company: company, account: account)
|
||||
available_contact = create(:contact, name: 'Jane Available', account: account)
|
||||
assigned_contact = create(:contact, name: 'Jane Assigned', company: other_company, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts/search",
|
||||
params: { q: 'Jane' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
contact_ids = response.parsed_body['payload'].pluck('id')
|
||||
expect(contact_ids).to contain_exactly(available_contact.id, assigned_contact.id)
|
||||
expect(contact_ids).not_to include(linked_contact.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies/{company.id}/contacts' do
|
||||
it 'links an existing contact to the company' do
|
||||
contact = create(:contact, name: 'Jane Contact', account: account, additional_attributes: { 'city' => 'Berlin' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts",
|
||||
params: { contact_id: contact.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(contact.reload.company_id).to eq(company.id)
|
||||
expect(contact.additional_attributes).to eq('city' => 'Berlin')
|
||||
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
|
||||
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/companies/{company.id}/contacts/{id}' do
|
||||
it 'removes a contact from the company' do
|
||||
contact = create(:contact, name: 'Jane Contact', company: company, account: account,
|
||||
additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts/#{contact.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(contact.reload.company_id).to be_nil
|
||||
expect(contact.additional_attributes).to eq('company_name' => 'Acme', 'city' => 'Berlin')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,8 @@ require 'rails_helper'
|
||||
RSpec.describe 'Companies API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before { account.enable_features!(:companies) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -222,6 +224,17 @@ RSpec.describe 'Companies API', type: :request do
|
||||
expect(response_body['payload']['name']).to eq(company.name)
|
||||
expect(response_body['payload']['id']).to eq(company.id)
|
||||
end
|
||||
|
||||
it 'returns company custom attributes' do
|
||||
company.update!(custom_attributes: { 'plan' => 'enterprise' })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']['custom_attributes']).to eq('plan' => 'enterprise')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -302,6 +315,60 @@ RSpec.describe 'Companies API', type: :request do
|
||||
expect(response_body['payload']['name']).to eq('Updated Company Name')
|
||||
expect(response_body['payload']['domain']).to eq('updated.com')
|
||||
end
|
||||
|
||||
it 'merges custom attributes without removing existing attributes' do
|
||||
company.update!(custom_attributes: { 'plan' => 'startup', 'region' => 'us' })
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
params: { company: { custom_attributes: { 'plan' => 'enterprise' } } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(company.reload.custom_attributes).to eq('plan' => 'enterprise', 'region' => 'us')
|
||||
expect(response.parsed_body['payload']['custom_attributes']).to eq('plan' => 'enterprise', 'region' => 'us')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies/{id}/destroy_custom_attributes' do
|
||||
let(:company) { create(:company, account: account, custom_attributes: { 'plan' => 'enterprise', 'region' => 'us' }) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'removes selected company custom attributes' do
|
||||
post "/api/v1/accounts/#{account.id}/companies/#{company.id}/destroy_custom_attributes",
|
||||
params: { custom_attributes: ['plan'] },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(company.reload.custom_attributes).to eq('region' => 'us')
|
||||
expect(response.parsed_body['payload']['custom_attributes']).to eq('region' => 'us')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}/avatar' do
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
company.avatar.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
end
|
||||
|
||||
it 'deletes the company avatar' do
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}/avatar",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect { company.avatar.attachment.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
expect(response.parsed_body['payload']['avatar_url']).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -122,6 +122,67 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
|
||||
it 'skips invalid legacy documents without counting them against the account cap' do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
|
||||
create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil,
|
||||
external_link: 'https://example.com'
|
||||
)
|
||||
invalid_document = build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago,
|
||||
last_sync_attempted_at: 2.days.ago,
|
||||
external_link: 'https://example.com/'
|
||||
)
|
||||
invalid_document.save!(validate: false)
|
||||
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to raise_error
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(invalid_document)
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
|
||||
end
|
||||
|
||||
it 'keeps paging due documents when invalid documents fill the first batch' do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
|
||||
stub_const("#{described_class}::DUE_DOCUMENT_BATCH_SIZE", 1)
|
||||
create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil,
|
||||
external_link: 'https://example.com'
|
||||
)
|
||||
invalid_document = build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago,
|
||||
last_sync_attempted_at: 3.days.ago,
|
||||
external_link: 'https://example.com/'
|
||||
)
|
||||
invalid_document.save!(validate: false)
|
||||
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
described_class.new.perform
|
||||
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when more documents are due than the account cap allows' do
|
||||
|
||||
@@ -114,6 +114,21 @@ describe Whatsapp::IncomingCallService do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'outbound connect with no local row yet' do
|
||||
it 'does not mint an inbound call when sdp_type is answer' do
|
||||
allow(inbox.channel).to receive(:voice_enabled?).and_return(true)
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: 'sdp_answer', sdp_type: 'answer' })
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.not_to change(Call, :count)
|
||||
expect(Rails.logger).to have_received(:warn).with(/Outbound connect for unknown call/)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'duplicate inbound connect' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
Delivered-To: test.user@example.com
|
||||
Received: by 192.0.2.1 with SMTP id smtp12345;
|
||||
Mon, 14 Jul 2025 15:23:57 -0700 (PDT)
|
||||
X-Google-Smtp-Source: TEST_SOURCE
|
||||
X-Received: by 198.51.100.2 with SMTP id smtp67890;
|
||||
Mon, 14 Jul 2025 15:23:57 -0700 (PDT)
|
||||
ARC-Seal: i=1; a=rsa-sha256; t=1752531837; cv=none;
|
||||
d=example.com; s=test;
|
||||
b=TEST_SIGNATURE==
|
||||
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=test;
|
||||
h=content-language:subject:to:from;
|
||||
bh=TESTHASH==;
|
||||
b=TEST_SIGNATURE==
|
||||
ARC-Authentication-Results: i=1; mx.example.com;
|
||||
dkim=pass header.i=@example.com header.s=test header.b="TESTKEY";
|
||||
spf=pass smtp.mailfrom=sender@example.com
|
||||
Return-Path: <sender@example.com>
|
||||
Received: from smtp.example.com (smtp.example.com. [203.0.113.10])
|
||||
by mx.example.com with ESMTPS id smtp7890
|
||||
for <test.user@example.com>;
|
||||
Mon, 14 Jul 2025 15:23:55 -0700 (PDT)
|
||||
Received-SPF: pass (example.com: domain of sender@example.com designates 203.0.113.10 as permitted sender)
|
||||
Authentication-Results: mx.example.com;
|
||||
dkim=pass header.i=@example.com header.s=test header.b="TESTKEY";
|
||||
spf=pass smtp.mailfrom=sender@example.com
|
||||
Received: from TEST-PC (unknown [192.0.2.100])
|
||||
(Authenticated sender: sender@example.com)
|
||||
by smtp.example.com (Postfix) with ESMTPA id ABCD123456
|
||||
for <test.user@example.com>; Mon, 14 Jul 2025 19:23:43 -0300 (BRT)
|
||||
DKIM-Signature: v=1; a=rsa-sha1; c=relaxed/relaxed; d=example.com;
|
||||
s=test; t=1752531824; bh=TESTHASH=;
|
||||
h=From:To:Subject:Date:From;
|
||||
b=TEST_SIGNATURE==
|
||||
From: <sender@example.com>
|
||||
To: <test.user@example.com>
|
||||
Subject: Test inline image without Content-Disposition
|
||||
Date: Mon, 14 Jul 2025 19:23:29 -0300
|
||||
Message-ID: <test-message-id@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related;
|
||||
boundary="----=_NextPart_000_0001"
|
||||
X-Mailer: Microsoft Outlook 16.0
|
||||
Thread-Index: TESTINDEX==
|
||||
Content-Language: en-us
|
||||
|
||||
This is a multipart message in MIME format.
|
||||
|
||||
------=_NextPart_000_0001
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="----=_NextPart_001_0002"
|
||||
|
||||
------=_NextPart_001_0002
|
||||
Content-Type: text/plain;
|
||||
charset="us-ascii"
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
This is a plain text version of the message.
|
||||
|
||||
------=_NextPart_001_0002
|
||||
Content-Type: text/html;
|
||||
charset="us-ascii"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html>
|
||||
<body>
|
||||
<p>This is an HTML message with an inline image.</p>
|
||||
<img src="cid:image001.jpg@test">
|
||||
</body>
|
||||
</html>
|
||||
|
||||
------=_NextPart_001_0002--
|
||||
|
||||
------=_NextPart_000_0001
|
||||
Content-Type: image/jpeg;
|
||||
filename="image001.jpg"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <image001.jpg@test>
|
||||
|
||||
/9j/4AAQSkZJRgABAQEASABIAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEB
|
||||
AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAAL
|
||||
CABkAGQBAREA/8QA...
|
||||
|
||||
------=_NextPart_000_0001--
|
||||
@@ -5,7 +5,6 @@ RSpec.describe Account::ContactsExportJob do
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, email: 'account-user-test@test.com') }
|
||||
let(:label) { create(:label, title: 'spec-billing', maccount: account) }
|
||||
|
||||
let(:email_filter) do
|
||||
{
|
||||
@@ -85,6 +84,43 @@ RSpec.describe Account::ContactsExportJob do
|
||||
expect(phone_numbers).to include('+910808080818', '+910808080808')
|
||||
end
|
||||
|
||||
it 'exports labels when requested through column names' do
|
||||
contact_with_labels = account.contacts.first
|
||||
create(:label, account: account, title: 'vip')
|
||||
contact_with_labels.add_labels(%w[vip])
|
||||
|
||||
described_class.perform_now(account.id, user.id, %w[id email labels], {})
|
||||
|
||||
csv_content = account.contacts_export.download.force_encoding('UTF-8').delete_prefix("\xEF\xBB\xBF")
|
||||
csv_data = CSV.parse(csv_content, headers: true)
|
||||
row = csv_data.find { |r| r['email'] == contact_with_labels.email }
|
||||
|
||||
expect(csv_data.headers).to eq(%w[id email labels])
|
||||
expect(row['labels']).to eq('vip')
|
||||
end
|
||||
|
||||
it 'bulk loads labels while exporting contacts' do
|
||||
create(:label, account: account, title: 'vip')
|
||||
create(:label, account: account, title: 'support')
|
||||
account.contacts.find_each { |contact| contact.add_labels(%w[vip support]) }
|
||||
account.contacts.first.add_labels('legacy_tag')
|
||||
|
||||
taggings_queries = []
|
||||
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, _started, _finished, _unique_id, payload|
|
||||
taggings_queries << payload[:sql] if payload[:sql].include?('FROM "taggings"')
|
||||
end
|
||||
|
||||
described_class.perform_now(account.id, user.id, [], {})
|
||||
csv_data = CSV.parse(account.contacts_export.download, headers: true)
|
||||
row = csv_data.find { |r| r['email'] == account.contacts.first.email }
|
||||
|
||||
expect(csv_data.headers).to include('labels')
|
||||
expect(row['labels'].split(described_class::LABELS_DELIMITER)).to match_array(%w[vip support])
|
||||
expect(taggings_queries.size).to eq(1)
|
||||
ensure
|
||||
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
||||
end
|
||||
|
||||
it 'prepends UTF-8 BOM to the exported CSV for spreadsheet compatibility' do
|
||||
described_class.perform_now(account.id, user.id, [], {})
|
||||
|
||||
|
||||
@@ -187,5 +187,101 @@ RSpec.describe DataImportJob do
|
||||
.to change { data_import.reload.status }.from('pending').to('failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the data contains labels column' do
|
||||
let(:data_with_labels) do
|
||||
[
|
||||
%w[id name email phone_number labels],
|
||||
['1', 'John Doe', 'john@example.com', '+918080808080', ' Customer , VIP , vip '],
|
||||
['2', 'Jane Smith', 'jane@example.com', '+918080808081', 'lead'],
|
||||
['3', 'Bob Wilson', 'bob@example.com', '+918080808082', '']
|
||||
]
|
||||
end
|
||||
let(:labels_data_import) { create(:data_import, import_file: generate_csv_file(data_with_labels)) }
|
||||
|
||||
before do
|
||||
%w[customer vip lead].each do |title|
|
||||
create(:label, account: labels_data_import.account, title: title)
|
||||
end
|
||||
end
|
||||
|
||||
it 'imports contacts with labels from CSV' do
|
||||
described_class.perform_now(labels_data_import)
|
||||
|
||||
john = Contact.from_email('john@example.com')
|
||||
expect(john).to be_present
|
||||
expect(john.label_list).to contain_exactly('customer', 'vip')
|
||||
|
||||
jane = Contact.from_email('jane@example.com')
|
||||
expect(jane).to be_present
|
||||
expect(jane.label_list).to contain_exactly('lead')
|
||||
|
||||
bob = Contact.from_email('bob@example.com')
|
||||
expect(bob).to be_present
|
||||
expect(bob.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'dispatches only the contact update event when importing labels for an existing contact' do
|
||||
existing_contact = create(:contact, account: labels_data_import.account, email: 'existing-labeled@example.com', name: 'Old Name')
|
||||
existing_contact.add_labels('customer')
|
||||
data_with_existing_contact = [
|
||||
%w[id name email phone_number labels],
|
||||
['1', 'Updated Name', existing_contact.email, '+918080808090', 'lead'],
|
||||
['2', 'New Labeled Contact', 'new-labeled@example.com', '+918080808091', 'customer']
|
||||
]
|
||||
existing_contact_import = create(:data_import, account: labels_data_import.account,
|
||||
import_file: generate_csv_file(data_with_existing_contact))
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
described_class.perform_now(existing_contact_import)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
Events::Types::CONTACT_UPDATED,
|
||||
anything,
|
||||
hash_including(contact: have_attributes(id: existing_contact.id))
|
||||
).once
|
||||
expect(existing_contact.reload.label_list).to contain_exactly('customer', 'lead')
|
||||
expect(labels_data_import.account.contacts.from_email('new-labeled@example.com').label_list).to contain_exactly('customer')
|
||||
end
|
||||
|
||||
it 'merges labels for duplicate contact rows without duplicate taggings' do
|
||||
data_with_duplicate_contact = [
|
||||
%w[id name email phone_number labels],
|
||||
['1', 'Duplicate User', 'duplicate-labeled@example.com', '+918080808092', 'lead'],
|
||||
['2', 'Duplicate User', 'duplicate-labeled@example.com', '+918080808092', 'customer,lead']
|
||||
]
|
||||
duplicate_contact_import = create(:data_import, account: labels_data_import.account,
|
||||
import_file: generate_csv_file(data_with_duplicate_contact))
|
||||
|
||||
described_class.perform_now(duplicate_contact_import)
|
||||
|
||||
contact = labels_data_import.account.contacts.from_email('duplicate-labeled@example.com')
|
||||
lead = ActsAsTaggableOn::Tag.find_by(name: 'lead')
|
||||
expect(contact.label_list).to contain_exactly('customer', 'lead')
|
||||
expect(ActsAsTaggableOn::Tagging.where(tag_id: lead.id, taggable: contact, context: 'labels').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rejects rows with labels that do not exist in the account before updating contacts' do
|
||||
existing_contact = create(:contact,
|
||||
account: labels_data_import.account,
|
||||
email: 'existing@example.com',
|
||||
phone_number: '+918080808085',
|
||||
name: 'Existing Name')
|
||||
data_with_unknown_labels = [
|
||||
%w[id name email phone_number labels],
|
||||
['1', 'Updated Name', existing_contact.email, '+918080808086', 'vip,unknown_label']
|
||||
]
|
||||
|
||||
unknown_label_import = create(:data_import, account: labels_data_import.account,
|
||||
import_file: generate_csv_file(data_with_unknown_labels))
|
||||
|
||||
described_class.perform_now(unknown_label_import)
|
||||
|
||||
expect(existing_contact.reload.name).to eq('Existing Name')
|
||||
expect(existing_contact.phone_number).to eq('+918080808085')
|
||||
expect(unknown_label_import.reload.failed_records).to be_attached
|
||||
expect(unknown_label_import.failed_records.download).to include('Unknown labels: unknown_label')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -238,5 +238,23 @@ describe CustomMarkdownRenderer do
|
||||
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captured values contain HTML-special characters' do
|
||||
# CommonMark angle-bracket link destinations `[text](<URL>)` permit characters
|
||||
# like `"` that the embed regex captures would otherwise pass through raw into
|
||||
# attribute values. Captures are HTML-escaped before interpolation so the
|
||||
# substituted value cannot break out of the surrounding attribute context.
|
||||
it 'escapes double quotes in captured YouTube video_id' do
|
||||
markdown = "\n[demo](<https://www.youtube.com/watch?v=x\" onload=\"alert(1)>)\n"
|
||||
output = render_markdown(markdown)
|
||||
expect(output).not_to include('onload="alert(1)"')
|
||||
expect(output).to include('"')
|
||||
end
|
||||
|
||||
it 'leaves legitimate alphanumeric IDs untouched' do
|
||||
output = render_markdown_link('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
expect(output).to include('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,4 +77,60 @@ RSpec.describe MailboxHelper do
|
||||
expect(text_content).to include(Rails.application.routes.url_helpers.url_for(mail_attachment[:blob]))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#body_references_cid?' do
|
||||
let(:helper_instance) { mailbox_helper_obj.new(conversation, processed_mail) }
|
||||
|
||||
it 'detects percent-encoded CID references in HTML content' do
|
||||
helper_instance.instance_variable_set(:@html_content, '<img src="cid:image001.jpg%40test">')
|
||||
|
||||
expect(helper_instance.send(:body_references_cid?, 'image001.jpg@test')).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#upload_inline_image' do
|
||||
let(:mail_attachment) do
|
||||
{
|
||||
original: OpenStruct.new(cid: 'image001.jpg@test'),
|
||||
blob: get_blob_for('spec/assets/avatar.png', 'image/png')
|
||||
}
|
||||
end
|
||||
let(:helper_instance) { mailbox_helper_obj.new(conversation, processed_mail) }
|
||||
|
||||
it 'replaces percent-encoded CID references in HTML content' do
|
||||
allow(Rails.application.routes.url_helpers).to receive(:url_for).and_return('/fake-image-url')
|
||||
helper_instance.instance_variable_set(:@html_content, '<img src="cid:image001.jpg%40test">')
|
||||
|
||||
helper_instance.send(:upload_inline_image, mail_attachment)
|
||||
|
||||
html_content = helper_instance.instance_variable_get(:@html_content)
|
||||
expect(html_content).to include('/fake-image-url"')
|
||||
expect(html_content).not_to include('cid:')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_attachments_to_message' do
|
||||
let(:mail) { create_inbound_email_from_fixture('cid_inline_images_without_disposition.eml').mail }
|
||||
let(:processed_mail) { MailPresenter.new(mail) }
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:helper_instance) { mailbox_helper_obj.new(conversation, processed_mail) }
|
||||
|
||||
before do
|
||||
helper_instance.send(:create_message)
|
||||
end
|
||||
|
||||
it 'detects inline image attachment by cid reference when Content-Disposition is missing' do
|
||||
allow(Rails.application.routes.url_helpers).to receive(:url_for).and_return('/fake-image-url')
|
||||
helper_instance.send(:add_attachments_to_message)
|
||||
|
||||
message = conversation.messages[0]
|
||||
|
||||
expect(message.attachments.count).to eq(0)
|
||||
|
||||
html_content = message.content_attributes[:email][:html_content][:full]
|
||||
|
||||
expect(html_content).to include('/fake-image-url"')
|
||||
expect(html_content).not_to include('cid:')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,6 +42,17 @@ RSpec.describe CustomAttributeDefinition do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_key: 'key()')
|
||||
expect(cad).not_to be_valid
|
||||
end
|
||||
|
||||
it 'allows company custom attributes' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_model: 'company_attribute')
|
||||
expect(cad).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects company custom attributes that conflict with standard company fields' do
|
||||
cad = build(:custom_attribute_definition, account: account, attribute_model: 'company_attribute', attribute_key: 'domain')
|
||||
expect(cad).not_to be_valid
|
||||
expect(cad.errors[:attribute_key]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::Client do
|
||||
let(:client) { described_class.new(business_id: 'biz-123', access_token: 'token-123') }
|
||||
let(:response) { instance_double(HTTParty::Response) }
|
||||
|
||||
describe '#image_send_capable?' do
|
||||
before do
|
||||
allow(HTTParty).to receive(:get).and_return(response)
|
||||
allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3')
|
||||
end
|
||||
|
||||
it 'returns true when IMAGE_SEND capability is enabled' do
|
||||
allow(client).to receive(:process_json_response).with(
|
||||
response,
|
||||
'Failed to fetch TikTok message capabilities'
|
||||
).and_return(
|
||||
{
|
||||
'data' => {
|
||||
'capability_infos' => [
|
||||
{ 'capability_type' => 'IMAGE_SEND', 'capability_result' => true }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = client.image_send_capable?('tt-conv-1')
|
||||
|
||||
expect(result).to be(true)
|
||||
expect(HTTParty).to have_received(:get).with(
|
||||
'https://business-api.tiktok.com/open_api/v1.3/business/message/capabilities/get/',
|
||||
query: {
|
||||
business_id: 'biz-123',
|
||||
conversation_id: 'tt-conv-1',
|
||||
conversation_type: 'SINGLE',
|
||||
capability_types: '["IMAGE_SEND"]'
|
||||
},
|
||||
headers: { 'Access-Token': 'token-123' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns false when IMAGE_SEND capability is not enabled' do
|
||||
allow(client).to receive(:process_json_response).with(
|
||||
response,
|
||||
'Failed to fetch TikTok message capabilities'
|
||||
).and_return(
|
||||
{
|
||||
'data' => {
|
||||
'capability_infos' => [
|
||||
{ 'capability_type' => 'IMAGE_SEND', 'capability_result' => false }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = client.image_send_capable?('tt-conv-1')
|
||||
|
||||
expect(result).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#upload_media' do
|
||||
let(:connection) { instance_double(Faraday::Connection) }
|
||||
let(:request) { instance_double(Faraday::Request, headers: {}) }
|
||||
let(:response) { instance_double(Faraday::Response, success?: true, body: response_body) }
|
||||
let(:response_body) do
|
||||
{
|
||||
code: 0,
|
||||
message: 'OK',
|
||||
data: { media_id: 'media-123' }
|
||||
}.to_json
|
||||
end
|
||||
let(:blob) do
|
||||
instance_double(
|
||||
ActiveStorage::Blob,
|
||||
content_type: 'image/png',
|
||||
filename: ActiveStorage::Filename.new('avatar.png')
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3')
|
||||
allow(Faraday).to receive(:new).and_return(connection)
|
||||
allow(blob).to receive(:open) do |&block|
|
||||
File.open(Rails.root.join('spec/assets/avatar.png'), 'rb', &block)
|
||||
end
|
||||
end
|
||||
|
||||
it 'posts media upload with access token header' do
|
||||
captured_endpoint = nil
|
||||
allow(connection).to receive(:post) do |endpoint, _payload, &block|
|
||||
captured_endpoint = endpoint
|
||||
block.call(request)
|
||||
response
|
||||
end
|
||||
|
||||
media_id = client.send(:upload_media, blob)
|
||||
|
||||
expect(media_id).to eq('media-123')
|
||||
expect(captured_endpoint).to eq('https://business-api.tiktok.com/open_api/v1.3/business/message/media/upload/')
|
||||
expect(request.headers['Access-Token']).to eq('token-123')
|
||||
end
|
||||
|
||||
it 'uploads media as a multipart file with filename and content type' do
|
||||
captured_payload = nil
|
||||
allow(connection).to receive(:post) do |_endpoint, payload, &block|
|
||||
captured_payload = payload
|
||||
block.call(request)
|
||||
response
|
||||
end
|
||||
|
||||
client.send(:upload_media, blob)
|
||||
|
||||
expect(captured_payload[:business_id]).to eq('biz-123')
|
||||
expect(captured_payload[:media_type]).to eq('IMAGE')
|
||||
expect(captured_payload[:file]).to be_a(Faraday::Multipart::FilePart)
|
||||
expect(captured_payload[:file].content_type).to eq('image/png')
|
||||
expect(captured_payload[:file].original_filename).to eq('avatar.png')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_media_message' do
|
||||
let(:file) { Struct.new(:blob).new('blob') }
|
||||
let(:attachment) { instance_double(Attachment, file: file) }
|
||||
|
||||
it 'sends image messages' do
|
||||
allow(client).to receive(:upload_media).with('blob', 'IMAGE').and_return('media-123')
|
||||
allow(client).to receive(:send_message).and_return('tt-msg-123')
|
||||
|
||||
message_id = client.send_media_message('tt-conv-1', attachment)
|
||||
|
||||
expect(message_id).to eq('tt-msg-123')
|
||||
expect(client).to have_received(:send_message).with('tt-conv-1', 'IMAGE', 'media-123')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,10 +6,11 @@ RSpec.describe Tiktok::MessageService do
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
let(:tiktok_client) { instance_double(Tiktok::Client, image_send_capable?: true) }
|
||||
let(:text_content) do
|
||||
{
|
||||
type: 'text',
|
||||
message_id: 'tt-msg-lock',
|
||||
message_id: 'tt-msg-1',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
text: { body: 'Hello from TikTok' },
|
||||
@@ -20,6 +21,10 @@ RSpec.describe Tiktok::MessageService do
|
||||
}.deep_symbolize_keys
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Tiktok::Client).to receive(:new).and_return(tiktok_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
subject(:perform_text_message) do
|
||||
service = described_class.new(channel: channel, content: current_content)
|
||||
@@ -30,20 +35,8 @@ RSpec.describe Tiktok::MessageService do
|
||||
let(:current_content) { text_content }
|
||||
|
||||
it 'creates an incoming text message' do
|
||||
content = {
|
||||
type: 'text',
|
||||
message_id: 'tt-msg-1',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
text: { body: 'Hello from TikTok' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
expect do
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
service = described_class.new(channel: channel, content: text_content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
end.to change(Message, :count).by(1)
|
||||
@@ -57,6 +50,18 @@ RSpec.describe Tiktok::MessageService do
|
||||
expect(message.content_attributes['is_unsupported']).to be_nil
|
||||
end
|
||||
|
||||
it 'stores TikTok conversation capabilities when creating a new conversation' do
|
||||
service = described_class.new(channel: channel, content: text_content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
|
||||
service.perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send')).to be(true)
|
||||
expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'updated_at')).to be_present
|
||||
expect(tiktok_client).to have_received(:image_send_capable?).with('tt-conv-1')
|
||||
end
|
||||
|
||||
it 'creates an incoming unsupported message for non-supported types' do
|
||||
content = {
|
||||
type: 'sticker',
|
||||
@@ -135,6 +140,30 @@ RSpec.describe Tiktok::MessageService do
|
||||
tempfile.close!
|
||||
end
|
||||
|
||||
it 'creates a conversation even when capability lookup fails' do
|
||||
allow(tiktok_client).to receive(:image_send_capable?).and_raise('TikTok capability API error')
|
||||
|
||||
content = {
|
||||
type: 'text',
|
||||
message_id: 'tt-msg-5',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
text: { body: 'Hello with capability failure' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
|
||||
expect { service.perform }.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.conversation.additional_attributes['tiktok_capabilities']).to be_nil
|
||||
end
|
||||
|
||||
context 'when lock_to_single_conversation is enabled' do
|
||||
it 'reuses the last resolved conversation' do
|
||||
inbox.update!(lock_to_single_conversation: true)
|
||||
|
||||
@@ -48,10 +48,33 @@ RSpec.describe Tiktok::SendOnTiktokService do
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first, referenced_message_id: nil)
|
||||
expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first)
|
||||
expect(message.reload.source_id).to eq('tt-msg-124')
|
||||
end
|
||||
|
||||
it 'sends outgoing image message without quote metadata' do
|
||||
allow(tiktok_client).to receive(:send_media_message).and_return('tt-msg-124')
|
||||
allow(tiktok_client).to receive(:send_text_message)
|
||||
|
||||
message = build(
|
||||
:message,
|
||||
message_type: :outgoing,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
account: inbox.account,
|
||||
content: nil,
|
||||
content_attributes: { in_reply_to_external_id: 'quoted-message-id' }
|
||||
)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first)
|
||||
expect(tiktok_client).not_to have_received(:send_text_message)
|
||||
end
|
||||
|
||||
it 'marks message as failed when sending multiple attachments' do
|
||||
allow(tiktok_client).to receive(:send_media_message)
|
||||
|
||||
@@ -67,5 +90,67 @@ RSpec.describe Tiktok::SendOnTiktokService do
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', kind_of(String))
|
||||
expect(tiktok_client).not_to have_received(:send_media_message)
|
||||
end
|
||||
|
||||
it 'marks message as failed when conversation cannot send images' do
|
||||
allow(tiktok_client).to receive(:send_media_message)
|
||||
conversation.update!(additional_attributes: { conversation_id: 'tt-conv-1', tiktok_capabilities: { image_send: false } })
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(
|
||||
message,
|
||||
'failed',
|
||||
'Sending image attachments is not supported for this TikTok conversation.'
|
||||
)
|
||||
expect(tiktok_client).not_to have_received(:send_media_message)
|
||||
end
|
||||
|
||||
it 'marks message as failed when attachment is not an image' do
|
||||
allow(tiktok_client).to receive(:send_media_message)
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'Only image attachments are supported on TikTok.')
|
||||
expect(tiktok_client).not_to have_received(:send_media_message)
|
||||
end
|
||||
|
||||
it 'marks message as failed when image format is unsupported' do
|
||||
allow(tiktok_client).to receive(:send_media_message)
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok supports only JPG and PNG images.')
|
||||
expect(tiktok_client).not_to have_received(:send_media_message)
|
||||
end
|
||||
|
||||
it 'marks message as failed when image is larger than 3 MB' do
|
||||
allow(tiktok_client).to receive(:send_media_message)
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
allow(message.attachments.first.file).to receive(:byte_size).and_return(4.megabytes)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok image attachments must be smaller than 3 MB.')
|
||||
expect(tiktok_client).not_to have_received(:send_media_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user