Compare commits

...
Author SHA1 Message Date
aakashb95 46bb601c9c chore(deps): enforce pnpm release age 2026-05-12 16:18:09 +05:30
71cc5168be feat(linear): Auto link Linear issues from private notes (#14405)
When an agent pastes a Linear issue URL into a private note on a
conversation, Chatwoot now links the issue to the conversation
automatically — no need to click "Link to Linear issue" first. The
standard activity message ("X linked Linear issue ABC-123") is posted
just like a manual link.

Fixes
[CW-7032](https://linear.app/chatwoot/issue/CW-7032/if-someone-post-a-linear-url-in-the-private-notes-automatically-link)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-12 13:03:40 +04:00
58fdd20625 test(voice): WhatsApp Cloud Calling specs [5] (#14357)
Backend test coverage for the WhatsApp Cloud Calling pipeline introduced
in #14356. Stacked on top of that PR so the controller and service under
test exist when CI runs.

## Closes
- Replaces #14348 (which was based on the abandoned \`feature/pla-150\`)

## What's covered

-
\`spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb\`
(new, ~210 lines)
- \`show / accept / reject / terminate / initiate / upload_recording\`
happy paths
- 422 paths: missing sdp_offer, missing recording, calling_disabled
inbox, missing contact phone, ringing-state guards, AlreadyAccepted,
NotRinging, CallFailed
- 138006 (no permission) → throttled opt-in template send under
conversation lock; idempotency on retry
  - \`upload_recording\` idempotency guard (\`already_uploaded\`)

- \`spec/enterprise/services/whatsapp/call_service_spec.rb\` (new, ~135
lines)
- State machine: ringing → in_progress → completed; ringing → failed
(reject); ringing → no_answer (terminate)
- Lock contention: concurrent terminate during accept doesn't corrupt
the message/conversation broadcast
- Provider failure paths surface as \`Voice::CallErrors::CallFailed\`
(transport and business)

- \`spec/models/channel/whatsapp_spec.rb\` — extends existing file with
\`voice_enabled?\` matrix (provider × source × calling_enabled)

## Verification

- 77/77 examples pass locally on this branch (controller + service +
channel + incoming-call + permission-reply + open-ai message builder)
- RuboCop clean

## Stack

- Backend: #14356 (\`feat/whatsapp-call-meta-bridge\` — base of this PR)
- FE: #14346 (\`feat/whatsapp-call-ui\`)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:40:13 +05:30
6c67eb9ba0 fix(notifications): Respect conversation access when notifying agents (#14412)
Agents with limited custom roles were receiving notifications (creation,
assignment, mentions, new messages, SLA) for conversations they couldn't
actually open. For example, an agent whose custom role only grants
`conversation_unassigned_manage` was getting notified about
conversations assigned to other agents.

Notifications now go through the same `ConversationPolicy#show?` check
that gates the conversation view itself, so an agent only gets notified
for conversations they're permitted to see. Administrators and agents
without custom roles are unaffected.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-05-12 10:57:29 +04:00
Sivin VargheseandGitHub 3df827c931 chore: update Captain documents filter UI (#14429) 2026-05-12 11:27:30 +05:30
de696a55cb feat(voice): add WhatsApp inbound call webhook pipeline [3] (#14315)
Adds the server-side flow that turns Meta WhatsApp Cloud Calling
webhooks into Chatwoot Calls, conversations, voice_call message bubbles,
and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider
methods); intentionally does not include the HTTP controller, routes, or
frontend (those land in PR-4 and PR-9).

## Closes
- Part of the WhatsApp Cloud Calling rollout. Linear: TBD

## What changed

**Webhook routing**
- `app/jobs/webhooks/whatsapp_events_job.rb` — append
`prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it
without forking.
- `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new)
— overlay that prepends `handle_message_events` to intercept `field:
'calls'` payloads (route to `Whatsapp::IncomingCallService`) and
`interactive.call_permission_reply` messages (route to
`Whatsapp::CallPermissionReplyService`); falls through with `super` for
regular messages.

**Services**
- `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) —
gated on `provider_config['calling_enabled']`; processes `connect`
(creates inbound call via `Voice::InboundCallBuilder` or transitions an
existing outbound call to `in_progress`) and `terminate` events; updates
conversation `additional_attributes` and broadcasts
`voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`.
- `enterprise/app/services/whatsapp/call_permission_reply_service.rb`
(new) — handles WhatsApp interactive `call_permission_reply` replies;
clears the conversation's `call_permission_requested_at` flag and
broadcasts `voice_call.permission_granted` so the agent UI can re-enable
the call button.

**Builder/model adjustments**
- `enterprise/app/services/voice/inbound_call_builder.rb` —
provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops
`account:` (now derived from `inbox.account` to keep the param count
under rubocop's ceiling without disabling cops), uses digits-only
`source_id` for WhatsApp ContactInbox (validation requires
`^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio
providers.
- `enterprise/app/services/voice/call_message_builder.rb` — adds
`create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses
direct `Message.create!` (bypasses `Messages::MessageBuilder`'s
incoming-on-non-Api-inbox guard, which would otherwise reject the system
bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'`
for Twilio. Backwards-compatible `perform!` retained for the existing
Twilio call sites.
- `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven
by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the
`inbound`/`outbound` strings the FE expects, and
`ringing?`/`in_progress?`/`terminal?` predicates used throughout the
pipeline.

**Outgoing-channel guard**
- `app/services/base/send_on_channel_service.rb` — extends
`invalid_message?` to skip messages with `content_type == 'voice_call'`.
Without this, agent-initiated outbound calls (PR-4) would deliver
\"WhatsApp Call\" as a text message to the contact every time.

**Twilio call-site update**
- `enterprise/app/controllers/twilio/voice_controller.rb` — drops the
now-redundant `account: current_account` kwarg from the
`Voice::InboundCallBuilder.perform!` call.

**Tests**
- New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb`
(5 examples — calling-disabled, inbound connect, outbound connect,
terminate completed, terminate no-answer, unknown event).
- New:
`spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb`
(3 examples — accept, reject, calling-disabled).
- Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb`
and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to
drop the `account:` kwarg from call expectations.

## How to test

In `rails console` against an account with a WhatsApp inbox where
`provider_config['calling_enabled']` is true:

```ruby
inbox = Inbox.find(<id>)
params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect',
                     session: { sdp: 'v=0...', sdp_type: 'offer' } }] }
Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform
# => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble
# => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide

# Then terminate it:
Whatsapp::IncomingCallService.new(inbox: inbox,
  params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] }
).perform
# => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires
```

End-to-end browser flow (Meta → cable → UI) requires the controller from
PR-4 and the frontend from PR-9.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:23:57 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
79a7423f9f chore(deps): bump nokogiri from 1.19.1 to 1.19.3 (#14410)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.19.1
to 1.19.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sparklemotion/nokogiri/releases">nokogiri's
releases</a>.</em></p>
<blockquote>
<h2>v1.19.3 / 2026-04-27</h2>
<h3>Fixed / Security</h3>
<ul>
<li>Address exponential regex backtracking in CSS selector tokenizer.
See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-c4rq-3m3g-8wgx">GHSA-c4rq-3m3g-8wgx</a>
for more information.</li>
<li>[CRuby] Address memory leak in
<code>XSLT::Stylesheet#transform</code>. See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-v2fc-qm4h-8hqv">GHSA-v2fc-qm4h-8hqv</a>
for more information.</li>
</ul>
<!-- raw HTML omitted -->

<pre><code>46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639
nokogiri-1.19.3-aarch64-linux-gnu.gem
8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7
nokogiri-1.19.3-aarch64-linux-musl.gem
3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f
nokogiri-1.19.3-arm-linux-gnu.gem
9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6
nokogiri-1.19.3-arm-linux-musl.gem
71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42
nokogiri-1.19.3-arm64-darwin.gem
40ea6ebf5cf2005dae1dee26dd557d3afb41fb6de6c9764aca8cf06fdb841db1
nokogiri-1.19.3-java.gem
8bb7132cad356c879a1286eaabcb5e68326cb2490317984280fbc62f456d506a
nokogiri-1.19.3-x64-mingw-ucrt.gem
77f3fba57d46c53ab31e62fc6c28f705109d1bf6264356c76f132b2be5728d4d
nokogiri-1.19.3-x86_64-darwin.gem
2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976
nokogiri-1.19.3-x86_64-linux-gnu.gem
248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f
nokogiri-1.19.3-x86_64-linux-musl.gem
78312cbac32a40c812780d9678221b79d51288eec00054c1a8d15f7ce05960e8
nokogiri-1.19.3.gem
</code></pre>
<h2>v1.19.2 / 2026-03-19</h2>
<h3>Dependencies</h3>
<ul>
<li>[JRuby] Saxon-HE is updated to 12.7, from 9.6.0-4. Saxon-HE is a
transitive dependency of nu.validator:jing, and this update addresses
CVEs in Saxon-HE's own transitive dependencies JDOM and dom4j. We don't
think this warrants a security release, however we're cutting a patch
release to help users whose security scanners are flagging this. <a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3611">#3611</a>
<a
href="https://github.com/flavorjones"><code>@​flavorjones</code></a></li>
</ul>
<h3>SHA256 Checksums</h3>

<pre><code>c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19
nokogiri-1.19.2-aarch64-linux-gnu.gem
7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515
nokogiri-1.19.2-aarch64-linux-musl.gem
b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081
nokogiri-1.19.2-arm-linux-gnu.gem
61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c
nokogiri-1.19.2-arm-linux-musl.gem
58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205
nokogiri-1.19.2-arm64-darwin.gem
e9d67034bc80ca71043040beea8a91be5dc99b662daa38a2bfb361b7a2cc8717
nokogiri-1.19.2-java.gem
8ccf25eea3363a2c7b3f2e173a3400582c633cfead27f805df9a9c56d4852d1a
nokogiri-1.19.2-x64-mingw-ucrt.gem
7d9af11fda72dfaa2961d8c4d5380ca0b51bc389dc5f8d4b859b9644f195e7a4
nokogiri-1.19.2-x86_64-darwin.gem
fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
nokogiri-1.19.2-x86_64-linux-gnu.gem
93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
nokogiri-1.19.2-x86_64-linux-musl.gem
38fdd8b59db3d5ea9e7dfb14702e882b9bf819198d5bf976f17ebce12c481756
nokogiri-1.19.2.gem
</code></pre>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/sparklemotion/nokogiri/compare/v1.19.1...v1.19.2">https://github.com/sparklemotion/nokogiri/compare/v1.19.1...v1.19.2</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md">nokogiri's
changelog</a>.</em></p>
<blockquote>
<h2>v1.19.3 / 2026-04-27</h2>
<h3>Fixed / Security</h3>
<ul>
<li>Address exponential regex backtracking in CSS selector tokenizer.
See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-c4rq-3m3g-8wgx">GHSA-c4rq-3m3g-8wgx</a>
for more information.</li>
<li>[CRuby] Address memory leak in
<code>XSLT::Stylesheet#transform</code>. See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-v2fc-qm4h-8hqv">GHSA-v2fc-qm4h-8hqv</a>
for more information.</li>
</ul>
<h2>v1.19.2 / 2026-03-19</h2>
<h3>Dependencies</h3>
<ul>
<li>[JRuby] Saxon-HE is updated to 12.7, from 9.6.0-4. Saxon-HE is a
transitive dependency of nu.validator:jing, and this update addresses
CVEs in Saxon-HE's own transitive dependencies JDOM and dom4j. We don't
think this warrants a security release, however we're cutting a patch
release to help users whose security scanners are flagging this. <a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3611">#3611</a>
<a
href="https://github.com/flavorjones"><code>@​flavorjones</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/c139a3da0fe0cae7499a0bafa20f2875877c585b"><code>c139a3d</code></a>
version bump to v1.19.3</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/7501a63b9f4246d12516e35b91fed8be34f854c0"><code>7501a63</code></a>
fix: backtracking in CSS tokenizer rules (v1.19.x backport) (<a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3627">#3627</a>)</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/03e7968a730a6544ab56a8d6c3e82dd630ad4339"><code>03e7968</code></a>
test: skip CSS tokenizer benchmarks on JRuby</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/b984b7e47f622d1aa97d54c16d5cd596c3eb9538"><code>b984b7e</code></a>
fix: ReDoS in CSS tokenizer ident rule</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/00926231e28d5a20e5b4873efba36099aea0d5c6"><code>0092623</code></a>
fix: ReDoS in CSS tokenizer STRING rule</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/ee17d33aff3adb30c14e71d3d4c8163465acaccf"><code>ee17d33</code></a>
fix: memory leak in XSLT transform (backport to v1.19.x) (<a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3624">#3624</a>)</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/ce188a395192e3757d8701949afb643dc025084c"><code>ce188a3</code></a>
doc: update CHANGELOG</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/caeaac41f874f0944f9397c78bf6c1bfac2cb472"><code>caeaac4</code></a>
fix: memory leak in XSLT transform</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/25220bf268c9808e28415563ed7f8ea8d5c332bf"><code>25220bf</code></a>
dep(test): test against libxml-ruby v6 (<a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3618">#3618</a>)</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/0caeb21a5c5e9ff45bbede88fb53655f6753bb0e"><code>0caeb21</code></a>
doc: add security warnings for untrusted XSLT stylesheets</li>
<li>Additional commits viewable in <a
href="https://github.com/sparklemotion/nokogiri/compare/v1.19.1...v1.19.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nokogiri&package-manager=bundler&previous-version=1.19.1&new-version=1.19.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>
2026-05-11 18:49:31 -07:00
Sivin VargheseandGitHub 85ddc68834 fix: prevent bulk action checkbox reset in team view (#14432) 2026-05-12 07:13:48 +05:30
Sojan JoseandGitHub 086aa36ffe feat(companies): add notes and history to company details (#14401) 2026-05-11 23:15:25 +05:30
f6be0d80ef feat: UI changes for document auto sync [AI-153] (#14258)
# Pull Request Template

## Description

FE code for document sync

Adds:
- UI to show counts (stats) of available web pages, stale and synced
documents and last synced at
- Bulk action and manual ways to sync web documents
- index to stats related columns

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)

## 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.

https://linear.app/chatwoot/issue/AI-153/fe-document-auto-sync

Documents dashboard:
<img width="2160" height="986" alt="CleanShot 2026-05-11 at 17 57 09@2x"
src="https://github.com/user-attachments/assets/6d934764-964c-4656-b005-1b4f0329e553"
/>

Filters:
<img width="1138" height="564" alt="CleanShot 2026-05-11 at 17 58 13@2x"
src="https://github.com/user-attachments/assets/cee780e6-eb8f-4aed-8cc5-b674244a821b"
/>

Needs update:
<img width="2222" height="966" alt="CleanShot 2026-05-11 at 17 57 53@2x"
src="https://github.com/user-attachments/assets/70c85ddd-7eb1-4328-ba14-7929e67e7b36"
/>

pdfs:
<img width="2180" height="558" alt="CleanShot 2026-05-11 at 17 58 30@2x"
src="https://github.com/user-attachments/assets/975b5c9f-bd1c-4979-9870-8f926d7f6e11"
/>

bulk actions:
<img width="2244" height="992" alt="CleanShot 2026-05-11 at 17 58 57@2x"
src="https://github.com/user-attachments/assets/bdb3c63f-d2de-41dc-a6d5-8821d3303be0"
/>

single url sync:
<img width="2264" height="722" alt="CleanShot 2026-05-11 at 17 59 19@2x"
src="https://github.com/user-attachments/assets/7d7323a5-0fcb-4be9-8635-55e56964999b"
/>



## 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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-05-11 20:13:29 +05:30
Shivam MishraandGitHub 3489298726 feat: add WidgetCreationService for onboarding web widget setup (#14314)
When a new account finishes onboarding we want to land them on a
dashboard with a working web widget already configured, branded, named,
and assigned to them, instead of an empty inbox list. This PR adds the
services that produce that widget. **No user-visible change yet:** the
services are dormant until the trigger and background job are wired up
in the follow-up PR.

## Context

Milestone 1 added `Account::BrandingEnrichmentJob`, which calls
context.dev during signup and stores brand data on
`account.custom_attributes['brand_info']`, plus the new onboarding form
that captures `domain`, `name`, `industry`, etc. Milestone 2 starts
using that data, and the first thing we want is a web widget
materialized automatically. Splitting the service layer from the
orchestration plumbing (Redis key, `onboarding_step` extension,
controller wiring, ActionCable) keeps this diff focused and lets the
LLM/widget logic merge independently.

## How to test

Run against an existing account that already has `brand_info` populated.

```ruby
account = Account.find(<account_id>)
user    = account.administrators.first
inbox   = WidgetCreationService.new(account, user).perform

