Compare commits

..
Author SHA1 Message Date
Sojan Jose 90b165b624 feat: update status on agent bot assignment 2026-06-26 14:30:47 -07:00
Sojan Jose ca9bedfba1 feat: expose assignable conversation owners 2026-06-26 02:07:41 -07:00
Sojan Jose 4edf626af2 feat: add agent bots to assignment dropdown 2026-06-26 01:12:54 -07:00
Sojan JoseandGitHub e86222034e fix(auth): record attribution for oauth signups (#14796)
Google OAuth signups now persist the same first-party attribution
cookies as email signups on Chatwoot Cloud.

This keeps attribution capture owned by the website and reuses the
existing Enterprise-only account attribution service. The OAuth callback
only records the already-shaped first-touch and last-touch cookie
payload after a new account is created.

## What changed

- Added Enterprise-only attribution persistence to the Google OAuth
signup account creation path.
- Reuses `Internal::Accounts::MarketingAttributionService`.
- Keeps attribution best-effort so failures do not interrupt OAuth
signup.
- Leaves existing email signup and SAML behavior unchanged.

## How to test

- Start from a Chatwoot Cloud-like setup with attribution cookies
present.
- Sign up using the Google OAuth button.
- Confirm the created account has
`internal_attributes['marketing_attribution']` with `first_touch` and
`last_touch`.
- Confirm existing Google OAuth login still redirects normally.

Validation run locally:

- `bundle exec rspec
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
- `bundle exec rspec
spec/controllers/devise/omniauth_callbacks_controller_spec.rb
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
- `bundle exec rubocop
enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
spec/enterprise/controllers/enterprise/devise_overrides/google_oauth_attribution_spec.rb`
2026-06-22 06:51:09 -07:00
2b37fef0e0 fix(companies): sync contact company names (#14759)
Fixes company-contact name drift when a company is renamed or deleted.

Closes: N/A

## Why
Contacts keep a denormalized `additional_attributes.company_name` for
display and filtering. Company rename/delete flows could leave that
copied value stale even though the actual `company_id` relationship
changed.

## What changed
- Enqueues an async company contact-name sync job when a company name
changes.
- Moves company deletion into `Companies::DeleteJob`.
- The delete job unlinks linked contacts, clears only the copied
`company_name`, and then deletes the company.
- Uses bulk JSON updates for the cleanup path so contact records are not
saved, which avoids contact update callbacks, webhook dispatch, and
automation side effects.

## How to test
- Link a contact to a company, rename the company, and confirm the
contact company name updates after the job runs.
- Delete a company with linked contacts and confirm the delete job
removes the company, unassigns linked contacts, and preserves other
contact additional attributes.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-22 06:49:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
44b32eacec chore(deps): bump faraday from 2.14.2 to 2.14.3 (#14811)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.14.2 to
2.14.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/lostisland/faraday/releases">faraday's
releases</a>.</em></p>
<blockquote>
<h2>v2.14.3</h2>
<h2>Security Note</h2>
<p>This release contains a security fix, we recommend all users to
upgrade as soon as possible.
A Security Advisory with more details will be posted shortly.</p>
<h2>What's Changed</h2>
<ul>
<li>Upgrade CI lint step from Ruby 3 to 4 by <a
href="https://github.com/larouxn"><code>@​larouxn</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1673">lostisland/faraday#1673</a></li>
<li>feat(test): add Stubs#clear to remove all stubs by <a
href="https://github.com/SAY-5"><code>@​SAY-5</code></a> in <a
href="https://redirect.github.com/lostisland/faraday/pull/1675">lostisland/faraday#1675</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/SAY-5"><code>@​SAY-5</code></a> made
their first contribution in <a
href="https://redirect.github.com/lostisland/faraday/pull/1675">lostisland/faraday#1675</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/lostisland/faraday/compare/v2.14.2...v2.14.3">https://github.com/lostisland/faraday/compare/v2.14.2...v2.14.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/lostisland/faraday/commit/f1ace878bae16c1df298b6af7c34657f2c4dcddb"><code>f1ace87</code></a>
Version bump to 2.14.3</li>
<li><a
href="https://github.com/lostisland/faraday/commit/36764bfc48aa9fc08336d36600ea67af149c80fa"><code>36764bf</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/lostisland/faraday/commit/59334e0e9b1918bd26b143f4a7bbc8c765de3ac4"><code>59334e0</code></a>
feat(test): add Stubs#clear to remove all stubs (<a
href="https://redirect.github.com/lostisland/faraday/issues/1675">#1675</a>)</li>
<li><a
href="https://github.com/lostisland/faraday/commit/469f25c793f25bcfdd416a54edeb454d045c08d6"><code>469f25c</code></a>
Upgrade CI lint step from Ruby 3 to 4 (<a
href="https://redirect.github.com/lostisland/faraday/issues/1673">#1673</a>)</li>
<li>See full diff in <a
href="https://github.com/lostisland/faraday/compare/v2.14.2...v2.14.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=faraday&package-manager=bundler&previous-version=2.14.2&new-version=2.14.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 17:36:08 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
5b7430f376 chore(deps): bump concurrent-ruby from 1.3.5 to 1.3.7 (#14810)
Bumps
[concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby)
from 1.3.5 to 1.3.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby-concurrency/concurrent-ruby/releases">concurrent-ruby's
releases</a>.</em></p>
<blockquote>
<h2>v1.3.7</h2>
<!-- raw HTML omitted -->
<p>There are 3 security fixes in this release, so updating is
recommended.
These security vulnerabilities are not very likely to be hit in practice
and have a corresponding <code>Low</code> severity score.</p>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<ul>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/security/advisories/GHSA-h8w8-99g7-qmvj">CVE-2026-54904</a>
<code>AtomicReference#update</code> livelocks when the stored value is
<code>Float::NAN</code>. Fix by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a> and <a
href="https://github.com/eregon"><code>@​eregon</code></a></li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/security/advisories/GHSA-wv3x-4vxv-whpp">CVE-2026-54905</a>
<code>ReentrantReadWriteLock</code> read-count overflow grants a write
lock without exclusivity. Fix by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a></li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/security/advisories/GHSA-6wx8-w4f5-wwcr">CVE-2026-54906</a>
<code>ReadWriteLock</code> allows wrong-thread write release and stray
read-release counter corruption. Fix by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a></li>
<li>concurrent-ruby-ext: fix build on Darwin 32-bit by <a
href="https://github.com/barracuda156"><code>@​barracuda156</code></a>
in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1064">ruby-concurrency/concurrent-ruby#1064</a></li>
<li>Add SECURITY.md by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1104">ruby-concurrency/concurrent-ruby#1104</a></li>
<li>Add Ruby 4.0 in CI by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1106">ruby-concurrency/concurrent-ruby#1106</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/barracuda156"><code>@​barracuda156</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1064">ruby-concurrency/concurrent-ruby#1064</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.6...v1.3.7">https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.6...v1.3.7</a></p>
<h2>v1.3.6</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<ul>
<li>Run tests without the C extension in CI by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1081">ruby-concurrency/concurrent-ruby#1081</a></li>
<li>Fix typo in Promise docs by <a
href="https://github.com/danieldiekmeier"><code>@​danieldiekmeier</code></a>
in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1083">ruby-concurrency/concurrent-ruby#1083</a></li>
<li>Correct word in readme by <a
href="https://github.com/wwahammy"><code>@​wwahammy</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1084">ruby-concurrency/concurrent-ruby#1084</a></li>
<li>Fix mistakes in MVar documentation by <a
href="https://github.com/trinistr"><code>@​trinistr</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1087">ruby-concurrency/concurrent-ruby#1087</a></li>
<li>Fix multi require concurrent/executor/cached_thread_pool by <a
href="https://github.com/OuYangJinTing"><code>@​OuYangJinTing</code></a>
in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1085">ruby-concurrency/concurrent-ruby#1085</a></li>
<li>Use typed data APIs by <a
href="https://github.com/nobu"><code>@​nobu</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1096">ruby-concurrency/concurrent-ruby#1096</a></li>
<li>Add Joshua Young to the list of maintainers by <a
href="https://github.com/eregon"><code>@​eregon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1097">ruby-concurrency/concurrent-ruby#1097</a></li>
<li>Asynchronous pruning for RubyThreadPoolExecutor by <a
href="https://github.com/joshuay03"><code>@​joshuay03</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1082">ruby-concurrency/concurrent-ruby#1082</a></li>
<li>Mark RubySingleThreadExecutor as a SerialExecutorService by <a
href="https://github.com/meineerde"><code>@​meineerde</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1070">ruby-concurrency/concurrent-ruby#1070</a></li>
<li>Allow TimerTask to be safely restarted after shutdown and avoid
duplicate tasks by <a
href="https://github.com/bensheldon"><code>@​bensheldon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1001">ruby-concurrency/concurrent-ruby#1001</a></li>
<li>Flaky test fix: allow ThreadPool to shutdown before asserting
completed_task_count by <a
href="https://github.com/bensheldon"><code>@​bensheldon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1098">ruby-concurrency/concurrent-ruby#1098</a></li>
<li><code>ThreadPoolExecutor#kill</code> will
<code>wait_for_termination</code> in JRuby; ensure <code>TimerSet</code>
timer thread shuts down cleanly by <a
href="https://github.com/bensheldon"><code>@​bensheldon</code></a> in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1044">ruby-concurrency/concurrent-ruby#1044</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/danieldiekmeier"><code>@​danieldiekmeier</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1083">ruby-concurrency/concurrent-ruby#1083</a></li>
<li><a href="https://github.com/wwahammy"><code>@​wwahammy</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1084">ruby-concurrency/concurrent-ruby#1084</a></li>
<li><a href="https://github.com/trinistr"><code>@​trinistr</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1087">ruby-concurrency/concurrent-ruby#1087</a></li>
<li><a
href="https://github.com/OuYangJinTing"><code>@​OuYangJinTing</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1085">ruby-concurrency/concurrent-ruby#1085</a></li>
<li><a href="https://github.com/nobu"><code>@​nobu</code></a> made their
first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1096">ruby-concurrency/concurrent-ruby#1096</a></li>
<li><a href="https://github.com/joshuay03"><code>@​joshuay03</code></a>
made their first contribution in <a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/pull/1082">ruby-concurrency/concurrent-ruby#1082</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.5...v1.3.6">https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.5...v1.3.6</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ruby-concurrency/concurrent-ruby/blob/master/CHANGELOG.md">concurrent-ruby's
changelog</a>.</em></p>
<blockquote>
<h2>Release v1.3.7 (16 June 2026)</h2>
<p>concurrent-ruby:</p>
<ul>
<li>See the <a
href="https://github.com/ruby-concurrency/concurrent-ruby/releases/tag/v1.3.7">release
notes on GitHub</a>.</li>
</ul>
<h2>Release v1.3.6 (13 December 2025)</h2>
<p>concurrent-ruby:</p>
<ul>
<li>See the <a
href="https://github.com/ruby-concurrency/concurrent-ruby/releases/tag/v1.3.6">release
notes on GitHub</a>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/4c8fc28ab6bb9bd8258a4c0c2fa6d35ebe77b3cb"><code>4c8fc28</code></a>
Release 1.3.7</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/d91ca9426cb819d6cc63f1bd64bfe54644d0beca"><code>d91ca94</code></a>
Fix AtomicReference#update livelock when stored value is Float::NAN on
JRuby ...</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/7e4d711bacf7a1dac3ef6bda44004387be2dc7e6"><code>7e4d711</code></a>
Fix <code>ReentrantReadWriteLock</code> read hold overflow into
write-lock bit</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/6e37e0644b83b182971dc540d2e4bee38df61386"><code>6e37e06</code></a>
Fix <code>AtomicReference#update</code> livelock when stored value is
<code>Float::NAN</code></li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/2825cfa12cb708b76557803957f76862eb1151a2"><code>2825cfa</code></a>
Cleanup spec</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/3fd493283ca5f84f0ef4e84aabd43ad68df4626b"><code>3fd4932</code></a>
Fix <code>ReadWriteLock</code> wrong-thread write release and stray read
release</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/1974b4772efc034ee8eaa562b4370343f4c5c54b"><code>1974b47</code></a>
Add Ruby 4.0 in CI</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/df8706d40c483d76bbb0b3a35a633c68fa9e17be"><code>df8706d</code></a>
Add SECURITY.md (<a
href="https://redirect.github.com/ruby-concurrency/concurrent-ruby/issues/1104">#1104</a>)</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/7a1b78941c081106c20a9ca0144ac73a48d254ab"><code>7a1b789</code></a>
Bump actions/upload-pages-artifact from 4 to 5</li>
<li><a
href="https://github.com/ruby-concurrency/concurrent-ruby/commit/9b2dbf712896a638a73d2fa221206961c8d6484d"><code>9b2dbf7</code></a>
Bump actions/deploy-pages from 4 to 5</li>
<li>Additional commits viewable in <a
href="https://github.com/ruby-concurrency/concurrent-ruby/compare/v1.3.5...v1.3.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=concurrent-ruby&package-manager=bundler&previous-version=1.3.5&new-version=1.3.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 16:08:06 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
5c00a1601a chore(deps): bump dompurify from 3.4.0 to 3.4.11 (#14798)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.0 to
3.4.11.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.11</h2>
<ul>
<li>Fixed an issue with a leaky config for hooks via
<code>setConfig</code>, thanks <a
href="https://github.com/trace37labs"><code>@​trace37labs</code></a></li>
<li>Bumped vulnerable development dependencies to arrive at plain 0 with
<code>npm audit</code></li>
<li>Updated the <code>osv-scanner</code> suppression list as no
vulnerable dependencies are left for now</li>
<li>Updated up the linting tool-chain and removed now-redundant lint
directives</li>
<li>Updated the documentation is several spots, README, wiki, etc.</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.10</h2>
<ul>
<li>Refactored codebase for clarity: extracted the public type
declarations into <code>types.ts</code></li>
<li>Decomposed the three largest sanitizer functions into focused
helpers</li>
<li>Removed duplicated defaults and dead branches, consolidated
<code>SAFE_FOR_TEMPLATES</code> scrubbing into single shared path</li>
<li>Improved per-node performance by hoisting the mXSS probe regexes and
testing <code>textContent</code> before <code>innerHTML</code></li>
<li>Added a deterministic micro-benchmark harness (<code>npm run
bench</code>) with a <code>--compare</code> mode</li>
<li>Reduced CI cost by running the full three-engine browser suite once
per PR</li>
<li>Refreshed the <code>demos/</code> folder so every demo runs again,
and added a SVG-via-<code>&lt;img&gt;</code> demo</li>
<li>Documented the bench and <code>test:happydom</code> scripts in the
README</li>
<li>Completed the Attack Classes &amp; Bypass History wiki page</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.9</h2>
<ul>
<li>Further improved the handling of Trusted Types config options,
thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Further improved the handling of <code>IN_PLACE</code> sanitization,
thanks <a
href="https://github.com/mozfreddyb"><code>@​mozfreddyb</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and Trusted Types
related usage</li>
<li>Bumped several dependencies where possible</li>
<li>Updated README and wiki with more accurate documentation &amp;
attack samples</li>
</ul>
<h2>DOMPurify 3.4.8</h2>
<ul>
<li>Cleaned up the repository root, renamed some and removed unneeded
files</li>
<li>Fixed an issue with handling of Trusted Types policies, thanks <a
href="https://github.com/fulstadev"><code>@​fulstadev</code></a></li>
<li>Fixed the node iterator for better template scrubbing, thanks <a
href="https://github.com/IamLeandrooooo"><code>@​IamLeandrooooo</code></a></li>
<li>Included formerly missing LICENSE-MPL in published npm package,
thanks <a
href="https://github.com/asamuzaK"><code>@​asamuzaK</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.7</h2>
<ul>
<li>Hardened the handling of Shadow Roots when using
<code>IN_PLACE</code>, thanks <a
href="https://github.com/GameZoneHacker"><code>@​GameZoneHacker</code></a></li>
<li>Removed a problem leading to permanent hook pollution, thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Refactored the test suite and expanded test coverage
significantly</li>
</ul>
<h2>DOMPurify 3.4.6</h2>
<ul>
<li>Fixed several issues with DOM Clobbering in <code>IN_PLACE</code>
mode, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Hardened the checks for cross-realm <code>IN_PLACE</code> and Shadow
DOM sanitization, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and general DOM
Clobbering attacks</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.5</h2>
<ul>
<li>Fixed a bypass caused by the new HTML element
<code>selectedcontent</code> added in 3.4.4, thanks <a
href="https://github.com/KabirAcharya"><code>@​KabirAcharya</code></a></li>
</ul>
<p><strong>Note that this is a security release for an issue introduced
in 3.4.4 and should be upgraded to immediately.</strong></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/0cae5187403132f96a6d357649e4b15633fc210a"><code>0cae518</code></a>
release: 3.4.11 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1494">#1494</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6ee5716f8336989753611beeca364957c0eb0c3e"><code>6ee5716</code></a>
release: 3.4.10 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1478">#1478</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/52102472d46035857c52df19e44285f8a1e102fc"><code>5210247</code></a>
release: 3.4.9 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1459">#1459</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bcdd8285412dc9c4c149652aed2d712e790d6ccf"><code>bcdd828</code></a>
release: 3.4.8 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1439">#1439</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/ca30f070c360df162a3e3848e80e6fd3c9e74bff"><code>ca30f07</code></a>
release: 3.4.7 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1414">#1414</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bb7739e5bccec7e1ab3dae3f3e42d02db3acaaae"><code>bb7739e</code></a>
release: 3.4.6 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1394">#1394</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/011b0c78f2a0f57ee54f5fcccb697a46ca6e63ea"><code>011b0c7</code></a>
release: 3.4.5 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1382">#1382</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/5817ad969c15e67dfcd6cb37248d6e9c1553e7c3"><code>5817ad9</code></a>
release: 3.4.4 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1374">#1374</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/520edb0371a9638f9b51f1798051299a250c686b"><code>520edb0</code></a>
release: 3.4.3 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1352">#1352</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6f67fd396a7b8c64294343999fe607ca1f5299c0"><code>6f67fd3</code></a>
Sync/3.4.2 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1322">#1322</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.0...3.4.11">compare
view</a></li>
</ul>
</details>
<details>
<summary>Install script changes</summary>
<p>This version adds <code>prepare</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dompurify&package-manager=npm_and_yarn&previous-version=3.4.0&new-version=3.4.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 14:58:07 +05:30
a4be38f8f3 fix(facebook): handle messenger sticker attachment type (#14793)
Facebook Messenger sticker messages no longer break message ingestion.
Meta recently changed its webhook payloads so a sticker now arrives as a
new `sticker` attachment type (alongside the existing `image` attachment
during the transition period). Chatwoot didn't recognise `sticker` as a
valid attachment file type, so the webhook job crashed and the message —
and any others in the same batch — failed to sync. Stickers now appear
in the conversation as a single image, just like before.

Fixes https://linear.app/chatwoot/issue/PLA-177

## How to reproduce

1. Connect a Facebook Page inbox.
2. Send a sticker from Messenger to that page.
3. Before this change: the `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'sticker' is not a valid file_type` and the message is
dropped.
4. After this change: the sticker shows up in the conversation as a
single image attachment.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:40:23 +04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sony Mathew
6fcc888aee chore(deps): bump oj from 3.16.10 to 3.17.3 (#14809)
Bumps [oj](https://github.com/ohler55/oj) from 3.16.10 to 3.17.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ohler55/oj/releases">oj's
releases</a>.</em></p>
<blockquote>
<h2>v3.17.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix stack limits by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1014">ohler55/oj#1014</a></li>
<li>Fix intern.c and fast.c by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1015">ohler55/oj#1015</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.17.1...v3.17.3">https://github.com/ohler55/oj/compare/v3.17.1...v3.17.3</a></p>
<h2>v3.17.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Add <code>max_integer_digits</code> limit for legacy integer parsing
by <a href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1009">ohler55/oj#1009</a></li>
<li>fix: remove unsafe exec() in fast.c by <a
href="https://github.com/orbisai0security"><code>@​orbisai0security</code></a>
in <a
href="https://redirect.github.com/ohler55/oj/pull/1011">ohler55/oj#1011</a></li>
<li>Revert &quot;fix: remove unsafe exec() in fast.c&quot; by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1012">ohler55/oj#1012</a></li>
<li>Fix reentrant parser by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1013">ohler55/oj#1013</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/orbisai0security"><code>@​orbisai0security</code></a>
made their first contribution in <a
href="https://redirect.github.com/ohler55/oj/pull/1011">ohler55/oj#1011</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.17.0...v3.17.1">https://github.com/ohler55/oj/compare/v3.17.0...v3.17.1</a></p>
<h2>v3.17.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Implement Oj::Parser.safe with configurable JSON safety limits by <a
href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1007">ohler55/oj#1007</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.16.17...v3.17.0">https://github.com/ohler55/oj/compare/v3.16.17...v3.17.0</a></p>
<h2>v3.16.17</h2>
<h2>What's Changed</h2>
<ul>
<li>Use fast_memcpy16 to quickly copy up to 16 bytes for tail handling
in ARM Neon code and small copies when generating JSON. by <a
href="https://github.com/samyron"><code>@​samyron</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/998">ohler55/oj#998</a></li>
<li>Bump jidicula/clang-format-action from 4.15.0 to 4.17.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/ohler55/oj/pull/999">ohler55/oj#999</a></li>
<li>Array hash as json by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1003">ohler55/oj#1003</a></li>
<li>Bump jidicula/clang-format-action from 4.17.0 to 4.18.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/ohler55/oj/pull/1001">ohler55/oj#1001</a></li>
<li>Handle unterminated strings in usual parser by <a
href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1002">ohler55/oj#1002</a></li>
<li>Fix read() not handling partial reads for large files by <a
href="https://github.com/ursm"><code>@​ursm</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1004">ohler55/oj#1004</a></li>
<li>Raise error for incomplete primitive literals by <a
href="https://github.com/meinac"><code>@​meinac</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/1005">ohler55/oj#1005</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/ursm"><code>@​ursm</code></a> made their
first contribution in <a
href="https://redirect.github.com/ohler55/oj/pull/1004">ohler55/oj#1004</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.16.16...v3.16.17">https://github.com/ohler55/oj/compare/v3.16.16...v3.16.17</a></p>
<h2>v3.16.14</h2>
<h2>What's Changed</h2>
<ul>
<li>Change first arg to oj_parse_options by <a
href="https://github.com/ohler55"><code>@​ohler55</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/987">ohler55/oj#987</a></li>
<li>Fix illegal instruction error on CPUs without SSE4.2 support by <a
href="https://github.com/sebyx07"><code>@​sebyx07</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/993">ohler55/oj#993</a></li>
<li>Optimize <code>oj_dump_cstr</code> using SSE4.2 and SSSE3. by <a
href="https://github.com/samyron"><code>@​samyron</code></a> in <a
href="https://redirect.github.com/ohler55/oj/pull/973">ohler55/oj#973</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/ohler55/oj/compare/v3.16.13...v3.16.14">https://github.com/ohler55/oj/compare/v3.16.13...v3.16.14</a></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ohler55/oj/blob/develop/CHANGELOG.md">oj's
changelog</a>.</em></p>
<blockquote>
<h2>3.17.3 - 2026-06-04</h2>
<ul>
<li>Fixed issue in intern.c and fast.c.</li>
</ul>
<h2>3.17.2 - 2026-05-27</h2>
<ul>
<li>Fixed multiple issues related to extreme sizes.</li>
</ul>
<h2>3.17.1 - 2026-05-15</h2>
<ul>
<li>Fixed &quot;quoted string not terminated&quot; error.</li>
</ul>
<h2>3.17.0 - 2026-04-19</h2>
<ul>
<li>A &quot;safe&quot; parser has been added as a variation of the
Oj:Parser thanks to <a
href="https://github.com/meinac"><code>@​meinac</code></a>.</li>
</ul>
<h2>3.16.17 - 2026-04-12</h2>
<ul>
<li>
<p>Rails optimize for Hash and Array now overrides <code>as_json</code>
for those
classes. Note that when either is optimized with
<code>Oj.optimize_rails</code>
the <code>Array.as_json</code> and <code>Hash.as_json</code> will not be
called.</p>
</li>
<li>
<p>Add support for the rails encoder <code>:only</code> and
<code>:except</code> options.</p>
</li>
<li>
<p>Handle unterminated strings in usual parser (<a
href="https://redirect.github.com/ohler55/oj/issues/1002">#1002</a>)</p>
</li>
<li>
<p>Fix read() not handling partial reads for large files (<a
href="https://redirect.github.com/ohler55/oj/issues/1004">#1004</a>)</p>
</li>
<li>
<p>Raise error for incomplete primitive literals (<a
href="https://redirect.github.com/ohler55/oj/issues/1005">#1005</a>)</p>
</li>
</ul>
<h2>3.16.16 - 2026-03-13</h2>
<ul>
<li>Not closed arrays and objects are reported corrected in the usual
parser.</li>
</ul>
<h2>3.16.15 - 2026-02-05</h2>
<ul>
<li>Fixed by putting the ostruct dependency back until a better way is
found to conditionally include it.</li>
</ul>
<h2>3.16.14 - 2026-02-04</h2>
<ul>
<li>
<p>Fixed SSE issue <a
href="https://redirect.github.com/ohler55/oj/issues/989">#989</a>.</p>
</li>
<li>
<p>Removed ostruct dependency.</p>
</li>
<li>
<p>Removed generic object JSON gem tests.</p>
</li>
</ul>
<h2>3.16.13 - 2025-12-05</h2>
<ul>
<li>Fixed rails encoding for Hash and Array subclasses.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ohler55/oj/commit/bbde91a679728f94c4492ebc3683f4fa3309049f"><code>bbde91a</code></a>
Fix intern.c and fast.c (<a
href="https://redirect.github.com/ohler55/oj/issues/1015">#1015</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/495cc38fc5a02681da2175960d4a667fae48f3c9"><code>495cc38</code></a>
Update changelog</li>
<li><a
href="https://github.com/ohler55/oj/commit/810a57acc7aaacef8410b460559c08762d0eb61a"><code>810a57a</code></a>
Update changelog</li>
<li><a
href="https://github.com/ohler55/oj/commit/ec368dbe936ef0104b782e4b0f67b17d6c7276f7"><code>ec368db</code></a>
Fix stack limits (<a
href="https://redirect.github.com/ohler55/oj/issues/1014">#1014</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/4587e87e23adc9a4163834dc8c9ba9d7206c6501"><code>4587e87</code></a>
Fix reentrant parser (<a
href="https://redirect.github.com/ohler55/oj/issues/1013">#1013</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/ea0c3b8fdc006807dfd768606548f6f15639b7dc"><code>ea0c3b8</code></a>
Fix compile warnings</li>
<li><a
href="https://github.com/ohler55/oj/commit/45d13099a64c37e231f6cd32bb126ce0270d81c4"><code>45d1309</code></a>
Revert &quot;fix: V-001 security vulnerability (<a
href="https://redirect.github.com/ohler55/oj/issues/1011">#1011</a>)&quot;
(<a
href="https://redirect.github.com/ohler55/oj/issues/1012">#1012</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/93ce414fc0b9b1ad2a19311101b6a443ec666320"><code>93ce414</code></a>
fix: V-001 security vulnerability (<a
href="https://redirect.github.com/ohler55/oj/issues/1011">#1011</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/d68a31ac5bcaaeeed22fb32aeff35aef467ab8d2"><code>d68a31a</code></a>
Add <code>max_integer_digits</code> limit for legacy integer parsing (<a
href="https://redirect.github.com/ohler55/oj/issues/1009">#1009</a>)</li>
<li><a
href="https://github.com/ohler55/oj/commit/babd7a11e78a221f6434c5e9e8b9ede9947243e3"><code>babd7a1</code></a>
Remove out of date performance comparisons</li>
<li>Additional commits viewable in <a
href="https://github.com/ohler55/oj/compare/v3.16.10...v3.17.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=oj&package-manager=bundler&previous-version=3.16.10&new-version=3.17.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-22 14:09:28 +05:30
Aakash BakhleandGitHub c27a5916bb fix: auto enable document auto-sync (#14806)
# Pull Request Template

## Description

Auto enables document auto-sync on paid plans

fixes:
https://linear.app/chatwoot/issue/AI-186/captain-auto-sync-doesnt-turn-on-automatically-on-subscription

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-22 13:39:43 +05:30
Sony MathewandGitHub 66609f06fd fix: Bump Nokogiri to 1.19.4 (#14807)
# Pull Request Template

## Description

This updates Nokogiri from `1.19.3` to `1.19.4` so the bundle-audit lint
step stops flagging the newly published Nokogiri advisories. Chatwoot's
direct Nokogiri usage appears limited to ordinary HTML/XML parsing and
selector traversal, but the locked dependency is below the patched
floor, so the safe remediation is the patch-level upgrade rather than an
advisory override.

Fixes
[CW-7397](https://linear.app/chatwoot/issue/CW-7397/upgrade-nokogiri-to-1194-for-bundle-audit-advisories)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Reference failed build in CI because of bundle audit:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114392/workflows/8a252bf9-5e58-45fd-af18-a32dbebe978b/jobs/160423

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
passed with no vulnerabilities found.
- `bundle exec rspec spec/services/website_branding_service_spec.rb
spec/presenters/html_parser_spec.rb
spec/enterprise/services/enterprise/website_branding_service_spec.rb
spec/enterprise/services/captain/tools/simple_page_crawl_service_spec.rb`
passed with 27 examples and 0 failures.
- `bundle exec rubocop app/services/website_branding_service.rb
app/presenters/html_parser.rb
enterprise/app/services/page_crawler_service.rb
enterprise/app/services/captain/tools/html_page_parser.rb
enterprise/app/services/captain/tools/simple_page_crawl_service.rb`
passed with no offenses.
- `git diff --check` passed.

Note: broad `bundle exec rubocop --parallel` still reports existing
generated DB/schema offenses unrelated to this lockfile-only dependency
bump.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-22 12:07:58 +04:00
6a262287d2 feat(captain): allow agents to report Captain messages (#14799)
Adds a cloud-only flow for agents to flag incorrect or problematic
Captain (AI) responses. Right-clicking a Captain message surfaces a
"Report message" option that opens a dialog to pick a problem type and
add a description, persisted to a new captain_message_reports table for
the team to review.


<img width="636" height="542" alt="Screenshot 2026-06-20 at 9 15 56 AM"
src="https://github.com/user-attachments/assets/afaa233d-6bd6-455a-8a33-a3796a3e3ef6"
/>
<img width="580" height="502" alt="Screenshot 2026-06-20 at 9 16 03 AM"
src="https://github.com/user-attachments/assets/2d220d99-98cc-4c5e-a325-778ceb4f7bc9"
/>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:47:08 -07:00
Sojan JoseandGitHub dfd656a7d9 feat(account): persist signup attribution (#14761)
This keeps Chatwoot-side attribution persistence intentionally small and
Enterprise-only.

The website owns attribution capture, normalization, and source
classification. Chatwoot Cloud only reads the already-shaped first-party
attribution cookies during the current web signup account creation path
and stores the decoded payload in internal account metadata. Self-hosted
installs remain unchanged in this repo.

## What changed

- Added Enterprise-only attribution persistence to the current Cloud web
signup account creation path.
- Stores attribution only when `ChatwootApp.chatwoot_cloud?` is true.
- Reads the existing first-touch and last-touch attribution cookies.
- Saves only the documented scalar attribution fields under account
internal metadata.
- Preserves raw attribution values and leaves escaping to display
boundaries.
- Bounds stored attribution values to the website field-size limit.
- Keeps OSS controller code unchanged.
- Keeps signup attribution request coverage in Enterprise specs.
- Avoids backend attribution derivation, referrer parsing, or fallback
classification.
- Skips authenticated add-workspace flows so additional workspaces are
not counted as signup attribution.
- Does not hook unused account creation paths or OmniAuth account
creation.

## How to test

- `bundle exec rubocop
spec/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb
enterprise/app/services/internal/accounts/marketing_attribution_service.rb
spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb`
- `bundle exec rspec spec/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb`
- On a cloud-like setup, create an account through the current web
signup path with attribution cookies and confirm account internal
metadata is populated.
- On a non-cloud setup or authenticated add-workspace flow, confirm
account-create behavior is unchanged and no attribution is stored.
2026-06-19 11:01:12 -07:00
Vishnu NarayananandGitHub b02e732dd1 fix: throttle DELETE conversation API per account (CW-7356) (#14747)
## Description

A single account deleted ~167,000 conversations via the API in 60
minutes (avg 46 rps, peak 151 rps/min) on 15 Jun 2026 21:00 to 22:00
IST. The cascade through `dependent: :destroy_async` on conversations,
then messages, then reporting_events, created enough job queue and DB
pressure to drive RDS CPU to ~93%, triggering 5xx alerts and
connection-pool timeouts.

This adds a `Rack::Attack` per-account throttle on `DELETE
/api/v1/accounts/:id/conversations/:id`:

- Default 60 req/min per account; configurable via the
`RATE_LIMIT_CONVERSATION_DELETE` env var.


Fixes [CW-7356](https://linear.app/chatwoot/issue/CW-7356)
2026-06-19 15:11:39 +05:30
80b132e155 feat(tiktok): add cloud warning (#14768)
Adds Cloud-only warning on the TikTok connect page while North American
account connections may be temporarily unavailable.

Fixes
https://linear.app/chatwoot/issue/CW-7322/cloud-show-tiktok-north-america-warning-on-connect-page

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-19 12:29:07 +04:00
Sivin VargheseandGitHub 32b63ad0c0 feat: add search to Help Center admin pages (#14772) 2026-06-19 12:41:21 +05:30
Sivin VargheseandGitHub ac15339456 fix: Resolve Firefox input issues and persist advanced filters (#14781)
# Pull Request Template

## Description

This PR fixes a few issues in Global Search and removes a non-functional
control.

* Fixes an issue in Firefox where characters could be dropped while
typing in the search input. The search now uses the latest input value
directly, preventing searches from running one character behind.

* Removes a stale query sync in `SearchHeader` that could overwrite
recently typed characters during the debounce window, causing the input
to appear out of sync.

* Fixes advanced search filters being removed from the URL on page
reload. The search page now waits for account data to load before
parsing URL parameters, ensuring agent, inbox, and date range filters
are preserved.

* Removes the non-functional "Sort by relevance" button from the search
tabs bar, as it was disabled and had no effect.


Fixes https://github.com/chatwoot/chatwoot/issues/14684
[CW-7305](https://linear.app/chatwoot/issue/CW-7305/global-search-drops-characters-while-typing-in-firefox-query-truncated)
[CW-7370](https://linear.app/chatwoot/issue/CW-7370/remove-non-functional-relevance-placeholder-button-from-global-search)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screencast

**Before**


https://github.com/user-attachments/assets/48d72a4e-20f4-4f24-91f4-2c9a9c065eba




**After**


https://github.com/user-attachments/assets/0e41a803-b4fd-410d-a739-549f72d418d6





## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-19 10:46:47 +04:00
Sony Mathew f99187646e Merge branch 'release/4.15.1' into develop 2026-06-17 18:07:48 +05:30
Sony Mathew 5ce6e00730 Bump version to 4.15.1 2026-06-17 18:06:42 +05:30
Vishnu NarayananandGitHub f66b551c7d revert: Sidebar unread counts for filters (CW-7262) (#14769)
## Description

Reverts [#14726](https://github.com/chatwoot/chatwoot/pull/14726)
(\"feat: Add sidebar unread counts for filters (CW-7262)\"), which
shipped in 4.15.0.

After 4.15.0 rolled out to prod the unread-counts-for-filters code path
caused a cascading incident:

- `Counter#ensure_filters_cache!` fires on every `/unread_counts/index`
and `update_last_seen` request.
- On cache miss it calls `Builder#build_filters_for!`, which:
- invokes `store.clear_user_filters!` -> `delete_matching` -> a Redis
`SCAN_each` over a per-user pattern keyspace, and
- runs 4 fresh SQL passes per user (mentions, participating, unattended,
and per-folder `Conversations::FilterService` queries).
- Threads blocked in the SCAN held their DB connections, the connection
pool exhausted, Sidekiq jobs were discarded with
`ActiveJob::DeserializationError: could not obtain a connection from the
pool`, and the enqueued queue blew past 200K.


Related:
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-17 14:58:48 +04:00
Sony Mathew c12e1f8348 Merge branch 'release/4.15.0' into develop 2026-06-17 00:11:59 +05:30
134 changed files with 1718 additions and 1729 deletions
+12 -12
View File
@@ -170,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.3.1)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -193,7 +193,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.7)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -274,8 +274,8 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.8.3)
bigdecimal (~> 3.0)
dry-types (1.9.1)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -304,7 +304,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.2)
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -474,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.8)
json (2.19.9)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -598,14 +598,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.3)
nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
@@ -627,7 +627,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10)
oj (3.17.3)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -674,7 +674,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
+1 -1
View File
@@ -1 +1 @@
4.15.0
4.15.1
@@ -6,6 +6,11 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
# During Meta's sticker webhook transition, a sticker message carries both an `image`
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
return if duplicate_sticker?(attachment, params[:external_url])
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
@@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_attachment_links(attachment_obj)
update_attachment_file_type(attachment_obj)
end
def fetch_attachment_links(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
def duplicate_sticker?(attachment, url)
return false unless attachment['type'].to_sym == :sticker
return false if url.blank?
@message.attachments.any? { |existing| existing.external_url == url }
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
@@ -21,6 +21,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@assignable_agents = @inbox.assignable_agents
end
def assignable_owners
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
@agent_bots = AgentBot.accessible_to(Current.account)
end
def campaigns
@campaigns = @inbox.campaigns
end
+1 -1
View File
@@ -142,7 +142,7 @@ class ConversationFinder
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'participating'
@conversations = @conversations.where(id: current_user.participating_conversations.where(account_id: current_account.id).select(:id))
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.unattended
end
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class MessageReports extends ApiClient {
constructor() {
super('captain/message_reports', { accountScoped: true });
}
}
export default new MessageReports();
@@ -16,6 +16,7 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -25,6 +26,7 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
host: this.url,
});
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId }) {
assignAgent({ conversationId, agentId, assigneeType }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
+4
View File
@@ -15,6 +15,10 @@ class Inboxes extends CacheEnabledApiClient {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}
getAssignableOwners(inboxId) {
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
}
deleteInboxAvatar(inboxId) {
return axios.delete(`${this.url}/${inboxId}/avatar`);
}
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -9,6 +9,7 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('create');
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
@@ -37,6 +38,13 @@ describe('#InboxesAPI', () => {
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
});
it('#getAssignableOwners', () => {
inboxesAPI.getAssignableOwners(2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/2/assignable_owners'
);
});
it('#deleteInboxAvatar', () => {
inboxesAPI.deleteInboxAvatar(2);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
@@ -210,9 +210,9 @@ const handleClick = id => {
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
@@ -223,19 +223,17 @@ const handleClick = id => {
{{ authorName || '-' }}
</span>
</div>
<span
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
>
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
<EmojiIcon
v-if="category?.icon"
:value="category.icon"
:color="category.icon_color"
class="flex-shrink-0 size-4"
/>
{{ categoryName }}
<span class="truncate">{{ categoryName }}</span>
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
@@ -247,7 +245,7 @@ const handleClick = id => {
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1">
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
{{ lastUpdatedAt }}
</span>
</div>
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
>
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<div v-if="activePortalName" class="relative shrink-0 group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
@@ -91,6 +91,9 @@ const togglePortalSwitcher = () => {
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<div class="flex justify-end min-w-0 grow">
<slot name="title-actions" />
</div>
</div>
<slot name="header-actions" />
</div>
@@ -24,6 +24,10 @@ const props = defineProps({
type: Set,
default: () => new Set(),
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
@@ -41,6 +45,7 @@ const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
return (
props.isCategoryArticles &&
!props.isSearching &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
@@ -18,6 +19,7 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
@@ -49,7 +51,12 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const emit = defineEmits([
'pageChange',
'fetchPortal',
'refreshArticles',
'search',
]);
const router = useRouter();
const route = useRoute();
@@ -65,6 +72,9 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const searchQuery = ref(route.query.search || '');
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
const { isEnterprise } = useConfig();
@@ -122,6 +132,7 @@ const updateRoute = newParams => {
categorySlug: newParams.categorySlug ?? categorySlug,
...newParams,
},
query: route.query,
});
};
@@ -145,7 +156,12 @@ const showCategoryHeaderControls = computed(
() => props.isCategoryArticles && !isSwitchingPortal.value
);
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
const getEmptyStateText = type => {
if (isSearching.value) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
}
if (props.isCategoryArticles) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
}
@@ -291,6 +307,19 @@ watch(
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
<template #title-actions>
<Input
v-if="!isSwitchingPortal"
v-model="searchQuery"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
"
type="search"
size="sm"
class="w-full max-w-[16rem] min-w-0"
@input="debouncedSearch"
/>
</template>
<template #header-actions>
<div class="flex items-end justify-between">
<ArticleHeaderControls
@@ -422,6 +451,7 @@ watch(
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:is-searching="isSearching"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@@ -433,7 +463,7 @@ watch(
class="pt-14"
:title="getEmptyStateTitle"
:subtitle="getEmptyStateSubtitle"
:show-button="hasNoArticlesInPortal"
:show-button="hasNoArticlesInPortal && !isSearching"
:button-label="
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
"
@@ -37,10 +37,21 @@ const { t } = useI18n();
const editCategoryDialog = ref(null);
const selectedCategory = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const hasCategories = computed(() => props.categories?.length > 0);
const filteredCategories = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.categories;
return props.categories.filter(category =>
category.name?.toLowerCase().includes(query)
);
});
const hasCategories = computed(() => filteredCategories.value?.length > 0);
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const updateRoute = (newParams, routeName) => {
const { accountId, portalSlug, locale } = route.params;
@@ -115,6 +126,7 @@ const reorderCategories = async reorderedGroup => {
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<CategoryHeaderControls
v-model:search-query="searchQuery"
:categories="categories"
:is-category-articles="false"
:allowed-locales="allowedLocales"
@@ -130,11 +142,18 @@ const reorderCategories = async reorderedGroup => {
</div>
<CategoryList
v-else-if="hasCategories"
:categories="categories"
:categories="filteredCategories"
:disable-drag="isSearching"
@click="openCategoryArticles"
@action="handleAction"
@reorder="reorderCategories"
/>
<CategoryEmptyState
v-else-if="isSearching"
class="pt-14"
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
/>
<CategoryEmptyState
v-else
class="pt-14"
@@ -6,6 +6,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
@@ -27,6 +28,11 @@ const props = defineProps({
const emit = defineEmits(['localeChange', 'newArticle']);
const searchQuery = defineModel('searchQuery', {
type: String,
default: '',
});
const route = useRoute();
const router = useRouter();
const getters = useStoreGetters();
@@ -162,23 +168,34 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div v-if="!hasSelectedCategory" class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
"
type="search"
size="sm"
class="w-48"
/>
<div class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
</div>
<div v-else class="relative flex items-center gap-2">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
@@ -8,6 +8,10 @@ const props = defineProps({
type: Array,
required: true,
},
disableDrag: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'action', 'reorder']);
@@ -15,7 +19,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return localCategories.value?.length > 1;
return !props.disableDrag && localCategories.value?.length > 1;
});
const handleClick = slug => {
@@ -2,8 +2,12 @@
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useI18n } from 'vue-i18n';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
@@ -19,7 +23,10 @@ const props = defineProps({
},
});
const { t } = useI18n();
const addLocaleDialogRef = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -28,6 +35,19 @@ const openAddLocaleDialog = () => {
};
const localeCount = computed(() => props.locales?.length);
const filteredLocales = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.locales;
return props.locales.filter(
locale =>
locale.name?.toLowerCase().includes(query) ||
locale.code?.toLowerCase().includes(query)
);
});
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const hasResults = computed(() => filteredLocales.value?.length > 0);
</script>
<template>
@@ -39,12 +59,21 @@ const localeCount = computed(() => props.locales?.length);
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
<div class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
type="search"
size="sm"
class="w-48"
/>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
</div>
</template>
<template #content>
@@ -54,7 +83,13 @@ const localeCount = computed(() => props.locales?.length);
>
<Spinner />
</div>
<LocaleList v-else :locales="locales" :portal="portal" />
<EmptyStateLayout
v-else-if="isSearching && !hasResults"
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
:show-backdrop="false"
/>
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
</template>
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
</HelpCenterLayout>
@@ -147,8 +147,14 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { replaceInstallationName } = useBranding();
const isCaptainMessage = computed(() => {
const senderType = props.sender?.type ?? props.senderType;
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
});
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
@@ -390,6 +396,10 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
report:
isOnChatwootCloud.value &&
isCaptainMessage.value &&
!isMessageDeleted.value,
};
});
@@ -22,10 +22,6 @@ const props = defineProps({
type: [Number, String],
default: 0,
},
badgeTooltip: {
type: String,
default: '',
},
});
const reauthorizationRequired = computed(() => {
@@ -38,7 +34,7 @@ const reauthorizationRequired = computed(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
<SidebarUnreadBadge :count="badgeCount" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -199,18 +199,6 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const mentionsUnreadCount = useMapGetter(
'conversationUnreadCounts/getMentionsUnreadCount'
);
const participatingUnreadCount = useMapGetter(
'conversationUnreadCounts/getParticipatingUnreadCount'
);
const unattendedUnreadCount = useMapGetter(
'conversationUnreadCounts/getUnattendedUnreadCount'
);
const getFolderUnreadCount = useMapGetter(
'conversationUnreadCounts/getFolderUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -292,19 +280,6 @@ const sortedLabels = computed(() =>
})
);
const unreadBadgeTooltips = computed(() => ({
ALL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.ALL'),
INBOX: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.INBOX'),
LABEL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.LABEL'),
TEAM: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.TEAM'),
FOLDER: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.FOLDER'),
MENTIONS: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.MENTIONS'),
PARTICIPATING: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.PARTICIPATING'),
UNATTENDED: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.UNATTENDED'),
}));
const unreadBadgeTooltip = type => unreadBadgeTooltips.value[type];
const closeMobileSidebar = () => {
if (!props.isMobileSidebarOpen) return;
emit('closeMobileSidebar');
@@ -360,15 +335,12 @@ const menuItems = computed(() => {
label: t('SIDEBAR.ALL_CONVERSATIONS'),
icon: 'i-lucide-inbox',
badgeCount: allUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('ALL'),
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
badgeCount: mentionsUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('MENTIONS'),
icon: 'i-lucide-at-sign',
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
@@ -376,8 +348,6 @@ const menuItems = computed(() => {
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
badgeCount: participatingUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('PARTICIPATING'),
icon: 'i-lucide-user-round-check',
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
@@ -385,8 +355,6 @@ const menuItems = computed(() => {
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
badgeCount: unattendedUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('UNATTENDED'),
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('conversation_unattended'),
@@ -402,8 +370,6 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
badgeCount: getFolderUnreadCount.value(view.id),
badgeTooltip: unreadBadgeTooltip('FOLDER'),
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -419,7 +385,6 @@ const menuItems = computed(() => {
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
badgeTooltip: unreadBadgeTooltip('TEAM'),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -435,7 +400,6 @@ const menuItems = computed(() => {
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
badgeTooltip: unreadBadgeTooltip('INBOX'),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -444,7 +408,6 @@ const menuItems = computed(() => {
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
badgeTooltip: leafProps.badgeTooltip,
}),
})),
},
@@ -460,7 +423,6 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
badgeTooltip: unreadBadgeTooltip('LABEL'),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
@@ -197,10 +197,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge
:count="subChild.badgeCount"
:tooltip="subChild.badgeTooltip"
/>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -223,10 +220,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge
:count="child.badgeCount"
:tooltip="child.badgeTooltip"
/>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -12,7 +12,6 @@ const props = defineProps({
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
badgeTooltip: { type: String, default: '' },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
});
@@ -53,14 +52,14 @@ const TREE_CONNECTOR =
<component
:is="component"
v-if="shouldRenderComponent"
v-bind="{ label, icon, active, badgeCount, badgeTooltip }"
v-bind="{ label, icon, active, badgeCount }"
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
<SidebarUnreadBadge :count="badgeCount" />
</template>
</component>
</Policy>
@@ -3,7 +3,6 @@ import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
tooltip: { type: String, default: '' },
});
const normalizedCount = computed(() => {
@@ -19,7 +18,6 @@ const displayCount = computed(() =>
<template>
<span
v-if="normalizedCount > 0"
v-tooltip.top="tooltip || null"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
>
@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
@@ -29,17 +28,6 @@ describe('ChannelLeaf', () => {
expect(badge.text()).toBe('3');
});
it('passes tooltip copy to unread badge', () => {
const wrapper = mountChannelLeaf({
badgeCount: 3,
badgeTooltip: 'Total unread conversations in this inbox',
});
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
'Total unread conversations in this inbox'
);
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
@@ -1,7 +1,6 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
@@ -45,17 +44,6 @@ describe('SidebarGroupLeaf', () => {
expect(badge.text()).toBe('7');
});
it('passes tooltip copy to unread badge', () => {
const wrapper = mountLeaf({
badgeCount: 7,
badgeTooltip: 'Total unread conversations in this label',
});
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
'Total unread conversations in this label'
);
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
@@ -75,17 +63,14 @@ describe('SidebarGroupLeaf', () => {
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
badgeTooltip: 'Total unread conversations in this inbox',
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
`${leafProps.badgeCount}:${leafProps.badgeTooltip}`
leafProps.badgeCount
),
});
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe(
'4:Total unread conversations in this inbox'
);
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
});
});
@@ -31,6 +31,7 @@ const mockUseMapGetter = (overrides = {}) => {
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -75,6 +76,32 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('includes agent bots when includeAgentBots is true', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableOwners': ref(() => [
{ id: 2, name: 'Agent', assignee_type: 'User' },
{
id: 1,
name: 'Bot',
thumbnail: '',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
},
]),
});
const { agentsList } = useAgentsList(true, true);
expect(agentsList.value).toContainEqual(
expect.objectContaining({
id: 1,
name: 'Bot',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
})
);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -10,14 +10,21 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
export function useAgentsList(
includeNoneAgent = true,
includeAgentBots = false
) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const assignableOwners = useMapGetter(
'inboxAssignableAgents/getAssignableOwners'
);
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
@@ -42,11 +49,22 @@ export function useAgentsList(includeNoneAgent = true) {
return inboxId.value ? assignable.value(inboxId.value) : [];
});
const owners = computed(() => {
return includeAgentBots && inboxId.value
? assignableOwners.value(inboxId.value)
: [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agents = includeAgentBots
? owners.value.filter(owner => owner.assignee_type === 'User')
: assignableAgents.value || [];
const bots = owners.value.filter(
owner => owner.assignee_type === 'AgentBot'
);
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -60,6 +78,7 @@ export function useAgentsList(includeNoneAgent = true) {
return [
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
...filteredAgentsByAvailability,
...bots,
];
});
@@ -304,6 +304,25 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"REPORT_MESSAGE": {
"LABEL": "Report message",
"TITLE": "Report Captain message",
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
"PROBLEM_TYPE": "Problem type",
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
"DESCRIPTION_LABEL": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
"SUBMIT": "Report",
"SUCCESS": "Thanks for reporting. Our team will take a look.",
"ERROR": "Could not report this message. Please try again.",
"REASONS": {
"incorrect_information": "Incorrect information",
"inappropriate_response": "Inappropriate response",
"incomplete_response": "Incomplete response",
"outdated_information": "Outdated information",
"other": "Other"
}
}
},
"SIDEBAR": {
@@ -552,6 +552,7 @@
"LOCALE": {
"ALL": "All locales"
},
"SEARCH_PLACEHOLDER": "Search articles...",
"NEW_ARTICLE": "New article"
},
"EMPTY_STATE": {
@@ -579,6 +580,10 @@
"CATEGORY": {
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
},
"SEARCH": {
"TITLE": "No matching articles",
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
}
},
"BULK_TRANSLATE": {
@@ -624,6 +629,7 @@
"CATEGORY_HEADER": {
"NEW_CATEGORY": "New category",
"EDIT_CATEGORY": "Edit category",
"SEARCH_PLACEHOLDER": "Search categories...",
"CATEGORIES_COUNT": "{n} category | {n} categories",
"BREADCRUMB": {
"CATEGORY_LOCALE": "Categories ({localeCode})",
@@ -634,6 +640,10 @@
"TITLE": "No categories found",
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
},
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching categories",
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
},
"CATEGORY_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles"
},
@@ -691,6 +701,11 @@
"LOCALES_PAGE": {
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
"NEW_LOCALE_BUTTON_TEXT": "New locale",
"SEARCH_PLACEHOLDER": "Search locales...",
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching locales",
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
},
"LOCALE_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
@@ -65,7 +65,8 @@
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
"ERROR_AUTH": "There was an error connecting to TikTok, please try again",
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
@@ -316,16 +316,6 @@
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",
"UNATTENDED_CONVERSATIONS": "Unattended",
"UNREAD_COUNT_TOOLTIP": {
"ALL": "Total unread conversations",
"INBOX": "Total unread conversations in this inbox",
"LABEL": "Total unread conversations in this label",
"TEAM": "Total unread conversations in this team",
"FOLDER": "Total unread conversations in this folder",
"MENTIONS": "Unread conversations where you were mentioned",
"PARTICIPATING": "Unread conversations you're participating in",
"UNATTENDED": "Unread unattended conversations"
},
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
@@ -14,6 +14,7 @@ import {
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
export default {
components: {
@@ -21,6 +22,7 @@ export default {
MenuItem,
ContextMenu,
NextButton,
ReportCaptainMessageDialog,
},
props: {
message: {
@@ -152,6 +154,10 @@ export default {
closeDeleteModal() {
this.showDeleteModal = false;
},
openReportDialog() {
this.handleClose();
this.$refs.reportDialog?.open();
},
},
};
</script>
@@ -243,6 +249,16 @@ export default {
variant="icon"
@click.stop="showCannedResponseModal"
/>
<hr v-if="enabledOptions['report']" />
<MenuItem
v-if="enabledOptions['report']"
:option="{
icon: 'warning',
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
}"
variant="icon"
@click.stop="openReportDialog"
/>
<hr v-if="enabledOptions['delete']" />
<MenuItem
v-if="enabledOptions['delete']"
@@ -255,6 +271,11 @@ export default {
/>
</div>
</ContextMenu>
<ReportCaptainMessageDialog
v-if="enabledOptions['report']"
ref="reportDialog"
:message-id="messageId"
/>
</div>
</template>
@@ -0,0 +1,119 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
const props = defineProps({
messageId: { type: [Number, String], required: true },
});
const { t } = useI18n();
const dialogRef = ref(null);
const isLoading = ref(false);
const REPORT_REASONS = [
'incorrect_information',
'inappropriate_response',
'incomplete_response',
'outdated_information',
'other',
];
const reasonOptions = computed(() =>
REPORT_REASONS.map(value => ({
value,
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
}))
);
const form = reactive({ reportReason: '', description: '' });
const isFormInvalid = computed(() => !form.reportReason);
const resetForm = () => {
form.reportReason = '';
form.description = '';
};
const open = () => {
resetForm();
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleConfirm = async () => {
if (isFormInvalid.value) return;
isLoading.value = true;
try {
await MessageReportsAPI.create({
message_id: props.messageId,
report_reason: form.reportReason,
description: form.description.trim() || null,
});
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
close();
} catch (error) {
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
} finally {
isLoading.value = false;
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
:is-loading="isLoading"
:disable-confirm-button="isFormInvalid"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
</label>
<Select
v-model="form.reportReason"
class="!w-full [&>select]:w-full"
:options="reasonOptions"
:placeholder="
t(
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
)
"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
</label>
<TextArea
v-model="form.description"
class="w-full"
:placeholder="
t(
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
)
"
:max-length="500"
show-character-count
auto-height
/>
</div>
</div>
</Dialog>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { ref, watch, useTemplateRef } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { ROLES } from 'dashboard/constants/permissions';
@@ -30,16 +30,6 @@ const onSelectRecentSearch = query => {
searchQuery.value = query;
onSearch(query);
};
watch(
() => props.initialQuery,
newValue => {
if (searchQuery.value !== newValue) {
searchQuery.value = newValue;
}
},
{ immediate: true }
);
</script>
<template>
@@ -30,10 +30,13 @@ const debouncedEmit = debounce(
500
);
const onInput = () => {
debouncedEmit(searchQuery.value);
const onInput = e => {
// Use the DOM value, not searchQuery.value: the defineModel ref updates a tick
// later, so reading it back here lags one character behind.
const value = e.target.value;
debouncedEmit(value);
if (searchQuery.value.trim()) {
if (value.trim()) {
showRecentSearches.value = false;
} else if (isInputFocused.value) {
showRecentSearches.value = true;
@@ -1,8 +1,6 @@
<script setup>
import { computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
tabs: {
@@ -17,8 +15,6 @@ const props = defineProps({
const emit = defineEmits(['tabChange']);
const { t } = useI18n();
const activeTab = ref(props.selectedTab);
watch(
@@ -51,14 +47,5 @@ const onTabChange = selectedTab => {
:initial-active-tab="activeTab"
@tab-changed="onTabChange"
/>
<Button
:label="t('SEARCH.SORT_BY.RELEVANCE')"
sm
link
slate
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
icon="i-lucide-arrow-up-down"
/>
</div>
</template>
@@ -1,8 +1,9 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useTrack } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
@@ -28,6 +29,7 @@ import SearchResultArticlesList from './SearchResultArticlesList.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { currentAccount } = useAccount();
const { t } = useI18n();
const PER_PAGE = 15; // Results per page
@@ -338,19 +340,26 @@ const onTabChange = tab => {
onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults');
store.dispatch('agents/get');
const parsedFilters = parseURLParams(
route.query,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
);
filters.value = parsedFilters;
// Auto-execute search if query parameter exists
if (route.query.q) {
onSearch(route.query.q);
}
});
// Wait for the account before restoring URL filters: the ADVANCED_SEARCH flag
// derives from account.features (loaded async), and reading it too early strips
// the filter params from the URL. `immediate` covers the already-loaded case.
watch(
() => currentAccount.value?.id,
id => {
if (!id) return;
filters.value = parseURLParams(
route.query,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
);
if (route.query.q) {
onSearch(route.query.q);
}
},
{ immediate: true }
);
onUnmounted(() => {
query.value = '';
store.dispatch('conversationSearch/clearSearchResults');
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList();
const { agentsList } = useAgentsList(true, true);
return {
agentsList,
};
@@ -81,18 +81,27 @@ export default {
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
const assignee = this.currentChat.meta.assignee;
if (!assignee) return assignee;
return {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
};
},
set(agent) {
const agentId = agent ? agent.id : null;
const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -158,6 +167,12 @@ export default {
return false;
},
},
mounted() {
this.$store.dispatch(
'inboxAssignableAgents/fetchAssignableOwners',
this.currentChat.inbox_id
);
},
methods: {
onSelfAssign() {
const {
@@ -174,6 +189,7 @@ export default {
account_id,
availability_status,
available_name,
assignee_type: 'User',
email,
id,
name,
@@ -183,7 +199,14 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
const currentAssigneeType = this.assignedAgent?.assignee_type || 'User';
const selectedAssigneeType = selectedItem.assignee_type || 'User';
if (
this.assignedAgent &&
this.assignedAgent.id === selectedItem.id &&
currentAssigneeType === selectedAssigneeType
) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -1,15 +1,17 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const pageNumber = ref(1);
const searchQuery = ref(route.query.search || '');
const allArticles = useMapGetter('articles/allArticles');
const articlesSortedByPosition = useMapGetter(
@@ -74,6 +76,7 @@ const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
status: status.value,
authorId: author.value,
categorySlug: selectedCategorySlug.value,
query: searchQuery.value || undefined,
});
};
@@ -81,6 +84,15 @@ const onPageChange = pageNumberParam => {
fetchArticles({ pageNumber: pageNumberParam });
};
const onSearch = query => {
searchQuery.value = query;
pageNumber.value = 1;
router.replace({
query: { ...route.query, search: query || undefined },
});
fetchArticles({ pageNumber: 1 });
};
const fetchPortalAndItsCategories = async locale => {
await store.dispatch('portals/index');
const selectedPortalParam = {
@@ -118,6 +130,7 @@ watch(
:portal-meta="portalMeta"
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@search="onSearch"
@fetch-portal="fetchPortalAndItsCategories"
@refresh-articles="fetchArticles"
/>
@@ -3,8 +3,12 @@ import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useAccount } from 'dashboard/composables/useAccount';
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const hasError = ref(false);
const errorStateMessage = ref('');
@@ -56,23 +60,37 @@ const requestAuthorization = async () => {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
</h6>
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
</p>
<Button
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
lg
icon="i-ri-tiktok-line"
:disabled="isRequestingAuthorization"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
@click="requestAuthorization()"
/>
<div class="flex flex-col items-center w-full max-w-2xl">
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
</h6>
<p class="max-w-xl py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
</p>
<Button
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
lg
icon="i-ri-tiktok-line"
:disabled="isRequestingAuthorization"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
@click="requestAuthorization()"
/>
<Banner v-if="isOnChatwootCloud" color="amber" class="w-full mt-6">
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.TIKTOK.NORTH_AMERICA_WARNING') }}
</span>
</div>
</Banner>
</div>
</div>
</div>
</div>
@@ -6,10 +6,6 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
const normalizeCount = count => {
@@ -41,18 +37,6 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
getMentionsUnreadCount($state) {
return $state.mentionsCount;
},
getParticipatingUnreadCount($state) {
return $state.participatingCount;
},
getUnattendedUnreadCount($state) {
return $state.unattendedCount;
},
getFolderUnreadCount: $state => folderId => {
return $state.folders[String(folderId)] || 0;
},
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -62,9 +46,6 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
getFolderUnreadCounts($state) {
return $state.folders;
},
};
export const actions = {
@@ -87,10 +68,6 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
$state.mentionsCount = normalizeCount(payload.mentions_count);
$state.participatingCount = normalizeCount(payload.participating_count);
$state.unattendedCount = normalizeCount(payload.unattended_count);
$state.folders = normalizeCounts(payload.folders);
},
};
@@ -208,23 +208,31 @@ const actions = {
}
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
assigneeType,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
setCurrentChatAssignee(
{ commit },
{ conversationId, assignee, assigneeType }
) {
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -108,10 +108,16 @@ export const mutations = {
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
chat.meta.assignee_type = assigneeType;
if (assigneeType === 'AgentBot') {
chat.status = 'pending';
} else if (assignee) {
chat.status = 'open';
}
}
},
@@ -7,7 +7,7 @@ import types from '../../mutation-types';
export const actions = {
index: async (
{ commit },
{ pageNumber, portalSlug, locale, status, authorId, categorySlug }
{ pageNumber, portalSlug, locale, status, authorId, categorySlug, query }
) => {
try {
commit(types.SET_UI_FLAG, { isFetching: true });
@@ -18,6 +18,7 @@ export const actions = {
status,
authorId,
categorySlug,
query,
});
const payload = camelcaseKeys(data.payload);
const meta = camelcaseKeys(data.meta);
@@ -1,7 +1,9 @@
import AssignableAgentsAPI from '../../api/assignableAgents';
import InboxesAPI from '../../api/inboxes';
const state = {
records: {},
ownerRecords: {},
uiFlags: {
isFetching: false,
},
@@ -10,6 +12,7 @@ const state = {
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
SET_INBOX_ASSIGNABLE_OWNERS: 'SET_INBOX_ASSIGNABLE_OWNERS',
};
export const getters = {
@@ -18,6 +21,7 @@ export const getters = {
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getAssignableOwners: $state => inboxId => $state.ownerRecords[inboxId] || [],
getUIFlags($state) {
return $state.uiFlags;
},
@@ -40,6 +44,15 @@ export const actions = {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
}
},
async fetchAssignableOwners({ commit }, inboxId) {
const {
data: { payload },
} = await InboxesAPI.getAssignableOwners(inboxId);
commit(types.SET_INBOX_ASSIGNABLE_OWNERS, {
inboxId,
owners: payload,
});
},
};
export const mutations = {
@@ -55,6 +68,12 @@ export const mutations = {
[inboxId]: members,
};
},
[types.SET_INBOX_ASSIGNABLE_OWNERS]: ($state, { inboxId, owners }) => {
$state.ownerRecords = {
...$state.ownerRecords,
[inboxId]: owners,
};
},
};
export default {
@@ -57,32 +57,10 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
folders: { 8: 9 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
expect(getters.getFolderUnreadCounts(state)).toEqual({ 8: 9 });
});
it('returns mentions, participating, unattended, and folder unread counts', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 3,
participatingCount: 4,
unattendedCount: 5,
folders: { 7: 8 },
};
expect(getters.getMentionsUnreadCount(state)).toBe(3);
expect(getters.getParticipatingUnreadCount(state)).toBe(4);
expect(getters.getUnattendedUnreadCount(state)).toBe(5);
expect(getters.getFolderUnreadCount(state)(7)).toBe(8);
expect(getters.getFolderUnreadCount(state)('7')).toBe(8);
expect(getters.getFolderUnreadCount(state)(8)).toBe(0);
});
});
@@ -21,13 +21,6 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
mentions_count: '8',
participating_count: 9,
unattended_count: '10',
folders: {
11: '12',
12: 0,
},
});
expect(state).toEqual({
@@ -35,10 +28,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 11: 12 },
});
});
@@ -48,10 +37,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 10: 11 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -61,10 +46,6 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
});
});
@@ -357,11 +357,12 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1 }
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
});
});
});
@@ -371,6 +372,7 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
@@ -699,11 +699,11 @@ describe('#mutations', () => {
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent to the correct conversation by ID', () => {
it('should assign agent bot to the correct conversation by ID', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [
{ id: 1, meta: {} },
{ id: 1, meta: {}, status: 'open' },
{ id: 2, meta: {} },
],
selectedChatId: 2,
@@ -712,10 +712,28 @@ describe('#mutations', () => {
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'AgentBot',
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
expect(state.allConversations[0].status).toEqual('pending');
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
it('should open the conversation when assigning a user', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {}, status: 'pending' }],
};
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'User',
});
expect(state.allConversations[0].status).toEqual('open');
});
});
describe('#ASSIGN_PRIORITY', () => {
@@ -1,12 +1,18 @@
import axios from 'axios';
import InboxesAPI from 'dashboard/api/inboxes';
import { actions, types } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
vi.mock('dashboard/api/inboxes');
describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
@@ -33,4 +39,20 @@ describe('#actions', () => {
]);
});
});
describe('#fetchAssignableOwners', () => {
it('stores assignable owners for the inbox', async () => {
InboxesAPI.getAssignableOwners.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetchAssignableOwners({ commit }, 1);
expect(InboxesAPI.getAssignableOwners).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_OWNERS, {
inboxId: 1,
owners: agentsData,
});
});
});
});
@@ -13,4 +13,16 @@ describe('#mutations', () => {
expect(state.records).toEqual({ 1: agentsData });
});
});
describe('#SET_INBOX_ASSIGNABLE_OWNERS', () => {
it('adds inbox owners to ownerRecords', () => {
const state = { ownerRecords: {} };
mutations[types.SET_INBOX_ASSIGNABLE_OWNERS](state, {
owners: [...agentsData],
inboxId: 1,
});
expect(state.ownerRecords).toEqual({ 1: agentsData });
});
});
});
@@ -67,7 +67,13 @@ export default {
this.$refs.searchbar.focus();
},
isActive(option) {
return this.selectedItems.some(item => item && option.id === item.id);
return this.selectedItems.some(item => {
if (!item || option.id !== item.id) return false;
return (
(option.assignee_type || 'User') === (item.assignee_type || 'User')
);
});
},
},
};
@@ -88,7 +94,10 @@ export default {
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
<div class="w-full max-h-[10rem]">
<WootDropdownMenu>
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
<WootDropdownItem
v-for="option in filteredOptions"
:key="`${option.assignee_type || 'User'}-${option.id}`"
>
<NextButton
slate
:variant="isActive(option) ? 'faded' : 'ghost'"
+2 -2
View File
@@ -91,10 +91,10 @@ class ActionCableListener < BaseListener
end
def conversation_unread_count_changed(event)
account, inbox_members, include_admins = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
tokens = include_admins ? user_tokens(account, inbox_members) : inbox_members.pluck(:pubsub_token)
tokens = user_tokens(account, inbox_members)
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
end
+1 -1
View File
@@ -181,7 +181,7 @@ class Account < ApplicationRecord
end
def clear_unread_conversation_counts_cache
::Conversations::UnreadCounts::Store.clear_all_account!(id)
::Conversations::UnreadCounts::Store.clear_account!(id)
end
trigger.after(:insert).for_each(:row) do
-9
View File
@@ -39,7 +39,6 @@ class AccountUser < ApplicationRecord
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
validates :user_id, uniqueness: { scope: :account_id }
@@ -80,14 +79,6 @@ class AccountUser < ApplicationRecord
def update_presence_in_redis
OnlineStatusTracker.set_status(account.id, user.id, availability)
end
def unread_filter_access_changed?
destroyed? || previous_changes.key?('role') || previous_changes.key?('custom_role_id')
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
AccountUser.prepend_mod_with('AccountUser')
+1
View File
@@ -30,6 +30,7 @@ module CacheKeys
update_cache_key_for_account(id, model.name.underscore)
end
::Conversations::UnreadCounts::Store.clear_account!(id)
dispatch_cache_update_event
end
-5
View File
@@ -28,7 +28,6 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -39,8 +38,4 @@ class ConversationParticipant < ApplicationRecord
def ensure_inbox_access
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
-9
View File
@@ -22,19 +22,10 @@ class CustomFilter < ApplicationRecord
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
after_commit :notify_unread_filter_counts_changed, on: [:create, :update, :destroy]
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
private
def notify_unread_filter_counts_changed
return unless conversation?
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
-12
View File
@@ -23,9 +23,7 @@ class InboxMember < ApplicationRecord
belongs_to :inbox
after_create :add_agent_to_round_robin
before_destroy :cache_unread_filter_notification_context
after_destroy :remove_agent_from_round_robin
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -36,16 +34,6 @@ class InboxMember < ApplicationRecord
def remove_agent_from_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
end
def cache_unread_filter_notification_context
@unread_filter_account = inbox&.account
@unread_filter_user = user
end
def notify_unread_filter_counts_changed
account = @unread_filter_account || inbox&.account
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: @unread_filter_user || user).perform
end
end
InboxMember.include_mod_with('Audit::InboxMember')
-5
View File
@@ -32,7 +32,6 @@ class Mention < ApplicationRecord
belongs_to :user
after_commit :notify_mentioned_user
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
scope :latest, -> { order(mentioned_at: :desc) }
@@ -56,8 +55,4 @@ class Mention < ApplicationRecord
def notify_mentioned_user
Rails.configuration.dispatcher.dispatch(CONVERSATION_MENTIONED, Time.zone.now, user: user, conversation: conversation)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
+4
View File
@@ -30,6 +30,10 @@ class InboxPolicy < ApplicationPolicy
true
end
def assignable_owners?
true
end
def agent_bot?
true
end
@@ -16,6 +16,7 @@ class Conversations::AssignmentService
def assign_agent
conversation.assignee = assignee
conversation.assignee_agent_bot = nil
conversation.status = :open if assignee
conversation.save!
assignee
end
@@ -25,6 +26,7 @@ class Conversations::AssignmentService
conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot
conversation.status = :pending
conversation.save!
agent_bot
end
+2 -8
View File
@@ -7,7 +7,8 @@ class Conversations::FilterService < FilterService
end
def perform
@conversations = filtered_relation
validate_query_operator
@conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
@@ -22,13 +23,6 @@ class Conversations::FilterService < FilterService
}
end
def filtered_relation
validate_query_operator
return base_relation if @params[:payload].blank?
query_builder(@filters['conversations'])
end
def base_relation
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
@@ -6,23 +6,13 @@ class Conversations::UnreadCounts::BroadcastScope
end
def perform
return user_scope if user.present?
return [conversation.account, conversation.inbox.members, true] if conversation.present?
return [conversation.account, conversation.inbox.members] if conversation.present?
deleted_conversation_scope
end
private
def user
event.data[:user]
end
def user_scope
account = event.data[:account] || user.account
[account, [user], false]
end
def conversation
event.data[:conversation]
end
@@ -34,7 +24,7 @@ class Conversations::UnreadCounts::BroadcastScope
account = Account.find_by(id: conversation_data[:account_id])
return if account.blank?
[account, inbox_members_for(account, conversation_data[:inbox_id]), true]
[account, inbox_members_for(account, conversation_data[:inbox_id])]
end
def inbox_members_for(account, inbox_id)
@@ -1,15 +0,0 @@
module Conversations::UnreadCounts::BuildLockKeys
private
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def filters_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account.id, user_id: user.id)
end
end
@@ -1,14 +1,5 @@
class Conversations::UnreadCounts::Builder
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
RELATIVE_DATE_FILTER_OPERATOR = 'days_before'.freeze
BATCH_SIZE = 1000
FILTER_ERRORS = [
ActiveRecord::StatementInvalid,
CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue
].freeze
attr_reader :account
@@ -33,37 +24,10 @@ class Conversations::UnreadCounts::Builder
build_assignment!
end
def build_filters_for!(user)
store.clear_user_filters!(account.id, user.id)
version_snapshot = store.filter_version_snapshot(account.id, user.id)
custom_filters = conversation_custom_filters(user).to_a
store.add_filter_memberships(
account_id: account.id,
user_id: user.id,
filters: {
mentions: mentioned_unread_conversation_ids(user),
participating: participating_unread_conversation_ids(user),
unattended: unattended_unread_conversation_ids(user)
},
folders: folder_unread_conversation_ids(custom_filters, user)
)
mark_filters_ready_if_current(user, custom_filters, version_snapshot)
end
private
def mark_filters_ready_if_current(user, custom_filters, version_snapshot)
store.mark_filters_ready_if_current!(
account.id,
user.id,
version_snapshot: version_snapshot,
expires_in: filters_ready_ttl(custom_filters)
)
end
def write_memberships(assignment:)
unread_conversations(open_only: true).in_batches(of: BATCH_SIZE) do |relation|
unread_conversations.in_batches(of: BATCH_SIZE) do |relation|
columns = %i[id inbox_id assignee_id cached_label_list team_id]
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
{
@@ -79,99 +43,14 @@ class Conversations::UnreadCounts::Builder
end
end
def mentioned_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.joins(:mentions)
.where(mentions: { account_id: account.id, user_id: user.id })
.pluck(:id)
end
def participating_unread_conversation_ids(user)
participating_visible_unread_conversations(user, open_only: true)
.where(id: user.participating_conversations.where(account_id: account.id).select(:id))
.pluck(:id)
end
def unattended_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.unattended
.pluck(:id)
end
def folder_unread_conversation_ids(custom_filters, user)
custom_filters.each_with_object({}) do |custom_filter, result|
result[custom_filter.id] = unread_ids_for_filter(custom_filter, user)
rescue *FILTER_ERRORS
next
end
end
def conversation_custom_filters(user)
account.custom_filters.where(user: user, filter_type: :conversation)
end
def filters_ready_ttl(custom_filters)
return Conversations::UnreadCounts::READY_TTL unless relative_date_filter?(custom_filters)
seconds_until_next_day
end
def relative_date_filter?(custom_filters)
custom_filters.any? do |custom_filter|
Array(custom_filter.query.with_indifferent_access[:payload]).any? do |condition|
condition[:filter_operator] == RELATIVE_DATE_FILTER_OPERATOR
end
end
end
def seconds_until_next_day
[(Time.zone.tomorrow.beginning_of_day - Time.current).ceil, 1].max
end
def unread_ids_for_filter(custom_filter, user)
filter_relation = ::Conversations::FilterService.new(custom_filter.query.with_indifferent_access, user, account).filtered_relation
filter_relation
.where(id: unread_conversations(open_only: false).select(:id))
.reorder(nil)
.distinct
.pluck(:id)
end
def unread_conversations(open_only:)
conversations = account.conversations
conversations = conversations.open if open_only
conversations.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def visible_unread_conversations(user, open_only:)
::Conversations::PermissionFilterService.new(unread_conversations(open_only: open_only), user, account).perform
end
def participating_visible_unread_conversations(user, open_only:)
return inbox_visible_unread_conversations(user, open_only: open_only) if custom_role_participating_permission?(user)
visible_unread_conversations(user, open_only: open_only)
end
def inbox_visible_unread_conversations(user, open_only:)
conversations = unread_conversations(open_only: open_only)
return conversations if account_user_for(user)&.administrator?
conversations.where(inbox: user.inboxes.where(account_id: account.id))
end
def custom_role_participating_permission?(user)
account_user = account_user_for(user)
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?(PARTICIPATING_PERMISSION)
end
def account_user_for(user)
account.account_users.find_by(user_id: user.id)
def unread_conversations
account.conversations
.open
.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def unread_since_last_seen_condition
@@ -1,7 +1,4 @@
class Conversations::UnreadCounts::Counter
include ::Conversations::UnreadCounts::BuildLockKeys
include ::Conversations::UnreadCounts::FilterCounter
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
@@ -21,38 +18,31 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
ensure_filters_cache!
inbox_counts = unread_inbox_counts
filter_counts = unread_filter_counts
{
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts,
mentions_count: filter_counts[:mentions_count],
participating_count: filter_counts[:participating_count],
unattended_count: filter_counts[:unattended_count],
folders: filter_counts[:folders]
teams: unread_team_counts
}
end
private
def ensure_base_cache!
ensure_cache_ready!(ready: -> { store.base_ready?(account.id) }, lock_key: base_build_lock_key) { builder.build_base! }
ensure_cache_ready!(
ready: -> { store.base_ready?(account.id) },
lock_key: base_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_base! }
end
def ensure_assignment_cache!
ensure_cache_ready!(ready: -> { store.assignment_ready?(account.id) }, lock_key: assignment_build_lock_key) { builder.build_assignment! }
end
def ensure_filters_cache!
ensure_cache_ready!(
ready: -> { store.filters_ready?(account.id, user.id) },
lock_key: filters_build_lock_key
) { builder.build_filters_for!(user) }
ready: -> { store.assignment_ready?(account.id) },
lock_key: assignment_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
end
def ensure_cache_ready!(ready:, lock_key:)
@@ -61,10 +51,9 @@ class Conversations::UnreadCounts::Counter
loop do
return if ready.call
lock_acquired = lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
return if ready.call
return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
wait_for_cache_ready(ready) unless lock_acquired
wait_for_cache_ready(ready)
end
end
@@ -73,6 +62,14 @@ class Conversations::UnreadCounts::Counter
sleep BUILD_WAIT_INTERVAL until ready.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
end
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def unread_inbox_counts
counts_for_grouped_keys(visible_inbox_ids.index_with { |inbox_id| inbox_keys_for_mode(inbox_id) })
end
@@ -197,14 +194,10 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
{ all_count: 0, inboxes: {}, labels: {}, teams: {}, mentions_count: 0, participating_count: 0, unattended_count: 0, folders: {} }
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
end
def store
::Conversations::UnreadCounts::Store
end
def builder
@builder ||= ::Conversations::UnreadCounts::Builder.new(account)
end
end
@@ -1,40 +0,0 @@
module Conversations::UnreadCounts::FilterCounter
private
def unread_filter_counts
keys = user_filter_keys
counts_by_key = store.counts_for_keys(keys.values + folder_keys.values)
{
mentions_count: counts_by_key[keys[:mentions]].to_i,
participating_count: counts_by_key[keys[:participating]].to_i,
unattended_count: counts_by_key[keys[:unattended]].to_i,
folders: folder_counts(counts_by_key)
}
end
def user_filter_keys
{
mentions: store.user_mentions_key(account.id, user.id),
participating: store.user_participating_key(account.id, user.id),
unattended: store.user_unattended_key(account.id, user.id)
}
end
def conversation_folder_ids
@conversation_folder_ids ||= account.custom_filters.where(user: user, filter_type: :conversation).pluck(:id)
end
def folder_keys
@folder_keys ||= conversation_folder_ids.index_with do |custom_filter_id|
store.user_folder_key(account.id, user.id, custom_filter_id)
end
end
def folder_counts(counts_by_key)
folder_keys.each_with_object({}) do |(custom_filter_id, key), result|
count = counts_by_key[key].to_i
result[custom_filter_id.to_s] = count if count.positive?
end
end
end
@@ -3,13 +3,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
if message.incoming?
refresh(message.conversation)
else
notify_filter_counts_changed(message.conversation)
end
refresh(message.conversation)
end
def conversation_status_changed(event)
@@ -18,21 +15,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
end
def conversation_updated(event)
return unless label_changed?(event.data[:changed_attributes])
conversation, = extract_conversation_and_account(event)
return unless conversation.account.feature_enabled?('conversation_unread_counts')
if label_changed?(event.data[:changed_attributes])
refresh(conversation, event.data[:changed_attributes])
elsif folder_filter_changed?(event.data[:changed_attributes])
notify_filter_counts_changed(conversation)
end
end
def conversation_contact_changed(event)
conversation, = extract_conversation_and_account(event)
return unless conversation.account.feature_enabled?('conversation_unread_counts')
notify_filter_counts_changed(conversation)
refresh(conversation, event.data[:changed_attributes])
end
def assignee_changed(event)
@@ -51,10 +37,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
filters_cleared = store.clear_filter_caches!(account.id)
memberships_removed = remove_deleted_conversation(account, conversation_data)
return unless memberships_removed || filters_cleared
return unless remove_deleted_conversation(account, conversation_data)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
end
@@ -107,16 +90,6 @@ class Conversations::UnreadCounts::Listener < BaseListener
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
end
def folder_filter_changed?(changed_attributes)
changed_attributes.present?
end
def notify_filter_counts_changed(conversation)
return unless store.clear_filter_caches!(conversation.account_id)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
end
def store
::Conversations::UnreadCounts::Store
end
@@ -11,9 +11,7 @@ class Conversations::UnreadCounts::Notifier
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
filters_cleared = ::Conversations::UnreadCounts::Store.clear_filter_caches!(conversation.account_id)
memberships_refreshed = ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
return false unless memberships_refreshed || filters_cleared
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
true
@@ -1,6 +1,5 @@
class Conversations::UnreadCounts::Store
extend ::Conversations::UnreadCounts::StoreKeys
extend ::Conversations::UnreadCounts::UserFilterStore
class << self
def base_ready?(account_id)
@@ -20,10 +19,6 @@ class Conversations::UnreadCounts::Store
end
def clear_account!(account_id)
account_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
end
def clear_all_account!(account_id)
delete_matching("#{account_prefix(account_id)}::*")
end
@@ -183,11 +178,9 @@ class Conversations::UnreadCounts::Store
end
def delete_matching(pattern)
deleted = 0
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
deleted += 1 if Redis::Alfred.delete(key)
Redis::Alfred.delete(key)
end
deleted.positive?
end
def assignment_key_patterns(account_id)
@@ -202,16 +195,5 @@ class Conversations::UnreadCounts::Store
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
]
end
def account_key_patterns(account_id)
prefix = account_prefix(account_id)
[
base_ready_key(account_id),
assignment_ready_key(account_id),
"#{prefix}::INBOX::*",
"#{prefix}::LABEL::*::INBOX::*",
"#{prefix}::TEAM::*::INBOX::*"
]
end
end
end
@@ -40,20 +40,4 @@ module Conversations::UnreadCounts::StoreKeys
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
end
def user_mentions_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_MENTIONS, account_id: account_id, user_id: user_id)
end
def user_participating_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_PARTICIPATING, account_id: account_id, user_id: user_id)
end
def user_unattended_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_UNATTENDED, account_id: account_id, user_id: user_id)
end
def user_folder_key(account_id, user_id, custom_filter_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FOLDER, account_id: account_id, user_id: user_id, custom_filter_id: custom_filter_id)
end
end
@@ -1,19 +0,0 @@
class Conversations::UnreadCounts::UserFilterNotifier
include Events::Types
attr_reader :account, :user
def initialize(account:, user:)
@account = account
@user = user
end
def perform
return false if account.blank? || user.blank?
return false unless account.feature_enabled?('conversation_unread_counts')
::Conversations::UnreadCounts::Store.clear_user_filters!(account.id, user.id)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, account: account, user: user)
true
end
end
@@ -1,100 +0,0 @@
module Conversations::UnreadCounts::UserFilterStore
USER_FILTER_KEY_SUFFIXES = [
'READY::FILTERS',
'MENTIONS',
'PARTICIPATING',
'UNATTENDED',
'FOLDER::*'
].freeze
def filters_ready?(account_id, user_id)
Redis::Alfred.exists?(filters_ready_key(account_id, user_id))
end
def mark_filters_ready!(account_id, user_id, expires_in: Conversations::UnreadCounts::READY_TTL)
Redis::Alfred.set(filters_ready_key(account_id, user_id), Time.current.to_i, ex: expires_in)
end
def mark_filters_ready_if_current!(account_id, user_id, version_snapshot:, expires_in: Conversations::UnreadCounts::READY_TTL)
return false unless filter_version_snapshot(account_id, user_id) == version_snapshot
mark_filters_ready!(account_id, user_id, expires_in: expires_in)
end
def filter_version_snapshot(account_id, user_id)
{
account: filter_version(account_filter_version_key(account_id)),
user: filter_version(user_filter_version_key(account_id, user_id))
}
end
def clear_filter_caches!(account_id)
bump_filter_version(account_filter_version_key(account_id))
delete_user_filter_patterns("#{account_prefix(account_id)}::USER::*")
end
def clear_user_filters!(account_id, user_id)
bump_filter_version(user_filter_version_key(account_id, user_id))
delete_user_filter_patterns(user_filter_prefix(account_id, user_id))
end
def add_filter_memberships(account_id:, user_id:, filters:, folders:)
memberships = {
user_mentions_key(account_id, user_id) => filters[:mentions],
user_participating_key(account_id, user_id) => filters[:participating],
user_unattended_key(account_id, user_id) => filters[:unattended]
}
folders.each do |custom_filter_id, conversation_ids|
memberships[user_folder_key(account_id, user_id, custom_filter_id)] = conversation_ids
end
write_membership_sets(memberships)
end
private
def filters_ready_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_READY, account_id: account_id, user_id: user_id)
end
def account_filter_version_key(account_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
end
def user_filter_version_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: user_id)
end
def user_filter_prefix(account_id, user_id)
"#{account_prefix(account_id)}::USER::#{user_id}"
end
def filter_version(key)
Redis::Alfred.get(key).to_i
end
def bump_filter_version(key)
Redis::Alfred.incr(key).tap { Redis::Alfred.expire(key, Conversations::UnreadCounts::SET_TTL) }
end
def delete_user_filter_patterns(prefix)
deleted = false
USER_FILTER_KEY_SUFFIXES.each do |suffix|
deleted = delete_matching("#{prefix}::#{suffix}") || deleted
end
deleted
end
def write_membership_sets(memberships)
memberships = memberships.transform_values { |conversation_ids| Array(conversation_ids).compact_blank }
memberships = memberships.select { |_key, conversation_ids| conversation_ids.present? }
return if memberships.blank?
Redis::Alfred.pipelined do |pipeline|
memberships.each do |key, conversation_ids|
conversation_ids.each { |conversation_id| pipeline.sadd(key, conversation_id) }
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
end
end
end
end
+1 -11
View File
@@ -11,7 +11,7 @@ class FilterService
}.with_indifferent_access
def initialize(params, user)
@params = normalize_params(params)
@params = params
@user = user
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@@ -140,16 +140,6 @@ class FilterService
private
def normalize_params(params)
return params unless params.respond_to?(:with_indifferent_access)
normalized_params = params.with_indifferent_access
normalized_params[:payload] = Array(normalized_params[:payload]).map do |condition|
condition.respond_to?(:with_indifferent_access) ? condition.with_indifferent_access : condition
end
normalized_params
end
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
@@ -0,0 +1,15 @@
owners = @assignable_agents.map { |agent| { type: 'User', resource: agent } }
owners += @agent_bots.map { |agent_bot| { type: 'AgentBot', resource: agent_bot } }
json.payload do
json.array! owners do |owner|
if owner[:type] == 'User'
json.partial! 'api/v1/models/agent', formats: [:json], resource: owner[:resource]
json.assignee_type 'User'
else
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: owner[:resource]
json.assignee_type 'AgentBot'
json.icon 'i-lucide-bot'
end
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.15.0'
version: '4.15.1'
development:
<<: *shared
+9
View File
@@ -203,6 +203,15 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
## Prevent abuse of conversation delete API (per account)
throttle('/api/v1/accounts/:account_id/conversations/:id DELETE',
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_DELETE', '60').to_i, period: 1.minute) do |req|
next unless req.delete?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/conversations/(?<id>\d+)/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent Abuse of attachment upload APIs ##
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
+2
View File
@@ -74,6 +74,7 @@ Rails.application.routes.draw do
resources :scenarios
end
resources :assistant_responses
resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
@@ -253,6 +254,7 @@ Rails.application.routes.draw do
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
get :assignable_agents, on: :member
get :assignable_owners, on: :member
get :campaigns, on: :member
get :agent_bot, on: :member
post :set_agent_bot, on: :member
@@ -0,0 +1,14 @@
class CreateCaptainMessageReports < ActiveRecord::Migration[7.1]
def change
create_table :captain_message_reports do |t|
t.references :account, null: false
t.references :conversation, null: false
t.references :message, null: false
t.references :user, null: false
t.string :report_reason, null: false
t.text :description
t.timestamps
end
end
end
+16 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -399,6 +399,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
end
create_table "captain_message_reports", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "conversation_id", null: false
t.bigint "message_id", null: false
t.bigint "user_id", null: false
t.string "report_reason", null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_captain_message_reports_on_account_id"
t.index ["conversation_id"], name: "index_captain_message_reports_on_conversation_id"
t.index ["message_id"], name: "index_captain_message_reports_on_message_id"
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
end
create_table "captain_scenarios", force: :cascade do |t|
t.string "title"
t.text "description"
@@ -0,0 +1,38 @@
class Api::V1::Accounts::Captain::MessageReportsController < Api::V1::Accounts::BaseController
before_action :ensure_cloud_installation
before_action :set_message
before_action :authorize_conversation
before_action :ensure_captain_message
def create
@message_report = @message.message_reports.create!(
user: Current.user,
report_reason: permitted_params[:report_reason],
description: permitted_params[:description]
)
end
private
def ensure_cloud_installation
render json: { error: 'Not available' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
def set_message
@message = Current.account.messages.find(permitted_params[:message_id])
end
def authorize_conversation
authorize @message.conversation, :show?
end
def ensure_captain_message
return if @message.sender_type == 'Captain::Assistant'
render json: { error: 'Only Captain messages can be reported' }, status: :unprocessable_entity
end
def permitted_params
params.permit(:message_id, :report_reason, :description)
end
end
@@ -49,7 +49,7 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
end
def destroy
@company.destroy!
Companies::DeleteJob.perform_later(company_id: @company.id)
head :ok
end
@@ -1,6 +1,20 @@
module Enterprise::Api::V1::AccountsSettings
def create
super
record_marketing_attribution
end
private
def record_marketing_attribution
return if current_user.present?
return if @account.blank?
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def permitted_settings_attributes
super + [{ conversation_required_attributes: [] }]
end
@@ -29,6 +29,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
private
def create_account_for_user
super
record_marketing_attribution
end
def record_marketing_attribution
return if @account.blank?
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def handle_saml_auth
account_id = extract_saml_account_id
relay_state = saml_relay_state
@@ -1,31 +1,5 @@
module Enterprise::ConversationFinder
def filter_by_conversation_type
return super unless params[:conversation_type] == 'participating' && custom_role_participating_permission?
@conversations = participating_visible_conversations.where(
id: current_user.participating_conversations.where(account_id: current_account.id).select(:id)
)
end
def conversations_base_query
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
end
private
def participating_visible_conversations
conversations = current_account.conversations
conversations = conversations.where(inbox_id: @inbox_ids) if params[:inbox_id]
return conversations if account_user&.administrator?
conversations.where(inbox: current_user.inboxes.where(account_id: current_account.id))
end
def custom_role_participating_permission?
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?('conversation_participating_manage')
end
def account_user
@account_user ||= current_account.account_users.find_by(user_id: current_user.id)
end
end
@@ -0,0 +1,28 @@
class Companies::DeleteJob < ApplicationJob
queue_as :low
BATCH_SIZE = 1000
CONTACT_COMPANY_CLEAR_SQL = <<~SQL.squish.freeze
company_id = NULL,
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) - 'company_name'
SQL
def perform(company_id:)
company = Company.find_by(id: company_id)
return if company.blank?
clear_contact_company_names(company)
company.destroy!
end
private
# Avoid contact callbacks so this cleanup does not dispatch contact automations/webhooks.
# rubocop:disable Rails/SkipsModelValidations
def clear_contact_company_names(company)
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
contacts.update_all(CONTACT_COMPANY_CLEAR_SQL)
end
end
# rubocop:enable Rails/SkipsModelValidations
end
@@ -0,0 +1,33 @@
class Companies::SyncContactNamesJob < ApplicationJob
queue_as :low
BATCH_SIZE = 1000
CONTACT_COMPANY_NAME_UPDATE_SQL = <<~SQL.squish.freeze
additional_attributes = jsonb_set(
COALESCE(additional_attributes, '{}'::jsonb),
'{company_name}',
?::jsonb,
true
)
SQL
def perform(company_id:)
return if company_id.blank?
company = Company.find_by(id: company_id)
return if company.blank?
sync_company_name(company)
end
private
# Denormalized display field sync; avoid contact validations, callbacks, and webhook/automation side effects.
# rubocop:disable Rails/SkipsModelValidations
def sync_company_name(company)
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
contacts.update_all([CONTACT_COMPANY_NAME_UPDATE_SQL, company.name.to_json])
end
end
# rubocop:enable Rails/SkipsModelValidations
end
@@ -0,0 +1,46 @@
# == Schema Information
#
# Table name: captain_message_reports
#
# id :bigint not null, primary key
# description :text
# report_reason :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# message_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_captain_message_reports_on_account_id (account_id)
# index_captain_message_reports_on_conversation_id (conversation_id)
# index_captain_message_reports_on_message_id (message_id)
# index_captain_message_reports_on_user_id (user_id)
#
class Captain::MessageReport < ApplicationRecord
self.table_name = 'captain_message_reports'
REPORT_REASONS = %w[incorrect_information inappropriate_response incomplete_response outdated_information other].freeze
belongs_to :account
# `Captain::Conversation` exists as a job namespace, so the association would
# resolve to that module instead of the top-level model without this override.
belongs_to :conversation, class_name: '::Conversation'
belongs_to :message
belongs_to :user
validates :report_reason, presence: true, inclusion: { in: REPORT_REASONS }
before_validation :ensure_account_and_conversation
private
def ensure_account_and_conversation
return if message.blank?
self.account ||= message.account
self.conversation ||= message.conversation
end
end
+5
View File
@@ -39,6 +39,7 @@ class Company < ApplicationRecord
has_many :contacts, dependent: :nullify
before_validation :prepare_jsonb_attributes
after_create_commit :fetch_favicon, if: -> { domain.present? }
after_update_commit :enqueue_contact_company_name_sync, if: :saved_change_to_name?
scope :ordered_by_name, -> { order(:name) }
scope :search_by_name_or_domain, lambda { |query|
@@ -76,4 +77,8 @@ class Company < ApplicationRecord
def fetch_favicon
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
end
def enqueue_contact_company_name_sync
Companies::SyncContactNamesJob.perform_later(company_id: id)
end
end
-23
View File
@@ -28,9 +28,6 @@ class CustomRole < ApplicationRecord
belongs_to :account
has_many :account_users, dependent: :nullify
before_destroy :cache_users_for_unread_filter_notification, prepend: true
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
PERMISSIONS = %w[
conversation_manage
conversation_unassigned_manage
@@ -42,24 +39,4 @@ class CustomRole < ApplicationRecord
validates :name, presence: true
validates :permissions, inclusion: { in: PERMISSIONS }
private
def unread_filter_access_changed?
destroyed? || previous_changes.key?('permissions')
end
def cache_users_for_unread_filter_notification
@users_for_unread_filter_notification = account_users.includes(:user).map(&:user)
end
def users_for_unread_filter_notification
@users_for_unread_filter_notification || account_users.includes(:user).map(&:user)
end
def notify_unread_filter_counts_changed
users_for_unread_filter_notification.each do |user|
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
end
@@ -3,5 +3,6 @@ module Enterprise::Concerns::Message
included do
has_one :call, dependent: :nullify
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
end
end
@@ -13,6 +13,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_instagram
channel_tiktok
captain_integration
captain_document_auto_sync
advanced_search_indexing
advanced_search
linear_integration
@@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService
# List of keys that can be managed through this service
# TODO: Add account_notes field in future
# This field can be used to store notes about account on Chatwoot cloud
VALID_KEYS = %w[manually_managed_features].freeze
VALID_KEYS = %w[manually_managed_features marketing_attribution].freeze
def initialize(account)
@account = account
@@ -0,0 +1,82 @@
# frozen_string_literal: true
require 'base64'
class Internal::Accounts::MarketingAttributionService
FIRST_TOUCH_COOKIE = 'cw_first_touch_attribution'
LAST_TOUCH_COOKIE = 'cw_last_touch_attribution'
FIELD_MAX_LENGTH = 500
ALLOWED_FIELDS = %w[
utm_source
utm_medium
utm_campaign
utm_term
utm_content
utm_id
gclid
gbraid
wbraid
dclid
fbclid
msclkid
ttclid
li_fat_id
twclid
rdt_cid
referrer
referrer_path
landing_page
source
source_type
captured_at
].freeze
pattr_initialize [:account!, :cookies!]
def perform
return unless ChatwootApp.chatwoot_cloud?
first_touch = attribution_cookie(FIRST_TOUCH_COOKIE)
last_touch = attribution_cookie(LAST_TOUCH_COOKIE)
return unless first_touch || last_touch
existing_attribution = internal_attributes_service.get('marketing_attribution') || {}
internal_attributes_service.set(
'marketing_attribution',
{
'first_touch' => first_touch || existing_attribution['first_touch'],
'last_touch' => last_touch || existing_attribution['last_touch'],
'captured_from' => 'cookie',
'stored_at' => Time.current.iso8601
}.compact
)
end
private
def attribution_cookie(cookie_name)
return if cookies[cookie_name].blank?
parse_cookie(cookies[cookie_name].to_s)
end
def parse_cookie(cookie_value)
validate_payload(JSON.parse(Base64.urlsafe_decode64(cookie_value)))
rescue JSON::ParserError, ArgumentError
nil
end
def validate_payload(payload)
return unless payload.is_a?(Hash)
payload.slice(*ALLOWED_FIELDS).filter_map do |key, value|
next if value.blank? || value.is_a?(Array) || value.is_a?(Hash)
[key, value.to_s.first(FIELD_MAX_LENGTH)]
end.to_h.presence
end
def internal_attributes_service
@internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
end
end
@@ -0,0 +1,7 @@
json.id @message_report.id
json.message_id @message_report.message_id
json.conversation_id @message_report.conversation_id
json.user_id @message_report.user_id
json.report_reason @message_report.report_reason
json.description @message_report.description
json.created_at @message_report.created_at.to_i
+1
View File
@@ -4,5 +4,6 @@
- sla
- custom_roles
- captain_integration
- captain_document_auto_sync
- csat_review_notes
- conversation_required_attributes
-15
View File
@@ -14,15 +14,8 @@ module Redis::RedisKeys
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d'.freeze
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::ASSIGNMENT'.freeze
UNREAD_CONVERSATIONS_USER_FILTERS_READY =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::READY::FILTERS'.freeze
UNREAD_CONVERSATIONS_FILTERS_VERSION = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::VERSION::FILTERS'.freeze
UNREAD_CONVERSATIONS_USER_FILTERS_VERSION =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::VERSION::FILTERS'.freeze
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::ASSIGNMENT'.freeze
UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::BUILD_LOCK::FILTERS'.freeze
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d'.freeze
UNREAD_CONVERSATIONS_LABEL_INBOX =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d'.freeze
@@ -40,14 +33,6 @@ module Redis::RedisKeys
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
UNREAD_CONVERSATIONS_USER_MENTIONS =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::MENTIONS'.freeze
UNREAD_CONVERSATIONS_USER_PARTICIPATING =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::PARTICIPATING'.freeze
UNREAD_CONVERSATIONS_USER_UNATTENDED =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::UNATTENDED'.freeze
UNREAD_CONVERSATIONS_USER_FOLDER =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::FOLDER::%<custom_filter_id>d'.freeze
## User Keys
# SSO Auth Tokens

Some files were not shown because too many files have changed in this diff Show More