Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline

This commit is contained in:
Tanmay Deep Sharma
2026-05-07 13:13:27 +07:00
committed by GitHub
96 changed files with 4517 additions and 466 deletions
+195
View File
@@ -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())
+13 -81
View File
@@ -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
View File
@@ -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)
+35 -17
View File
@@ -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'] }
);
});
});
});
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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
@@ -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.'
);
});
});
@@ -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 };
+351 -7
View File
@@ -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',
},
})
);
});
});
+41 -4
View File
@@ -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
View File
@@ -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)
+6 -12
View File
@@ -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
+9 -6
View File
@@ -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
+6 -8
View File
@@ -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
+44 -11
View File
@@ -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
+17 -5
View File
@@ -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)
+18 -1
View File
@@ -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]
+11
View File
@@ -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
+3
View File
@@ -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)
+18
View File
@@ -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
@@ -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?
+3 -2
View File
@@ -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
View File
@@ -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",
+40 -58
View File
@@ -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):
+26 -26
View File
@@ -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
1 id name email identifier phone_number labels ip_address company_name custom_attribute_1 custom_attribute_2
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
3 2 Marieann Creegan mcreegan1@cornell.edu e60bab4c-9fbb-47eb-8f75-42025b789c47 +15417543010 168.186.4.241 Acme Inc Random-value0 Random-value0
4 3 Nancey Windibank nwindibank2@bluehost.com f793e813-4210-4bf3-a812-711418de25d2 +15417543011 73.44.41.59 Acme Inc Random-value1 Random-value1
5 4 Sibel Stennine sstennine3@yellowbook.com d6e35a2d-d093-4437-a577-7df76316b937 +15417543011 115.249.27.155 Acme Inc Random-value2 Random-value2
6 5 Tina O'Lunney tolunney4@si.edu 3540d40a-5567-4f28-af98-5583a7ddbc56 +15417543011 219.181.212.8 Acme Inc Random-value3 Random-value3
7 6 Quinn Neve qneve5@army.mil ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5 +15417543011 231.210.115.166 Acme Inc Random-value4 Random-value4
8 7 Karylin Gaunson kgaunson6@tripod.com d24cac79-c81b-4b84-a33e-0441b7c6a981 +15417543011 160.189.41.11 Acme Inc Random-value5 Random-value5
9 8 Jamison Shenton jshenton7@upenn.edu 29a7a8c0-c7f7-4af9-852f-761b1a784a7a +15417543011 53.94.18.201 Acme Inc Random-value6 Random-value6
10 9 Gavan Threlfall gthrelfall8@spotify.com 847d4943-ddb5-47cc-8008-ed5092c675c5 +15417543011 18.87.247.249 Acme Inc Random-value7 Random-value7
11 10 Katina Hemmingway khemmingway9@ameblo.jp 8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048 +15417543011 25.191.96.124 Acme Inc Random-value8 Random-value8
12 11 Jillian Deinhard jdeinharda@canalblog.com bd952787-1b05-411f-9975-b916ec0950cc +15417543011 11.211.174.93 Acme Inc Random-value9 Random-value9
13 12 Blake Finden bfindenb@wsj.com 12c95613-e49d-4fa2-86fb-deabb6ebe600 +15417543011 47.26.205.153 Acme Inc Random-value10 Random-value10
14 13 Liane Maxworthy lmaxworthyc@un.org 36b68e4c-40d6-4e09-bf59-7db3b27b18f0 +15417543011 157.196.34.166 Acme Inc Random-value11 Random-value11
15 14 Martynne Ledley mledleyd@sourceforge.net 1856bceb-cb36-415c-8ffc-0527f3f750d8 +15417543011 109.231.152.148 Acme Inc Random-value12 Random-value12
16 15 Katharina Ruffli krufflie@huffingtonpost.com 604de5c9-b154-4279-8978-41fb71f0f773 +15417543011 20.43.146.179 Acme Inc Random-value13 Random-value13
17 16 Tucker Simmance tsimmancef@bbc.co.uk 0a8fc3a7-4986-4a51-a503-6c7f974c90ad +15417543011 179.76.226.171 Acme Inc Random-value14 Random-value14
18 17 Wenona Martinson wmartinsong@census.gov 0e5ea6e3-6824-4e78-a6f5-672847eafa17 +15417543011 92.243.194.160 Acme Inc Random-value15 Random-value15
19 18 Gretna Vedyasov gvedyasovh@lycos.com 6becf55b-a7b5-48f6-8788-b89cae85b066 +15417543011 25.22.86.101 Acme Inc Random-value16 Random-value16
20 19 Lurline Abdon labdoni@archive.org afa9429f-9034-4b06-9efa-980e01906ebf +15417543011 150.249.116.118 Acme Inc Random-value17 Random-value17
21 20 Fiann Norcliff fnorcliffj@istockphoto.com 59f72dec-14ba-4d6e-b17c-0d962e69ffac +15417543011 237.167.197.197 Acme Inc Random-value18 Random-value18
22 21 Zed Linn zlinnk@phoca.cz 95f7bc56-be92-4c9c-ad58-eff3e63c7bea +15417543011 88.102.64.113 Acme Inc Random-value19 Random-value19
23 22 Averyl Simyson asimysonl@livejournal.com bde1fe59-c9bd-440c-bb39-79fe61dac1d1 +15417543011 141.248.89.29 Acme Inc Random-value20 Random-value20
24 23 Camella Blackadder cblackadderm@nifty.com 0c981752-5857-487c-b9b5-5d0253df740a +15417543011 118.123.138.115 Acme Inc Random-value21 Random-value21
25 24 Aurie Spatig aspatign@printfriendly.com 4cf22bfb-2c3f-41d1-9993-6e3758e457ba +15417543011 157.45.102.235 Acme Inc Random-value22 Random-value22
26 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
@@ -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--
+37 -1
View File
@@ -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, [], {})
+96
View File
@@ -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
+18
View File
@@ -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('&quot;')
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
+56
View File
@@ -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
+136
View File
@@ -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
+43 -14
View File
@@ -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