diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py
new file mode 100644
index 000000000..064361b26
--- /dev/null
+++ b/.github/scripts/ghsa_linear_sync.py
@@ -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())
diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml
index 961114bc9..a21fbea7f 100644
--- a/.github/workflows/ghsa-linear-sync.yml
+++ b/.github/workflows/ghsa-linear-sync.yml
@@ -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
diff --git a/Gemfile.lock b/Gemfile.lock
index d85999b57..bd21b7a36 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/javascript/dashboard/api/companies.js b/app/javascript/dashboard/api/companies.js
index 090b530c4..b98a59983 100644
--- a/app/javascript/dashboard/api/companies.js
+++ b/app/javascript/dashboard/api/companies.js
@@ -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();
diff --git a/app/javascript/dashboard/api/specs/companies.spec.js b/app/javascript/dashboard/api/specs/companies.spec.js
index 82fdc1c97..ca1d905de 100644
--- a/app/javascript/dashboard/api/specs/companies.spec.js
+++ b/app/javascript/dashboard/api/specs/companies.spec.js
@@ -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'] }
+ );
+ });
});
});
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
index 4603521eb..fe4385bbb 100644
--- a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
@@ -1,7 +1,7 @@
-
+
{
{{ displayName }}
-
- {{ domain }}
+
+ {{ contactsCountLabel }}
-
-
+
+
{{ domain }}
-
- {{ description }}
-
-
-
-
- {{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
-
- {{ formattedUpdatedAt }}
+ {{ formattedLastActivityAt }}
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesDetailsLayout.vue b/app/javascript/dashboard/components-next/Companies/CompaniesDetailsLayout.vue
new file mode 100644
index 000000000..7736ccffd
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesDetailsLayout.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue
index 0eeb42233..f85b613a3 100644
--- a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue
@@ -19,22 +19,15 @@ const emit = defineEmits(['search', 'update:sort']);
-
+
{{ headerTitle }}
-
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue
index 8cf75d555..198a2f278 100644
--- a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue
@@ -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"
/>
@@ -112,6 +117,7 @@ const handleOrderChange = value => {
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
+ sub-menu-position="left"
@update:model-value="handleOrderChange"
/>
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue
new file mode 100644
index 000000000..2ca1b33c4
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue
@@ -0,0 +1,354 @@
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_DESCRIPTION') }}
+
+
+
+
+
+
+
+
+ {{ row.badge }}
+
+
+
+
+
+
+
+ {{ row.primary }}
+
+
+ {{ row.secondary }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
+
+
+ {{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
+
+
+
+
+ {{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
+
+
+
+ {{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue
new file mode 100644
index 000000000..3328e801e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+ {{ attribute.attributeDisplayName }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributes.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributes.vue
new file mode 100644
index 000000000..9ed72169c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributes.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ t('COMPANIES.DETAIL.ATTRIBUTES.UNUSED_ATTRIBUTES', {
+ count: unusedAttributesCount,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.ATTRIBUTES.NO_ATTRIBUTES') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue
new file mode 100644
index 000000000..1ffbf0c18
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue
@@ -0,0 +1,203 @@
+
+
+
+
+ {{ t('COMPANIES.DETAIL.LOADING') }}
+
+
+
+
+
+
+
+
+ {{ displayName }}
+
+
{{ summary }}
+
+ {{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue
new file mode 100644
index 000000000..a689fb079
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
index ba8090e02..d3342e18a 100644
--- a/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
+++ b/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
@@ -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"
/>
-
- {{ title }}
-
+
+
+ {{ title }}
+
+
+
{{ description }}
@@ -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"
>
- {{ $t('CHANNEL_SELECTOR.COMING_SOON') }} ๐
+ {{ t('CHANNEL_SELECTOR.COMING_SOON') }} ๐
diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue
index 31c58da47..2cbad72c5 100644
--- a/app/javascript/dashboard/components/widgets/ChannelItem.vue
+++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue
@@ -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"
/>
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
index cf8171385..f23e9e6a2 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
@@ -21,6 +21,7 @@ import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSes
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
+import { copyTextToClipboard } from 'shared/helpers/clipboard';
const props = defineProps({
chat: {
@@ -149,6 +150,15 @@ const startWhatsappCall = async () => {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
}
};
+
+const copyConversationId = async () => {
+ try {
+ await copyTextToClipboard(String(props.chat.id));
+ useAlert(t('CONVERSATION.HEADER.COPY_ID_SUCCESS'));
+ } catch (error) {
+ // error
+ }
+};
@@ -191,9 +201,18 @@ const startWhatsappCall = async () => {
diff --git a/app/javascript/dashboard/routes/dashboard/companies/pages/CompanyDetailView.vue b/app/javascript/dashboard/routes/dashboard/companies/pages/CompanyDetailView.vue
new file mode 100644
index 000000000..313df49d3
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/companies/pages/CompanyDetailView.vue
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.LOADING') }}
+
+
+
+
+ {{ t('COMPANIES.DETAIL.EMPTY_STATE.TITLE') }}
+
+
+ {{ t('COMPANIES.DETAIL.EMPTY_STATE.SUBTITLE') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.DELETE.SECTION_TITLE') }}
+
+
+ {{ t('COMPANIES.DETAIL.DELETE.SECTION_DESCRIPTION') }}
+
+
+
+
+
+
+
+
+ (selectedCandidate = contact)"
+ @remove-contact="handleRemoveContact"
+ @update:current-page="loadCompanyContactsPage"
+ />
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/companies/routes.js b/app/javascript/dashboard/routes/dashboard/companies/routes.js
index 69cbba762..42ed6bd18 100644
--- a/app/javascript/dashboard/routes/dashboard/companies/routes.js
+++ b/app/javascript/dashboard/routes/dashboard/companies/routes.js
@@ -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,
+ },
+ ],
+ },
];
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index 653b43840..fe95f9e99 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
@@ -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(() => {
+
+
toggleSidebarUIState('is_shared_files_open', value)
+ "
+ >
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
new file mode 100644
index 000000000..272407a53
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
@@ -0,0 +1,421 @@
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
+
+ {{ mediaAttachments.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayDuration(attachment) }}
+
+
+
+ {{ displayTime(attachment) }}
+
+
+
+
+
+
+
+
+
+ {{
+ t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
+ count: mediaOverflow,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
+
+ {{ fileAttachments.length }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
index 2aa2f28cd..61582fd32 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
@@ -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(() =>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
index 2ff6122b7..b79d78e30 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
@@ -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)"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
index 0b916a4ab..b941598fe 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
@@ -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 => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
index 432b0d46b..b8b00aac9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
@@ -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 {
@@ -55,8 +89,13 @@ export default {
@@ -69,13 +108,18 @@ export default {
class="text-n-brand size-4"
/>
-
- {{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
+
+ {{ publicVisibilityDescription }}
@@ -111,6 +155,7 @@ export default {
solid
:label="$t('MACROS.HEADER_BTN_TXT_SAVE')"
class="w-full"
+ :disabled="readOnly"
@click="$emit('submit')"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
index 934a451de..f7edcb512 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
@@ -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')
+);
@@ -85,13 +97,14 @@ const visibilityLabel = computed(() => {
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
>
+ 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.'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
new file mode 100644
index 000000000..268a54240
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
@@ -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: '
',
+ },
+ BaseTableCell: {
+ template: '
',
+ },
+ Button: true,
+ RouterLink: {
+ template: '',
+ },
+ },
+ },
+ });
+
+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);
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/attributes.js b/app/javascript/dashboard/store/modules/attributes.js
index c7cb771a5..1c7826cea 100644
--- a/app/javascript/dashboard/store/modules/attributes.js
+++ b/app/javascript/dashboard/store/modules/attributes.js
@@ -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
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index 333009707..5e85c423c 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -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;
diff --git a/app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js
index 9d8f027f5..77cf0748b 100644
--- a/app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js
@@ -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: {
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
index 69bf9c2ac..a7ee3833e 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
@@ -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 };
diff --git a/app/javascript/dashboard/stores/companies.js b/app/javascript/dashboard/stores/companies.js
index d1e8e56e9..e31f1677a 100644
--- a/app/javascript/dashboard/stores/companies.js
+++ b/app/javascript/dashboard/stores/companies.js
@@ -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 };
+ },
}),
});
diff --git a/app/javascript/dashboard/stores/companies.spec.js b/app/javascript/dashboard/stores/companies.spec.js
new file mode 100644
index 000000000..1c44d4292
--- /dev/null
+++ b/app/javascript/dashboard/stores/companies.spec.js
@@ -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',
+ },
+ })
+ );
+ });
+});
diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb
index a66928a79..52b3412e0 100644
--- a/app/jobs/account/contacts_export_job.rb
+++ b/app/jobs/account/contacts_export_job.rb
@@ -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
diff --git a/app/jobs/data_import_job.rb b/app/jobs/data_import_job.rb
index 4149ee16f..8e7161b37 100644
--- a/app/jobs/data_import_job.rb
+++ b/app/jobs/data_import_job.rb
@@ -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)
#
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)
diff --git a/app/mailboxes/mailbox_helper.rb b/app/mailboxes/mailbox_helper.rb
index 2b5fa1a26..f7a44ccca 100644
--- a/app/mailboxes/mailbox_helper.rb
+++ b/app/mailboxes/mailbox_helper.rb
@@ -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 = "
"
diff --git a/app/mailboxes/mailbox_inline_attachment_helper.rb b/app/mailboxes/mailbox_inline_attachment_helper.rb
new file mode 100644
index 000000000..2308e2d4e
--- /dev/null
+++ b/app/mailboxes/mailbox_inline_attachment_helper.rb
@@ -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
diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb
index 07d0a9535..35f822335 100644
--- a/app/models/custom_attribute_definition.rb
+++ b/app/models/custom_attribute_definition.rb
@@ -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
diff --git a/app/policies/macro_policy.rb b/app/policies/macro_policy.rb
index 5d7af755b..d70b688a8 100644
--- a/app/policies/macro_policy.rb
+++ b/app/policies/macro_policy.rb
@@ -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
diff --git a/app/services/tiktok/client.rb b/app/services/tiktok/client.rb
index dfbe8ba1f..5b112d8d7 100644
--- a/app/services/tiktok/client.rb
+++ b/app/services/tiktok/client.rb
@@ -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
diff --git a/app/services/tiktok/messaging_helpers.rb b/app/services/tiktok/messaging_helpers.rb
index 0f9d9e0b3..ce2aea817 100644
--- a/app/services/tiktok/messaging_helpers.rb
+++ b/app/services/tiktok/messaging_helpers.rb
@@ -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)
diff --git a/app/services/tiktok/send_on_tiktok_service.rb b/app/services/tiktok/send_on_tiktok_service.rb
index d7db5f326..58771cd05 100644
--- a/app/services/tiktok/send_on_tiktok_service.rb
+++ b/app/services/tiktok/send_on_tiktok_service.rb
@@ -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
diff --git a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
index 167b18390..8bd647f27 100644
--- a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
+++ b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
@@ -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]
diff --git a/config/routes.rb b/config/routes.rb
index d785e8b62..ab6321ea6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/db/migrate/20260422133000_add_additional_attributes_to_companies.rb b/db/migrate/20260422133000_add_additional_attributes_to_companies.rb
new file mode 100644
index 000000000..10835490f
--- /dev/null
+++ b/db/migrate/20260422133000_add_additional_attributes_to_companies.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 240ef77f0..9470861ae 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -612,6 +612,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_090000) 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"
diff --git a/enterprise/app/controllers/api/v1/accounts/companies/contacts_controller.rb b/enterprise/app/controllers/api/v1/accounts/companies/contacts_controller.rb
new file mode 100644
index 000000000..a67ad907b
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/companies/contacts_controller.rb
@@ -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
diff --git a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
index 3ec547cda..1df0e7d88 100644
--- a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
@@ -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
diff --git a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
index 3c2c55132..4103fd271 100644
--- a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
+++ b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
@@ -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)
diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb
index 8581675bd..25ba51935 100644
--- a/enterprise/app/models/company.rb
+++ b/enterprise/app/models/company.rb
@@ -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
diff --git a/enterprise/app/policies/company_policy.rb b/enterprise/app/policies/company_policy.rb
index 756edfc32..d12ea3600 100644
--- a/enterprise/app/policies/company_policy.rb
+++ b/enterprise/app/policies/company_policy.rb
@@ -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
diff --git a/enterprise/app/services/companies/contact_membership_service.rb b/enterprise/app/services/companies/contact_membership_service.rb
new file mode 100644
index 000000000..fe3f32168
--- /dev/null
+++ b/enterprise/app/services/companies/contact_membership_service.rb
@@ -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
diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb
index f3388d4f5..7b8b0e684 100644
--- a/enterprise/app/services/voice/inbound_call_builder.rb
+++ b/enterprise/app/services/voice/inbound_call_builder.rb
@@ -46,12 +46,16 @@ class Voice::InboundCallBuilder
# Always look up by (inbox, source_id) first โ that pair has a UNIQUE index, so
# creating with a colliding source_id under a different contact would raise
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
+ # A concurrent message webhook for the same wa_id can win the (inbox_id, source_id)
+ # race; rescue and re-find so the call path doesn't drop the connect.
def ensure_contact_inbox!
sid = source_id_for_provider
existing = inbox.contact_inboxes.find_by(source_id: sid)
return existing if existing
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
+ rescue ActiveRecord::RecordNotUnique
+ inbox.contact_inboxes.find_by!(source_id: sid)
end
def ensure_contact!
diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb
index e05e06e5f..8e060226b 100644
--- a/enterprise/app/services/whatsapp/incoming_call_service.rb
+++ b/enterprise/app/services/whatsapp/incoming_call_service.rb
@@ -60,8 +60,10 @@ class Whatsapp::IncomingCallService
return accept_outbound_call(call, payload) if call.outgoing?
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
- rescue ActiveRecord::RecordNotUnique
- Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{payload[:id]}"
+ end
+
+ def inbound_offer?(payload)
+ payload.dig(:session, :sdp_type).to_s.downcase == 'offer'
end
def inbound_offer?(payload)
diff --git a/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder
deleted file mode 100644
index 8b8772507..000000000
--- a/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder
+++ /dev/null
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/companies/avatar.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/avatar.json.jbuilder
new file mode 100644
index 000000000..48f181f66
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/avatar.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'api/v1/models/company', formats: [:json], resource: @company
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/contacts/_contact.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/contacts/_contact.json.jbuilder
new file mode 100644
index 000000000..2b0885169
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/contacts/_contact.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/companies/contacts/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/contacts/create.json.jbuilder
new file mode 100644
index 000000000..103e8ef17
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/contacts/create.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'api/v1/accounts/companies/contacts/contact', formats: [:json], contact: @contact
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/contacts/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/contacts/index.json.jbuilder
new file mode 100644
index 000000000..5537d8adc
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/contacts/index.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/companies/contacts/search.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/contacts/search.json.jbuilder
new file mode 100644
index 000000000..5537d8adc
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/contacts/search.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder
index b3bc80cfd..48f181f66 100644
--- a/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder
@@ -1,3 +1,3 @@
json.payload do
- json.partial! 'company', company: @company
+ json.partial! 'api/v1/models/company', formats: [:json], resource: @company
end
diff --git a/enterprise/app/views/api/v1/accounts/companies/destroy_custom_attributes.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/destroy_custom_attributes.json.jbuilder
new file mode 100644
index 000000000..48f181f66
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/destroy_custom_attributes.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'api/v1/models/company', formats: [:json], resource: @company
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder
index ef9c67723..798490d90 100644
--- a/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/companies/search.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/search.json.jbuilder
index ef9c67723..798490d90 100644
--- a/enterprise/app/views/api/v1/accounts/companies/search.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/companies/search.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder
index b3bc80cfd..48f181f66 100644
--- a/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder
@@ -1,3 +1,3 @@
json.payload do
- json.partial! 'company', company: @company
+ json.partial! 'api/v1/models/company', formats: [:json], resource: @company
end
diff --git a/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder
index b3bc80cfd..48f181f66 100644
--- a/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder
@@ -1,3 +1,3 @@
json.payload do
- json.partial! 'company', company: @company
+ json.partial! 'api/v1/models/company', formats: [:json], resource: @company
end
diff --git a/enterprise/app/views/api/v1/models/_company.json.jbuilder b/enterprise/app/views/api/v1/models/_company.json.jbuilder
new file mode 100644
index 000000000..2e7f11ed4
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/_company.json.jbuilder
@@ -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?
diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index e7eefe5c8..d5c9a3351 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -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
diff --git a/package.json b/package.json
index bb5b59ba3..1fbef2b1f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2a3aee897..b018dbdfe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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):
diff --git a/public/downloads/import-contacts-sample.csv b/public/downloads/import-contacts-sample.csv
index e81aaf403..2cea6430d 100644
--- a/public/downloads/import-contacts-sample.csv
+++ b/public/downloads/import-contacts-sample.csv
@@ -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
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index e007f4900..19d080b47 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -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
diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb
index 1af908e11..1501309a0 100644
--- a/spec/controllers/api/v1/accounts/macros_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb
@@ -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)
diff --git a/spec/enterprise/controllers/api/v1/accounts/companies/contacts_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/companies/contacts_controller_spec.rb
new file mode 100644
index 000000000..d18014e8c
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/companies/contacts_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
index 3dadcec43..84b182669 100644
--- a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
index 66828ef9f..76604a0b5 100644
--- a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
+++ b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
@@ -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
diff --git a/spec/fixtures/files/cid_inline_images_without_disposition.eml b/spec/fixtures/files/cid_inline_images_without_disposition.eml
new file mode 100644
index 000000000..08bc4846b
--- /dev/null
+++ b/spec/fixtures/files/cid_inline_images_without_disposition.eml
@@ -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:
+Received: from smtp.example.com (smtp.example.com. [203.0.113.10])
+ by mx.example.com with ESMTPS id smtp7890
+ for ;
+ 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 ; 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:
+To:
+Subject: Test inline image without Content-Disposition
+Date: Mon, 14 Jul 2025 19:23:29 -0300
+Message-ID:
+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
+
+
+
+ This is an HTML message with an inline image.
+
+
+
+
+------=_NextPart_001_0002--
+
+------=_NextPart_000_0001
+Content-Type: image/jpeg;
+ filename="image001.jpg"
+Content-Transfer-Encoding: base64
+Content-ID:
+
+/9j/4AAQSkZJRgABAQEASABIAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEB
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAAL
+CABkAGQBAREA/8QA...
+
+------=_NextPart_000_0001--
diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb
index 561015ea7..0714fb55b 100644
--- a/spec/jobs/account/contacts_export_job_spec.rb
+++ b/spec/jobs/account/contacts_export_job_spec.rb
@@ -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, [], {})
diff --git a/spec/jobs/data_import_job_spec.rb b/spec/jobs/data_import_job_spec.rb
index 4d3e0a05f..af227b816 100644
--- a/spec/jobs/data_import_job_spec.rb
+++ b/spec/jobs/data_import_job_spec.rb
@@ -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
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 3415a811d..cb13f6cf9 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -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]()` 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]()\n"
+ output = render_markdown(markdown)
+ expect(output).not_to include('onload="alert(1)"')
+ expect(output).to include('"')
+ end
+
+ it 'leaves legitimate alphanumeric IDs untouched' do
+ output = render_markdown_link('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
+ expect(output).to include('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"')
+ end
+ end
end
end
diff --git a/spec/mailboxes/mailbox_helper_spec.rb b/spec/mailboxes/mailbox_helper_spec.rb
index 8352a46a1..613040cb3 100644
--- a/spec/mailboxes/mailbox_helper_spec.rb
+++ b/spec/mailboxes/mailbox_helper_spec.rb
@@ -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, '
')
+
+ 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, '
')
+
+ 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
diff --git a/spec/models/custom_attribute_definition_spec.rb b/spec/models/custom_attribute_definition_spec.rb
index 648296b69..c529fc609 100644
--- a/spec/models/custom_attribute_definition_spec.rb
+++ b/spec/models/custom_attribute_definition_spec.rb
@@ -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
diff --git a/spec/services/tiktok/client_spec.rb b/spec/services/tiktok/client_spec.rb
new file mode 100644
index 000000000..9bf02be91
--- /dev/null
+++ b/spec/services/tiktok/client_spec.rb
@@ -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
diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb
index ee7b113ac..75878fe9f 100644
--- a/spec/services/tiktok/message_service_spec.rb
+++ b/spec/services/tiktok/message_service_spec.rb
@@ -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)
diff --git a/spec/services/tiktok/send_on_tiktok_service_spec.rb b/spec/services/tiktok/send_on_tiktok_service_spec.rb
index 0543fa695..2ca1ffd03 100644
--- a/spec/services/tiktok/send_on_tiktok_service_spec.rb
+++ b/spec/services/tiktok/send_on_tiktok_service_spec.rb
@@ -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