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
36 changed files with 483 additions and 36 deletions
+3 -3
View File
@@ -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
@@ -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)
@@ -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
@@ -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');
@@ -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,
];
});
@@ -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;
@@ -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';
}
}
},
@@ -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 {
@@ -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'"
+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
@@ -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
View File
@@ -254,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
@@ -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
@@ -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
@@ -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
+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
+1 -1
View File
@@ -69,7 +69,7 @@
"countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
"dompurify": "3.4.0",
"dompurify": "3.4.11",
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
+15 -7
View File
@@ -130,8 +130,8 @@ importers:
specifier: ^1.3.3
version: 1.3.8(date-fns@2.21.1)
dompurify:
specifier: 3.4.0
version: 3.4.0
specifier: 3.4.11
version: 3.4.11
flag-icons:
specifier: ^7.2.3
version: 7.2.3
@@ -1635,6 +1635,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.17.0:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
hasBin: true
activestorage@5.2.8:
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
@@ -2218,8 +2223,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.4.0:
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@@ -6380,6 +6385,9 @@ snapshots:
acorn@8.16.0: {}
acorn@8.17.0:
optional: true
activestorage@5.2.8:
dependencies:
spark-md5: 3.0.2
@@ -6999,7 +7007,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.4.0:
dompurify@3.4.11:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -9511,7 +9519,7 @@ snapshots:
terser@5.33.0:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
acorn: 8.17.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
@@ -9898,7 +9906,7 @@ snapshots:
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
dependencies:
dompurify: 3.4.0
dompurify: 3.4.11
vue: 3.5.12(typescript@5.6.2)
vue-eslint-parser@9.4.3(eslint@8.57.0):
@@ -44,6 +44,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
end
it 'assigns a user to the conversation' do
conversation.update!(status: :pending)
params = { assignee_id: agent.id }
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
@@ -52,10 +53,13 @@ RSpec.describe 'Conversation Assignment API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.assignee).to eq(agent)
conversation.reload
expect(conversation.assignee).to eq(agent)
expect(conversation.status).to eq('open')
end
it 'assigns an agent bot to the conversation' do
conversation.update!(status: :open)
params = { assignee_id: agent_bot.id, assignee_type: 'AgentBot' }
expect(Conversations::AssignmentService).to receive(:new)
@@ -72,6 +76,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
conversation.reload
expect(conversation.assignee_agent_bot).to eq(agent_bot)
expect(conversation.assignee).to be_nil
expect(conversation.status).to eq('pending')
end
it 'assigns a team to the conversation' do
@@ -251,6 +251,28 @@ RSpec.describe 'Inboxes API', type: :request do
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/assignable_owners' do
let(:inbox) { create(:inbox, account: account) }
let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
before do
create(:inbox_member, user: agent, inbox: inbox)
end
it 'returns assignable agents and accessible agent bots' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignable_owners",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body['payload']
expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
expect(response_data.pluck('name')).to include(agent.name, admin.name, account_bot.name, global_bot.name)
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/campaigns' do
let(:inbox) { create(:inbox, account: account) }
@@ -385,13 +385,13 @@ RSpec.describe 'Companies API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'deletes the company' do
company
it 'enqueues company deletion' do
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(-1)
end.to have_enqueued_job(Companies::DeleteJob).with(company_id: company.id)
expect(response).to have_http_status(:ok)
end
end
@@ -0,0 +1,53 @@
require 'rails_helper'
require 'base64'
RSpec.describe 'Enterprise Google OAuth attribution', type: :request do
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
let(:email) { 'oauth-attribution@example.com' }
let(:account_builder) { double }
let(:account) { create(:account) }
let(:first_touch_cookie) { encoded_cookie('source' => 'reddit', 'source_type' => 'paid_social') }
let(:last_touch_cookie) { encoded_cookie('source' => 'github', 'source_type' => 'referral') }
before do
allow(ChatwootApp).to receive(:enterprise?).and_return(true)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
allow(email_validation_service).to receive(:perform).and_return(true)
allow(AccountBuilder).to receive(:new).and_return(account_builder)
allow(account_builder).to receive(:perform) do
[create(:user, email: email, account: account), account]
end
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
provider: 'google',
uid: '123545',
info: {
name: 'OAuth Attribution',
email: email,
image: 'https://example.com/image.jpg'
}
)
end
it 'records marketing attribution for Google OAuth signups' do
cookies[Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE] = first_touch_cookie
cookies[Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE] = last_touch_cookie
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
get '/omniauth/google_oauth2/callback'
follow_redirect!
end
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['captured_from']).to eq('cookie')
expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
end
def encoded_cookie(payload)
Base64.urlsafe_encode64(payload.to_json, padding: false)
end
end
@@ -0,0 +1,19 @@
require 'rails_helper'
RSpec.describe Companies::DeleteJob, type: :job do
describe '#perform' do
it 'unlinks contacts, clears company names, and deletes the company' do
account = create(:account)
company = create(:company, account: account, name: 'Acme')
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
other_contact = create(:contact, account: account, additional_attributes: { 'company_name' => 'Acme' })
described_class.perform_now(company_id: company.id)
expect { company.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(contact.reload.company_id).to be_nil
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(other_contact.reload.additional_attributes).to eq('company_name' => 'Acme')
end
end
end
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Companies::SyncContactNamesJob, type: :job do
let(:account) { create(:account) }
let(:company) { create(:company, account: account, name: 'Acme') }
describe '#perform' do
it 'updates linked contact company names' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs', 'city' => 'Berlin')
end
it 'uses the current company name when a stale rename job runs' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs')
end
it 'does not save contacts while syncing the denormalized company name' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
original_updated_at = contact.reload.updated_at
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.updated_at).to eq(original_updated_at)
end
end
end
+11
View File
@@ -46,4 +46,15 @@ RSpec.describe Company, type: :model do
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
end
end
describe 'contact company name sync' do
let(:account) { create(:account) }
let(:company) { create(:company, account: account, name: 'Acme') }
it 'enqueues contact company name sync when the company name changes' do
expect do
company.update!(name: 'Acme Labs')
end.to have_enqueued_job(Companies::SyncContactNamesJob).with(company_id: company.id)
end
end
end
@@ -23,16 +23,17 @@ describe Conversations::AssignmentService do
context 'when assigning a user' do
before do
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil, status: :pending)
end
it 'sets the agent and clears agent bot' do
it 'sets the agent, clears agent bot and opens the conversation' do
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
conversation.reload
expect(result).to eq(agent)
expect(conversation.assignee_id).to eq(agent.id)
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.status).to eq('open')
end
end
@@ -45,8 +46,8 @@ describe Conversations::AssignmentService do
)
end
it 'sets the agent bot and clears human assignee' do
conversation.update!(assignee: agent, assignee_agent_bot: nil)
it 'sets the agent bot, clears human assignee and marks the conversation pending' do
conversation.update!(assignee: agent, assignee_agent_bot: nil, status: :open)
result = service.perform
@@ -54,6 +55,7 @@ describe Conversations::AssignmentService do
expect(result).to eq(agent_bot)
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
expect(conversation.assignee_id).to be_nil
expect(conversation.status).to eq('pending')
end
end
end