inbox.channel.widget_color     # color from brand_info, or '#1f93ff'
inbox.channel.welcome_title    # brand_info[:title], or account.name
inbox.channel.welcome_tagline  # LLM tagline (Enterprise + system key set),
                               # else brand_info[:slogan]/[:description]/nil
inbox.inbox_members.pluck(:user_id)
```

Toggle `InstallationConfig['CAPTAIN_OPEN_AI_API_KEY']` to flip between
LLM and brand-text tagline paths. To verify failure isolation, raise
inside `Captain::Llm::WidgetTaglineService#perform` and confirm widget
creation still succeeds with the fallback tagline.
2026-05-11 16:10:48 +05:30
Shivam MishraandGitHub 2e13f69fdf chore: log errors from context.dev (#14310)
This PR updates the way we log errors and results from context.dev to
have better visibility on the enrichment process for onboarding
2026-05-11 16:09:45 +05:30
Shivam MishraandGitHub bc768bf04f chore: verbosely log errors for leadsquare activity failure (#14407) 2026-05-11 10:58:23 +05:30
Shivam MishraandGitHub cc612e755b fix: SafeFetch dependency loading (#14408)
The SafeFetch spec suite was failing in CI with `NameError:
uninitialized constant SafeFetch::Fetcher` across every example that
exercised `SafeFetch.fetch`. From a product perspective, this made the
external-file fetch path look unreliable even though the failure
happened before any network validation, SSRF protection, content-type
checks, or tempfile handling could run.

The symptom pointed to a load-order issue rather than an actual fetch
behavior regression. `SafeFetch.fetch` referenced `Fetcher` from the
top-level module, but that nested class was not guaranteed to be loaded
in every test execution path before the method was invoked.

This change keeps the existing SafeFetch split between the public API
and the implementation classes, but makes the public entry point
responsible for loading the implementation it needs before use. That is
intentionally smaller than folding all of the fetcher logic into
`lib/safe_fetch.rb`; the separate files still keep the request option
parsing and streaming implementation readable, while the public API no
longer depends on Rails or the test runner having loaded nested
constants in a particular order.

The file also now uses a single `SafeFetch` module declaration. That
removes the awkward reopen pattern and makes the dependency boundary
easier to see: constants and errors are defined first, then the public
`fetch` method loads and delegates to the implementation classes.
2026-05-08 21:18:50 +05:30
Aakash BakhleandGitHub aa10d42237 chore: bump RubyLLM version [AI-152] (#14387)
# Pull Request Template

## Description

Bump RubyLLM version and update model registry

## Type of change

Version bump on package

## 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.

locally and specs


## 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-05-08 18:44:15 +05:30
202403873d feat: Ability to specify the authentication type for imap server (#12306)
# Pull Request Template

## Description

This PR adds IMAP authentication mechanism selection to Chatwoot's email
inbox configuration. Users can now choose between 'plain', 'login', and
'cram-md5' authentication methods when configuring IMAP settings,
providing flexibility for different email providers that require
specific authentication types.

https://github.com/chatwoot/chatwoot/issues/8867

The implementation includes:
- Frontend dropdown with numeric keys (1, 2, 3) matching SMTP auth style
- Backend API validation for allowed authentication mechanisms
- Consistent 'cram-md5' format throughout the codebase
- Updated IMAP service to handle different auth types properly

This feature maintains consistency with existing SMTP authentication
options and follows the established UI/UX patterns in the application.

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] 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?

### Manual Testing:
- Tested in Docker environment
- Verified IMAP auth dropdown appears in inbox settings
- Confirmed all three auth mechanisms (plain, login, cram-md5) can be
selected and saved
- Tested API validation by attempting to save invalid auth mechanisms

### Automated Testing:
- Updated existing IMAP service tests to use consistent lowercase values
- Updated API controller tests for authentication parameter handling
- All tests pass locally with the new changes

### Test Configuration:
- Tested with both new and existing inbox configurations

## 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
- [x] 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

## Additional Notes

- This feature is backward compatible and doesn't break existing IMAP
configurations
- The 'cram-md5' format is used consistently throughout (UI, API,
storage, services)
- Net::IMAP compatibility is maintained by converting to 'CRAM-MD5'
internally
- Follows the same pattern established by SMTP authentication
configuration

---------

Co-authored-by: João Santos <joao.santos@madigital.eu>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-05-08 16:40:15 +05:30
9c1d1c4070 feat(labels): remove label associations asynchronously on delete (#13531)
## Summary
- Remove label deletion dependency on association cleanup by deleting
immediately and enqueueing a background job.
- Add `Labels::RemoveAssociationsJob` to strip deleted label references
from tagged conversations and contacts.
- Keep this version simple by removing the label count/prompt
requirement requested.

## Implementation notes
- Enqueue job from `Api::V1::Accounts::LabelsController#destroy` with
label title + account id.
- Background work performed in `Labels::DestroyService`.

## References
- Linear issue:
https://linear.app/chatwoot/issue/CW-4765/cw-2857-enhancement-removing-labels-is-inconsistent
- GitHub issue: https://github.com/chatwoot/chatwoot/issues/1249

## Testing
- `bundle exec rspec
spec/controllers/api/v1/accounts/labels_controller_spec.rb
spec/services/labels/destroy_service_spec.rb
spec/jobs/labels/remove_associations_job_spec.rb
spec/services/labels/update_service_spec.rb`
- `bundle exec rubocop
app/controllers/api/v1/accounts/labels_controller.rb
app/jobs/labels/remove_associations_job.rb
spec/controllers/api/v1/accounts/labels_controller_spec.rb
spec/jobs/labels/remove_associations_job_spec.rb
spec/services/labels/destroy_service_spec.rb`

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-08 13:40:36 +05:30
5c6ea78ce6 fix(security): Enforce admin authorization on custom attribute definitions API (#14392)
Custom attribute definitions can now only be created, edited, or deleted
by administrators, matching the existing settings UI restriction.
Previously, an agent could call the `custom_attribute_definitions` API
directly and modify account configuration that they couldn't reach
through the dashboard — a Broken Access Control vulnerability reported
externally.

Fixes
https://linear.app/chatwoot/issue/CW-7038/broken-access-control-on-custom-attribute-definitions-api

## How to test

1. Sign in as an agent.
2. Try to create a custom attribute by calling `POST
/api/v1/accounts/<id>/custom_attribute_definitions` directly (the
settings page is hidden for agents — use curl with the agent's
`api_access_token`).
3. Expect `401 Unauthorized` with body `{"error":"You are not authorized
to do this action"}`. Repeat for `PATCH` and `DELETE`.
4. Sign in as an administrator and confirm create/edit/delete still work
from Settings → Custom Attributes.
5. As either role, the listing endpoint (`GET
.../custom_attribute_definitions`) should still succeed — agents need
this to render attributes in conversation and contact panels.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-08 11:42:23 +04:00
Tanmay Deep SharmaandGitHub e6b8f48b3b fix: settle captain credits on subscription cancellation (#14089)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6875/captain-credits-3-bugs-in-stripe-subscription-lifecycle-cancel-ratchet

## Description

Fixes Captain credit settlement on subscription cancellation. Previously
`limits['captain_responses']` and `captain_responses_usage` were left in
their pre-cancellation state, which caused incorrect credit totals when
a customer re-subscribed. Cancellation now settles the monthly allotment
(preserving any remaining topup) and resets the usage counter.

## Type of change

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

## How Has This Been Tested?

1. Set up an account subscribed to a paid plan (e.g. Startups) so
`limits['captain_responses']` reflects the plan allotment.
2. Fire `customer.subscription.deleted` for that account's Stripe
customer. Confirm the limits.
3. Fire `customer.subscription.updated` re-subscribing to the paid plan.
Confirm the limits.
4. Repeat cancel → re-subscribe several times;


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-08 12:18:53 +05:30
Sojan JoseandGitHub 10597863d7 feat(companies): add company creation flow (#14402) 2026-05-08 11:46:33 +05:30
53b2a517d7 fix: resolve SendReplyJob flaky specs (#14394)
`SendReplyJob` was caching reloadable service class objects in
`CHANNEL_SERVICES`. In test, a request spec can trigger Rails constant
reloading after `SendReplyJob` has already been loaded, leaving the job
with stale class objects while later specs stub the reloaded constants.
This resolves the channel service at perform time so the job follows the
current Rails constant table.

How to reproduce
Run the CircleCI shard that contains send_reply_job_spec, or the
minimized order-dependent reproduction:
```sh
bundle exec rspec --format progress spec/builders/v2/reports/label_summary_builder_spec.rb spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb spec/jobs/send_reply_job_spec.rb:32
```

What changed
- Store service class names in `SendReplyJob::CHANNEL_SERVICES` instead
of class objects.
- Resolve the service with constantize inside perform so reloads do not
leave stale cached classes.

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-07 22:47:09 +05:30
146 changed files with 44610 additions and 2871 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ARG PNPM_VERSION="10.33.4"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
+2 -2
View File
@@ -195,10 +195,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.9.1'
gem 'ai-agents', '>= 0.10.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm', '>= 1.14.1'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
+16 -16
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -307,8 +307,8 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
@@ -466,7 +466,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.2)
json (2.19.5)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -590,14 +590,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.1)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -835,17 +835,17 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.2)
ruby_llm (1.15.0)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
marcel (~> 1)
ruby_llm-schema (~> 0)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_llm-schema (0.3.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -1019,7 +1019,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.4)
zeitwerk (2.7.5)
PLATFORMS
arm64-darwin-20
@@ -1039,7 +1039,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.9.1)
ai-agents (>= 0.10.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1144,7 +1144,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm (>= 1.14.1)
ruby_llm-schema
scout_apm
scss_lint
@@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
# The Attachment row is saved before the blob is attached, so the
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
# for audio so the bubble updates without waiting on transcription.
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
end
def attachment_params(attachment)
+15
View File
@@ -27,6 +27,8 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!(
notification_type: notification_type,
@@ -36,4 +38,17 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user
)
end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
@@ -18,7 +18,16 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok
end
+18 -10
View File
@@ -17,15 +17,12 @@ module Api::V1::InboxesHelper
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
# Validate the user-selected auth mechanism before opening the connection.
authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication])
check_imap_connection(channel_data)
# Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command,
# not SASL AUTH=LOGIN.
check_imap_connection(channel_data, authentication)
end
def validate_smtp(channel_data)
@@ -37,8 +34,8 @@ module Api::V1::InboxesHelper
check_smtp_connection(channel_data, smtp)
end
def check_imap_connection(channel_data)
Mail.connection {} # rubocop:disable:block
def check_imap_connection(channel_data, authentication)
imap = open_imap_connection(channel_data, authentication)
rescue SocketError => e
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
rescue Net::IMAP::NoResponseError => e
@@ -53,9 +50,20 @@ module Api::V1::InboxesHelper
rescue StandardError => e
raise StandardError, e.message
ensure
imap.disconnect if imap.present? && !imap.disconnected?
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end
def open_imap_connection(channel_data, authentication)
imap = build_imap_connection(channel_data)
Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password])
imap
end
def build_imap_connection(channel_data)
Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl])
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
@@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId } = {}) {
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
search_key: searchKey,
assistant_id: assistantId,
filter,
source,
sort,
},
});
}
sync(id) {
return axios.post(`${this.url}/${id}/sync`);
}
}
export default new CaptainDocument();
@@ -28,6 +28,14 @@ class CompanyAPI extends ApiClient {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
@@ -56,9 +56,14 @@ const closeMobileSidebar = () => {
<div
v-if="slots.sidebar"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
<div
@@ -105,9 +110,14 @@ const closeMobileSidebar = () => {
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
>
<slot name="sidebar" />
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
</Transition>
</div>
@@ -2,6 +2,7 @@
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
import CompanyMoreActions from './components/CompanyMoreActions.vue';
defineProps({
showSearch: { type: Boolean, default: true },
@@ -11,7 +12,7 @@ defineProps({
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['search', 'update:sort']);
const emit = defineEmits(['search', 'update:sort', 'create']);
</script>
<template>
@@ -48,6 +49,7 @@ const emit = defineEmits(['search', 'update:sort']);
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<CompanyMoreActions @create="emit('create')" />
</div>
</div>
</div>
@@ -0,0 +1,45 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const showActionsDropdown = ref(false);
const menuItems = [
{
label: t('COMPANIES.ACTIONS.CREATE'),
action: 'create',
value: 'create',
icon: 'i-lucide-plus',
},
];
const handleAction = ({ action }) => {
if (action === 'create') emit('create');
showActionsDropdown.value = false;
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -12,7 +12,12 @@ defineProps({
showPaginationFooter: { type: Boolean, default: true },
});
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
const emit = defineEmits([
'update:currentPage',
'update:sort',
'search',
'create',
]);
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -31,6 +36,7 @@ const updateCurrentPage = page => {
:active-ordering="activeOrdering"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@create="emit('create')"
/>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
@@ -0,0 +1,110 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const form = reactive({ name: '', domain: '', description: '' });
const isFormInvalid = computed(() => !form.name.trim());
const resetForm = () => {
form.name = '';
form.domain = '';
form.description = '';
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
emit('create', {
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
};
const closeDialog = () => {
dialogRef.value?.close();
};
const onSuccess = () => {
resetForm();
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleConfirm"
@close="resetForm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.CREATE.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
autofocus
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
/>
</div>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isLoading"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="t('COMPANIES.CREATE.ACTIONS.SAVE')"
color="blue"
type="submit"
:disabled="isFormInvalid || isLoading"
:is-loading="isLoading"
/>
</div>
</template>
</Dialog>
</template>
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-6 pt-1">
<div class="flex flex-col gap-6 px-6 pb-8">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
@@ -288,7 +288,7 @@ const handleContactSelect = contactId => {
<div
v-else-if="!hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
@@ -346,7 +346,7 @@ const handleContactSelect = contactId => {
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="px-0 before:hidden"
class="!px-0 before:hidden bg-transparent"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
@@ -0,0 +1,67 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
conversations: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = contactsById.value(sender.id);
return contact.id ? contact : sender;
};
const conversationInbox = conversation =>
stateInbox.value(conversation.inboxId) || {
name: '',
channelType: conversation.meta?.channel,
};
</script>
<template>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="conversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in conversations"
:key="conversation.id"
:conversation="conversation"
:contact="conversationContact(conversation)"
:state-inbox="conversationInbox(conversation)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p
v-else
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
</p>
</template>
@@ -0,0 +1,114 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const hasNotes = computed(() => props.notes.length > 0);
const contactName = contact =>
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note?.user?.name || 'Bot';
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
</script>
<template>
<div v-if="hasNotes" class="flex flex-col px-6">
<div class="flex flex-col divide-y divide-n-strong">
<div
v-for="note in notes"
:key="note.id"
class="flex flex-col gap-2 py-4 group/note"
>
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
:name="contactName(note.contact)"
:src="note.contact?.thumbnail"
:size="16"
rounded-full
hide-offline-status
/>
<div
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
>
<button
type="button"
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
@click="openContact(note.contact.id)"
>
{{ contactName(note.contact) }}
</button>
<div class="min-w-0 truncate">
<span
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
>
<span class="font-medium text-n-slate-11">
{{ getWrittenBy(note) }}
</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-11">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</div>
</div>
<div
v-else-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<p
v-else
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
</p>
</template>
@@ -0,0 +1,113 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedIds: { type: Set, default: () => new Set() },
documents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedIds',
'bulkSyncQueued',
'bulkDeleteSucceeded',
]);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
return props.documents
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
.map(doc => doc.id);
});
const hasSyncableSelection = computed(
() => syncableSelectedIds.value.length > 0
);
const selectAllLabel = computed(() => {
const count = props.documents.length;
const isAllSelected = props.selectedIds.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
);
const handleBulkSync = async () => {
const ids = syncableSelectedIds.value;
if (!ids.length) return;
try {
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
ids,
});
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
if (queuedCount === 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
} else if (queuedCount > 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
count: queuedCount,
});
}
useAlert(message);
emit('update:selectedIds', new Set());
if (queuedCount > 0) emit('bulkSyncQueued');
} catch (error) {
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
}
};
</script>
<template>
<div>
<BulkSelectBar
:model-value="selectedIds"
:all-items="documents"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': selectedIds.size > 0 }"
@update:model-value="emit('update:selectedIds', $event)"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template v-if="hasSyncableSelection" #secondaryActions>
<Button
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
sm
slate
ghost
icon="i-lucide-refresh-cw"
class="!px-1.5"
@click="handleBulkSync"
/>
</template>
</BulkSelectBar>
<BulkDeleteDialog
ref="bulkDeleteDialog"
:bulk-ids="selectedIds"
type="AssistantDocument"
@delete-success="emit('bulkDeleteSucceeded')"
/>
</div>
</template>
@@ -5,14 +5,17 @@ import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isPdfDocument,
isSafeHttpLink,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
const props = defineProps({
id: {
@@ -31,10 +34,38 @@ const props = defineProps({
type: String,
required: true,
},
pdfDocument: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
status: {
type: String,
default: null,
},
syncStatus: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
lastSyncErrorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
syncStaleAfterHours: {
type: Number,
default: null,
},
isSelected: {
type: Boolean,
default: false,
@@ -64,6 +95,20 @@ const modelValue = computed({
set: () => emit('select', props.id),
});
const isPdf = computed(() => props.pdfDocument);
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
const canManage = computed(() => checkPermissions(['administrator']));
const isAvailable = computed(() => props.status === 'available');
const canSync = computed(
() => canManage.value && !isPdf.value && isAvailable.value
);
const isSyncing = computed(() => props.syncStatus === 'syncing');
const isFailed = computed(() => props.syncStatus === 'failed');
const isRetryableSync = computed(
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
);
const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
@@ -74,7 +119,19 @@ const menuItems = computed(() => {
},
];
if (checkPermissions(['administrator'])) {
if (canSync.value) {
allOptions.push({
label: isRetryableSync.value
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
if (canManage.value) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
@@ -86,17 +143,25 @@ const menuItems = computed(() => {
return allOptions;
});
const createdAt = computed(() => dynamicTime(props.createdAt));
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const displayLink = computed(() =>
isPdf.value
? formatDocumentLink(props.externalLink)
: getDocumentDisplayPath(props.externalLink)
);
const linkIcon = computed(() =>
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
</script>
<template>
@@ -141,17 +206,41 @@ const handleAction = ({ action, value }) => {
<span
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<i class="i-woot-captain" />
<Icon icon="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<a
v-if="!isPdf && hasSafeLink"
:href="externalLink"
:title="externalLink"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click.stop
>
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
</a>
<span
v-else
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<i :class="linkIcon" class="shrink-0" />
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
:last-synced-at="lastSyncedAt"
:error-code="lastSyncErrorCode"
:sync-in-progress="syncInProgress"
:stale-after-hours="syncStaleAfterHours"
:show-retry="canSync && isRetryableSync"
@retry="handleRetry"
/>
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAtLabel }}
</div>
</div>
</CardLayout>
@@ -0,0 +1,61 @@
<script setup>
import { computed, ref } from 'vue';
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
const emit = defineEmits(['change']);
const source = ref('all');
const status = ref(null);
const sort = ref('recently_updated');
const hasActiveFilters = computed(
() => source.value !== 'all' || Boolean(status.value)
);
const buildParams = (page = 1) => {
const params = { page };
if (source.value !== 'all') params.source = source.value;
if (status.value) params.filter = status.value;
if (sort.value) params.sort = sort.value;
return params;
};
const emitChange = () => emit('change');
const handleSourceSelect = sourceKey => {
source.value = sourceKey;
if (sourceKey !== 'web') status.value = null;
emitChange();
};
const handleStatusSelect = statusKey => {
status.value = statusKey;
if (statusKey) source.value = 'web';
emitChange();
};
const handleSortSelect = sortKey => {
sort.value = sortKey;
emitChange();
};
const reset = () => {
source.value = 'all';
status.value = null;
sort.value = 'recently_updated';
};
defineExpose({ buildParams, reset, hasActiveFilters });
</script>
<template>
<DocumentFiltersBar
:active-source-filter="source"
:active-status-filter="status"
:active-sort="sort"
@select-source="handleSourceSelect"
@select-status="handleStatusSelect"
@select-sort="handleSortSelect"
/>
</template>
@@ -0,0 +1,141 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
activeSourceFilter: { type: String, default: 'all' },
activeStatusFilter: { type: String, default: null },
activeSort: { type: String, default: 'recently_updated' },
});
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
const { t } = useI18n();
const openMenu = ref(null);
const MENU_CONFIG = [
{
key: 'source',
activeKey: 'activeSourceFilter',
dropdownClass: 'min-w-48',
options: [
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
],
},
{
key: 'status',
activeKey: 'activeStatusFilter',
dropdownClass: 'min-w-52',
options: [
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
{
labelKey: 'STATUS.UPDATED',
value: 'synced',
icon: 'i-lucide-check-circle',
},
{
labelKey: 'STATUS.NEEDS_UPDATE',
value: 'stale',
icon: 'i-lucide-clock',
},
{
labelKey: 'STATUS.UPDATING',
value: 'syncing',
icon: 'i-lucide-refresh-cw',
},
{
labelKey: 'STATUS.FAILED',
value: 'failed',
icon: 'i-lucide-circle-x',
},
],
},
{
key: 'sort',
activeKey: 'activeSort',
dropdownClass: 'min-w-56',
options: [
{
labelKey: 'SORT.RECENTLY_UPDATED',
value: 'recently_updated',
icon: 'i-lucide-arrow-down-up',
},
{
labelKey: 'SORT.RECENTLY_CREATED',
value: 'recently_created',
icon: 'i-lucide-clock',
},
],
},
];
const filterMenus = computed(() =>
MENU_CONFIG.filter(
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
).map(menu => {
const active = props[menu.activeKey];
const items = menu.options.map(opt => ({
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
value: opt.value,
icon: opt.icon,
action: menu.key,
isSelected: opt.value === active,
}));
return {
...menu,
items,
selected: items.find(item => item.isSelected) || items[0],
};
})
);
const closeMenu = () => {
openMenu.value = null;
};
const toggleMenu = menu => {
openMenu.value = openMenu.value === menu ? null : menu;
};
const handleMenuAction = ({ action, value }) => {
closeMenu();
if (action === 'source') emit('selectSource', value);
else if (action === 'status') emit('selectStatus', value);
else if (action === 'sort') emit('selectSort', value);
};
</script>
<template>
<div
v-on-click-outside="closeMenu"
class="inline-flex flex-wrap items-center gap-2 pt-2 w-fit"
>
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
<Button
:icon="menu.selected.icon"
slate
size="sm"
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
@click="toggleMenu(menu.key)"
>
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
</Button>
<DropdownMenu
v-if="openMenu === menu.key"
:menu-items="menu.items"
:class="menu.dropdownClass"
class="top-full mt-2 ltr:left-0 rtl:right-0"
@action="handleMenuAction"
/>
</div>
</div>
</template>
@@ -0,0 +1,153 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
status: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
errorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
staleAfterHours: {
type: Number,
default: null,
},
showRetry: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['retry']);
const { t } = useI18n();
const SECONDS_PER_HOUR = 3600;
const SYNCING = 'syncing';
const FAILED = 'failed';
const ERROR_CODE_LABELS = {
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
};
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
const hasSyncingStatus = computed(() => props.status === SYNCING);
const isSyncing = computed(
() => hasSyncingStatus.value && props.syncInProgress
);
const isStaleSync = computed(
() => hasSyncingStatus.value && !props.syncInProgress
);
const isFailed = computed(() => props.status === FAILED);
const canRetry = computed(() => isFailed.value || isStaleSync.value);
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
const ageInHours = computed(() => {
if (!props.lastSyncedAt) return null;
const nowSeconds = Date.now() / 1000;
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
});
const staleAfterHours = computed(() => Number(props.staleAfterHours));
const hasStaleThreshold = computed(
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
);
const isStale = computed(
() =>
hasStaleThreshold.value &&
ageInHours.value !== null &&
ageInHours.value >= staleAfterHours.value
);
const errorLabel = computed(() =>
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
);
const label = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const fullLabel = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
error: errorLabel.value,
});
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const tone = computed(() => {
if (isSyncing.value) return 'amber';
if (isStaleSync.value) return 'amber';
if (isFailed.value) return 'ruby';
if (isStale.value) return 'amber';
return 'slate';
});
const textClass = computed(() => {
if (tone.value === 'amber') return 'text-n-amber-11';
if (tone.value === 'ruby') return 'text-n-ruby-11';
return 'text-n-slate-11';
});
const statusIcon = computed(() => {
if (isFailed.value || isStale.value || isStaleSync.value) {
return 'i-lucide-circle-alert';
}
return 'i-lucide-refresh-cw';
});
</script>
<template>
<span
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
:class="textClass"
:title="fullLabel"
>
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
<span class="truncate">{{ label }}</span>
<Button
v-if="showRetry && canRetry"
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
xs
link
ruby
icon="i-lucide-refresh-cw"
class="hover:!no-underline !gap-1 ms-1"
@click.stop="emit('retry')"
/>
</span>
</template>
@@ -15,7 +15,7 @@ defineProps({
},
});
const emit = defineEmits(['close']);
const emit = defineEmits(['close', 'createSuccess']);
const { t } = useI18n();
const store = useStore();
@@ -26,6 +26,7 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
emit('createSuccess');
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
@@ -1,9 +1,8 @@
<script setup>
import { useTemplateRef, computed, ref, onMounted } from 'vue';
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -19,7 +18,6 @@ const props = defineProps({
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedTeam = ref(null);
@@ -77,10 +75,6 @@ const handleDismiss = () => {
selectedTeam.value = null;
toggleDropdown(false);
};
onMounted(() => {
store.dispatch('teams/get');
});
</script>
<template>
+1
View File
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
@@ -22,6 +22,19 @@
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
@@ -31,9 +44,17 @@
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
"CONTACTS": "Contacts"
"CONTACTS": "Contacts",
"HISTORY": "History",
"NOTES": "Notes"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search attributes...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
@@ -1011,7 +1011,8 @@
"LABEL": "Password",
"PLACE_HOLDER": "Password"
},
"ENABLE_SSL": "Enable SSL"
"ENABLE_SSL": "Enable SSL",
"AUTH_MECHANISM": "Authentication"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -742,6 +742,7 @@
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete",
"BULK_SYNC_BUTTON": "Refresh",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
@@ -749,6 +750,51 @@
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "Failed"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "updating...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "Page not found",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -793,11 +839,15 @@
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
@@ -1,15 +1,19 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { computed, onUnmounted, ref, nextTick, watch } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { debounce } from '@chatwoot/utils';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import DocumentFilter from 'dashboard/components-next/captain/assistant/DocumentFilter.vue';
import DocumentBulkActions from 'dashboard/components-next/captain/assistant/DocumentBulkActions.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
@@ -18,6 +22,7 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { useI18n } from 'vue-i18n';
const route = useRoute();
@@ -25,6 +30,9 @@ const store = useStore();
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const SYNC_POLL_INTERVAL_MS = 5000;
const SYNC_POLL_MAX_DURATION_MS = 15 * 60 * 1000;
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
const documents = useMapGetter('captainDocuments/getRecords');
@@ -36,7 +44,6 @@ const canManageDocuments = computed(() => checkPermissions(['administrator']));
const selectedDocument = ref(null);
const deleteDocumentDialog = ref(null);
const bulkDeleteDialog = ref(null);
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
@@ -66,6 +73,160 @@ const handleCreateDialogClose = () => {
showCreateDialog.value = false;
};
const documentFilter = ref(null);
const syncIntervalHours = ref(null);
const searchQuery = ref('');
const currentAssistantId = () =>
Number.isFinite(selectedAssistantId.value) ? selectedAssistantId.value : null;
const buildDocumentFilterParams = (page = 1) => {
const filterParams = documentFilter.value?.buildParams(page) ?? {
page,
sort: 'recently_updated',
};
const assistantId = currentAssistantId();
if (assistantId) filterParams.assistantId = assistantId;
const trimmedQuery = searchQuery.value.trim();
if (trimmedQuery) filterParams.searchKey = trimmedQuery;
return filterParams;
};
let documentsRequestId = 0;
let fetchingListRequestId = null;
const isCurrentDocumentRequest = (requestId, filterParams) =>
requestId === documentsRequestId &&
(filterParams.assistantId || null) === currentAssistantId();
const pruneSelectionToDocuments = nextDocuments => {
if (!bulkSelectedIds.value.size) return;
const visibleDocumentIds = new Set(nextDocuments.map(doc => doc.id));
const selectedIds = new Set(
[...bulkSelectedIds.value].filter(id => visibleDocumentIds.has(id))
);
if (selectedIds.size !== bulkSelectedIds.value.size) {
bulkSelectedIds.value = selectedIds;
}
};
const fetchDocuments = async (page = 1, { showLoader = true } = {}) => {
documentsRequestId += 1;
const requestId = documentsRequestId;
const filterParams = buildDocumentFilterParams(page);
if (showLoader) {
fetchingListRequestId = requestId;
store.dispatch('captainDocuments/setFetchingList', true);
}
try {
const response = await CaptainDocumentAPI.get(filterParams);
if (!isCurrentDocumentRequest(requestId, filterParams)) {
return [];
}
const { payload, meta } = response.data;
store.dispatch('captainDocuments/setRecords', { records: payload, meta });
pruneSelectionToDocuments(payload);
syncIntervalHours.value = Number(meta?.sync_interval_hours) || null;
return payload;
} catch (error) {
if (isCurrentDocumentRequest(requestId, filterParams)) {
throw error;
}
return [];
} finally {
if (showLoader && fetchingListRequestId === requestId) {
fetchingListRequestId = null;
store.dispatch('captainDocuments/setFetchingList', false);
}
}
};
const refreshDocumentsPage = (
page = documentsMeta.value?.page || 1,
{ showLoader = false } = {}
) => {
return fetchDocuments(page, { showLoader }).catch(() => {});
};
const onFiltersChanged = () => {
bulkSelectedIds.value = new Set();
fetchDocuments(1);
};
const debouncedSearch = debounce(() => {
bulkSelectedIds.value = new Set();
fetchDocuments(1);
}, 300);
const syncPollStartedAt = ref(null);
const hasDocumentsSyncing = computed(() =>
(documents.value || []).some(doc => doc.sync_in_progress)
);
const hasSyncingDocuments = computed(() => hasDocumentsSyncing.value);
const isWithinSyncPollWindow = () =>
syncPollStartedAt.value &&
Date.now() - syncPollStartedAt.value < SYNC_POLL_MAX_DURATION_MS;
const shouldContinueSyncPolling = () =>
hasSyncingDocuments.value && isWithinSyncPollWindow();
let syncPollingControls;
function stopSyncPolling() {
syncPollingControls.pause();
syncPollStartedAt.value = null;
}
async function pollSyncDocuments() {
try {
await refreshDocumentsPage();
} catch (error) {
// Keep the existing polling decision based on the last known sync state.
}
if (!shouldContinueSyncPolling()) {
stopSyncPolling();
}
}
function scheduleSyncPoll({ extendWindow = false } = {}) {
if (extendWindow || !syncPollStartedAt.value) {
syncPollStartedAt.value = Date.now();
}
if (syncPollingControls.isActive.value) return;
syncPollingControls.resume();
}
syncPollingControls = useTimeoutPoll(pollSyncDocuments, SYNC_POLL_INTERVAL_MS, {
immediate: false,
});
watch(hasSyncingDocuments, isSyncing => {
if (isSyncing) {
scheduleSyncPoll();
}
});
const handleSync = async id => {
try {
await store.dispatch('captainDocuments/sync', id);
useAlert(t('CAPTAIN.DOCUMENTS.SYNC.QUEUED_MESSAGE'));
scheduleSyncPoll({ extendWindow: true });
} catch (error) {
useAlert(t('CAPTAIN.DOCUMENTS.SYNC.ERROR_MESSAGE'));
}
};
const handleAction = ({ action, id }) => {
selectedDocument.value = documents.value.find(
captainDocument => id === captainDocument.id
@@ -76,19 +237,12 @@ const handleAction = ({ action, id }) => {
handleDelete();
} else if (action === 'viewRelatedQuestions') {
handleShowRelatedDocument();
} else if (action === 'sync') {
handleSync(id);
}
});
};
const fetchDocuments = (page = 1) => {
const filterParams = { page };
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
store.dispatch('captainDocuments/get', filterParams);
};
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
fetchDocuments(page);
@@ -101,31 +255,14 @@ const onPageChange = page => {
const onDeleteSuccess = () => {
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
onPageChange(documentsMeta.value.page - 1);
} else {
refreshDocumentsPage();
}
};
const buildSelectedCountLabel = computed(() => {
const count = documents.value?.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.DOCUMENTS.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
const shouldShowSelectionControl = docId => {
return (
canManageDocuments.value &&
(hoveredCard.value === docId || hasBulkSelection.value)
);
};
const shouldShowSelectionControl = docId =>
canManageDocuments.value &&
(hoveredCard.value === docId || bulkSelectedIds.value.size > 0);
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
@@ -152,12 +289,33 @@ const fetchDocumentsAfterBulkAction = () => {
bulkSelectedIds.value = new Set();
};
const onBulkDeleteSuccess = () => {
fetchDocumentsAfterBulkAction();
const onCreateSuccess = () => {
refreshDocumentsPage(1);
};
onMounted(() => {
fetchDocuments();
const hasActiveDocumentFilters = computed(
() =>
(documentFilter.value?.hasActiveFilters ?? false) ||
Boolean(searchQuery.value.trim())
);
watch(
selectedAssistantId,
async () => {
documentFilter.value?.reset();
searchQuery.value = '';
bulkSelectedIds.value = new Set();
syncIntervalHours.value = null;
stopSyncPolling();
await fetchDocuments(1);
if (hasSyncingDocuments.value) scheduleSyncPoll();
},
{ immediate: true }
);
onUnmounted(() => {
stopSyncPolling();
documentsRequestId += 1;
});
</script>
@@ -170,27 +328,43 @@ onMounted(() => {
:current-page="documentsMeta.page"
:show-pagination-footer="!isFetching && !!documents.length"
:is-fetching="isFetching"
:is-empty="!documents.length"
:show-know-more="false"
:is-empty="!documents.length && !hasActiveDocumentFilters"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
<template #subHeader>
<Policy :permissions="['administrator']">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="documents"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': bulkSelectedIds.size > 0 }"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
<template #search>
<div
v-if="bulkSelectedIds.size === 0"
class="flex gap-3 justify-between w-full items-center"
>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.DOCUMENTS.FILTERS.SEARCH_PLACEHOLDER')"
class="max-w-64 min-w-0 w-full"
size="sm"
type="search"
@input="debouncedSearch"
/>
</Policy>
</div>
</template>
<template #subHeader>
<Policy :permissions="['administrator']">
<DocumentBulkActions
v-model:selected-ids="bulkSelectedIds"
:documents="documents"
@bulk-sync-queued="scheduleSyncPoll({ extendWindow: true })"
@bulk-delete-succeeded="fetchDocumentsAfterBulkAction"
/>
</Policy>
<DocumentFilter
v-show="!bulkSelectedIds.size"
ref="documentFilter"
class="mb-2"
@change="onFiltersChanged"
/>
</template>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
@@ -214,15 +388,34 @@ onMounted(() => {
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<div
v-if="!documents.length && hasActiveDocumentFilters"
class="flex flex-col items-center justify-center min-h-80 gap-2 text-center"
>
<span class="text-base font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_TITLE') }}
</span>
<span class="max-w-md text-sm text-n-slate-11">
{{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_SUBTITLE') }}
</span>
</div>
<div v-else class="flex flex-col gap-4">
<DocumentCard
v-for="doc in documents"
:id="doc.id"
:key="doc.id"
:name="doc.name || doc.external_link"
:external-link="doc.external_link"
:pdf-document="doc.pdf_document"
:assistant="doc.assistant"
:created-at="doc.created_at"
:status="doc.status"
:sync-status="doc.sync_status"
:last-synced-at="doc.last_synced_at"
:last-sync-error-code="doc.last_sync_error_code"
:sync-in-progress="doc.sync_in_progress"
:sync-stale-after-hours="syncIntervalHours"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
@@ -244,6 +437,7 @@ onMounted(() => {
v-if="showCreateDialog"
ref="createDocumentDialog"
:assistant-id="selectedAssistantId"
@create-success="onCreateSuccess"
@close="handleCreateDialogClose"
/>
<DeleteDialog
@@ -253,12 +447,5 @@ onMounted(() => {
type="Documents"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="AssistantDocument"
@delete-success="onBulkDeleteSuccess"
/>
</PageLayout>
</template>
@@ -3,11 +3,13 @@ import { ref, computed, onMounted, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { debounce } from '@chatwoot/utils';
import { useCompaniesStore } from 'dashboard/stores/companies';
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const DEFAULT_SORT_FIELD = 'name';
const DEBOUNCE_DELAY = 300;
@@ -26,6 +28,7 @@ const uiFlags = computed(() => companiesStore.getUIFlags);
const searchQuery = computed(() => route.query?.search || '');
const searchValue = ref(searchQuery.value);
const createCompanyDialogRef = ref(null);
const pageNumber = computed(() => Number(route.query?.page) || 1);
const parseSortSettings = (sortString = '') => {
@@ -51,6 +54,7 @@ const activeSort = computed(() => sortState.activeSort);
const activeOrdering = computed(() => sortState.activeOrdering);
const isFetchingList = computed(() => uiFlags.value.fetchingList);
const isCreatingCompany = computed(() => uiFlags.value.creatingItem);
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
@@ -121,6 +125,21 @@ const showCompany = companyId => {
});
};
const openCreateCompanyDialog = () => {
createCompanyDialogRef.value?.dialogRef.open();
};
const createCompany = async company => {
try {
const newCompany = await companiesStore.create(company);
createCompanyDialogRef.value?.onSuccess();
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
showCompany(newCompany.id);
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
}
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -155,6 +174,7 @@ onMounted(() => {
@update:current-page="onPageChange"
@update:sort="handleSort"
@search="onSearch"
@create="openCreateCompanyDialog"
>
<div v-if="isFetchingList" class="flex items-center justify-center p-8">
<span class="text-n-slate-11 text-base">{{
@@ -182,5 +202,10 @@ onMounted(() => {
@show-company="showCompany"
/>
</div>
<CompanyCreateDialog
ref="createCompanyDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</CompaniesListLayout>
</template>
@@ -8,7 +8,10 @@ import Policy from 'dashboard/components/policy.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CompaniesDetailsLayout from 'dashboard/components-next/Companies/CompaniesDetailsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import CompanyContactsSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue';
import CompanyHistorySidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyHistorySidebar.vue';
import CompanyNotesSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyNotesSidebar.vue';
import CompanyProfileCard from 'dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue';
import ConfirmCompanyDeleteDialog from 'dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
@@ -20,11 +23,16 @@ const { t } = useI18n();
const confirmDeleteDialogRef = ref(null);
const selectedCandidate = ref(null);
const activeSidebarTab = ref('history');
const companyId = computed(() => Number(route.params.companyId));
const company = computed(() => companiesStore.getRecord(companyId.value));
const companyContacts = computed(() => companiesStore.companyContacts);
const companyContactsMeta = computed(() => companiesStore.companyContactsMeta);
const companyConversations = computed(
() => companiesStore.companyConversations || []
);
const companyNotes = computed(() => companiesStore.companyNotes || []);
const contactSearchResults = computed(
() => companiesStore.contactSearchResults
);
@@ -32,6 +40,10 @@ const uiFlags = computed(() => companiesStore.getUIFlags);
const isFetchingCompany = computed(() => uiFlags.value.fetchingItem);
const isFetchingContacts = computed(() => uiFlags.value.fetchingContacts);
const isFetchingConversations = computed(
() => uiFlags.value.fetchingConversations
);
const isFetchingNotes = computed(() => uiFlags.value.fetchingNotes);
const isSearchingContacts = computed(() => uiFlags.value.searchingContacts);
const isManagingContacts = computed(
() => uiFlags.value.creatingContact || uiFlags.value.removingContact
@@ -50,6 +62,27 @@ const breadcrumbItems = computed(() => [
: []),
]);
const SIDEBAR_TABS_OPTIONS = [
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
{ key: 'CONTACTS', value: 'contacts' },
];
const sidebarTabs = computed(() =>
SIDEBAR_TABS_OPTIONS.map(tab => ({
label: {
notes: t('COMPANIES.DETAIL.SIDEBAR.TABS.NOTES'),
history: t('COMPANIES.DETAIL.SIDEBAR.TABS.HISTORY'),
contacts: `${t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS')} (${Number(companyContactsMeta.value.totalCount || 0)})`,
}[tab.value],
value: tab.value,
}))
);
const activeSidebarTabIndex = computed(() =>
SIDEBAR_TABS_OPTIONS.findIndex(tab => tab.value === activeSidebarTab.value)
);
const goToCompaniesIndex = () => {
router.push({
name: 'companies_dashboard_index',
@@ -79,6 +112,19 @@ const clearSelectedCandidate = () => {
selectedCandidate.value = null;
};
const loadSidebarTab = tab => {
if (!companyId.value) return;
if (tab === 'notes') companiesStore.getCompanyNotes(companyId.value);
if (tab === 'history') {
companiesStore.getCompanyConversations(companyId.value);
}
};
const handleSidebarTabChange = tab => {
activeSidebarTab.value = tab.value;
loadSidebarTab(tab.value);
};
const handleContactSearch = async query => {
await companiesStore.searchCompanyContactCandidates({
companyId: companyId.value,
@@ -143,10 +189,12 @@ watch(
async id => {
companiesStore.resetCompanyDetailState();
clearSelectedCandidate();
activeSidebarTab.value = 'history';
if (!id) return;
await Promise.allSettled([
companiesStore.show(id),
companiesStore.getCompanyContacts(id),
companiesStore.getCompanyConversations(id),
]);
},
{ immediate: true }
@@ -207,8 +255,29 @@ onBeforeUnmount(() => {
</Policy>
</div>
<template #sidebarHeader>
<div class="px-6 pt-6 pb-3">
<TabBar
:tabs="sidebarTabs"
:initial-active-tab="activeSidebarTabIndex"
class="w-full [&>button]:w-full bg-n-alpha-black2"
@tab-changed="handleSidebarTabChange"
/>
</div>
</template>
<template v-if="hasCompany" #sidebar>
<CompanyNotesSidebar
v-if="activeSidebarTab === 'notes'"
:notes="companyNotes"
:is-loading="isFetchingNotes"
/>
<CompanyHistorySidebar
v-if="activeSidebarTab === 'history'"
:conversations="companyConversations"
:is-loading="isFetchingConversations"
/>
<CompanyContactsSidebar
v-if="activeSidebarTab === 'contacts'"
:company="company"
:contacts="companyContacts"
:meta="companyContactsMeta"
@@ -5,11 +5,13 @@ import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFie
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelectDropdown from './components/SingleSelectDropdown.vue';
export default {
components: {
SettingsFieldSection,
NextButton,
SingleSelectDropdown,
},
props: {
inbox: {
@@ -28,6 +30,12 @@ export default {
login: '',
password: '',
isSSLEnabled: true,
authMechanism: 'plain',
authMechanisms: [
{ key: 1, value: 'plain' },
{ key: 2, value: 'login' },
{ key: 3, value: 'cram-md5' },
],
};
},
validations: {
@@ -56,6 +64,7 @@ export default {
imap_login,
imap_password,
imap_enable_ssl,
imap_authentication,
} = this.inbox;
this.isIMAPEnabled = imap_enabled;
this.address = imap_address;
@@ -63,6 +72,7 @@ export default {
this.login = imap_login;
this.password = imap_password;
this.isSSLEnabled = imap_enable_ssl;
this.authMechanism = imap_authentication || 'plain';
},
async updateInbox() {
try {
@@ -77,6 +87,7 @@ export default {
imap_login: this.login,
imap_password: this.password,
imap_enable_ssl: this.isSSLEnabled,
imap_authentication: this.authMechanism,
},
};
@@ -90,6 +101,9 @@ export default {
useAlert(error.message);
}
},
handleAuthMechanismChange(mode) {
this.authMechanism = mode;
},
},
};
</script>
@@ -155,6 +169,13 @@ export default {
/>
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
</label>
<SingleSelectDropdown
class="w-full"
:label="$t('INBOX_MGMT.IMAP.AUTH_MECHANISM')"
:selected="authMechanism"
:options="authMechanisms"
:action="handleAuthMechanismChange"
/>
</div>
<NextButton
type="submit"
@@ -61,5 +61,18 @@ export default createStore({
});
return response;
},
handleBulkSync: async function handleBulkSync({ dispatch }, { ids }) {
const response = await dispatch('processBulkAction', {
type: 'AssistantDocument',
actionType: 'sync',
ids,
});
await dispatch('captainDocuments/markSyncing', response.ids || [], {
root: true,
});
return response;
},
}),
});
@@ -1,15 +1,55 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { createStore } from '../storeFactory';
const SYNCING_STATE = 'syncing';
const markRecordsSyncing = (records, ids) => {
const idSet = new Set(ids);
return records.map(record =>
idSet.has(record.id)
? {
...record,
sync_status: SYNCING_STATE,
sync_in_progress: true,
last_sync_attempted_at: Math.floor(Date.now() / 1000),
last_sync_error_code: null,
}
: record
);
};
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
getters: {
getRecords: state => state.records,
},
actions: mutations => ({
setFetchingList({ commit }, isFetching) {
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
},
setRecords({ commit }, { records, meta }) {
commit(mutations.SET, records);
commit(mutations.SET_META, meta);
},
removeBulkRecords({ commit, getters }, ids) {
const records = getters.getRecords.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, records);
},
markSyncing({ commit, getters }, ids) {
commit(mutations.SET, markRecordsSyncing(getters.getRecords, ids));
},
async sync({ dispatch }, id) {
try {
await CaptainDocumentAPI.sync(id);
dispatch('markSyncing', [id]);
return id;
} catch (error) {
return throwErrorMessage(error);
}
},
}),
});
@@ -134,10 +134,13 @@ export const createVuexStore = options => {
* @returns {Function} Pinia store composable
*/
export const createPiniaStore = options => {
const { name, API, actions, getters } = options;
const { name, API, actions, getters, state } = options;
return defineStore(name.toLowerCase(), {
state: createInitialState,
state: () => ({
...createInitialState(),
...(state ? state() : {}),
}),
getters: {
...createGetters(),
@@ -8,10 +8,13 @@ const createInitialUIFlags = () => ({
fetchingList: false,
fetchingItem: false,
updatingItem: false,
creatingItem: false,
deletingItem: false,
deletingAvatar: false,
deletingCustomAttributes: false,
fetchingContacts: false,
fetchingConversations: false,
fetchingNotes: false,
searchingContacts: false,
creatingContact: false,
removingContact: false,
@@ -66,6 +69,16 @@ export const useCompaniesStore = createStore({
name: 'companies',
type: 'pinia',
API: CompanyAPI,
state: () => ({
activeCompanyId: null,
companyContacts: [],
companyContactsMeta: {},
companyConversations: [],
companyNotes: [],
contactSearchResults: [],
contactSearchMeta: {},
activeContactSearchQuery: '',
}),
getters: {
getCompaniesList: state => state.records,
@@ -172,6 +185,22 @@ export const useCompaniesStore = createStore({
}
},
async create(companyAttrs) {
this.setUIFlag({ creatingItem: true });
try {
const {
data: { payload },
} = await CompanyAPI.create(buildCompanyRequestPayload(companyAttrs));
const company = camelizeCompany(payload);
this.upsertCompanyRecord(company);
return company;
} catch (error) {
return throwErrorMessage(error);
} finally {
this.setUIFlag({ creatingItem: false });
}
},
async delete(id) {
this.setUIFlag({ deletingItem: true });
try {
@@ -253,6 +282,74 @@ export const useCompaniesStore = createStore({
}
},
async getCompanyNotes(companyId) {
this.setUIFlag({ fetchingNotes: true });
this.ensureActiveCompanyContext(companyId);
const activeCompanyId = Number(companyId);
const requestToken = (this.companyNotesRequestToken || 0) + 1;
this.companyNotesRequestToken = requestToken;
try {
const {
data: { payload },
} = await CompanyAPI.listNotes(companyId);
const notes = camelcaseKeys(payload || [], { deep: true });
if (
this.companyNotesRequestToken !== requestToken ||
this.activeCompanyId !== activeCompanyId
) {
return notes;
}
this.companyNotes = notes;
return notes;
} catch (error) {
return throwErrorMessage(error);
} finally {
if (
this.companyNotesRequestToken === requestToken &&
this.activeCompanyId === activeCompanyId
) {
this.setUIFlag({ fetchingNotes: false });
}
}
},
async getCompanyConversations(companyId) {
this.setUIFlag({ fetchingConversations: true });
this.ensureActiveCompanyContext(companyId);
const activeCompanyId = Number(companyId);
const requestToken = (this.companyConversationsRequestToken || 0) + 1;
this.companyConversationsRequestToken = requestToken;
try {
const {
data: { payload },
} = await CompanyAPI.listConversations(companyId);
const conversations = camelcaseKeys(payload || [], { deep: true });
if (
this.companyConversationsRequestToken !== requestToken ||
this.activeCompanyId !== activeCompanyId
) {
return conversations;
}
this.companyConversations = conversations;
return conversations;
} catch (error) {
return throwErrorMessage(error);
} finally {
if (
this.companyConversationsRequestToken === requestToken &&
this.activeCompanyId === activeCompanyId
) {
this.setUIFlag({ fetchingConversations: false });
}
}
},
async searchCompanyContactCandidates({ companyId, search, page = 1 }) {
const query = search?.trim() || '';
if (!query) {
@@ -362,10 +459,15 @@ export const useCompaniesStore = createStore({
(this.companyDetailRequestToken || 0) + 1;
this.companyContactsRequestToken =
(this.companyContactsRequestToken || 0) + 1;
this.companyConversationsRequestToken =
(this.companyConversationsRequestToken || 0) + 1;
this.companyNotesRequestToken = (this.companyNotesRequestToken || 0) + 1;
this.contactSearchRequestToken =
(this.contactSearchRequestToken || 0) + 1;
this.companyContacts = [];
this.companyContactsMeta = {};
this.companyConversations = [];
this.companyNotes = [];
this.contactSearchResults = [];
this.contactSearchMeta = {};
this.activeContactSearchQuery = '';
@@ -5,6 +5,7 @@
// Constants for document processing
const PDF_PREFIX = 'PDF:';
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
const URL_DISPLAY_PREFIX_PATTERN = /^https?:\/\/(www\.)?/i;
/**
* Checks if a document is a PDF based on its external link
@@ -16,10 +17,26 @@ export const isPdfDocument = externalLink => {
return externalLink.startsWith(PDF_PREFIX);
};
/**
* Checks if a link is safe to bind to an href attribute (http/https only).
* Guards against schemes like `javascript:` that would execute on click.
* @param {string} externalLink - The external link string
* @returns {boolean} True if the link uses http or https
*/
export const isSafeHttpLink = externalLink => {
if (!externalLink) return false;
try {
const { protocol } = new URL(externalLink);
return protocol === 'http:' || protocol === 'https:';
} catch (e) {
return false;
}
};
/**
* Formats the display link for documents
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
* For regular URLs: returns as-is
* For regular URLs: strips http(s):// and www. for a denser list view
*
* @param {string} externalLink - The external link string
* @returns {string} Formatted display link
@@ -34,5 +51,28 @@ export const formatDocumentLink = externalLink => {
return fullName.replace(TIMESTAMP_PATTERN, '');
}
return externalLink;
return externalLink.replace(URL_DISPLAY_PREFIX_PATTERN, '');
};
/**
* Returns the path of a URL for compact display in document lists. This avoids
* repeating the domain while preserving enough context to distinguish pages.
* Falls back to the bare hostname for root URLs and formatDocumentLink for
* malformed URLs and PDFs.
*/
export const getDocumentDisplayPath = externalLink => {
if (!externalLink) return '';
if (isPdfDocument(externalLink)) return formatDocumentLink(externalLink);
try {
const { pathname, hostname } = new URL(externalLink);
const path = pathname.replace(/^\/+/, '');
if (!path) return hostname.replace(/^www\./i, '');
try {
return decodeURIComponent(path);
} catch (e) {
return path;
}
} catch (e) {
return formatDocumentLink(externalLink);
}
};
@@ -1,5 +1,6 @@
import {
isPdfDocument,
isSafeHttpLink,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
@@ -31,6 +32,35 @@ describe('documentHelper', () => {
});
});
describe('#isSafeHttpLink', () => {
it('returns true for http and https URLs', () => {
expect(isSafeHttpLink('http://example.com')).toBe(true);
expect(isSafeHttpLink('https://example.com/path?q=1#x')).toBe(true);
expect(isSafeHttpLink('HTTPS://EXAMPLE.COM')).toBe(true);
});
/* eslint-disable no-script-url */
it('returns false for javascript: and other dangerous schemes', () => {
expect(isSafeHttpLink('javascript:alert(1)')).toBe(false);
expect(isSafeHttpLink('JavaScript:alert(1)')).toBe(false);
expect(isSafeHttpLink('data:text/html,<script>alert(1)</script>')).toBe(
false
);
expect(isSafeHttpLink('vbscript:msgbox(1)')).toBe(false);
expect(isSafeHttpLink('file:///etc/passwd')).toBe(false);
expect(isSafeHttpLink('ftp://files.example.com/doc.pdf')).toBe(false);
});
/* eslint-enable no-script-url */
it('returns false for invalid or empty values', () => {
expect(isSafeHttpLink('')).toBe(false);
expect(isSafeHttpLink(null)).toBe(false);
expect(isSafeHttpLink(undefined)).toBe(false);
expect(isSafeHttpLink('not a url')).toBe(false);
expect(isSafeHttpLink('//example.com')).toBe(false);
});
});
describe('#formatDocumentLink', () => {
describe('PDF documents', () => {
it('removes PDF: prefix from PDF documents', () => {
@@ -78,32 +108,30 @@ describe('documentHelper', () => {
});
describe('Regular URLs', () => {
it('returns regular URLs unchanged', () => {
expect(formatDocumentLink('https://example.com')).toBe(
'https://example.com'
);
it('removes http(s) and www prefixes for compact display', () => {
expect(formatDocumentLink('https://example.com')).toBe('example.com');
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
'http://docs.example.com/api'
'docs.example.com/api'
);
expect(formatDocumentLink('https://github.com/user/repo')).toBe(
'https://github.com/user/repo'
expect(formatDocumentLink('https://www.github.com/user/repo')).toBe(
'github.com/user/repo'
);
});
it('handles URLs with query parameters', () => {
expect(formatDocumentLink('https://example.com?param=value')).toBe(
'https://example.com?param=value'
'example.com?param=value'
);
expect(
formatDocumentLink(
'https://api.example.com/docs?version=v1&format=json'
)
).toBe('https://api.example.com/docs?version=v1&format=json');
).toBe('api.example.com/docs?version=v1&format=json');
});
it('handles URLs with fragments', () => {
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
'https://example.com/docs#section1'
'example.com/docs#section1'
);
});
});
+4 -1
View File
@@ -3,7 +3,10 @@ class Account::BrandingEnrichmentJob < ApplicationJob
def perform(account_id, email)
result = WebsiteBrandingService.new(email).perform
return if result.blank?
if result.blank?
Rails.logger.info "[BrandingEnrichment] Enrichment failed for account=#{account_id} email=#{email}"
return
end
account = Account.find(account_id)
account.name = result[:title] if result[:title].present?
+17 -10
View File
@@ -3,19 +3,19 @@ class HookJob < MutexApplicationJob
queue_as :medium
INTEGRATION_PROCESSORS = {
'slack' => :process_slack_integration,
'dialogflow' => :process_dialogflow_integration,
'google_translate' => :google_translate_integration,
'leadsquared' => :process_leadsquared_integration_with_lock,
'linear' => :process_linear_integration
}.freeze
def perform(hook, event_name, event_data = {})
return if hook.disabled?
case hook.app_id
when 'slack'
process_slack_integration(hook, event_name, event_data)
when 'dialogflow'
process_dialogflow_integration(hook, event_name, event_data)
when 'google_translate'
google_translate_integration(hook, event_name, event_data)
when 'leadsquared'
process_leadsquared_integration_with_lock(hook, event_name, event_data)
end
processor = INTEGRATION_PROCESSORS[hook.app_id]
send(processor, hook, event_name, event_data) if processor
rescue StandardError => e
Rails.logger.error e
end
@@ -57,6 +57,13 @@ class HookJob < MutexApplicationJob
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
end
def process_linear_integration(hook, event_name, event_data)
return unless event_name == 'message.created'
message = event_data[:message]
Integrations::Linear::AutoLinkService.new(account: hook.account, message: message).perform
end
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
# Why do we need a mutex here? glad you asked
# When a new conversation is created. We get a contact created event, immediately followed by
@@ -0,0 +1,11 @@
class Labels::RemoveAssociationsJob < ApplicationJob
queue_as :default
def perform(label_title:, account_id:, label_deleted_at:)
Labels::DestroyService.new(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
).perform
end
end
+2
View File
@@ -128,3 +128,5 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
+2 -1
View File
@@ -62,7 +62,8 @@ class HookListener < BaseListener
'slack' => ['message.created', 'message.updated'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved'],
'linear' => ['message.created']
}
return false unless supported_events_map.key?(hook.app_id)
+12
View File
@@ -104,11 +104,23 @@ class Attachment < ApplicationRecord
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
# which makes <audio> elements download instead of play. Force inline so the
# call-recording chip (and any other audio bubble) can stream directly.
data_url: inline_audio_url,
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def inline_audio_url
return '' unless file.attached?
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
end
def file_metadata
metadata = {
extension: extension,
+2 -1
View File
@@ -6,6 +6,7 @@
# email :string not null
# forward_to_email :string not null
# imap_address :string default("")
# imap_authentication :string default("plain")
# imap_enable_ssl :boolean default(TRUE)
# imap_enabled :boolean default(FALSE)
# imap_login :string default("")
@@ -47,7 +48,7 @@ class Channel::Email < ApplicationRecord
end
self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :imap_authentication,
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication, :provider, :verified_for_sending].freeze
+10
View File
@@ -40,6 +40,16 @@ class Channel::Whatsapp < ApplicationRecord
'Whatsapp'
end
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled?
provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['calling_enabled'].present? &&
account.feature_enabled?('channel_voice')
end
def provider_service
if provider == 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
@@ -0,0 +1,21 @@
class CustomAttributeDefinitionPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account_user.agent?
end
def show?
@account_user.administrator? || @account_user.agent?
end
def create?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
end
end
+2 -1
View File
@@ -46,7 +46,8 @@ class Base::SendOnChannelService
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
message.private? || outgoing_message_originated_from_channel?
# voice_call bubbles are call status indicators, not deliverable messages
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
end
def validate_target_channel
@@ -89,11 +89,19 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
metadata[metadata_key] = activity_id
store_conversation_metadata(conversation, metadata)
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}"
log_activity_error(e, activity_type, conversation, payload: { lead_id: lead_id, activity_code: activity_code, activity_note: activity_note })
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}"
log_activity_error(e, activity_type, conversation)
end
def log_activity_error(error, activity_type, conversation, payload: nil)
ChatwootExceptionTracker.new(error, account: @account).capture_exception
context = "account_id=#{conversation.account_id}, conversation_display_id=#{conversation.display_id}"
if payload
context += ", http_status=#{error.code}, prospect_id=#{payload[:lead_id]}, " \
"activity_event=#{payload[:activity_code]}, note_bytes=#{payload[:activity_note].to_s.bytesize}"
end
Rails.logger.error("LeadSquared #{activity_type} activity failed: #{error.message} (#{context})")
end
def get_activity_code(key)
+31
View File
@@ -0,0 +1,31 @@
module Imap::Authentication
DEFAULT_MECHANISM = 'plain'.freeze
USER_CONFIGURABLE_MECHANISMS = %w[plain login cram-md5].freeze
module_function
def normalize(mechanism)
mechanism.presence || DEFAULT_MECHANISM
end
def validate_user_configurable!(mechanism)
normalized_mechanism = normalize(mechanism).to_s.downcase
return normalized_mechanism if USER_CONFIGURABLE_MECHANISMS.include?(normalized_mechanism)
allowed_values = USER_CONFIGURABLE_MECHANISMS.join(', ')
raise StandardError, "Invalid IMAP authentication mechanism. Allowed values: #{allowed_values}"
end
def authenticate!(imap, mechanism, username, password)
normalized_mechanism = normalize(mechanism).to_s.downcase
case normalized_mechanism
when 'cram-md5'
imap.authenticate('CRAM-MD5', username, password)
when 'login'
imap.login(username, password)
else
imap.authenticate(normalize(mechanism), username, password)
end
end
end
@@ -130,8 +130,9 @@ class Imap::BaseFetchEmailService
end
def build_imap_client
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
imap.authenticate(authentication_type, channel.imap_login, imap_password)
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: channel.imap_enable_ssl)
Imap::Authentication.authenticate!(imap, authentication_type, channel.imap_login, imap_password)
imap.select('INBOX')
imap
end
+1 -1
View File
@@ -6,7 +6,7 @@ class Imap::FetchEmailService < Imap::BaseFetchEmailService
private
def authentication_type
'PLAIN'
channel.imap_authentication || 'plain'
end
def imap_password
+60
View File
@@ -0,0 +1,60 @@
class Labels::DestroyService
pattr_initialize [:label_title!, :account_id!, :label_deleted_at!]
def perform
remove_conversation_labels
remove_contact_labels
end
private
def remove_conversation_labels
tagged_conversations.find_in_batches do |conversation_batch|
conversation_batch.each do |conversation|
update_conversation_cached_labels(conversation)
end
delete_label_taggings('Conversation', conversation_batch.map(&:id))
end
end
def remove_contact_labels
contact_label_taggings.in_batches do |tagging_batch|
ActsAsTaggableOn::Tagging.where(id: tagging_batch.select(:id)).delete_all
end
end
def update_conversation_cached_labels(conversation)
label_list = conversation.label_list.dup
label_list.remove(label_title)
# We only want the acts-as-taggable-on cache effect here, not Conversation callbacks/events.
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:cached_label_list, label_list.join("#{ActsAsTaggableOn.delimiter} "))
# rubocop:enable Rails/SkipsModelValidations
end
def tagged_conversations
account.conversations.where(id: label_taggings_for('Conversation').select(:taggable_id))
end
def contact_label_taggings
label_taggings_for('Contact').where(taggable_id: account.contacts.select(:id))
end
def delete_label_taggings(taggable_type, taggable_ids)
ActsAsTaggableOn::Tagging
.where(id: label_taggings_for(taggable_type).where(taggable_id: taggable_ids).select(:id))
.delete_all
end
def label_taggings_for(taggable_type)
ActsAsTaggableOn::Tagging
.joins(:tag)
.where(context: 'labels', taggable_type: taggable_type, tags: { name: label_title })
.where('taggings.created_at <= ?', label_deleted_at)
end
def account
@account ||= Account.find(account_id)
end
end
+1 -1
View File
@@ -8,8 +8,8 @@ class Messages::MentionService
return if validated_mentioned_ids.blank?
Conversations::UserMentionJob.perform_later(validated_mentioned_ids, message.conversation.id, message.account.id)
generate_notifications_for_mentions(validated_mentioned_ids)
add_mentioned_users_as_participants(validated_mentioned_ids)
generate_notifications_for_mentions(validated_mentioned_ids)
end
private
@@ -0,0 +1,75 @@
class Onboarding::WebWidgetCreationService
DEFAULT_WIDGET_COLOR = '#1f93ff'.freeze
# context.dev descriptions and LLM completions are unbounded; bound the
# stored tagline so a long string doesn't render as a wall of text in the
# widget UI (and so backends that enforce varchar limits don't raise).
WELCOME_TAGLINE_MAX_LENGTH = 255
def initialize(account, user)
@account = account
@user = user
end
def perform
existing = existing_web_widget_inbox
if existing
Rails.logger.info "[WidgetCreation] Reusing existing web widget inbox #{existing.id} for account #{@account.id}"
return existing
end
if website_url.blank?
Rails.logger.info "[WidgetCreation] Skipping for account #{@account.id}: no website_url available"
return nil
end
attrs = channel_attributes
ActiveRecord::Base.transaction do
channel = @account.web_widgets.create!(attrs)
inbox = @account.inboxes.create!(name: @account.name, channel: channel)
InboxMember.find_or_create_by!(inbox: inbox, user: @user)
inbox
end
rescue StandardError => e
Rails.logger.error "[WidgetCreation] #{e.message}"
nil
end
private
def existing_web_widget_inbox
@account.inboxes.find_by(channel_type: 'Channel::WebWidget')
end
def channel_attributes
{
website_url: website_url,
widget_color: widget_color,
welcome_title: welcome_title,
welcome_tagline: welcome_tagline_text&.truncate(WELCOME_TAGLINE_MAX_LENGTH)
}
end
def brand_info
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
end
def website_url
@account.domain.presence || brand_info[:domain].presence
end
def widget_color
hex = brand_info[:colors]&.first&.dig(:hex)
hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_WIDGET_COLOR
end
def welcome_title
brand_info[:title].presence || @account.name
end
def welcome_tagline_text
brand_info[:slogan].presence || brand_info[:description].presence
end
end
Onboarding::WebWidgetCreationService.prepend_mod_with('Onboarding::WebWidgetCreationService')
+1 -1
View File
@@ -86,7 +86,7 @@ class Whatsapp::FacebookApiClient
body: {
override_callback_uri: callback_url,
verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes]
subscribed_fields: %w[messages smb_message_echoes calls]
}.to_json
)
@@ -90,6 +90,7 @@ if resource.email?
json.imap_port resource.channel.try(:imap_port)
json.imap_enabled resource.channel.try(:imap_enabled)
json.imap_enable_ssl resource.channel.try(:imap_enable_ssl)
json.imap_authentication resource.channel.try(:imap_authentication)
if resource.channel.try(:microsoft?) || resource.channel.try(:google?) || resource.channel.try(:legacy_google?)
json.reauthorization_required resource.channel.try(:provider_config).empty? || resource.channel.try(:reauthorization_required?)
@@ -142,3 +143,6 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
+13
View File
@@ -0,0 +1,13 @@
# Allow audio attachments (call recordings, voice notes) to serve inline so the
# in-app <audio> player can stream them. Without this, ActiveStorage's blob model
# forces Content-Disposition: attachment for any MIME outside the default allowlist
# (images + PDF), which makes the browser download instead of play.
Rails.application.config.active_storage.content_types_allowed_inline += %w[
audio/webm
audio/ogg
audio/mpeg
audio/mp4
audio/x-m4a
audio/wav
audio/x-wav
]
+39788 -2540
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -116,6 +116,13 @@ en:
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
calls:
not_enabled: 'Calling is not enabled for this inbox'
no_recording: 'No recording file provided'
no_message: 'Call has no associated message'
sdp_offer_required: 'sdp_offer is required'
contact_phone_required: 'Contact phone number is required'
permission_request_failed: 'Failed to send call permission request'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
@@ -246,6 +253,9 @@ en:
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
voice_call:
twilio: 'Voice Call'
whatsapp: 'WhatsApp Call'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -290,6 +300,9 @@ en:
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
whatsapp_call:
permission_requested: 'Sent a call permission request to %{contact_name}.'
permission_granted: '%{contact_name} accepted the call permission request.'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
auto_resolve:
@@ -354,6 +367,7 @@ en:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
attachment_link_title: 'Conversation (#%{conversation_id}) with %{name}'
notion:
name: 'Notion'
short_description: 'Integrate databases, documents and pages directly with Captain.'
+17
View File
@@ -187,6 +187,8 @@ Rails.application.routes.draw do
get :search
end
end
resources :conversations, only: [:index]
resources :notes, only: [:index]
end
end
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
@@ -226,6 +228,21 @@ Rails.application.routes.draw do
end
end
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
resources :whatsapp_calls, only: [:show] do
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
collection do
post :initiate
end
end
end
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -0,0 +1,12 @@
class AddSyncStatsIndexToCaptainDocuments < ActiveRecord::Migration[7.0]
def up
add_index :captain_documents,
[:account_id, :assistant_id, :sync_status, :last_synced_at],
name: 'idx_captain_documents_on_account_assistant_sync_stats',
if_not_exists: true
end
def down
remove_index :captain_documents, name: 'idx_captain_documents_on_account_assistant_sync_stats', if_exists: true
end
end
@@ -0,0 +1,5 @@
class AddImapAuthenticationToChannelEmail < ActiveRecord::Migration[7.0]
def change
add_column :channel_email, :imap_authentication, :string, default: 'plain'
end
end
+4 -2
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_04_30_114500) do
ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -381,11 +381,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
t.integer "sync_status"
t.datetime "last_synced_at"
t.datetime "last_sync_attempted_at"
t.index ["account_id", "assistant_id", "sync_status", "last_synced_at"], name: "idx_captain_documents_on_account_assistant_sync_stats"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
end
create_table "captain_inboxes", force: :cascade do |t|
@@ -472,6 +473,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
t.boolean "smtp_enable_ssl_tls", default: false
t.jsonb "provider_config", default: {}
t.string "provider"
t.string "imap_authentication", default: "plain"
t.boolean "verified_for_sending", default: false, null: false
t.index ["email"], name: "index_channel_email_on_email", unique: true
t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true
+2 -2
View File
@@ -229,7 +229,7 @@ function install_dependencies() {
nodejs patch ruby-dev zlib1g-dev liblzma-dev \
libgmp-dev libncurses5-dev libffi-dev libgdbm6 libgdbm-dev sudo \
libvips python3-pip
npm install -g pnpm
npm install -g pnpm@10.33.4
}
##############################################################################
@@ -940,7 +940,7 @@ function get_pnpm() {
return
fi
echo "pnpm is not installed. Installing pnpm..."
npm install -g pnpm
npm install -g pnpm@10.33.4
echo "Cleaning up existing node_modules directory..."
sudo -i -u chatwoot << "EOF"
cd chatwoot
+2 -2
View File
@@ -3,7 +3,7 @@ FROM node:24-alpine as node
FROM ruby:3.4.4-alpine3.21 AS pre-builder
ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ARG PNPM_VERSION="10.33.4"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -99,7 +99,7 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
FROM ruby:3.4.4-alpine3.21
ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ARG PNPM_VERSION="10.33.4"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -2,13 +2,12 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call'
super
end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
def voice_call_inbox?
@conversation.inbox.channel.try(:voice_enabled?)
end
end
@@ -77,7 +77,12 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
next unless document.available?
next if document.sync_in_progress?
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
document.update!(
sync_status: :syncing,
sync_step: nil,
last_sync_error_code: nil,
last_sync_attempted_at: Time.current
)
Captain::Documents::PerformSyncJob.perform_later(document)
synced_document_ids << document.id
end
@@ -11,8 +11,13 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def index
base_query = @documents
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
base_query = apply_source_filter(base_query, permitted_params[:source])
base_query = apply_filter(base_query, permitted_params[:filter])
base_query = apply_search(base_query, permitted_params[:search_key])
base_query = apply_sort(base_query, permitted_params[:sort])
@documents_count = base_query.count
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
end
@@ -34,7 +39,12 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
@document.update!(
sync_status: :syncing,
sync_step: nil,
last_sync_error_code: nil,
last_sync_attempted_at: Time.current
)
Captain::Documents::PerformSyncJob.perform_later(@document)
head :accepted
end
@@ -47,7 +57,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
private
def set_documents
@documents = Current.account.captain_documents.includes(:assistant).ordered
@documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant)
end
def set_document
@@ -63,7 +73,58 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
end
def permitted_params
params.permit(:assistant_id, :page, :id, :account_id)
params.permit(:assistant_id, :page, :id, :account_id, :filter, :source, :sort, :search_key)
end
def apply_source_filter(scope, source)
case source
when 'web' then scope.syncable
when 'pdf' then scope.pdf_documents
else scope
end
end
def apply_filter(scope, filter)
case filter
when 'stale' then stale_documents(scope.syncable)
when 'synced' then up_to_date_documents(scope.syncable)
when 'syncing' then scope.syncable.sync_in_progress
when 'failed' then scope.syncable.sync_failed
else scope
end
end
def apply_search(scope, search_key)
return scope if search_key.blank?
query = "%#{ActiveRecord::Base.sanitize_sql_like(search_key)}%"
scope.where('captain_documents.name ILIKE :query OR captain_documents.external_link ILIKE :query', query: query)
end
def apply_sort(scope, sort)
case sort
when 'recently_created' then scope.order(created_at: :desc)
else scope.order(updated_at: :desc)
end
end
def stale_documents(scope)
return scope.none unless current_sync_interval
scope.sync_synced.where(Captain::Document.arel_table[:last_synced_at].lt(current_sync_interval.ago))
end
def up_to_date_documents(scope)
documents = scope.sync_synced
return documents unless current_sync_interval
documents.where(Captain::Document.arel_table[:last_synced_at].gteq(current_sync_interval.ago))
end
def current_sync_interval
return @current_sync_interval if defined?(@current_sync_interval)
@current_sync_interval = Current.account.captain_document_sync_interval
end
def document_params
@@ -0,0 +1,24 @@
class Api::V1::Accounts::Companies::BaseController < Api::V1::Accounts::EnterpriseAccountsController
before_action :ensure_companies_enabled!
before_action :fetch_company
private
def ensure_companies_enabled!
return if Current.account.feature_enabled?('companies')
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
end
def fetch_company
@company = Current.account.companies.find(params[:company_id])
end
def authorize_company_read!
authorize(@company, :show?)
end
def authorize_company_update!
authorize(@company, :update?)
end
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::EnterpriseAccountsController
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 15
CONTACT_SEARCH_QUERY = [
'contacts.name ILIKE :search',
@@ -7,8 +7,6 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Ente
'contacts.identifier ILIKE :search'
].join(' OR ')
before_action :ensure_companies_enabled!
before_action :fetch_company
before_action :authorize_company_read!, only: [:index, :search]
before_action :authorize_company_update!, only: [:create, :destroy]
before_action :set_current_page, only: [:index, :search]
@@ -45,10 +43,6 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Ente
@current_page = params[:page] || 1
end
def fetch_company
@company = Current.account.companies.find(params[:company_id])
end
def fetch_contact
@contact = @company.contacts.find(params[:id])
end
@@ -70,18 +64,4 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Ente
def membership_service
@membership_service ||= Companies::ContactMembershipService.new(company: @company)
end
def ensure_companies_enabled!
return if Current.account.feature_enabled?('companies')
render json: { error: 'Companies are not enabled for this account' }, status: :forbidden
end
def authorize_company_read!
authorize(@company, :show?)
end
def authorize_company_update!
authorize(@company, :update?)
end
end
@@ -0,0 +1,15 @@
class Api::V1::Accounts::Companies::ConversationsController < Api::V1::Accounts::Companies::BaseController
before_action :authorize_company_read!
def index
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(contact_id: @company.contacts.select(:id))
@conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform.order(last_activity_at: :desc).limit(20)
end
end
@@ -0,0 +1,11 @@
class Api::V1::Accounts::Companies::NotesController < Api::V1::Accounts::Companies::BaseController
before_action :authorize_company_read!
def index
@notes = Current.account.notes
.where(contact_id: @company.contacts.select(:id))
.latest
.includes(:contact, :user)
.limit(20)
end
end
@@ -10,7 +10,8 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
account: Current.account,
inbox: voice_inbox,
user: Current.user,
contact: contact
contact: contact,
conversation: existing_conversation
)
render json: {
@@ -38,4 +39,20 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
inbox
end
end
# Reuse the open conversation when the caller is already inside it; ignore
# the hint if it doesn't belong to the picked voice inbox or dialed contact.
# Only reuse open conversations — Message#reopen_conversation skips outgoing
# messages, so dropping a voice_call bubble into a resolved/snoozed/pending
# thread leaves it stuck in that state.
def existing_conversation
return nil if params[:conversation_id].blank?
conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
return nil unless conversation
return nil unless conversation.inbox_id == voice_inbox.id && conversation.contact_id == contact.id
return nil unless conversation.open?
conversation
end
end
@@ -0,0 +1,187 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
PERMISSION_REQUEST_THROTTLE = 5.minutes
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_conversation, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
before_action :ensure_recording_present, only: :upload_recording
before_action :ensure_call_message, only: :upload_recording
rescue_from Voice::CallErrors::NotRinging,
Voice::CallErrors::AlreadyAccepted,
Voice::CallErrors::CallFailed,
with: :render_call_error
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
def show; end
def accept
call_service.accept
end
def reject
call_service.reject
end
def terminate
call_service.terminate
end
def upload_recording
@upload_status = @call.message.with_lock { attach_recording_idempotently }
end
def initiate
@call = create_outbound_call
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
private
def call_service
@call_service ||= Whatsapp::CallService.new(call: @call, agent: Current.user, sdp_answer: params[:sdp_answer])
end
def provider_service
@provider_service ||= @conversation.inbox.channel.provider_service
end
def set_call
@call = Current.account.calls.whatsapp.find(params[:id])
authorize @call.conversation, :show?
end
def set_conversation
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show?
end
def ensure_calling_enabled
channel = @conversation.inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def ensure_sdp_offer
return if params[:sdp_offer].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
end
def ensure_contact_phone
return if @conversation.contact&.phone_number.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
def ensure_recording_present
return if params[:recording].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
end
def ensure_call_message
return if @call.message.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
end
def attach_recording_idempotently
return 'already_uploaded' if @call.message.attachments.exists?(file_type: :audio)
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
'uploaded'
end
def create_outbound_call
contact_phone = @conversation.contact.phone_number.delete('+')
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
Current.account.calls.create!(
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
accepted_by_agent_id: Current.user.id,
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
)
end
# Meta error 138006 means the contact hasn't opted in yet; send the opt-in
# template (throttled, behind a conversation lock to prevent double-send).
def render_permission_request
status = nil
@conversation.with_lock do
if permission_request_throttled?
status = 'permission_pending'
next
end
sent = send_permission_request_safely
if sent
record_permission_request_wamid(sent)
emit_permission_requested_activity
status = 'permission_requested'
else
status = 'failed'
end
end
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
# 422 (not 200) so any client treating 2xx as "call placed" can't mistake
# the permission-template path for a successful dial. The FE composable
# detects this status and surfaces the banner instead of throwing.
render json: { status: status }, status: :unprocessable_entity
end
def permission_request_throttled?
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
end
# Treat transport errors as a falsy return so we render 422 rather than 500.
def send_permission_request_safely
provider_service.send_call_permission_request(
@conversation.contact.phone_number.delete('+'),
*permission_request_body_args
)
rescue StandardError => e
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
nil
end
# Pass the inbox-level override only when present so the provider falls back
# to the i18n default for inboxes that haven't customized the prompt.
def permission_request_body_args
custom_body = @conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence
custom_body ? [custom_body] : []
end
def emit_permission_requested_activity
content = I18n.t(
'conversations.activity.whatsapp_call.permission_requested',
contact_name: @conversation.contact.name
)
::Conversations::ActivityMessageJob.perform_later(
@conversation,
{ account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: :activity, content: content }
)
end
# Stash the outbound wamid so the reply webhook can match context.id back here.
def record_permission_request_wamid(sent)
attrs = (@conversation.additional_attributes || {}).merge(
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
)
@conversation.update!(additional_attributes: attrs)
end
def render_call_error(error)
render_could_not_create_error(error.message)
end
end
@@ -94,7 +94,6 @@ class Twilio::VoiceController < ApplicationController
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
@@ -20,13 +20,13 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
exception_class: error.class.name)
end
# Permanent errors (404, 403, empty content) — no point retrying, discard immediately.
# Permanent errors (404, 403, empty content) - no point retrying, discard immediately.
# Document is already marked failed by SyncService before the exception reaches here.
discard_on(Captain::Documents::SyncService::PermanentSyncError)
# TransientSyncError is raised by SyncService when the customer's site is unreachable —
# TransientSyncError is raised by SyncService when the customer's site is unreachable -
# timeouts, TLS errors, 5xx, connection drops. Four attempts with backoff gives the site
# a chance to recover before we give up.
# a chance to recover before we mark the document failed.
#
# The exhaustion block absorbs the exception so it doesn't propagate to Sentry —
# site flakiness isn't an application bug.
@@ -36,6 +36,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
attempts: 4
) do |job, error|
document = job.arguments.first
job.send(:mark_sync_failed, document, error.message)
job.send(:log_sync_outcome, document, result: :transient_retry_exhausted, error_code: error.message)
end
@@ -47,7 +48,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
mark_sync_started(document)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
@@ -78,13 +79,26 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
raise error
end
def handle_unexpected_failure(document, error, start_time)
def mark_sync_failed(document, error_code)
document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: 'sync_error',
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
end
def mark_sync_started(document)
document.update!(
sync_status: :syncing,
sync_step: nil,
last_sync_error_code: nil,
last_sync_attempted_at: Time.current
)
end
def handle_unexpected_failure(document, error, start_time)
mark_sync_failed(document, 'sync_error')
log_sync_outcome(document, result: :unexpected_failure, error_code: 'sync_error',
exception_class: error.class.name,
duration_ms: duration_ms_since(start_time))
@@ -82,7 +82,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
end
def reserve_sync_slot(document)
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
mark_sync_started(document)
true
rescue ActiveRecord::RecordInvalid => e
log_document_skip(document, e)
@@ -112,4 +112,13 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
def mark_sync_started(document)
document.update!(
sync_status: :syncing,
sync_step: nil,
last_sync_error_code: nil,
last_sync_attempted_at: Time.current
)
end
end
@@ -0,0 +1,59 @@
module Enterprise::Webhooks::WhatsappEventsJob
def handle_message_events(channel, params)
return handle_call_events(channel, params) if call_event?(params)
return handle_call_permission_reply(channel, params) if call_permission_reply?(params)
super
end
private
# Lock per-call_id inside handle_call_events instead of the parent's per-sender mutex.
def contact_sender_id(params)
return nil if call_event?(params)
super
end
def call_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
end
def call_permission_reply?(params)
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
end
# Per-call_id mutex so connect/status/terminate for the same call serialize
# across batches. Meta delivers two payload shapes under field=calls:
# - value.calls[] → event-based (connect, terminate)
# - value.statuses[] → status-based (RINGING, ACCEPTED) — the real pickup
# signal for outbound; without this, only `connect` (tunnel-up) is seen
# and timer/recorder kick off before the contact actually answers.
def handle_call_events(channel, params)
value = params.dig(:entry, 0, :changes, 0, :value) || {}
Array(value[:calls]).each do |call_payload|
with_call_lock(channel, call_payload[:id]) do
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
end
end
Array(value[:statuses]).each do |status_payload|
next unless status_payload[:type] == 'call'
with_call_lock(channel, status_payload[:id]) do
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { statuses: [status_payload] }).perform
end
end
end
def with_call_lock(channel, call_id, &)
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
inbox_id: channel.inbox.id, sender_id: "call:#{call_id}")
with_lock(lock_key, 30.seconds, &)
end
def handle_call_permission_reply(channel, params)
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
end
end
+27 -2
View File
@@ -29,13 +29,16 @@
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
#
class Call < ApplicationRecord
# All valid call statuses
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
# Frontend voice bubbles/stores expect inbound/outbound string values
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
@@ -65,6 +68,28 @@ class Call < ApplicationRecord
"conf_account_#{account_id}_call_#{id}"
end
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
def self.default_ice_servers
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
[{ urls: urls }]
end
def direction_label
DISPLAY_DIRECTION[direction]
end
def ringing?
status == 'ringing'
end
def in_progress?
status == 'in_progress'
end
def terminal?
TERMINAL_STATUSES.include?(status)
end
def display_status
status.to_s.tr('_', '-')
end
+11
View File
@@ -4,8 +4,10 @@
#
# id :bigint not null, primary key
# content :text
# content_fingerprint :string
# external_link :string not null
# last_sync_attempted_at :datetime
# last_sync_error_code :string
# last_synced_at :datetime
# metadata :jsonb
# name :string
@@ -18,6 +20,7 @@
#
# Indexes
#
# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
# index_captain_documents_on_assistant_id (assistant_id)
@@ -62,6 +65,14 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
scope :pdf_documents, -> { where("external_link LIKE 'PDF:%' OR external_link LIKE '%.pdf'") }
scope :sync_in_progress, -> { sync_syncing.where(arel_table[:last_sync_attempted_at].gteq(SYNC_STALE_TIMEOUT.ago)) }
scope :stale, lambda { |stale_before|
sync_failed.or(sync_synced.where(arel_table[:last_synced_at].lt(stale_before)))
}
scope :synced_since, lambda { |time|
sync_synced.where(arel_table[:last_synced_at].gteq(time))
}
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
@@ -3,6 +3,11 @@ module Enterprise::Concerns::Attachment
included do
after_create_commit :enqueue_audio_transcription
# Broadcast the message update so the FE bubble picks up the new audio
# attachment immediately. Without this, the FE has to wait until Whisper
# finishes (or fall back to a page refresh) — and if Whisper returns blank,
# the bubble never gets the audio at all.
after_create_commit :broadcast_message_update_for_audio
end
private
@@ -10,6 +15,20 @@ module Enterprise::Concerns::Attachment
def enqueue_audio_transcription
return unless file_type.to_sym == :audio
# No file.attached? guard: the social-media ingest path saves the
# Attachment before attaching the blob. AudioTranscriptionJob retries
# on ActiveStorage::FileNotFoundError to ride out that race.
Messages::AudioTranscriptionJob.perform_later(id)
end
def broadcast_message_update_for_audio
return unless file_type.to_sym == :audio
return unless message
# Without an attached file, the message serializer's audio_metadata path
# dereferences `file.metadata[:width]` on nil and raises. The pre-attach
# broadcast wouldn't carry useful audio info anyway — skip until upload completes.
return unless file.attached?
message.reload.send_update_event
end
end
@@ -13,6 +13,12 @@ module Enterprise::Conversation
super + %w[sla_policy_id]
end
# Surface call lifecycle changes to the FE: writes to additional_attributes
# call_status/call_direction should rebroadcast conversation_updated.
def allowed_keys?
super || call_attributes_changed?
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
@@ -30,4 +36,13 @@ module Enterprise::Conversation
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
def call_attributes_changed?
return false if previous_changes['additional_attributes'].blank?
# Compare before/after values for call keys — checking key presence alone
# rebroadcasts on any unrelated additional_attributes write once the keys exist.
before, after = previous_changes['additional_attributes']
%w[call_status call_direction].any? { |key| (before || {})[key] != (after || {})[key] }
end
end
@@ -7,6 +7,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
def stats?
true
end
def tools?
@account_user.administrator?
end
@@ -15,10 +15,7 @@ class Captain::Documents::SyncService
@document.update!(sync_step: 'fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success
mark_failed(result.error_code)
raise_for_error_code(result.error_code)
end
handle_fetch_error(result.error_code) unless result.success
@document.update!(sync_step: 'comparing')
new_fingerprint = compute_fingerprint(result.content)
@@ -75,8 +72,11 @@ class Captain::Documents::SyncService
)
end
def raise_for_error_code(error_code)
raise PermanentSyncError, error_code if PERMANENT_ERROR_CODES.include?(error_code)
def handle_fetch_error(error_code)
if PERMANENT_ERROR_CODES.include?(error_code)
mark_failed(error_code)
raise PermanentSyncError, error_code
end
raise TransientSyncError, error_code
end
@@ -0,0 +1,5 @@
class Captain::Llm::WidgetTaglineSchema < RubyLLM::Schema
string :tagline,
description: 'Short marketing tagline for a customer-support chat widget. Plain text, no quotes, no emoji, no trailing punctuation.',
max_length: 60
end
@@ -0,0 +1,78 @@
class Captain::Llm::WidgetTaglineService < Captain::BaseTaskService
RESPONSE_SCHEMA = Captain::Llm::WidgetTaglineSchema
pattr_initialize [:account!]
def perform
response = make_api_call(model: tagline_model, messages: messages, schema: RESPONSE_SCHEMA)
return response if response[:error]
response.merge(message: extract_tagline(response[:message]))
end
private
def extract_tagline(message)
tagline = message.is_a?(Hash) ? (message['tagline'] || message[:tagline]) : message
tagline.to_s.strip
end
def messages
[
{ role: 'system', content: system_prompt },
{ role: 'user', content: user_prompt }
]
end
def system_prompt
<<~PROMPT
You write a short marketing tagline for a company's customer-support chat widget.
Use the provided company context to make the tagline specific and on-brand.
PROMPT
end
def user_prompt
parts = [
"Company: #{account.name}",
("Title: #{brand_info[:title]}" if brand_info[:title].present?),
("Description: #{brand_info[:description]}" if brand_info[:description].present?),
("Slogan: #{brand_info[:slogan]}" if brand_info[:slogan].present?),
("Industries: #{industries_text}" if industries_text.present?)
].compact
parts.join("\n")
end
def brand_info
@brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
end
def industries_text
Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence
end
def event_name
'widget_tagline'
end
def llm_credential
@llm_credential ||= system_llm_credential
end
def captain_tasks_enabled?
true
end
# Tagline generation runs on the operator's OpenAI key during onboarding;
# the customer should not have captain_responses quota deducted for it.
def counts_toward_usage?
false
end
def tagline_model
@tagline_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
end
def build_follow_up_context?
false
end
end
@@ -4,15 +4,17 @@ class Enterprise::Billing::CreateStripeCustomerService
DEFAULT_QUANTITY = 2
def perform
return if existing_subscription?
active_sub = active_subscription
return false if active_sub && !default_plan_subscription?(active_sub)
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
subscription = active_sub || Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
custom_attributes = build_custom_attributes(customer_id, subscription)
custom_attributes.except!('is_creating_customer')
account.update!(custom_attributes: custom_attributes)
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
true
end
private
@@ -44,18 +46,21 @@ class Enterprise::Billing::CreateStripeCustomerService
price_ids.first
end
def existing_subscription?
def active_subscription
stripe_customer_id = account.custom_attributes['stripe_customer_id']
return false if stripe_customer_id.blank?
return nil if stripe_customer_id.blank?
subscriptions = Stripe::Subscription.list(
Stripe::Subscription.list(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
subscriptions.data.present?
).data.first
end
def default_plan_subscription?(subscription)
default_plan['price_ids'].include?(subscription['plan']['id'])
end
def build_custom_attributes(customer_id, subscription)
@@ -47,9 +47,8 @@ class Enterprise::Billing::HandleStripeEventService
def current_plan_credits
plan_name = account.custom_attributes['plan_name']
return { responses: 0, documents: 0 } if plan_name.blank?
get_plan_credits(plan_name)
plan_credits = get_plan_credits(plan_name) if plan_name.present?
plan_credits || { responses: 0, documents: 0 }
end
def update_account_attributes(subscription, plan)
@@ -71,19 +70,28 @@ class Enterprise::Billing::HandleStripeEventService
# skipping self hosted plan events
return if account.blank?
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
previous_monthly_credits = current_plan_credits[:responses]
return unless Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
account.with_lock do
previous_usage = { responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: previous_monthly_credits }
adjust_captain_credits(previous_usage, new_plan_credits: 0)
account.reset_response_usage
end
end
def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {}
adjust_captain_credits(previous_usage, new_plan_credits: get_plan_credits(plan['name'])[:responses])
end
def adjust_captain_credits(previous_usage, new_plan_credits:)
current_limits = account.limits || {}
current_credits = current_limits['captain_responses'].to_i
new_plan_credits = get_plan_credits(plan['name'])[:responses]
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
updated_credits = [current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits, 0].max
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
Rails.logger.info("Updating captain credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
@@ -0,0 +1,11 @@
module Enterprise::Onboarding::WebWidgetCreationService
private
def welcome_tagline_text
response = Captain::Llm::WidgetTaglineService.new(account: @account).perform
response&.dig(:message).to_s.strip.presence || super
rescue StandardError => e
Rails.logger.error "[WidgetCreation] LLM tagline failed: #{e.message}"
super
end
end
@@ -34,7 +34,10 @@ module Enterprise::WebsiteBrandingService
def process_response(response)
@http_status = response.code
raise "API Error: #{response.message} (Status: #{response.code})" unless response.success?
unless response.success?
Rails.logger.warn "[WebsiteBranding] Context.dev returned #{response.code}: #{response.parsed_response}"
return nil
end
brand = response.parsed_response&.dig('brand')
return nil if brand.blank?
@@ -1,4 +1,9 @@
module Enterprise::Whatsapp::Providers::WhatsappCloudService
# Calls API + the call_permission_request interactive message both require Graph
# API v17+; OSS phone_id_path is locked at v13.0 for legacy /messages compatibility.
# Use the configured global version (defaulting to v22.0) for call-flow endpoints.
WHATSAPP_CALLING_API_VERSION_FALLBACK = 'v22.0'.freeze
def pre_accept_call(call_id, sdp_answer)
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
end
@@ -17,7 +22,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
response = HTTParty.post(
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
"#{calls_phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
)
unless response.success?
@@ -30,13 +35,19 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
"#{calls_phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
)
process_initiate_call_response(response)
end
private
def calls_phone_id_path
base = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
version = GlobalConfigService.load('WHATSAPP_API_VERSION', WHATSAPP_CALLING_API_VERSION_FALLBACK)
"#{base}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def call_action_body(call_id, action, sdp_answer = nil)
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
@@ -44,7 +55,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
end
def call_api(action_name, body)
url = "#{phone_id_path}/calls"
url = "#{calls_phone_id_path}/calls"
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
@@ -65,7 +76,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
def initiate_call_body(to_phone_number, sdp_offer)
{
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
messaging_product: 'whatsapp', to: to_phone_number, action: 'connect',
session: { sdp: sdp_offer, sdp_type: 'offer' }
}.to_json
end
@@ -7,15 +7,30 @@ class Voice::CallMessageBuilder
call.message || create_message!
end
def update_status!(status:, agent: nil, duration_seconds: nil)
message = call.message
return unless message
patch = {
'status' => status&.to_s&.tr('_', '-'),
'accepted_by' => agent && { 'id' => agent.id, 'name' => agent.name },
'duration_seconds' => duration_seconds
}.compact
message.update!(content_attributes: (message.content_attributes || {}).deep_merge('data' => patch))
message
end
private
attr_reader :call
def create_message!
params = {
content: 'Voice Call',
content: I18n.t("conversations.messages.voice_call.#{call.provider}"),
message_type: call.outgoing? ? 'outgoing' : 'incoming',
content_type: 'voice_call'
content_type: 'voice_call',
content_attributes: { 'data' => build_data_payload }
}
Messages::MessageBuilder.new(sender, call.conversation, params).perform
end
@@ -23,4 +38,15 @@ class Voice::CallMessageBuilder
def sender
call.outgoing? ? call.accepted_by_agent : call.contact
end
# call_source lets the FE disambiguate WhatsApp vs Twilio without re-fetching the Call.
def build_data_payload
{
'call_id' => call.id,
'call_sid' => call.provider_call_id,
'call_source' => call.provider,
'call_direction' => call.direction_label,
'status' => call.display_status
}
end
end
@@ -1,15 +1,17 @@
class Voice::InboundCallBuilder
attr_reader :account, :inbox, :from_number, :call_sid
attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta
def self.perform!(account:, inbox:, from_number:, call_sid:)
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
new(inbox: inbox, from_number: from_number, call_sid: call_sid,
provider: provider, extra_meta: extra_meta).perform!
end
def initialize(account:, inbox:, from_number:, call_sid:)
@account = account
def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
@inbox = inbox
@from_number = from_number
@call_sid = call_sid
@provider = provider.to_sym
@extra_meta = extra_meta || {}
end
def perform!
@@ -17,8 +19,8 @@ class Voice::InboundCallBuilder
return existing if existing
ActiveRecord::Base.transaction do
contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact)
contact_inbox = ensure_contact_inbox!
contact = contact_inbox.contact
conversation = resolve_conversation!(contact, contact_inbox)
call = create_call!(contact, conversation)
message = Voice::CallMessageBuilder.new(call).perform!
@@ -26,15 +28,34 @@ class Voice::InboundCallBuilder
call
end
rescue ActiveRecord::RecordNotUnique
# A concurrent Twilio retry won the create race; return what now exists.
# A concurrent provider retry won the create race; return what now exists.
find_existing_call || raise
end
private
def account
inbox.account
end
def find_existing_call
Call.where(account_id: account.id, inbox_id: inbox.id)
.find_by(provider: :twilio, provider_call_id: call_sid)
.find_by(provider: provider, provider_call_id: call_sid)
end
# Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
# creating with a colliding source_id under a different contact would raise
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
# A concurrent message webhook for the same wa_id can win the (inbox_id, source_id)
# race; rescue and re-find so the call path doesn't drop the connect.
def ensure_contact_inbox!
sid = source_id_for_provider
existing = inbox.contact_inboxes.find_by(source_id: sid)
return existing if existing
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
rescue ActiveRecord::RecordNotUnique
inbox.contact_inboxes.find_by!(source_id: sid)
end
def ensure_contact!
@@ -43,13 +64,14 @@ class Voice::InboundCallBuilder
end
end
def ensure_contact_inbox!(contact)
ContactInbox.find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id
) do |record|
record.source_id = from_number
end
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
# Run BR/AR-style wa_id normalization (same path messaging uses) so an inbound call
# finds the existing ContactInbox instead of forking a new contact/conversation.
def source_id_for_provider
return from_number unless provider == :whatsapp
digits = from_number.to_s.delete_prefix('+')
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud)
end
def resolve_conversation!(contact, contact_inbox)
@@ -76,13 +98,14 @@ class Voice::InboundCallBuilder
inbox: inbox,
conversation: conversation,
contact: contact,
provider: :twilio,
provider: provider,
direction: :incoming,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }
meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys)
)
call.update!(conference_sid: call.default_conference_sid)
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
call.update!(conference_sid: call.default_conference_sid) if call.twilio?
call
end
end
@@ -1,15 +1,16 @@
class Voice::OutboundCallBuilder
attr_reader :account, :inbox, :user, :contact
def self.perform!(account:, inbox:, user:, contact:)
new(account: account, inbox: inbox, user: user, contact: contact).perform!
def self.perform!(account:, inbox:, user:, contact:, conversation: nil)
new(account: account, inbox: inbox, user: user, contact: contact, conversation: conversation).perform!
end
def initialize(account:, inbox:, user:, contact:)
def initialize(account:, inbox:, user:, contact:, conversation: nil)
@account = account
@inbox = inbox
@user = user
@contact = contact
@existing_conversation = conversation
end
def perform!
@@ -18,7 +19,7 @@ class Voice::OutboundCallBuilder
ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox!
conversation = create_conversation!(contact_inbox)
conversation = @existing_conversation || create_conversation!(contact_inbox)
call_sid = initiate_call!
call = create_call!(conversation, call_sid)
message = Voice::CallMessageBuilder.new(call).perform!
@@ -0,0 +1,73 @@
class Whatsapp::CallPermissionReplyService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.voice_enabled?
reply_data = extract_reply_data
return unless reply_data&.dig(:accepted)
conversation = find_requesting_conversation(reply_data[:context_id])
return unless conversation
clear_permission_flag(conversation)
emit_permission_granted_activity(conversation)
broadcast_permission_granted(conversation.contact, conversation)
end
private
def emit_permission_granted_activity(conversation)
content = I18n.t(
'conversations.activity.whatsapp_call.permission_granted',
contact_name: conversation.contact.name
)
::Conversations::ActivityMessageJob.perform_later(
conversation,
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, content: content }
)
end
def extract_reply_data
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
reply = message&.dig(:interactive, :call_permission_reply)
return unless reply
accepted = reply[:response] == 'accept'
Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}"
{ from_number: message[:from], accepted: accepted, context_id: message.dig(:context, :id) }
end
# Match the reply to the conversation whose request message it actually points
# at (interactive replies carry context.id = our outbound wamid). Recency-based
# lookup would broadcast to the wrong thread when a contact has multiple
# parallel pending requests.
def find_requesting_conversation(context_id)
return if context_id.blank?
inbox.conversations
.where.not(status: :resolved)
.where("additional_attributes ->> 'call_permission_request_message_id' = ?", context_id)
.first
end
def clear_permission_flag(conversation)
attrs = (conversation.additional_attributes || {}).except(
'call_permission_requested_at', 'call_permission_request_message_id'
)
conversation.update!(additional_attributes: attrs)
end
def broadcast_permission_granted(contact, conversation)
ActionCable.server.broadcast(
"account_#{inbox.account_id}",
{
event: 'voice_call.permission_granted',
data: {
account_id: inbox.account_id, conversation_id: conversation.id,
contact_name: contact.name, contact_phone: contact.phone_number
}
}
)
end
end
@@ -0,0 +1,110 @@
class Whatsapp::CallService
pattr_initialize [:call!, :agent!, :sdp_answer]
def accept
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
# All side effects under the lock so a concurrent terminate cannot finalize
# the call between status update and the message/conversation/broadcast writes.
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
update_conversation_call_status(call.display_status)
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
end
def reject
call.with_lock do
next if call.terminal? || call.in_progress?
invoke_provider!(:reject_call)
finalize_call('failed')
end
call
end
def terminate
call.with_lock do
next if call.terminal?
invoke_provider!(:terminate_call)
# Compute duration from started_at locally — the webhook arrives after the
# call is already terminal and the idempotency guard there bails before it
# can fill these fields, so we have to record them here.
if call.in_progress?
duration = call.started_at ? (Time.current - call.started_at).to_i : nil
finalize_call('completed', duration_seconds: duration, end_reason: 'agent_hangup')
else
# Agent hangs up before contact picks up → no_answer; mirrors the webhook terminate path.
finalize_call('no_answer', end_reason: 'agent_hangup')
end
end
call
end
private
def transition_to_in_progress!
# Order matters: in_progress and terminal both make ringing? false, so we have to
# branch on in_progress? first to surface the distinct AlreadyAccepted state.
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
invoke_provider!(:pre_accept_call, sdp_answer)
invoke_provider!(:accept_call, sdp_answer)
end
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
end
# Raise on Meta failure (bool false or transport error) so callers bail before
# finalizing local state — otherwise we'd mark a still-active call as ended
# and broadcast voice_call.ended while Meta thinks it's live.
def invoke_provider!(method, *)
success = call.inbox.channel.provider_service.public_send(method, call.provider_call_id, *)
raise Voice::CallErrors::CallFailed, "Meta #{method} failed" unless success
rescue Voice::CallErrors::CallFailed
raise
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] #{method} failed: #{e.class} #{e.message}"
raise Voice::CallErrors::CallFailed, "Meta #{method} failed"
end
def finalize_call(status, **attrs)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
call.update!(status: status, meta: meta, **attrs)
update_message_status(status, duration_seconds: attrs[:duration_seconds])
update_conversation_call_status(call.display_status)
broadcast(:ended, status: call.display_status)
end
def update_message_status(status, duration_seconds: nil)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: agent, duration_seconds: duration_seconds)
end
def update_conversation_call_status(status)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge('call_status' => status)
)
end
def broadcast(event, **extra)
payload = {
event: "voice_call.#{event}",
data: { id: call.id, call_id: call.provider_call_id, provider: call.provider,
conversation_id: call.conversation_id, account_id: call.account_id }.merge(extra)
}
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
end
@@ -0,0 +1,186 @@
class Whatsapp::IncomingCallService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.voice_enabled?
Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) }
Array(params[:statuses]).each { |s| handle_status(s.with_indifferent_access) }
end
private
def handle_event(payload)
case payload[:event]
when 'connect' then handle_connect(payload)
when 'terminate' then handle_terminate(payload)
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{payload[:event]}"
end
end
# Meta's `connect` event for outbound calls fires when the WebRTC tunnel is
# up — empirically ~20s before the contact actually answers. The real pickup
# is reported as a separate webhook with status=ACCEPTED, and is what
# `terminate.start_time` aligns to. Treat ACCEPTED as the pickup transition.
def handle_status(payload)
return unless payload[:type] == 'call'
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
return unless call
case payload[:status]
when 'ACCEPTED' then mark_outbound_accepted(call, payload)
when 'RINGING' then nil # informational
else Rails.logger.info "[WHATSAPP CALL] Unhandled call status: #{payload[:status]} for #{payload[:id]}"
end
end
# with_lock + reload here serializes against Whatsapp::CallService (agent
# actions hold call.with_lock across the Meta API call), so a webhook racing
# with a terminate can't overwrite a freshly-finalized terminal status.
def mark_outbound_accepted(call, payload)
call.with_lock do
next unless call.outgoing?
next if call.in_progress? || call.terminal?
started_at = Time.zone.at(payload[:timestamp].to_i) if payload[:timestamp].present?
update_call!(call, 'in_progress', started_at: started_at || Time.current)
broadcast(call, 'voice_call.outbound_accepted')
end
end
def handle_connect(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
if call.nil?
# Only an `offer` payload is a real inbound caller. An `answer` with no
# local row means Meta beat our outbound `Call.create!` (tiny window
# between initiate API response and DB insert) — do not mint an inbound
# row for it; the next status webhook (or a retry) will find it.
return create_inbound_call(payload) if inbound_offer?(payload)
Rails.logger.warn "[WHATSAPP CALL] Outbound connect for unknown call #{payload[:id]}; skipping"
return
end
return accept_outbound_call(call, payload) if call.outgoing?
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
end
def inbound_offer?(payload)
payload.dig(:session, :sdp_type).to_s.downcase == 'offer'
end
def create_inbound_call(payload)
sdp_offer = payload.dig(:session, :sdp)
call = Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp,
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
)
update_conversation(call)
broadcast_incoming(call, sdp_offer)
end
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
# Meta's SDP answer so the handshake completes during ringing; the call
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on
# in_progress: an out-of-order ACCEPTED can flip status before connect is
# processed, and dropping the SDP answer would leave the browser without
# the data it needs to complete the handshake. Use the stored answer as
# the idempotency key instead.
def accept_outbound_call(call, payload)
call.with_lock do
next if call.terminal?
next if call.meta&.dig('sdp_answer').present?
# Pin setup:active so browsers don't renegotiate when Meta echoes actpass.
sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active')
call.update!(meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
end
end
def handle_terminate(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
if call.nil?
# No row yet means either an out-of-order terminate (rare in practice — Meta
# delivery is FIFO) or, more dangerously, an outbound terminate landing in
# the window between the controller's Meta API call and Call.create!.
# Materialising as inbound here would collide with the unique
# (provider, provider_call_id) index. Skip; controller commits seal it.
Rails.logger.warn "[WHATSAPP CALL] Terminate for unknown call #{payload[:id]}; skipping"
return
end
call.with_lock do
# Webhook retries can re-deliver terminate after we've already finalized the
# call; don't recompute status or a duration=0 retry can flip a completed
# short call back to no_answer.
next if call.terminal?
duration = payload[:duration]&.to_i
reason = payload[:terminate_reason].to_s
status = derive_terminate_status(call, duration, reason)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
update_call!(call, status, duration_seconds: duration, end_reason: reason, meta: meta)
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
end
end
# Provider-reported failures trump the answered/no_answer heuristic. An
# in_progress call that Meta later terminates with a failure reason would
# otherwise be recorded as 'completed' purely because it had been accepted.
FAILURE_REASONS = %w[failed error rejected busy invalid_offer cancelled].freeze
def derive_terminate_status(call, duration, reason)
return 'failed' if FAILURE_REASONS.any? { |r| reason.include?(r) }
answered?(call, duration) ? 'completed' : 'no_answer'
end
# accepted_by_agent_id is the initiating agent on outbound calls, so it only signals "answered" for inbound.
def answered?(call, duration)
call.in_progress? || duration.to_i.positive? || (call.incoming? && call.accepted_by_agent_id.present?)
end
def update_call!(call, status, **attrs)
call.update!(status: status, **attrs)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: call.accepted_by_agent,
duration_seconds: attrs[:duration_seconds])
update_conversation(call)
end
def update_conversation(call)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge(
'call_status' => call.display_status, 'call_direction' => call.direction_label
)
)
end
# Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
token = call.conversation.assignee&.pubsub_token
broadcast(call, 'voice_call.incoming',
streams: token ? [token] : account_streams,
direction: call.direction_label, inbox_id: call.inbox_id,
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url })
end
def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }
end
def account_streams
["account_#{inbox.account_id}"]
end
def base_payload(call)
{ account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id,
provider: 'whatsapp', conversation_id: call.conversation_id }
end
end
@@ -7,4 +7,5 @@ end
json.meta do
json.total_count @documents_count
json.page @current_page
json.sync_interval_hours @sync_interval_hours if @sync_interval_hours.present?
end

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