Compare commits

...
Author SHA1 Message Date
Sony MathewandGitHub b5e53a0a52 Merge branch 'develop' into codex/docker-ci-cache-optimization 2026-07-08 23:51:32 +05:30
Sony MathewandGitHub 66cfb26c77 feat: Add unread count filters feature flag (1/6) (#14885)
## Description

Adds the account-level `unread_count_for_filters` feature flag as the
dark-launch gate for filtered sidebar unread counts. This reuses the
deprecated `quoted_email_reply` flag slot, resets the reused bit for
existing accounts, and removes stale defaults so new accounts do not
reference the old flag.

This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders.

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

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
2026-07-08 23:50:40 +05:30
Shivam MishraandGitHub b9536fb8ed fix: use reserved example domain in SAML placeholders (#14958) 2026-07-08 21:55:38 +05:30
Sony MathewandGitHub 873d16f54c feat: Extend account feature flag storage (#14947)
# Pull Request Template

## Description

Extends account-level feature flags by adding a second bigint bitset
column, `feature_flags_ext_2`, while preserving the existing
`flag_shih_tzu` feature check and enable/disable APIs. Existing flags
continue to live on `feature_flags`; future flags can opt into the
extension column through `config/features.yml` metadata.

Fixes
[CW-7238](https://linear.app/chatwoot/issue/CW-7238/feature-flag-extension)

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && RAILS_ENV=test
POSTGRES_DATABASE=chatwoot_test_31a6 bundle exec rspec
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb
spec/controllers/platform/api/v1/accounts_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb
spec/enterprise/models/account_spec.rb` - 144 examples, 0 failures
- `eval "$(rbenv init -)" && bundle exec rubocop
app/models/concerns/featurable.rb app/models/account.rb
db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb
spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb
spec/lib/config_loader_spec.rb spec/enterprise/models/account_spec.rb` -
7 files inspected, no offenses detected
- `ruby -ryaml -e "features =
YAML.safe_load(File.read('config/features.yml')); abort unless
features.size == 63; puts features.group_by { |f| f['column'] ||
'feature_flags' }.transform_values(&:size).inspect"` - `{"feature_flags"
=> 63}`
- `git diff --check`

## 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
2026-07-08 17:38:26 +05:30
d3c588ff10 chore(inbox): re-enable Instagram inbox creation (#14955)
Brings the Instagram channel back in inbox creation and onboarding.
Instagram was temporarily disabled along with WhatsApp embedded signup
in #14943; this re-enables Instagram while keeping WhatsApp embedded
signup and WhatsApp Call inbox creation disabled.

Fixes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-08 17:37:46 +05:30
Tanmay Deep SharmaandGitHub f6c18f5225 feat: account calls dashboard index endpoint (#14780)
## Description

Adds a backend endpoint that powers an account-wide calls dashboard,
letting users list and filter all calls in the account.

## Linear Ticket
- https://linear.app/chatwoot/issue/UPM-28/voice-call-dashboard-view

## Type of change

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

## 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-07-08 15:31:03 +05:30
Sojan JoseandGitHub 0e07a27c74 fix: enforce inbox limits at model level (#14949)
Fixes https://linear.app/chatwoot/issue/CW-7559/inbox-limit-abuse

## Why

The regular inbox API checked limits in the controller, but WhatsApp
embedded signup creates inboxes through a service using `Inbox.create!`.
That let Enterprise account inbox limits be skipped for embedded signup.

## What this change does

- Adds an Inbox create-time validation hook in OSS and implements the
limit check in the Enterprise Inbox module.
- Removes the duplicate controller/helper limit check so the model is
the single enforcement point.
- Preserves the existing `402 Payment Required` API response for account
inbox limit failures.
- Keeps updates to existing inboxes allowed when an account is already
at its inbox limit.

## Validation

- `bundle exec rspec
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/enterprise/models/inbox_spec.rb`
2026-07-08 13:18:07 +04:00
ce8c8e9a11 fix: sanitize control characters in team names (#14865)
Team names created via the API could contain control characters (for
example a trailing newline). Because the team-delete confirmation dialog
requires you to retype the team name and matches it against the stored
value, a hidden control character meant the typed name never matched —
leaving the team impossible to delete from the UI. This sanitizes team
names on save so they stay clean and deletable.

#### How to reproduce
1. Create a team via `POST /api/v1/accounts/{account_id}/teams` with
`{"name": "test\n"}`.
2. The team is created with the trailing newline stored in `name`.
3. In **Settings → Teams**, click delete and type the team name to
confirm — the match fails, so the team cannot be deleted.

#### What changed
- `app/models/team.rb`: the existing `before_validation` now strips
control characters and surrounding whitespace before downcasing the
name. Names that reduce to blank (e.g. only newlines/tabs) are rejected
loudly by the existing `presence` validation.
- Fixing at the model layer covers the API and every other create/update
path, rather than relying on the frontend confirm-dialog `.trim()`
(which only handles leading/trailing whitespace, not internal control
characters).

Note: this prevents new malformed names. Any team already saved with a
control character can be made deletable again simply by renaming it (an
update re-runs the same sanitization).

| Input | Stored as | Result |
|---|---|---|
| `"test\n"` | `"test"` | valid, deletable |
| `"te\nst"` (internal) | `"test"` | valid |
| `"\t\n "` (only control/ws) | — | rejected: "Name must not be blank" |
| `"Customer Support"` | `"customer support"` | unchanged behavior |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:04:24 -07:00
Shivam MishraandGitHub 29d0b92f1c fix: captain hours saved metric (#14948)
Reworks the Captain assistant "hours saved" metric so it produces a
believable number. It previously multiplied assistant reply count by
customer reply-wait time, which inflated the figure into the millions of
hours. It now estimates saved time as replies × a fixed ~2 min per-reply
effort assumption. On the overview card the value renders in days once
it passes 100 hours, and the label/hint copy was updated to match.

## How to test
1. Open the Captain assistant overview page.
2. Check the "Time saved" card shows a sensible value (hours, or days
past 100h).
3. Hover the hint and confirm it reflects the per-reply estimate.
2026-07-07 19:16:24 +05:30
a5fcecb3f6 feat: assistant overview page [CW-7408] (#14889)
This PR adds a Captain Assistant **Overview** page to show some KPI
metrics (conversations handled, auto-resolution, handoff, hours saved,
reopen-after-resolve, conversation depth) with trend deltas vs the
previous window, a real knowledge card, and a lazily-loaded, cached LLM
welcome summary.

### Highlights

- **Two contextual banners** on the overview:
- **Inbox banner** — prompts the user to connect an inbox when the
assistant has none, so it can actually do work.
- **Coverage banner** — warns when FAQ coverage is below 85% with more
than 100 responses pending review, linking straight to the pending
queue. Dismissal persists per-assistant for 24h via localStorage.
- **Batched stats builder** (`Captain::AssistantStatsBuilder`) computes
both windows in single FILTER-aggregated scans to cut round trips,
behind new `stats`/`summary` endpoints.
- **Cards included but intentionally left dummy / not rendered yet:**
`ResponseQualityCard` (flagged responses) and `CreditUsageCard` (credit
usage + daily chart). Credits are an account-wide counter with no
per-assistant or daily history, so there is no real data to back them
yet; they ship in the codebase but are not wired into the page.

### Index migration

- Replaces `index_messages_on_sender_type_and_sender_id` with
`index_messages_on_sender_and_created` `(sender_type, sender_id,
created_at)`.
- **Why it helps:** the per-assistant windowed lookups filter `sender_*`
*and* a `created_at` range. The old 2-column index matched every
lifetime row for the assistant and filtered the time slice at the heap
(~89% of rows discarded); adding `created_at` as a range column lets
Postgres scan only the window, and fixes the row-count estimate so the
planner picks a hash join over a nested loop on `reporting_events`.
- **Why dropping the old index is safe:** the new index is a left-prefix
superset `(sender_type, sender_id, ...)`, so every query the old one
served is still served. No code references it by name, and dropping it
keeps write amplification on `messages` neutral. Built/dropped with
`CONCURRENTLY` and `if_not_exists`/`if_exists` guards.


## Preview

<img width="2572" height="1754" alt="CleanShot 2026-06-29 at 22 38
51@2x"
src="https://github.com/user-attachments/assets/3798d09e-7850-48e4-b2cd-508533f15cea"
/>

## Banners

#### Inbox connect alert

<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 26 55@2x"
src="https://github.com/user-attachments/assets/373c371c-bb7d-4291-a0f9-620673078302"
/>

#### Coverage alert
<img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 25 41@2x"
src="https://github.com/user-attachments/assets/e12d6308-11b6-4ba2-88a2-8a3077dd3e8f"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 17:54:13 +05:30
Vishnu NarayananandGitHub 1e7218d439 fix: compare HMAC identity hashes securely (#14945)
This switches widget and public inbox HMAC identity checks from direct
string equality to constant-time comparison with a length guard.

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7227](https://linear.app/chatwoot/issue/CW-7227)
2026-07-07 17:37:22 +05:30
c8bfbadae2 fix: validate identifier hash on widget contact update (#14884)
## Description

The widget contact update endpoint (`PATCH /api/v1/widget/contact`)
applied updates that set an `identifier` without validating the
`identifier_hash`, unlike `set_user`, which already does. This change
runs the same validation on the update path whenever an `identifier` is
supplied, keeping identity validation consistent across the widget
contact endpoints.

Anonymous updates that don't pass an identifier (prechat
name/email/phone and custom attributes) keep working unchanged,
including on inboxes with mandatory identity validation.

Fixes https://linear.app/chatwoot/issue/CW-7118


---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 16:40:19 +05:30
Aakash BakhleandGitHub 83dda621c5 feat: default new accounts to captain v2 (#14917)
# Pull Request Template

## Description

defaults new accounts to captain v2

## Type of change

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

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-07-07 16:21:51 +05:30
Sony MathewandGitHub eab859cd04 fix: Sanitize query canceled API errors (#14933)
# Pull Request Template

## Description

Message creation API failures caused by PostgreSQL query cancellations
now return a generic retryable error instead of exposing raw database
internals such as `PG::QueryCanceled`, tuple identifiers, or relation
names to customers. Existing validation failures continue to return
their specific validation messages.

Closes
[CW-7538](https://linear.app/chatwoot/issue/CW-7538/do-not-expose-pgquerycanceled-details-in-messages-api-errors)

## Type of change

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

## How Has This Been Tested?

Reproduced the messages API path by raising
`ActiveRecord::QueryCanceled` from `Messages::MessageBuilder` and
verified the response contains the customer-safe localized error without
`PG::QueryCanceled` details.

Validation run locally:

- `bundle exec rspec
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb`
- `bundle exec rubocop
app/controllers/concerns/request_exception_handler.rb
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:20:52 +05:30
Sojan JoseandGitHub b823764199 refactor: optimize agent bot assignment dropdown (#14866) 2026-07-07 14:25:54 +05:30
397ac2e18a chore(inbox): temporarily disable Instagram and WhatsApp embedded signup inbox creation (#14943)
This temporarily turns off inbox creation for channels that rely on
Meta's embedded signup — the WhatsApp Cloud signup popup, Instagram
(OAuth-only), and WhatsApp Call. WhatsApp Cloud inbox creation now falls
back to the manual API-key form, while the Instagram and WhatsApp Call
tiles appear disabled. Both channels are also hidden from the
new-account onboarding flow. Existing inboxes are not affected.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-07 12:18:17 +04:00
Tanmay Deep SharmaandGitHub 11deffdd5d feat: billing brl pix new users (#14617)
## Linear ticket
- https://linear.app/chatwoot/issue/CW-7253/billing-brl-pix-new-users

## Description

New accounts that sign up in Brazilian Portuguese are now billed in BRL
instead of USD. Their Stripe customer is created with a Brazil address
and Portuguese locale (so the Stripe portal offers Real prices and PIX),
and the AI credit top-up flow shows packages priced in the account's
billing currency. Currency support is config-driven, so adding another
currency later is a configuration change rather than a code change.


## Type of change

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

## How Has This Been Tested?

- https://www.loom.com/share/c8d3d08c1b844ed6b820438d4209491a

## Screenshot
<img width="904" height="440" alt="image"
src="https://github.com/user-attachments/assets/6f19fad8-e6af-46ea-b99f-b0265bb9eeec"
/>


## 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-07-06 16:35:38 +05:30
8818d276b9 fix(captain): read OpenAI key from InstallationConfig in article search terms (#14915)
generate_article_search_terms still pulled ENV['OPENAI_API_KEY'], left
over from before the Jan 2025 Captain migration moved the key into
InstallationConfig as CAPTAIN_OPEN_AI_API_KEY. Every other Captain LLM
call site got updated then; this one (used by Portal::ArticleIndexingJob
for help center article embedding search terms) didn't, so it sent a
blank bearer token unless you also happened to have the old env var set.

Also drops the stale OPENAI_API_KEY line from .env.example and points to
where the key actually lives now (Super Admin > App Configs > Captain).

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-02 19:29:50 +05:30
Sivin VargheseandGitHub 90b9dba9f9 fix: correct report drilldown drawer position in RTL/LTR (#14919) 2026-07-02 18:44:25 +05:30
Sivin VargheseandGitHub 2a21d075f6 fix: preserve link URLs on channels without hyperlink support (#14912) 2026-07-02 17:23:53 +05:30
Shivam MishraandGitHub b8545019e1 feat: add catch-all failure handling to article writer (#14881)
Help-center onboarding jobs were getting stuck in the `generating` state
indefinitely. `Onboarding::HelpCenterArticleWriterJob` only finalized
the generation counter for two exception types
(`Firecrawl::FirecrawlError`, `ArticleBuildFailed`). Any other error
exhausted Sidekiq's default retries and landed in the dead set without
ever bumping `finished`, leaving state at `total - 1` / `generating`
until the 7-day Redis TTL expired. Observed in production: 4 generations
wedged at exactly `finished = total - 1`, four days after the run, with
no further progress — the onboarding UI showed "generating" the whole
time.


This PR adds a `discard_on StandardError` catch-all to
`HelpCenterArticleWriterJob`, after the existing `retry_on
Firecrawl::FirecrawlError` and `discard_on ArticleBuildFailed` handlers.
ActiveJob matches in declaration order, first match wins, so existing
behavior is unchanged — the catch-all only absorbs errors that
previously fell through to unhandled retry-then-dead-set. It routes
through the same `on_writer_failure` → `finalize` path, so state always
progresses to `completed`.

<details><summary>

##### Script to remove dead jobs

</summary>
<p>

```rb
# One-off: unstick Onboarding::HelpCenterGenerationState keys that are wedged in
# "generating" because a writer job died on an unhandled exception (neither
# FirecrawlError nor ArticleBuildFailed) and exhausted Sidekiq retries without
# ever calling record_article_finished.
#
# These portals have real articles (finished is 1 short of total). Marking them
# "completed" is the honest terminal state: generation is done, one article failed.
#
# Run on a prod box (dry-run first, then REMOVE_DRY_RUN=1):
#   RAILS_ENV=production bundle exec rails runner scripts/help_center_investigation/unstick_generating.rb
#
# To actually write, set REMOVE_DRY_RUN=1 in the environment.

pattern = format(Redis::Alfred::HELP_CENTER_GENERATION, id: '*')
dry_run = ENV['REMOVE_DRY_RUN'].blank?

stuck = []
Redis::Alfred.with do |conn|
  conn.scan_each(match: pattern, count: 1000) do |key|
    h = conn.hgetall(key)
    next unless h['status'] == 'generating'

    gen_id = key.sub('HELP_CENTER_GENERATION::', '')
    stuck << { gen_id: gen_id, total: h['total'], finished: h['finished'], key: key }

    unless dry_run
      conn.hset(key, 'status', 'completed')
      conn.expire(key, Onboarding::HelpCenterGenerationState::TTL)
    end
  end
end

puts "#{dry_run ? '[DRY RUN] ' : ''}Found #{stuck.size} stuck 'generating' states:"
stuck.each do |s|
  puts "  gen=#{s[:gen_id]} total=#{s[:total]} finished=#{s[:finished]} -> #{dry_run ? 'would mark completed' : 'marked completed'}"
end
puts
puts 'Re-run with REMOVE_DRY_RUN=1 to apply.' if dry_run
````

</p>
</details>
2026-07-02 16:17:34 +05:30
6a7ca9dd3b feat: Add report bar drilldown drawer (#14626)
## Description

Adds drilldown support for report bar charts powered by
`ReportContainer`. Clicking a non-zero report bar now opens a right-side
drawer with the conversations or messages that contributed to that
bucket, with each row linking to the underlying conversation and message
rows linking with `messageId`.

This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown`
endpoint, backend drilldown builders/serializers, generic chart click
emission, local drawer state via `useReportDrilldown`, compact drilldown
cards, pagination, stale-response protection, and validation for
unsupported drilldown dimensions.

Fixes # CW-4497

https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report

## Type of change

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

## How Has This Been Tested?

Ran the focused backend and frontend checks for the drilldown endpoint,
builder, chart click handling, drawer/card UI, API helper, and
stale-response handling.

Here are the screenshots on how it looks like:
<img width="1792" height="1199" alt="Screenshot 2026-06-02 at 11 32
11 PM"
src="https://github.com/user-attachments/assets/6bdb8832-b9df-4bf3-9a2a-beaefe203b6e"
/>
<img width="1791" height="1230" alt="Screenshot 2026-06-02 at 11 32
34 PM"
src="https://github.com/user-attachments/assets/36e92eb7-3208-4855-87f4-0c7f316df54d"
/>
<img width="1784" height="1235" alt="Screenshot 2026-06-02 at 11 32
46 PM"
src="https://github.com/user-attachments/assets/f7a53916-74f2-4622-9305-042e0ac9e877"
/>



## 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
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-07-02 16:07:26 +05:30
6c9efc4e92 fix: assign outbound voice call conversation to the calling agent (#14906)
Outbound voice calls were being auto-assigned to the wrong agent. When
an agent placed an outbound call, the conversation was created without
an assignee, so inboxes with auto-assignment enabled would round-robin
it to a different agent instead of keeping it with the person who
actually made the call. This made it hard to tell which agent was on an
active call.

## What changed

- Set the calling agent as the conversation's assignee when creating an
outbound voice call conversation.
- This prevents the generic auto-assignment handler from treating the
conversation as unassigned and reassigning it.
- Added specs covering the assignment, including a regression case with
inbox auto-assignment enabled.

**Note:** this applies to newly placed calls; it does not retroactively
fix conversations that were already mis-assigned.

---------

Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-07-02 16:06:11 +05:30
Sivin VargheseandGitHub 34d8741b9b feat: drag to reorder help center articles across pages (#14910) 2026-07-02 15:47:01 +05:30
7bf76057c2 fix: SLA handling for blocked contacts (#14861)
# Pull Request Template

## Description

Blocked contacts are now excluded from SLA assignment, processing,
reports, and conversation SLA UI while they remain blocked. Existing SLA
records are preserved, and SLA behavior resumes if the contact is
unblocked.

Fixes
https://linear.app/chatwoot/issue/CW-7435/sla-should-not-trigger-for-blocked-contacts

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/enterprise/models/conversation_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb` —
78 examples, 0 failures
- `bundle exec rubocop
enterprise/app/controllers/api/v1/accounts/applied_slas_controller.rb
enterprise/app/jobs/sla/process_account_applied_slas_job.rb
enterprise/app/models/applied_sla.rb
enterprise/app/models/enterprise/concerns/conversation.rb
enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb
enterprise/app/services/enterprise/action_service.rb
enterprise/app/services/sla/evaluate_applied_sla_service.rb
lib/tasks/apply_sla.rake
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/models/conversation_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb` — no
offenses
- `pnpm exec vitest --no-watch --no-cache --no-coverage
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— 2 tests passed
- `pnpm exec eslint
app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue
app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue
app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— passed with existing raw-text warnings in `ConversationHeader.vue`
- `git diff --cached --check` — clean

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-02 14:16:29 +05:30
Tanmay Deep SharmaandGitHub a3c7f3b204 fix: finalize WhatsApp calls when terminate webhook overtakes connect (#14836)
## Description

Some inbound WhatsApp calls stayed stuck in "ringing" forever. When a
caller hung up within ~1s of dialing, Meta delivered the terminate
webhook before the connect webhook. The terminate arrived with no call
record yet and was dropped, then connect created the call in ringing
with nothing left to close it. These calls now correctly land as missed
(no_answer), and an agent who taps Accept on a call that already ended
gets a clean "call ended" instead of a generic error.

## Type of change

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

## How Has This Been Tested?

- local UI testing

## 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-07-02 13:58:36 +05:30
49b0ab0e1f fix: Consider business hours when computing SLA breaches (#13392)
- Fixes SLA breach computation to respect the "Only during business
hours" setting
- Backend now pre-computes SLA deadlines, simplifying frontend logic

## How it works

Before: SLA deadlines were calculated using wall-clock time, ignoring
business hours.

After: When an SLA policy has "Only during business hours" enabled and
the inbox has working hours configured, the deadline is calculated by
adding threshold time only during business hours.


**How you check if a conversation has a SLA hit or miss?**

<img width="474" height="510" alt="Screenshot 2026-01-28 at 7 06 53 PM"
src="https://github.com/user-attachments/assets/54ec8581-18b8-45c6-a356-de8c778ea78d"
/>


**Example:**
- Conversation created: Friday 4:30 PM
- FRT threshold: 1 hour
- Business hours: Mon-Fri 9 AM - 5 PM

| | Breach time |
|--|--|
| Before | Friday 5:30 PM |
| After | Monday 9:30 AM |

## Test plan

- [x] Create an SLA policy with "Only during business hours" enabled
- [x] Configure inbox with business hours (e.g., Mon-Fri 9-5)
- [x] Conversation created during business hours
  - Create a conversation on Wednesday 10:00 AM UTC
- Expected: FRT deadline shows Wednesday 12:00 PM UTC (2 business hours
later)
- [x] Conversation created before business hours
  - Create a conversation on Wednesday 7:00 AM UTC
- Expected: FRT deadline shows Wednesday 11:00 AM UTC (counting starts
at 9 AM)
- [x] Conversation created after business hours
  - Create a conversation on Wednesday 6:00 PM UTC
- Expected: FRT deadline shows Thursday 11:00 AM UTC (counting starts
next day 9 AM)
- [x] Conversation created on weekend
  - Create a conversation on Saturday 10:00 AM UTC
  - Expected: FRT deadline shows Monday 11:00 AM UTC (skips weekend)
- [x] Threshold spans weekend
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
- Expected: FRT deadline shows Monday 10:00 AM UTC (1h Friday + 1h
Monday)
- [x] SLA without business hours
  - Create an SLA policy with only_during_business_hours: false
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Friday 6:00 PM UTC (wall-clock time)
- [x] All Day marked as closed_all_day
  - Create a conversation on Tuesday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Thursday 10:00 AM UTC
- [x] All Day marked as open_all_day
  - Create a conversation on Saturday 10:00 AM UTC with 2-hour FRT
  - Expected: FRT deadline shows Saturday 12:00 PM UTC 
- [x] UI displays correct countdown
  - Verify conversation card shows correct SLA timer
  - Verify timer shows flame icon when breached
  - Verify timer shows alarm icon when within threshold
  - Time updates automatically when time passes
- [x] Verify the breach with a different timezone than your local
timezone

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-02 13:33:22 +05:30
Aakash BakhleandGitHub 926a9d8a69 fix(captain): default temperature to 0.5 and remove UI control (#14879)
# Pull Request Template

## Description

- Default temperature to 0.5 and remove UI control
- No migrations needed for existing accounts, their current settings are
preserved

## Type of change

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

locally and spec

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 15:14:03 +05:30
Aakash BakhleandGitHub 9caceea858 fix(deps): update msgpack for CVE-2026-54522 (#14898)
# Pull Request Template

## Description

This updates the locked `msgpack` gem from `1.8.0` to `1.8.3` so the
bundle-audit check no longer flags CVE-2026-54522. The upgrade stays
within the existing transitive dependency constraints used by `bootsnap`
and `datadog`.

Fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114757/workflows/f8c7b37f-27d5-45d4-9f1b-1d1782ebc4e3/jobs/162250

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec bundle audit update && bundle
exec bundle audit check -v`
- `eval "$(rbenv init -)" && bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `eval "$(rbenv init -)" && RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle
exec rubocop --no-server Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:56:34 +05:30
Shivam MishraandGitHub 8670f66155 feat: broaden search scope for help center generation (#14880)
**Broaden the Firecrawl `map` search term list so help-center onboarding
doesn't skip sites with non-standard docs paths**

Help-center onboarding was skipping ~70% of new accounts, and ~60% of
those skips came from a single failure: `"map returned no links"`. The
root cause was an overly narrow hardcoded search query passed to
Firecrawl's `map` endpoint.

The curator (`Onboarding::HelpCenterCurator`) calls `Firecrawl.map(url,
search: MAP_SEARCH)` to discover candidate pages before the LLM curation
step. `MAP_SEARCH` was hardcoded to `"docs help support faq"` — a 4-term
list that only matched sites whose help content sat at `/docs`, `/help`,
`/support`, or `/faq`. Sites using `/resources`, `/guides`, `/kb`,
`/articles`, `/handbook`, `/learn`, `/how-to`, `/tutorial`,
`/troubleshooting`, or a docs subdomain found nothing, so the job raised
`CurationSkipped` and left the portal empty.

Firecrawl's `search` param is a grep-style substring filter across URL,
title, and description (not a semantic query), so the fix is to broaden
the term list rather than drop it. The LLM curator downstream
(`Captain::Llm::HelpCenterCurationService`) already filters returned
links by quality — it has the full URL-path-priority prompt and a
25-article hard ceiling — so a wider crawl net is safe and doesn't
change the final article quality bar.

**What changed**

- `enterprise/app/services/onboarding/help_center_curator.rb`:
`MAP_SEARCH` broadened from `"docs help support faq"` to a 13-term list
covering common help-content path hints. Comment added documenting why
the term list exists and that the LLM curator does the real filtering.
2026-06-30 14:37:15 +05:30
Sivin VargheseandGitHub 2767bd434b fix: Show a clear error when message translation fails (#14891) 2026-06-30 14:23:47 +05:30
Aakash BakhleandGitHub ce2e10e89e fix: tighten captain v2 (#14883)
# Pull Request Template

## Description

Tightens v2 prompt and config to match v1

## Type of change

Please delete options that are not relevant.

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


## How Has This Been Tested?

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

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:20:54 +05:30
Sivin VargheseandGitHub 56275b750e fix: respect companies feature flag for auto-association (#14886)
# Pull Request Template

## Description

This PR stops new contacts from getting an auto-assigned company name
when the Companies feature is disabled.

Since #14496, email-domain company auto-association also updates a
contact's `company_name`. However, the callback isn't gated behind the
Companies feature flag, so accounts without the feature enabled still
auto-create companies and overwrite any `company_name` provided via the
SDK/`setUser`.

This PR gates `should_associate_company?` behind
`account.feature_enabled?('companies')`, so auto-association only runs
when the Companies feature is enabled.

Fixes
https://linear.app/chatwoot/issue/CW-7462/setuser-overwrites-contact-company-name-for-accounts-that-dont-use

## Type of change

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


## 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
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-29 17:23:31 -07:00
299bc6c0a4 fix: recover from stale LeadSquared lead ids on activity sync (#14818)
LeadSquared sync now recovers automatically when a contact's cached lead
has been deleted or merged on the LeadSquared side. Previously the stale
lead id was never cleared, so every new conversation or contact update
for that contact failed with "Lead not found"
(`MXInvalidEntityReferenceException`) indefinitely.

## What changed
- Activity sync: on a "Lead not found" error while posting a
conversation/transcript activity, clear the cached `leadsquared_id`,
re-resolve the contact to a fresh lead, and retry the activity once
(guarded against loops and duplicate leads).
- Contact sync: on the same error while updating an existing lead, clear
the cached id and create a fresh lead instead.
- Fix `get_lead_id` to actually return early for unidentifiable contacts
(the guard previously fell through).

## How to reproduce
1. For a LeadSquared-enabled account, point a contact's cached lead id
at a lead that no longer exists in LeadSquared.
2. Update the contact, or create/resolve a conversation for it.
3. Before: the sync fails repeatedly with "Lead not found" and never
self-corrects. After: the stale id is cleared, a fresh lead is
resolved/created, and subsequent syncs reuse the healed id.

---------

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-06-29 16:38:08 +05:30
7522457740 feat: v2 - generations get trace level attributes (#14878)
# Pull Request Template

~~Note: merge only after https://github.com/chatwoot/ai-agents/pull/74
has been merged~~

## Description

Before:
<img width="436" height="617" alt="image"
src="https://github.com/user-attachments/assets/fd5be8dc-abab-4e01-b251-366648b998ea"
/>


After:
<img width="446" height="577" alt="image"
src="https://github.com/user-attachments/assets/8d96d2f2-c126-4cca-b3a2-f2a62b204adf"
/>


<img width="441" height="558" alt="image"
src="https://github.com/user-attachments/assets/89da4157-48a5-4fdc-9c67-9bf987e31638"
/>



## Type of change

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

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-29 13:50:17 +05:30
Sony MathewandGitHub e4ef2de8c8 fix(security): Update crass to 1.0.7 (#14882)
## Description

Updates the transitive `crass` dependency from `1.0.6` to `1.0.7` so the
bundle-audit security check no longer flags the Crass denial-of-service
advisories published on June 25, 2026. `crass` is pulled in through
`rails-html-sanitizer -> loofah`, and this change only updates the
resolved lockfile version.

Fixes # N/A

## Type of change

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

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle exec rspec spec/mailboxes/mailbox_helper_spec.rb
spec/mailboxes/reply_mailbox_spec.rb
spec/mailboxes/imap/imap_mailbox_spec.rb
spec/models/channel/telegram_spec.rb
spec/lib/integrations/slack/send_on_slack_service_spec.rb
spec/lib/integrations/slack/update_slack_message_service_spec.rb
spec/presenters/html_parser_spec.rb`
- `bundle exec rubocop Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-29 13:47:57 +05:30
Sony Mathew af9b81afc9 ci(docker): optimize CI build caching 2026-06-17 12:31:04 +05:30
327 changed files with 14377 additions and 607 deletions
+7
View File
@@ -1,14 +1,21 @@
.bundle
.codex
.env
.env.*
.git
.github
.agents
docker-compose.*
docker/Dockerfile
docker/dockerfiles
log
spec
storage
tests
public/system
tmp
.codeclimate.yml
coverage
public/packs
node_modules
vendor/bundle
+3 -3
View File
@@ -272,9 +272,9 @@ AZURE_APP_SECRET=
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features
## OpenAI key
# OPENAI_API_KEY=
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
+8 -2
View File
@@ -78,6 +78,12 @@ jobs:
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ee
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ee
- name: Export digest
run: |
@@ -125,7 +131,7 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -139,5 +145,5 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools inspect $TAG
+8 -2
View File
@@ -83,6 +83,12 @@ jobs:
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ce
- name: Export digest
run: |
@@ -130,7 +136,7 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -144,5 +150,5 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG
+6 -2
View File
@@ -39,5 +39,9 @@ jobs:
platforms: ${{ matrix.platform }}
push: false
load: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
type=gha,scope=${{ matrix.platform }}-ee
+1 -1
View File
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.10.0'
gem 'ai-agents', '>= 0.12.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
+4 -4
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.10.0)
ai-agents (0.12.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -198,7 +198,7 @@ GEM
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
crass (1.0.7)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -570,7 +570,7 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.0)
msgpack (1.8.3)
multi_json (1.15.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
@@ -1058,7 +1058,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.10.0)
ai-agents (>= 0.12.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
+2 -1
View File
@@ -1,6 +1,7 @@
# Variables
APP_NAME := chatwoot
RAILS_ENV ?= development
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown)
# Targets
setup:
@@ -61,6 +62,6 @@ debug_worker:
overmind connect worker
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
docker build --build-arg GIT_SHA=$(GIT_SHA) -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
@@ -0,0 +1,213 @@
class V2::Reports::DrilldownBuilder
include DateRangeHelper
include TimezoneHelper
DEFAULT_GROUP_BY = 'day'.freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
MESSAGE_METRICS = {
'incoming_messages_count' => :incoming,
'outgoing_messages_count' => :outgoing
}.freeze
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
pattr_initialize :account, :params
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def meta
{
metric: metric,
record_type: record_type,
bucket: {
since: bucket_range.begin.to_i,
until: bucket_range.end.to_i
},
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count
}
end
def conversation_count
return paginated_records.total_count if conversation_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
if message_metric?
message_scope
elsif conversation_metric?
conversation_scope
else
reporting_event_scope
end
end
def message_scope
scope.messages
.where(account_id: account.id, created_at: bucket_range)
.public_send(MESSAGE_METRICS.fetch(metric))
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
def conversation_scope
scope.conversations
.where(account_id: account.id, created_at: bucket_range)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def reporting_event_scope
events = scope.reporting_events
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
.order(created_at: :desc)
if raw_count_strategy == :exclude_bot_handoffs
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
elsif raw_count_strategy == :distinct_conversation
events = events.where(id: distinct_conversation_event_ids(events))
end
events
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def distinct_conversation_event_ids(events)
events.reorder(nil)
.where.not(conversation_id: nil)
.select('MAX(reporting_events.id)')
.group(:conversation_id)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
account,
metric,
use_business_hours?,
records
)
end
def bucket_range
@bucket_range ||= begin
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
bucket_end = bucket_end_for(bucket_start)
requested_start = Time.zone.at(params[:since].to_i)
requested_end = Time.zone.at(params[:until].to_i)
[bucket_start, requested_start].max...[bucket_end, requested_end].min
end
end
def bucket_end_for(bucket_start)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def scope
case dimension_type
when 'account' then account
when 'inbox' then inbox
when 'agent' then user
when 'label' then label
when 'team' then team
else
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
end
end
def inbox = @inbox ||= account.inboxes.find(params[:id])
def user = @user ||= account.users.find(params[:id])
def label = @label ||= account.labels.find(params[:id])
def team = @team ||= account.teams.find(params[:id])
def metric
params[:metric].to_s
end
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def record_type
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
'conversation'
end
def message_metric?
MESSAGE_METRICS.key?(metric)
end
def conversation_metric?
metric == 'conversations_count'
end
def dimension_type
(params[:type].presence || 'account').to_s
end
def group_by
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
def current_page
[params[:page].to_i, DEFAULT_PAGE].max
end
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
end
@@ -0,0 +1,199 @@
class V2::Reports::DrilldownRecordSerializer
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
attr_reader :account, :metric, :use_business_hours, :records
def initialize(account, metric, use_business_hours, records = [])
@account = account
@metric = metric
@use_business_hours = use_business_hours
@records = records
end
def serialize(record)
return serialize_message(record) if record.is_a?(Message)
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
serialize_conversation(record)
end
private
def serialize_message(message, metric_value: nil, occurred_at: nil)
{
record_type: 'message',
conversation: conversation_attributes(message.conversation),
message: message_attributes(message),
metric_value: metric_value,
occurred_at: (occurred_at || message.created_at).to_i
}
end
def serialize_conversation_event(event)
inferred_message = inferred_message_for(event)
if inferred_message.present?
return serialize_message(
inferred_message,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event)
)
end
serialize_conversation(
event.conversation,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event),
event_name: event.name
)
end
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
serialized_record = {
record_type: 'conversation',
conversation: conversation_attributes(conversation),
message: nil,
metric_value: metric_value,
occurred_at: (occurred_at || conversation&.created_at)&.to_i
}
serialized_record[:event_name] = event_name if event_name.present?
serialized_record
end
def conversation_attributes(conversation)
return {} if conversation.blank?
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
contact_name: conversation.contact&.name,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox&.name,
assignee_id: conversation.assignee_id,
assignee_name: conversation.assignee&.name,
status: conversation.status,
created_at: conversation.created_at.to_i,
last_activity_at: conversation.last_activity_at.to_i,
last_message: last_message_attributes(conversation)
}
end
def message_attributes(message)
{
id: message.id,
content: message.content,
message_type: message.message_type,
sender_name: message.sender&.try(:name),
created_at: message.created_at.to_i
}
end
def last_message_attributes(conversation)
message = latest_messages_by_conversation_id[conversation.id]
return if message.blank?
message_attributes(message)
end
def inferred_message_for(event)
return unless MESSAGE_EVENT_METRICS.include?(metric)
return if event.conversation.blank? || event.event_end_time.blank?
inferred_messages_by_event_id[event.id]
end
def first_response_event_with_user?(event)
metric == 'avg_first_response_time' && event.user_id.present?
end
def message_inference_range(event)
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
end
def event_metric_value(event)
use_business_hours ? event.value_in_business_hours : event.value
end
def event_timestamp(event)
event.event_end_time || event.created_at
end
def latest_messages_by_conversation_id
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
{}
else
latest_messages.index_by(&:conversation_id)
end
end
def latest_messages
Message
.where(account_id: account.id, conversation_id: conversation_ids)
.where.not(message_type: :activity)
.select('DISTINCT ON (messages.conversation_id) messages.*')
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
.includes(:sender)
end
def inferred_messages_by_event_id
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
message_matches_event?(message, event)
end
end
end
def inferred_message_candidates
@inferred_message_candidates ||= if inference_events.blank?
[]
else
inferred_messages.to_a
end
end
def inferred_messages
Message
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
.where(created_at: inference_time_range)
.where(message_type: %i[outgoing template])
.includes(:sender)
.reorder(created_at: :desc, id: :desc)
end
def message_matches_event?(message, event)
message.conversation_id == event.conversation_id &&
message.created_at.between?(
message_inference_range(event).begin,
message_inference_range(event).end
) &&
message_sender_matches_event?(message, event)
end
def message_sender_matches_event?(message, event)
return true unless first_response_event_with_user?(event)
message.sender_id == event.user_id && message.sender_type == 'User'
end
def inference_time_range
event_end_times = inference_events.map(&:event_end_time)
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
end
def inference_events
@inference_events ||= records.select do |record|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
end
end
def conversation_ids
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
end
def conversation_id_for(record)
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
record.id
end
end
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
render json: { positions: positions }
end
private
@@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -66,6 +66,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -74,4 +75,10 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end
private
@@ -1,27 +1,40 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -38,4 +51,11 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end
@@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
@@ -164,6 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -8,8 +8,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
end
private
@@ -55,7 +57,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_error_response(error)
def render_embedded_signup_error(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
def drilldown
return head :unauthorized unless Current.account_user.administrator?
return head :unprocessable_entity unless valid_drilldown_params?
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
end
def conversations
return head :unprocessable_entity if params[:type].blank?
@@ -133,6 +140,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
})
end
def drilldown_params
permitted_params = params.permit(
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
).to_h.symbolize_keys
permitted_params.merge(
type: (params[:type].presence || 'account').to_sym,
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
)
end
def valid_drilldown_params?
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
end
def conversation_params
{
type: params[:type].to_sym,
@@ -1,8 +1,15 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -18,6 +25,9 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -31,8 +41,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -59,4 +69,19 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
@@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
@@ -35,11 +35,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
def handle_limit_error(error)
redirect_to_error_page(
error_type: error.class.name,
code: Rack::Utils.status_code(error.http_status),
error_message: error.message
)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
-6
View File
@@ -114,10 +114,4 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end
+12
View File
@@ -22,4 +22,16 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
+10
View File
@@ -68,6 +68,8 @@ module Filters::FilterHelper
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
default_filter(query_hash, filter_operator_value)
end
end
@@ -82,10 +84,18 @@ module Filters::FilterHelper
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_cast_filter(query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_search_on_display_id?(query_hash)
query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
@@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds) {
get(inboxIds, { includeAgentBots = false } = {}) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds },
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
});
}
}
@@ -1,6 +1,10 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -21,6 +25,18 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
}
export default new CaptainAssistant();
@@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId }) {
assignAgent({ conversationId, agentId, assigneeType }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
+36
View File
@@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient {
});
}
getDrilldown({
metric,
bucketTimestamp,
from,
to,
type = 'account',
id,
groupBy,
businessHours,
page,
perPage,
signal,
}) {
const requestConfig = {
params: {
metric,
bucket_timestamp: bucketTimestamp,
since: from,
until: to,
type,
id,
group_by: groupBy,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
page,
per_page: perPage,
},
};
if (signal) {
requestConfig.signal = signal;
}
return axios.get(`${this.url}/drilldown`, requestConfig);
}
// eslint-disable-next-line default-param-last
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, {
@@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
it('#getAssignableAgents with agent bots', () => {
assignableAgentsAPI.get([1], { includeAgentBots: true });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
});
});
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -1,6 +1,8 @@
import reportsAPI from '../reports';
import ApiClient from '../ApiClient';
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
describe('#Reports API', () => {
it('creates correct instance', () => {
expect(reportsAPI).toBeInstanceOf(ApiClient);
@@ -11,6 +13,7 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('update');
expect(reportsAPI).toHaveProperty('delete');
expect(reportsAPI).toHaveProperty('getReports');
expect(reportsAPI).toHaveProperty('getDrilldown');
expect(reportsAPI).toHaveProperty('getSummary');
expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports');
@@ -42,11 +45,14 @@ describe('#Reports API', () => {
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
params: {
business_hours: undefined,
group_by: undefined,
id: undefined,
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account',
timezone_offset: -0,
timezone_offset: timezoneOffset(),
},
});
});
@@ -59,13 +65,70 @@ describe('#Reports API', () => {
group_by: undefined,
id: undefined,
since: 1621103400,
timezone_offset: -0,
timezone_offset: timezoneOffset(),
type: 'account',
until: 1621621800,
},
});
});
it('#getDrilldown', () => {
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
from: 1621103400,
to: 1621621800,
type: 'inbox',
id: 1,
groupBy: 'day',
businessHours: false,
page: 2,
perPage: 25,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: 1621103400,
until: 1621621800,
type: 'inbox',
id: 1,
group_by: 'day',
business_hours: false,
timezone_offset: timezoneOffset(),
page: 2,
per_page: 25,
},
});
});
it('#getDrilldown with abort signal', () => {
const controller = new AbortController();
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: undefined,
until: undefined,
type: 'account',
id: undefined,
group_by: undefined,
business_hours: undefined,
timezone_offset: timezoneOffset(),
page: undefined,
per_page: undefined,
},
signal: controller.signal,
});
});
it('#getAgentReports', () => {
reportsAPI.getAgentReports({
from: 1621103400,
@@ -16,6 +16,10 @@ const props = defineProps({
type: Array,
required: true,
},
contact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
@@ -49,7 +53,9 @@ const unreadMessagesCount = computed(() => {
const hasSlaThreshold = computed(() => {
return (
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
!props.contact?.blocked &&
slaCardLabelRef.value?.hasSlaThreshold &&
props.conversation?.appliedSla?.id
);
});
@@ -126,6 +126,7 @@ const onCardClick = e => {
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:contact="contact"
:account-labels="accountLabels"
/>
</div>
@@ -51,7 +51,9 @@ const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
() =>
!props.currentContact?.blocked &&
(props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold)
);
const selectedModel = computed({
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
const props = defineProps({
conversation: {
@@ -19,16 +19,8 @@ const slaStatus = ref({
icon: null,
});
// TODO: Remove this once we update the helper from utils
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
const convertObjectCamelCaseToSnakeCase = object => {
return Object.keys(object).reduce((acc, key) => {
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
return acc;
}, {});
};
const appliedSLA = computed(() => props.conversation?.appliedSla);
const slaEvents = computed(() => props.conversation?.slaEvents);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
@@ -41,8 +33,9 @@ const slaStatusText = computed(() => {
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
appliedSla: appliedSLA.value || {},
chat: props.conversation,
slaEvents: slaEvents.value || [],
});
};
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
@@ -27,6 +27,7 @@ defineOptions({
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
@@ -34,6 +35,7 @@ const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
@@ -0,0 +1,378 @@
<script setup>
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
import { useEventListener, onKeyStroke, useRafFn } from '@vueuse/core';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// Reorderable list with cross-page drag. It must stay mounted during a page
// fetch — hide the list spinner while `dragging`, or the held item is lost.
const props = defineProps({
items: { type: Array, required: true },
itemKey: { type: String, default: 'id' },
disabled: { type: Boolean, default: false },
currentPage: { type: Number, default: 1 },
totalPages: { type: Number, default: 1 },
});
const emit = defineEmits(['reorder', 'navigatePage', 'dragging']);
const DRAG_THRESHOLD = 5;
const EDGE_BAND = 72; // px from a side that arms a page turn
const AUTO_PAGE_DELAY = 600; // ms hovering an edge before it flips
const SCROLL_BAND = 60; // px from top/bottom that autoscrolls the list
const SCROLL_STEP = 10; // px scrolled per frame while parked at an edge
const PILL = 36; // px, the page-turn arrow button on each edge
const root = ref(null);
const isDragging = ref(false);
const dragged = ref(null);
const pointer = ref({ x: 0, y: 0 });
const insertBefore = ref(null); // key the gap sits before, null = end of list
const activeEdge = ref(null);
const bounds = ref({ left: 0, right: 0, top: 0, bottom: 0 }); // visible list rect
const grab = ref({ dx: 0, dy: 0 }); // cursor offset inside the grabbed card
const dragHeight = ref(0);
let press = null; // pending press, before it becomes a drag
let dwell = null; // timer that flips the page after hovering an edge
let paging = false; // waiting for a flipped page to load
let scroller = null; // scrollable ancestor, found when a drag begins
let scrollDir = 0; // -1 up, +1 down, 0 idle
let sourcePage = 1; // page the drag started on, to resolve cross-page end-drops
const keyOf = item => String(item?.[props.itemKey]);
const others = item => props.items.filter(o => keyOf(o) !== keyOf(item));
const isRTL = () =>
document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl';
const canPage = dir =>
dir === 'next' ? props.currentPage < props.totalPages : props.currentPage > 1;
// Key of the item after `key` in `list`, or null when it is the last one.
const keyAfter = (list, key) => {
const next = list[list.findIndex(o => keyOf(o) === key) + 1];
return next ? keyOf(next) : null;
};
const edges = computed(() =>
['prev', 'next'].filter(canPage).map(dir => {
const onLeft = isRTL() ? dir === 'next' : dir === 'prev';
return {
dir,
onLeft,
icon: onLeft ? 'i-lucide-chevrons-left' : 'i-lucide-chevrons-right',
};
})
);
// Cursor overlay: an edge glow band, a page-turn arrow per edge, and a ghost of
// the dragged row that follows the cursor and shrinks when aimed at an edge.
const EDGE_BAND_W = 80; // px width of the glow band on each pageable edge
const bandStyle = onLeft => {
const x = onLeft ? bounds.value.left : bounds.value.right - EDGE_BAND_W;
return {
width: `${EDGE_BAND_W}px`,
height: `${bounds.value.bottom - bounds.value.top}px`,
transform: `translate(${x}px, ${bounds.value.top}px)`,
};
};
const pillStyle = onLeft => {
const x = onLeft ? bounds.value.left + 12 : bounds.value.right - PILL - 12;
return { transform: `translate(${x}px, ${pointer.value.y - PILL / 2}px)` };
};
const ghostStyle = computed(() => ({
width: `${bounds.value.right - bounds.value.left}px`,
transform: `translate(${pointer.value.x - grab.value.dx}px, ${pointer.value.y - grab.value.dy}px)`,
}));
const scaleStyle = computed(() => ({
transformOrigin: `${grab.value.dx}px ${grab.value.dy}px`,
}));
// The list as shown mid-drag: the dragged row slotted into the gap at `insertBefore`.
const displayItems = computed(() => {
if (!isDragging.value || !dragged.value) return props.items;
const rest = others(dragged.value);
const at = rest.findIndex(o => keyOf(o) === insertBefore.value);
rest.splice(at === -1 ? rest.length : at, 0, dragged.value);
return rest;
});
// Move the gap when the cursor crosses the midpoint of the card it is over.
const aim = () => {
const card = document
.elementFromPoint(pointer.value.x, pointer.value.y)
?.closest('[data-drag-id]');
const key = card?.dataset.dragId;
if (!key || key === keyOf(dragged.value)) return;
const { top, height } = card.getBoundingClientRect();
const above = pointer.value.y < top + height / 2;
insertBefore.value = above ? key : keyAfter(others(dragged.value), key);
};
const flip = dir => {
if (paging || !isDragging.value) return;
paging = true;
emit('navigatePage', props.currentPage + (dir === 'next' ? 1 : -1));
};
const aimEdge = x => {
const rect = root.value?.getBoundingClientRect();
if (!rect) return;
// Clip the list rect to the scroll viewport so the glow band and its top/bottom
// fade always sit on the visible edges, not the far ends of the full content.
const view = scroller?.getBoundingClientRect();
const viewTop = Math.max(view?.top ?? 0, 0);
const viewBottom = Math.min(
view?.bottom ?? window.innerHeight,
window.innerHeight
);
bounds.value = {
left: rect.left,
right: rect.right,
top: Math.max(rect.top, viewTop),
bottom: Math.min(rect.bottom, viewBottom),
};
let dir = null;
if (x <= rect.left + EDGE_BAND) dir = isRTL() ? 'next' : 'prev';
else if (x >= rect.right - EDGE_BAND) dir = isRTL() ? 'prev' : 'next';
if (dir && !canPage(dir)) dir = null;
if (dir === activeEdge.value) return;
activeEdge.value = dir;
clearTimeout(dwell);
if (dir) dwell = setTimeout(() => flip(dir), AUTO_PAGE_DELAY);
};
// Nearest scrollable ancestor, so a drag can reach rows that are off-screen.
const scrollParent = () => {
let el = root.value?.parentElement;
while (el) {
const { overflowY } = getComputedStyle(el);
if (overflowY === 'auto' || overflowY === 'scroll') return el;
el = el.parentElement;
}
return null;
};
// While parked at the top/bottom edge, keep scrolling and re-aim as rows slide by.
const { pause: pauseScroll, resume: resumeScroll } = useRafFn(
() => {
if (!scroller || !scrollDir) return;
scroller.scrollTop += scrollDir * SCROLL_STEP;
aim();
aimEdge(pointer.value.x);
},
{ immediate: false }
);
const updateAutoScroll = y => {
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const atTop = scroller.scrollTop <= 0;
const atBottom =
scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
if (y < rect.top + SCROLL_BAND && !atTop) scrollDir = -1;
else if (y > rect.bottom - SCROLL_BAND && !atBottom) scrollDir = 1;
else scrollDir = 0;
if (scrollDir) resumeScroll();
else pauseScroll();
};
const reset = () => {
isDragging.value = false;
dragged.value = null;
insertBefore.value = null;
activeEdge.value = null;
clearTimeout(dwell);
paging = false;
scrollDir = 0;
scroller = null;
pauseScroll();
document.body.classList.remove('select-none');
emit('dragging', false);
};
// Positions sit on a gap-of-10 grid; a midpoint (±5 at the ends) slots between two rows.
// Cross-page end-drops are the tricky case: moving an item off its source page pulls the
// target page's boundary row into the vacated slot. So at the leading edge after moving
// DOWN (or the trailing edge after moving UP) we land between the two boundary rows, or
// the item sorts onto the adjacent page and vanishes from view.
const drop = (item, rawBefore) => {
const pos = o => o?.position || 0;
const mid = (a, b) => Math.floor((pos(a) + pos(b)) / 2);
const rest = others(item);
// No other rows to position against — e.g. the lone article on a page dropped
// without crossing to another page. Leave the order untouched.
if (!rest.length) return;
// A page flip can leave the aimed key pointing at a row that is no longer on
// this page. Like displayItems, resolve an unknown key to null (end of list),
// so what the user sees and what we save agree.
const before = rest.some(o => keyOf(o) === rawBefore) ? rawBefore : null;
const movedDown = props.currentPage > sourcePage;
const movedUp = props.currentPage < sourcePage;
let position;
if (before === null) {
position =
movedUp && rest.length >= 2
? mid(rest.at(-2), rest.at(-1))
: pos(rest.at(-1)) + 5;
} else {
const i = rest.findIndex(o => keyOf(o) === before);
if (i > 0) {
position = mid(rest[i - 1], rest[i]);
} else if (movedDown) {
// Top-of-page after moving down: the old first row slid up into the source
// page's gap, so land just after it to stay this page's first.
position = rest.length >= 2 ? mid(rest[0], rest[1]) : pos(rest[0]) + 5;
} else {
position = pos(rest[0]) - 5;
}
}
emit('reorder', { [item[props.itemKey]]: position });
};
const startDrag = () => {
isDragging.value = true;
dragged.value = press.item;
grab.value = { dx: press.dx, dy: press.dy };
dragHeight.value = press.h;
insertBefore.value = keyAfter(props.items, keyOf(press.item));
sourcePage = props.currentPage;
scroller = scrollParent();
document.body.classList.add('select-none');
emit('dragging', true);
};
const onPointerDown = (item, e) => {
if (e.button !== 0 || props.disabled) return;
if (e.target.closest('button, a, input, [role="button"]')) return;
const rect = e.currentTarget.getBoundingClientRect();
press = {
item,
x: e.clientX,
y: e.clientY,
dx: e.clientX - rect.left,
dy: e.clientY - rect.top,
h: rect.height,
};
};
useEventListener(window, 'pointermove', e => {
if (!press) return;
if (!isDragging.value) {
const moved = Math.hypot(e.clientX - press.x, e.clientY - press.y);
if (moved < DRAG_THRESHOLD) return;
startDrag();
}
e.preventDefault();
pointer.value = { x: e.clientX, y: e.clientY };
aim();
aimEdge(e.clientX);
updateAutoScroll(e.clientY);
});
useEventListener(window, 'pointerup', () => {
if (!press) return;
press = null;
if (!isDragging.value) return; // a press without movement is a click
const item = dragged.value;
const before = insertBefore.value;
const flipping = paging;
reset();
if (!flipping) drop(item, before);
});
// When the flipped page loads, re-aim under the held cursor so a parked edge keeps flipping.
watch(
() => props.items,
() => {
if (!isDragging.value || !paging) return;
paging = false;
nextTick(() => {
if (!isDragging.value) return;
aim();
activeEdge.value = null;
aimEdge(pointer.value.x);
});
}
);
onKeyStroke('Escape', () => {
if (!isDragging.value) return;
press = null;
reset();
});
onBeforeUnmount(() => {
clearTimeout(dwell);
document.body.classList.remove('select-none');
});
</script>
<template>
<div ref="root" class="relative w-full h-full">
<ul class="w-full h-full space-y-4">
<li
v-for="(item, index) in displayItems"
:key="keyOf(item)"
:data-drag-id="keyOf(item)"
class="relative list-none"
:class="{ 'cursor-grab': !disabled && !isDragging }"
@pointerdown="onPointerDown(item, $event)"
@dragstart.prevent
>
<div
v-if="isDragging && keyOf(item) === keyOf(dragged)"
:style="{ height: `${dragHeight}px` }"
class="border-2 border-dashed rounded-2xl border-n-brand/50 bg-n-brand/5"
/>
<slot v-else name="item" :item="item" :index="index" />
</li>
</ul>
<Teleport v-if="isDragging" to="body">
<div
v-for="edge in edges"
:key="`band-${edge.dir}`"
:style="bandStyle(edge.onLeft)"
class="fixed top-0 left-0 z-40 pointer-events-none from-n-brand/15 to-transparent transition-opacity duration-200 [mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)]"
:class="[
edge.onLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l',
activeEdge === edge.dir ? 'opacity-100' : 'opacity-0',
]"
/>
<div
v-for="edge in edges"
:key="edge.dir"
:style="pillStyle(edge.onLeft)"
class="fixed top-0 left-0 z-50 flex items-center justify-center transition-all duration-150 border rounded-full pointer-events-none size-9 backdrop-blur-sm"
:class="
activeEdge === edge.dir
? 'scale-110 border-n-brand bg-n-brand/20 text-n-brand shadow-md'
: 'opacity-70 border-n-weak/60 bg-n-solid-1/70 text-n-slate-10'
"
>
<Icon
:icon="edge.icon"
class="size-4"
:class="activeEdge === edge.dir && 'animate-pulse'"
/>
</div>
<div
v-if="dragged"
:style="ghostStyle"
class="fixed top-0 left-0 z-50 pointer-events-none select-none"
>
<div
:style="scaleStyle"
class="transition-transform duration-150 shadow-2xl rounded-2xl"
:class="{ 'scale-50': activeEdge }"
>
<slot name="ghost" :item="dragged" />
</div>
</div>
</Teleport>
</div>
</template>
@@ -0,0 +1,222 @@
import { mount } from '@vue/test-utils';
import { h, nextTick } from 'vue';
import DraggableReorderList from '../DraggableReorderList.vue';
// The component is pointer-driven, so we drive it through real pointer events on
// window while mocking the layout APIs jsdom does not implement: elementFromPoint
// (which card is under the cursor) and getBoundingClientRect (its geometry).
const elementAtPoint = { current: null };
const move = (clientX, clientY) =>
window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY }));
const release = () => window.dispatchEvent(new MouseEvent('pointerup'));
// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list.
const stubGeometry = wrapper => {
wrapper.element.getBoundingClientRect = () => ({
left: 0,
right: 500,
top: 0,
bottom: 600,
});
wrapper.findAll('[data-drag-id]').forEach((li, index) => {
const top = index * 50;
li.element.getBoundingClientRect = () => ({
top,
height: 40,
bottom: top + 40,
});
});
};
const mountList = (props = {}) =>
mount(DraggableReorderList, {
props: { items: [], ...props },
slots: {
item: scope => h('div', { class: 'card' }, scope.item.title),
ghost: scope => h('div', { class: 'ghost' }, scope.item.title),
},
global: { stubs: { Icon: true, teleport: true } },
});
describe('DraggableReorderList', () => {
let wrapper;
beforeEach(() => {
elementAtPoint.current = null;
document.elementFromPoint = vi.fn(() => elementAtPoint.current);
});
afterEach(() => {
wrapper?.unmount();
vi.useRealTimers();
});
const startDragging = async id => {
stubGeometry(wrapper);
wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent(
new MouseEvent('pointerdown', {
button: 0,
clientX: 250,
clientY: 20,
bubbles: true,
})
);
await nextTick();
};
it('renders each item through the item slot', () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
});
const cards = wrapper.findAll('.card');
expect(cards).toHaveLength(2);
expect(cards[0].text()).toBe('Alpha');
expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true);
expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true);
});
it('shows a grab affordance only when enabled', () => {
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] });
expect(wrapper.find('[data-drag-id="1"]').classes()).toContain(
'cursor-grab'
);
wrapper.unmount();
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true });
expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain(
'cursor-grab'
);
});
it('does not start a drag when disabled', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
disabled: true,
});
await startDragging(1);
move(250, 200);
await nextTick();
expect(wrapper.emitted('dragging')).toBeUndefined();
});
it('emits dragging true then false across a drag', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
});
await startDragging(1);
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
move(250, 60);
await nextTick();
expect(wrapper.emitted('dragging')[0]).toEqual([true]);
release();
await nextTick();
expect(wrapper.emitted('dragging')[1]).toEqual([false]);
});
it('emits the midpoint position when dropped between two rows', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
{ id: 3, title: 'Gamma', position: 30 },
],
});
await startDragging(1);
// Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap
// sits before Gamma; dropping there lands halfway between Beta and Gamma.
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
move(250, 85);
await nextTick();
release();
await nextTick();
expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 });
});
it('does not reorder when the only row on a page is dropped in place', async () => {
// P1: dragging the lone article on a later page and releasing without
// crossing to another page must be a no-op, not move it to the top.
wrapper = mountList({
items: [{ id: 5, title: 'Solo', position: 260 }],
currentPage: 2,
totalPages: 2,
});
await startDragging(5);
move(250, 300);
await nextTick();
release();
await nextTick();
expect(wrapper.emitted('dragging')).toEqual([[true], [false]]);
expect(wrapper.emitted('reorder')).toBeUndefined();
});
it('turns the page after dwelling on a pageable edge', async () => {
vi.useFakeTimers();
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
],
currentPage: 1,
totalPages: 2,
});
await startDragging(1);
// Drag to the right edge over blank space (no card) and hold.
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
expect(wrapper.emitted('navigatePage')[0]).toEqual([2]);
});
it('can still turn pages after releasing during a pending flip', async () => {
// Releasing while a flip fetch is in flight must clear paging state, or every
// later drag would be stuck unable to navigate.
vi.useFakeTimers();
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
],
currentPage: 1,
totalPages: 2,
});
// First drag: park at the edge to start a flip, then release before the new
// page arrives (items never change here).
await startDragging(1);
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
release();
await nextTick();
// Second drag must be able to flip again.
await startDragging(1);
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]);
});
});
@@ -1,6 +1,5 @@
<script setup>
import { ref, computed, watch } from 'vue';
import Draggable from 'vuedraggable';
import { computed, ref } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -10,6 +9,7 @@ import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
const props = defineProps({
articles: {
@@ -28,9 +28,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalPages: {
type: Number,
default: 1,
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const emit = defineEmits([
'translateArticle',
'toggleSelect',
'navigatePage',
'dragging',
]);
const { ARTICLE_STATUS_TYPES } = wootConstants;
@@ -39,14 +52,14 @@ const route = useRoute();
const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
const canReorder = props.articles?.length > 1 || props.totalPages > 1;
return (
props.isCategoryArticles &&
!props.isSearching &&
localArticles.value?.length > 1 &&
canReorder &&
props.selectedArticleIds.size === 0
);
});
@@ -63,6 +76,10 @@ const handleCardHover = (isHovered, id) => {
const getCategoryById = useMapGetter('categories/categoryById');
const getCategory = categoryId => {
return getCategoryById.value(categoryId) || { name: '', icon: '' };
};
const openArticle = id => {
const { tab, categorySlug, locale } = route.params;
if (props.isCategoryArticles) {
@@ -83,38 +100,25 @@ const openArticle = id => {
}
};
const onReorder = async reorderedGroup => {
const onReorder = async positionsHash => {
const [movedId] = Object.keys(positionsHash);
// A same-page reorder updates optimistically in the store, so it needs no
// refetch. Only a cross-page drop must refresh, to pull the moved article
// onto this page in its new spot.
const isCrossPage = !props.articles.some(
article => String(article.id) === movedId
);
try {
await store.dispatch('articles/reorder', {
reorderedGroup,
reorderedGroup: positionsHash,
portalSlug: route.params.portalSlug,
});
if (isCrossPage) emit('navigatePage', props.currentPage);
} catch {
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
}
};
const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedArticlePositions = localArticles.value
.map((article, index) => article.position || index + 1)
.sort((a, b) => a - b);
const orderedArticles = localArticles.value.map(article => article.id);
// Create a map of article IDs to their new positions
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
obj[key] = sortedArticlePositions[index];
return obj;
}, {});
onReorder(reorderedGroup);
};
const getCategory = categoryId => {
return getCategoryById.value(categoryId) || { name: '', icon: '' };
};
const getStatusMessage = (status, isSuccess) => {
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
const statusMap = {
@@ -184,54 +188,46 @@ const updateArticle = ({ action, value, id }) => {
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
// Watch for changes in the articles prop and update the localArticles ref
watch(
() => props.articles,
newArticles => {
localArticles.value = newArticles;
},
{ deep: true }
);
</script>
<template>
<Draggable
v-model="localArticles"
<DraggableReorderList
:items="articles"
:disabled="!dragEnabled"
item-key="id"
tag="ul"
ghost-class="article-ghost-class"
class="w-full h-full space-y-4"
@end="onDragEnd"
:current-page="currentPage"
:total-pages="totalPages"
@reorder="onReorder"
@navigate-page="page => emit('navigatePage', page)"
@dragging="value => emit('dragging', value)"
>
<template #item="{ element }">
<li class="list-none rounded-2xl">
<ArticleCard
:id="element.id"
:key="element.id"
:title="element.title"
:status="element.status"
:author="element.author"
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
<template #item="{ item }">
<ArticleCard
:id="item.id"
:title="item.title"
:status="item.status"
:author="item.author"
:category="getCategory(item.category.id)"
:views="item.views || 0"
:updated-at="item.updatedAt"
:is-selected="selectedArticleIds.has(item.id)"
selectable
:show-selection-control="shouldShowSelectionControl(item.id)"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, item.id)"
/>
</template>
</Draggable>
<template #ghost="{ item }">
<ArticleCard
:id="item.id"
:title="item.title"
:status="item.status"
:author="item.author"
:category="getCategory(item.category.id)"
:views="item.views || 0"
:updated-at="item.updatedAt"
/>
</template>
</DraggableReorderList>
</template>
<style lang="scss" scoped>
.article-ghost-class {
@apply opacity-50 bg-n-solid-1;
}
</style>
@@ -70,6 +70,7 @@ const isFeatureEnabledonAccount = useMapGetter(
);
const selectedArticleIds = ref(new Set());
const isArticleDragging = ref(false);
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const searchQuery = ref(route.query.search || '');
@@ -148,6 +149,8 @@ const articlesCount = computed(() => {
return Number(countMap[tab] || countMap['']);
});
const totalPages = computed(() => Math.ceil(articlesCount.value / 25) || 1);
const showArticleHeaderControls = computed(
() => !props.isCategoryArticles && !isSwitchingPortal.value
);
@@ -343,7 +346,7 @@ watch(
</template>
<template #content>
<div
v-if="isLoading"
v-if="isLoading && !isArticleDragging"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
@@ -453,9 +456,13 @@ watch(
:is-category-articles="isCategoryArticles"
:is-searching="isSearching"
:selected-article-ids="selectedArticleIds"
:current-page="Number(meta.currentPage)"
:total-pages="totalPages"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
@navigate-page="handlePageChange"
@dragging="isArticleDragging = $event"
/>
</template>
<ArticleEmptyState
@@ -182,7 +182,8 @@ const handleCreateAssistant = () => {
</div>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-2">
<slot name="headerActions" />
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
@@ -29,7 +29,6 @@ const initialState = {
handoffMessage: '',
resolutionMessage: '',
instructions: '',
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => {
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
@@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
temperature: state.temperature || 1,
},
};
@@ -131,26 +128,6 @@ watch(
class="z-0"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@@ -0,0 +1,96 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const route = useRoute();
const router = useRouter();
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
const DISMISS_STORE = 'captain_overview_coverage_banner';
const accountId = computed(() => route.params.accountId);
const assistantId = computed(() => route.params.assistantId);
// Re-read the stored flag whenever the assistant changes, otherwise the banner
// would keep the first assistant's dismissed state after switching.
const dismissed = ref(false);
watch(
[accountId, assistantId],
([account, assistant]) => {
dismissed.value = LocalStorage.getFlag(DISMISS_STORE, account, assistant);
},
{ immediate: true }
);
// Thin coverage paired with a large review backlog: approving the pending FAQs
// is the quickest lever to lift auto-resolution, so nudge the team to act.
const COVERAGE_THRESHOLD = 85;
const PENDING_THRESHOLD = 100;
const showBanner = computed(
() =>
!dismissed.value &&
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
);
const dismiss = () => {
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
dismissed.value = true;
};
const goToPending = () => {
router.push({
name: 'captain_assistants_responses_pending',
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
count: knowledge.pending,
coverage: knowledge.coverage,
})
}}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToPending"
>
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
@click="dismiss"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
const route = useRoute();
const router = useRouter();
const store = useStore();
const dismissed = ref(false);
const assistantId = computed(() => route.params.assistantId);
const inboxes = useMapGetter('captainInboxes/getRecords');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
// Only surface once we know the assistant has no connected inboxes.
const showBanner = computed(
() =>
!dismissed.value &&
!uiFlags.value.fetchingList &&
inboxes.value.length === 0
);
watch(
assistantId,
id => {
dismissed.value = false;
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
},
{ immediate: true }
);
const goToInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToInboxes"
>
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
@click="dismissed = true"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const { t } = useI18n();
const route = useRoute();
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
const linkTo = routeName => ({
name: routeName,
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
const stats = computed(() => [
{
key: 'approved',
value: props.knowledge.approved,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
to: linkTo('captain_assistants_responses_index'),
},
{
key: 'pending',
value: props.knowledge.pending,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
to: linkTo('captain_assistants_responses_pending'),
},
{
key: 'documents',
value: props.knowledge.documents,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
to: linkTo('captain_assistants_documents_index'),
},
]);
</script>
<template>
<div
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
</span>
<span class="text-sm tabular-nums text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<RouterLink
v-for="stat in stats"
:key="stat.key"
:to="stat.to"
class="flex flex-col gap-1 group/stat"
>
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
{{ stat.value }}
</span>
<span
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
>
{{ stat.label }}
<span
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
/>
</span>
</RouterLink>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
trend: { type: String, default: '' },
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
});
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
</script>
<template>
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ value }}
</span>
<span class="text-sm font-medium tabular-nums" :class="trendClass">
{{ trend }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
const { t } = useI18n();
const route = useRoute();
const { isOnChatwootCloud } = useAccount();
const assistantParams = computed(() => ({
accountId: route.params.accountId,
assistantId: route.params.assistantId,
}));
const links = computed(() => [
{
key: 'docs',
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
icon: 'i-lucide-book-open',
href: getHelpUrlForFeature('captain'),
},
{
key: 'playground',
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
icon: 'i-lucide-flask-conical',
to: {
name: 'captain_assistants_playground_index',
params: assistantParams.value,
},
},
{
key: 'billing',
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
icon: 'i-lucide-credit-card',
to: {
name: 'billing_settings_index',
params: { accountId: route.params.accountId },
},
},
]);
</script>
<template>
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<component
:is="link.href ? 'a' : RouterLink"
v-for="link in links"
:key="link.key"
:href="link.href"
:to="link.to"
:target="link.href ? '_blank' : undefined"
:rel="link.href ? 'noopener noreferrer' : undefined"
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
>
<span
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
>
<span :class="link.icon" class="size-4" />
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ link.title }}
</span>
<span class="text-xs truncate text-n-slate-11">
{{ link.description }}
</span>
</div>
<span
:class="
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
"
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
/>
</component>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const modelValue = defineModel({ type: String, default: '30' });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const DAY_RANGES = ['7', '30', '90'];
const decorate = item => ({
...item,
action: 'select',
isSelected: item.value === modelValue.value,
});
const menuSections = computed(() => {
const dayItems = DAY_RANGES.map(value =>
decorate({
value,
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
})
);
const monthItems = [
decorate({
value: 'this_month',
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
}),
decorate({
value: 'last_month',
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
}),
];
return [{ items: dayItems }, { items: monthItems }];
});
const menuItems = computed(() =>
menuSections.value.flatMap(section => section.items)
);
const selectedLabel = computed(
() => menuItems.value.find(item => item.isSelected)?.label || ''
);
const handleAction = ({ value }) => {
toggleDropdown(false);
modelValue.value = value;
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
trailing-icon
icon="i-lucide-chevron-down"
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
const props = defineProps({
range: {
type: String,
default: '30',
},
});
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
// Markdown summary generated by the model from the assistant's stats (served by
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
// **bold** so we can highlight them (see prose-strong styling below).
const welcomeMarkdown = ref('');
const isLoading = ref(false);
const fetchSummary = async () => {
isLoading.value = true;
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
});
welcomeMarkdown.value = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
}
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
// handles paragraphs, line breaks, links, lists and emphasis consistently.
const formattedSummary = computed(
() => new MessageFormatter(welcomeMarkdown.value).formattedMessage
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
<div class="flex items-center gap-1.5 text-n-slate-10">
<span class="i-lucide-sparkles size-3.5" />
<span class="text-xs">
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
</span>
</div>
<div
v-if="isLoading"
class="flex flex-col gap-5"
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
>
<div class="flex flex-col gap-2.5">
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div
v-else
v-dompurify-html="formattedSummary"
class="max-w-none prose prose-p:text-lg prose-p:leading-relaxed prose-p:mt-0 prose-p:mb-3 last:prose-p:mb-0 prose-strong:font-bold prose-strong:tabular-nums prose-strong:text-n-brand text-n-slate-12"
/>
</div>
</template>
@@ -65,7 +65,7 @@ const handleAssistantChange = async assistant => {
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_responses_index';
currentRouteName || 'captain_assistants_overview_index';
await fetchDataForRoute(targetRouteName, assistant.id);
@@ -75,6 +75,16 @@ const hasConversationUnreadCounts = computed(() => {
);
});
const hasFilteredUnreadCounts = computed(() => {
return (
hasConversationUnreadCounts.value &&
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS
)
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -199,6 +209,18 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const mentionsUnreadCount = useMapGetter(
'conversationUnreadCounts/getMentionsUnreadCount'
);
const participatingUnreadCount = useMapGetter(
'conversationUnreadCounts/getParticipatingUnreadCount'
);
const unattendedUnreadCount = useMapGetter(
'conversationUnreadCounts/getUnattendedUnreadCount'
);
const getFolderUnreadCount = useMapGetter(
'conversationUnreadCounts/getFolderUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -226,14 +248,22 @@ watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const hasUnreadCountsForSection = section => {
if (section === SIDEBAR_SORT_SECTIONS.FOLDERS) {
return hasFilteredUnreadCounts.value;
}
return hasConversationUnreadCounts.value;
};
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasConversationUnreadCounts.value,
hasUnreadCounts: hasUnreadCountsForSection(section),
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasConversationUnreadCounts.value,
hasUnreadCounts: hasUnreadCountsForSection(section),
});
const updateSortPreference = (section, sortBy) => {
@@ -253,6 +283,7 @@ const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
unreadCountKey: view => getFolderUnreadCount.value(view.id),
})
);
@@ -342,6 +373,9 @@ const menuItems = computed(() => {
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
icon: 'i-lucide-at-sign',
badgeCount: hasFilteredUnreadCounts.value
? mentionsUnreadCount.value
: 0,
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
@@ -349,6 +383,9 @@ const menuItems = computed(() => {
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
icon: 'i-lucide-user-round-check',
badgeCount: hasFilteredUnreadCounts.value
? participatingUnreadCount.value
: 0,
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
@@ -357,6 +394,9 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
badgeCount: hasFilteredUnreadCounts.value
? unattendedUnreadCount.value
: 0,
to: accountScopedRoute('conversation_unattended'),
},
{
@@ -370,6 +410,9 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
badgeCount: hasFilteredUnreadCounts.value
? getFolderUnreadCount.value(view.id)
: 0,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -440,6 +483,14 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
{
name: 'Overview',
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
activeOn: ['captain_assistants_overview_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_overview_index',
}),
},
{
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
@@ -123,6 +123,10 @@ onMounted(() => {
a {
@apply p-4;
}
.ProseMirror a {
@apply p-0;
}
}
}
}
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -58,6 +59,7 @@ const isActive = computed(() => {
if (key === 'whatsapp_call') {
return (
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
@@ -62,7 +62,10 @@ export default {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
this.$store.dispatch('inboxAssignableAgents/fetch', {
inboxIds: [inboxId],
includeAgentBots: true,
});
}
},
},
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
@@ -57,7 +58,13 @@ const showMetaSection = computed(() => {
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const isAgentBotAssignee = computed(
() => props.chat?.meta?.assignee_type === 'AgentBot'
);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
@@ -157,10 +164,15 @@ watch(
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center gap-px truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
<Icon
:icon="
isAgentBotAssignee ? 'i-lucide-bot' : 'i-lucide-user-round'
"
class="size-3 text-n-slate-11 flex-shrink-0"
/>
<span class="truncate">{{ assignee.name }}</span>
</span>
<CardPriorityIcon
:priority="chat.priority"
@@ -93,7 +93,9 @@ const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !currentContact.value?.blocked
);
const copyConversationId = async () => {
try {
@@ -123,7 +123,13 @@ export default {
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<div
v-if="
currentChat.meta.assignee &&
currentChat.meta.assignee_type !== 'AgentBot'
"
class="flex items-center gap-2"
>
<input
id="assignee"
v-model="selectedType"
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -61,6 +61,7 @@ const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
@@ -33,10 +33,19 @@ defineProps({
v-if="variant === 'agent'"
:name="option.label"
:src="option.thumbnail"
:icon-name="option.iconName"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
>
<template v-if="option.iconName && option.thumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2" />
</div>
</template>
</Avatar>
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
@@ -0,0 +1,60 @@
import { shallowMount } from '@vue/test-utils';
import ConversationCard from '../ConversationCard.vue';
const defaultChat = {
id: 1,
labels: [],
messages: [],
priority: null,
unread_count: 0,
timestamp: 1700000000,
created_at: 1700000000,
};
const mountComponent = (chat, currentContact = {}) =>
shallowMount(ConversationCard, {
props: {
chat: { ...defaultChat, ...chat },
currentContact: {
name: 'Jane Doe',
thumbnail: '',
availability_status: 'offline',
...currentContact,
},
inbox: { id: 1 },
},
global: {
stubs: {
'fluent-icon': true,
},
},
});
describe('ConversationCard', () => {
it('does not reserve the labels row when only a persisted SLA policy id is present', () => {
const wrapper = mountComponent({ sla_policy_id: 1, applied_sla: null });
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
});
it('shows the labels row when an active applied SLA is present', () => {
const wrapper = mountComponent({
sla_policy_id: 1,
applied_sla: { id: 1 },
});
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(true);
});
it('does not reserve the labels row when the contact is blocked', () => {
const wrapper = mountComponent(
{
sla_policy_id: 1,
applied_sla: { id: 1 },
},
{ blocked: true }
);
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
});
});
@@ -26,11 +26,12 @@ const mockNoneAgent = {
};
const mockUseMapGetter = (overrides = {}) => {
const getAssignableAgents = vi.fn(() => allAgentsData);
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
'inboxAssignableAgents/getAssignableAgents': ref(getAssignableAgents),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -53,6 +54,24 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: false });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('requests agent bots when explicitly included', () => {
const { agentsList, assignableAgents } = useAgentsList(true, {
includeAgentBots: true,
});
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: true });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
@@ -10,9 +10,14 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {Object} [options] - Options for the assignable agents list.
* @param {boolean} [options.includeAgentBots=false] - Whether to include AgentBot assignees. Only pass this from surfaces that thread `assignee_type` through the assignment request.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
export function useAgentsList(
includeNoneAgent = true,
{ includeAgentBots = false } = {}
) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
@@ -39,7 +44,9 @@ export function useAgentsList(includeNoneAgent = true) {
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
return inboxId.value
? assignable.value(inboxId.value, { includeAgentBots })
: [];
});
/**
@@ -161,6 +161,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
// 409 = the call already ended before accept landed (e.g. caller hung up mid-ring).
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
markDismissed(callSid);
@@ -0,0 +1,35 @@
// Single source of truth for billing currencies on the frontend.
// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to
// SUPPORTED_BILLING_CURRENCIES, and add its label key under
// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files.
export const DEFAULT_BILLING_CURRENCY = 'usd';
// Order here drives the order of the currency toggle in the UI.
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
export const BILLING_CURRENCY_CONFIG = {
usd: {
code: 'usd',
intlLocale: 'en-US',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
},
brl: {
code: 'brl',
intlLocale: 'pt-BR',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
},
};
export const getCurrencyConfig = code =>
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
export const formatCurrencyAmount = (amount, code, options = {}) => {
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
return new Intl.NumberFormat(intlLocale, {
style: 'currency',
currency: currencyCode.toUpperCase(),
...options,
}).format(amount);
};
+16 -1
View File
@@ -188,6 +188,16 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'insertTable',
];
// [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer
// escapes; the re-parse renders them literally, unescaping would crash it.
const flattenLink = (_match, text, url) => {
const cleanUrl = url
.trim()
.replace(/\s+["'(].*$/, '')
.replace(/^<|>$/g, '');
return text === cleanUrl ? cleanUrl : `${text}: ${cleanUrl}`;
};
/**
* Markdown formatting patterns for stripping unsupported formatting.
*
@@ -264,7 +274,12 @@ export const MARKDOWN_PATTERNS = [
{
type: 'link', // PM: link
patterns: [
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
// Escape-aware label + URL captures so a \] or \) can't cut the match
// short and leave link markup that crashes the re-parse.
{
pattern: /\[((?:\\.|[^\]\\])*)\]\(((?:\\.|[^)\\])*)\)/g,
replacement: flattenLink,
},
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
],
@@ -78,3 +78,7 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
// Temporarily disables WhatsApp embedded signup and WhatsApp Call inbox
// creation. Flip to false when the channel is brought back.
export const IS_WHATSAPP_INBOX_CREATION_DISABLED = true;
+1
View File
@@ -47,6 +47,7 @@ export const FEATURE_FLAGS = {
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
UNREAD_COUNT_FOR_FILTERS: 'unread_count_for_filters',
};
export const PREMIUM_FEATURES = [
@@ -17,6 +17,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS =
UNREAD_COUNTS_REFETCH_THROTTLE_MS;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -25,6 +32,9 @@ class ActionCableConnector extends BaseActionCableConnector {
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.mentionUnreadCountsFetchTimer = null;
this.mentionUnreadCountsRetryTimer = null;
this.filteredUnreadCountsRetryTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -140,7 +150,12 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onConversationUnreadCountChanged = () => {
this.refreshConversationUnreadCountsWithFilteredRetry();
};
refreshConversationUnreadCountsWithFilteredRetry = () => {
this.throttledFetchConversationUnreadCounts();
this.scheduleFilteredUnreadCountsRetry();
};
throttledFetchConversationUnreadCounts = () => {
@@ -171,6 +186,51 @@ class ActionCableConnector extends BaseActionCableConnector {
this.unreadCountsFetchTimer = null;
};
scheduleMentionUnreadCountsFetch = () => {
if (!this.isFilteredUnreadCountsEnabled()) return;
// Mention invalidation runs through the async dispatcher, and stale snapshots
// can be served until the filtered-count backend refresh window opens.
this.scheduleUnreadCountsFetchAfter(
'mentionUnreadCountsFetchTimer',
MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS
);
this.scheduleUnreadCountsFetchAfter(
'mentionUnreadCountsRetryTimer',
getFilteredUnreadCountsRefreshRetryDelay(),
{ reset: true }
);
};
scheduleFilteredUnreadCountsRetry = () => {
if (!this.isFilteredUnreadCountsEnabled()) return;
// Filtered snapshots can intentionally stay stale until the backend
// refresh window opens.
this.scheduleUnreadCountsFetchAfter(
'filteredUnreadCountsRetryTimer',
getFilteredUnreadCountsRefreshRetryDelay(),
{ reset: true }
);
};
scheduleUnreadCountsFetchAfter = (
timerName,
delay,
{ reset = false } = {}
) => {
if (this[timerName]) {
if (!reset) return;
clearTimeout(this[timerName]);
}
this[timerName] = setTimeout(() => {
this[timerName] = null;
this.throttledFetchConversationUnreadCounts();
}, delay);
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
@@ -189,6 +249,17 @@ class ActionCableConnector extends BaseActionCableConnector {
);
};
isFilteredUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return (
isFeatureEnabled?.(accountId, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
isFeatureEnabled?.(accountId, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -212,6 +283,7 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationMentioned = data => {
this.app.$store.dispatch('addMentions', data);
this.scheduleMentionUnreadCountsFetch();
};
clearTimer = conversationId => {
@@ -273,6 +345,12 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
if (this.isFilteredUnreadCountsEnabled()) {
// Inbox/team/label visibility changes can change the accessible set used
// by filtered unread counts even when no conversation row changes.
this.refreshConversationUnreadCountsWithFilteredRetry();
}
};
onVoiceCallIncoming = data => {
@@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = (
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id
item.id === currentUser.id && (item.assignee_type || 'User') === 'User'
? {
...item,
availability_status: currentUser.accounts.find(
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain: 'https://chwt.app/captain-docs',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
@@ -20,6 +20,8 @@ export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
@@ -0,0 +1,150 @@
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
/**
* Formats seconds into a human-readable time string
* @param {number} seconds - The time in seconds (can be negative for overdue)
* @returns {string} Formatted time string like "2h 30m" or "1d 4h"
*/
const formatSLATime = seconds => {
const absSeconds = Math.abs(seconds);
const units = {
y: 31536000,
mo: 2592000,
d: 86400,
h: 3600,
m: 60,
};
if (absSeconds < 60) {
return '1m';
}
const parts = [];
let remaining = absSeconds;
Object.entries(units).forEach(([unit, value]) => {
if (parts.length >= 2) return;
const count = Math.floor(remaining / value);
if (count > 0) {
parts.push(`${count}${unit}`);
remaining -= count * value;
}
});
return parts.join(' ');
};
const toUnixTimestamp = value => {
if (!value || typeof value === 'number') return value;
const numericValue = Number(value);
if (!Number.isNaN(numericValue)) return numericValue;
const parsedTimestamp = Date.parse(value);
return Number.isNaN(parsedTimestamp)
? value
: Math.floor(parsedTimestamp / 1000);
};
/**
* Evaluates SLA status using backend-computed due times
* @param {Object} params - Parameters object
* @param {Object} params.appliedSla - The applied SLA with due_at timestamps
* @param {Object} params.chat - The conversation object
* @param {Array} params.slaEvents - Recorded SLA miss events for this conversation
* @returns {Object} SLA status with type, threshold, icon, and isSlaMissed
*/
export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
const emptyStatus = { type: '', threshold: '', icon: '', isSlaMissed: false };
if (!appliedSla || !chat) {
return emptyStatus;
}
const sla = useCamelCase(appliedSla);
const conversation = useCamelCase(chat);
const events = useCamelCase(slaEvents || []);
const currentTime = Math.floor(Date.now() / 1000);
const slaStatuses = [];
const dueAtByType = {
FRT: sla.slaFrtDueAt,
RT: sla.slaRtDueAt,
};
const slaTypes = ['FRT', 'NRT', 'RT'];
events.forEach(event => {
const type = event.eventType?.toUpperCase();
if (!slaTypes.includes(type)) return;
const missedAt =
type === 'NRT' ? event.createdAt : dueAtByType[type] || event.createdAt;
if (!missedAt) return;
slaStatuses.push({
type,
threshold: missedAt - currentTime,
icon: 'flame',
isSlaMissed: true,
});
});
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
slaStatuses.push({
type: 'FRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
slaStatuses.push({
type: 'NRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
if (slaStatuses.length === 0) {
return emptyStatus;
}
// Show existing breaches before upcoming deadlines, then pick the closest timer.
slaStatuses.sort((a, b) => {
if (a.isSlaMissed !== b.isSlaMissed) {
return a.isSlaMissed ? -1 : 1;
}
return Math.abs(a.threshold) - Math.abs(b.threshold);
});
const mostUrgent = slaStatuses[0];
return {
type: mostUrgent.type,
threshold: formatSLATime(mostUrgent.threshold),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
};
};
@@ -1,5 +1,6 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
@@ -17,6 +18,9 @@ global.chatwootConfig = {
websocketURL: 'wss://test.chatwoot.com',
};
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
describe('ActionCableConnector - Copilot Tests', () => {
let store;
let actionCable;
@@ -39,6 +43,8 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('copilot event handlers', () => {
@@ -81,12 +87,223 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
it('should refetch unread counts when unread count changes', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
vi.advanceTimersByTime(37499);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('does not retry unread count changes when filtered counts are disabled', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(45000);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
it('delays unread count refetch when a conversation is mentioned', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const conversation = { id: 1, account_id: 1 };
actionCable.onReceived({
event: 'conversation.mentioned',
data: conversation,
});
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(4999);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not schedule mention unread count fetches when filtered counts are disabled', () => {
vi.useFakeTimers();
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
const conversation = { id: 1, account_id: 1 };
actionCable.onReceived({
event: 'conversation.mentioned',
data: conversation,
});
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
vi.advanceTimersByTime(45000);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('retries mentioned unread counts after the backend refresh window', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(32499);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(unreadCountFetches()).toHaveLength(2);
});
it('reschedules mentioned unread count retries for later invalidations', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0);
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(10000);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(2);
vi.advanceTimersByTime(10000);
expect(unreadCountFetches()).toHaveLength(2);
vi.advanceTimersByTime(15000);
expect(unreadCountFetches()).toHaveLength(3);
});
it('refetches filtered unread counts after account cache invalidation', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
const cacheKeys = {
label: 'label-key',
inbox: 'inbox-key',
team: 'team-key',
};
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
actionCable.onReceived({
event: 'account.cache_invalidated',
data: { account_id: 1, cache_keys: cacheKeys },
});
expect(mockDispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: cacheKeys.label,
});
expect(mockDispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: cacheKeys.inbox,
});
expect(mockDispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: cacheKeys.team,
});
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(37499);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(unreadCountFetches()).toHaveLength(2);
});
it('does not refetch unread counts after cache invalidation when filtered counts are disabled', () => {
vi.useFakeTimers();
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
actionCable.onReceived({
event: 'account.cache_invalidated',
data: {
account_id: 1,
cache_keys: {
label: 'label-key',
inbox: 'inbox-key',
team: 'team-key',
},
},
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(45000);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('does not refetch unread counts when unread count feature is disabled', () => {
@@ -1,4 +1,9 @@
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
import {
EditorState,
EditorView,
buildMessageSchema,
MessageMarkdownTransformer,
} from '@chatwoot/prosemirror-schema';
import { FORMATTING } from 'dashboard/constants/editor';
import { Schema } from 'prosemirror-model';
import {
@@ -978,13 +983,77 @@ describe('stripUnsupportedFormatting', () => {
);
});
it('strips links but keeps text', () => {
it('keeps link text and URL when schema does not support links', () => {
expect(
stripUnsupportedFormatting(
'Check [this link](https://example.com)',
emptySchema
)
).toBe('Check this link');
).toBe('Check this link: https://example.com');
});
it('drops the hidden link title when preserving the URL', () => {
expect(
stripUnsupportedFormatting(
'Check [docs](https://example.com "Docs")',
emptySchema
)
).toBe('Check docs: https://example.com');
expect(
stripUnsupportedFormatting(
'Check [docs](<https://example.com> "Docs")',
emptySchema
)
).toBe('Check docs: https://example.com');
});
// Output is re-parsed before sending, so assert the final text
// (strip + re-parse); the re-parse turns serializer escapes into literals.
describe('links round-trip through re-parse without crashing', () => {
const smsSchema = buildMessageSchema([], []); // no marks, no nodes
const sendAs = md =>
new MessageMarkdownTransformer(smsSchema).parse(
stripUnsupportedFormatting(md, smsSchema)
).textContent;
it('keeps escaped parens/underscores anywhere in the URL', () => {
expect(
sendAs('See [wiki](https://en.wikipedia.org/wiki/Foo\\_\\(bar\\))')
).toBe('See wiki: https://en.wikipedia.org/wiki/Foo_(bar)');
expect(sendAs('See [wiki](https://host/a\\_\\(b\\)c)')).toBe(
'See wiki: https://host/a_(b)c'
);
});
it('drops the label when it equals the URL even when escaped', () => {
expect(
sendAs(
'[www.example.com/Foo\\_\\(bar\\)](www.example.com/Foo\\_\\(bar\\))'
)
).toBe('www.example.com/Foo_(bar)');
});
it('does not reintroduce emphasis from an escaped label', () => {
expect(sendAs('[Use \\_id\\_](https://example.com)')).toBe(
'Use _id_: https://example.com'
);
});
it('flattens a label containing an escaped closing bracket', () => {
expect(sendAs('[FAQ \\[v2\\]](https://example.com)')).toBe(
'FAQ [v2]: https://example.com'
);
});
});
it('leaves bare URLs untouched so channels can auto-link them', () => {
expect(
stripUnsupportedFormatting('Visit www.example.com now', emptySchema)
).toBe('Visit www.example.com now');
expect(
stripUnsupportedFormatting('Visit <https://example.com>', emptySchema)
).toBe('Visit https://example.com');
});
it('converts autolinks to plain URLs when schema does not support links', () => {
@@ -1049,7 +1118,7 @@ describe('stripUnsupportedFormatting', () => {
it('handles complex content with multiple formatting types', () => {
const content =
'**Bold** and *italic* with `code` and [link](url)\n- list item';
const expected = 'Bold and italic with code and link\nlist item';
const expected = 'Bold and italic with code and link: url\nlist item';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
});
@@ -148,7 +148,7 @@ describe('#normalizeSidebarSortPreferences', () => {
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: 'unsupported_sort',
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
@@ -171,6 +171,15 @@ describe('#getSidebarSortOptions', () => {
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('keeps folder unread count options when filtered unread counts are enabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
hasUnreadCounts: true,
});
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
@@ -180,6 +189,16 @@ describe('#getSidebarSortOptions', () => {
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
it('removes folder unread count options when filtered unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
hasUnreadCounts: false,
});
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
describe('#resolveSidebarSort', () => {
@@ -202,4 +221,14 @@ describe('#resolveSidebarSort', () => {
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
it('falls back to alphabetical sort for folders when filtered unread counts are disabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.FOLDERS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: false }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
@@ -0,0 +1,450 @@
import { evaluateSLAStatus } from '../slaHelper';
describe('#SLA Helpers', () => {
const currentTimestamp = 1700000000; // Fixed timestamp for testing
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(currentTimestamp * 1000);
});
afterEach(() => {
vi.useRealTimers();
});
describe('evaluateSLAStatus', () => {
describe('when inputs are invalid', () => {
it('returns empty status when appliedSla is null', () => {
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
it('returns empty status when chat is null', () => {
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
});
describe('FRT (First Response Time)', () => {
it('returns FRT status when first reply not made and within threshold', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it('returns missed FRT status when threshold is exceeded', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return FRT when first reply was made before due time', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
const chat = {
first_reply_created_at: currentTimestamp - 1000,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('FRT');
});
it('does not return FRT when first reply was made at due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
});
it('returns missed FRT when first reply was made after due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 300,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('10m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: new Date(
(currentTimestamp - 300) * 1000
).toISOString(),
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('10m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the due time for a missed FRT event created after the deadline', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
const chat = { first_reply_created_at: null, status: 'open' };
const slaEvents = [
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
});
describe('NRT (Next Response Time)', () => {
it('returns NRT status when waiting for response and within threshold', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it('returns missed NRT status when threshold is exceeded', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 2700,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('15m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return NRT when not waiting for response', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: null,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('NRT');
});
it('returns missed NRT when a recorded NRT miss exists after response', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: null,
status: 'open',
};
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('15m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
const appliedSla = {
sla_nrt_due_at: currentTimestamp + 300,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 60,
status: 'open',
};
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return NRT when first reply not made', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: null,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('NRT');
});
});
describe('RT (Resolution Time)', () => {
it('returns RT status when conversation is open and within threshold', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('2h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it.each(['pending', 'snoozed'])(
'returns RT status when conversation is %s and within threshold',
status => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status,
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('2h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
}
);
it('returns missed RT status when threshold is exceeded', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
const chat = {
first_reply_created_at: currentTimestamp - 7200,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the due time for a missed RT event created after the deadline', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
status: 'open',
};
const slaEvents = [
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return RT when conversation is resolved', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status: 'resolved',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('');
});
});
describe('priority selection', () => {
it('returns most urgent SLA when multiple are active', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
sla_rt_due_at: currentTimestamp + 3600, // 1h
};
const chat = {
first_reply_created_at: null,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
// FRT is selected because first_reply_created_at is null
// NRT is not checked when first_reply_created_at is null
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
});
it('returns most urgent missed SLA over upcoming SLA', () => {
const appliedSla = {
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
};
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 2100,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.isSlaMissed).toBe(true);
});
it('returns an existing missed SLA over a closer upcoming SLA', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
};
const chat = {
first_reply_created_at: null,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('2h');
expect(result.isSlaMissed).toBe(true);
});
});
describe('time formatting', () => {
it('formats time in days and hours', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1d 1h');
});
it('formats time less than a minute as 1m', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1m');
});
it('formats months correctly', () => {
const appliedSla = {
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
}; // 1 month + 1 day
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1mo 1d');
});
});
describe('empty status scenarios', () => {
it('returns empty when no SLA thresholds are set', () => {
const appliedSla = {};
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
it('returns empty when all conditions are met', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp + 3600,
sla_nrt_due_at: currentTimestamp + 1800,
sla_rt_due_at: currentTimestamp + 7200,
};
const chat = {
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
waiting_since: null, // Not waiting, so NRT not applicable
status: 'resolved', // RT not applicable
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
});
});
});
+10 -1
View File
@@ -1,4 +1,7 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import {
CONTENT_TYPES,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
@@ -101,6 +104,7 @@ export function handleVoiceCallCreated(
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
@@ -108,6 +112,11 @@ export function handleVoiceCallCreated(
senderId,
} = extractCallData(message);
// A voice_call message can be created already terminal when the caller hangs
// up before connect. Only ring while the call is actually ringing; mirrors the
// guard in seedCallsFromHydratedMessages.
if (status !== VOICE_CALL_STATUS.RINGING) return;
if (
!shouldShowCall({
callDirection,
@@ -392,6 +392,93 @@
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"OVERVIEW": {
"HEADER": "Overview",
"WELCOME": {
"LABEL": "Captain summary",
"LOADING": "Generating summary…"
},
"INBOX_BANNER": {
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
"ACTION": "Connect inbox",
"DISMISS": "Dismiss"
},
"COVERAGE_BANNER": {
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
"ACTION": "Review FAQs",
"DISMISS": "Dismiss"
},
"RANGES": {
"LAST_DAYS": "Last {count} days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month"
},
"METRICS": {
"HANDLED": {
"LABEL": "Conversations handled",
"HINT": "Distinct conversations this assistant replied in."
},
"AUTO_RESOLUTION": {
"LABEL": "Auto-resolution rate",
"HINT": "Share of handled conversations closed without a human reply."
},
"HANDOFF": {
"LABEL": "Handoff rate",
"HINT": "Share of handled conversations escalated to a human agent."
},
"HOURS_SAVED": {
"LABEL": "Time saved",
"HINT": "Estimate: Captain replies times ~2 minutes of assumed agent effort per reply. Directional, not measured labor."
},
"REOPEN": {
"LABEL": "Reopen rate",
"HINT": "Auto-resolved conversations that were reopened afterwards."
},
"DEPTH": {
"LABEL": "Messages / conversation",
"HINT": "Average replies the assistant sends per conversation."
}
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
"APPROVED": "Approved FAQs",
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"FLAGGED": {
"TITLE": "Response quality",
"TOTAL": "{count} flagged · {rate}"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"AXIS_START": "{count}d ago",
"AXIS_END": "Today"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
"DESCRIPTION": "Guides and how-tos for Captain"
},
"PLAYGROUND": {
"TITLE": "Playground",
"DESCRIPTION": "Test this assistant's replies"
},
"BILLING": {
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
},
"FLAG_REASONS": {
"INCORRECT": "Incorrect info",
"INCOMPLETE": "Incomplete",
"OUTDATED": "Outdated",
"INAPPROPRIATE": "Inappropriate",
"OTHER": "Other"
}
},
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
@@ -494,10 +581,6 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -121,6 +121,26 @@
"CLEAR_FILTER": "Clear filter",
"EMPTY_LIST": "No results found"
},
"DRILLDOWN": {
"TITLE": "{metric} details",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations",
"RESULT_COUNT_MESSAGE": "{count} message | {count} messages",
"EMPTY": "No records found for this bar.",
"ERROR": "Could not load records. Please try again.",
"ADMIN_ONLY": "Only administrators can drill down into report records.",
"LOAD_MORE": "Load more",
"CLOSE": "Close details",
"PREVIOUS_BUCKET": "Previous bar",
"NEXT_BUCKET": "Next bar",
"UNKNOWN_CONTACT": "Unknown contact",
"UNKNOWN_INBOX": "Unknown inbox",
"UNASSIGNED_AGENT": "Unassigned",
"NO_MESSAGE_CONTENT": "No message content",
"MESSAGE_CREATED_AT": "Message created at {time}",
"EVENT_OCCURRED_AT": "Event occurred at {time}",
"INCOMING_MESSAGE": "Incoming message",
"OUTGOING_MESSAGE": "Outgoing message"
},
"PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results",
"PER_PAGE_TEMPLATE": "{size} / page"
@@ -324,6 +324,7 @@
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
@@ -467,7 +468,18 @@
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"SEAT_COUNT": "Number of seats",
"RENEWS_ON": "Renews on"
"RENEWS_ON": "Renews on",
"CURRENCY": "Currency"
},
"CURRENCY": {
"SELECT": {
"TITLE": "Choose your billing currency",
"DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created."
},
"OPTIONS": {
"USD": "US Dollar (USD)",
"BRL": "Brazilian Real (BRL)"
}
},
"VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
@@ -503,6 +515,7 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"RETRY": "Retry",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
@@ -529,7 +542,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
"PLACEHOLDER": "https://sso.example.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
@@ -549,7 +562,7 @@
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
"PLACEHOLDER": "https://sso.example.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
@@ -501,7 +501,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "A URL para onde as solicitações de autenticação SAML serão enviadas",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
"PLACEHOLDER": "https://sso.example.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Certificado de assinatura no formato PEM",
@@ -521,7 +521,7 @@
"IDP_ENTITY_ID": {
"LABEL": "ID da Entidade do Provedor de Identidade",
"HELP": "Identificador exclusivo do seu provedor de identidade (geralmente encontrado na configuração do IdP)",
"PLACEHOLDER": "https://seu-idp.com/saml"
"PLACEHOLDER": "https://sso.example.com/saml"
},
"UPDATE_BUTTON": "Atualizar configurações de SAML",
"API": {
@@ -6,6 +6,7 @@ import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
import {
ACCOUNT_EVENTS,
@@ -119,16 +120,20 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
handleTranslate() {
async handleTranslate() {
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
const agentLocale = this.getUISettings?.locale;
const targetLanguage = agentLocale || accountLocale || 'en';
this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
try {
await this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
} catch (error) {
useAlert(parseAPIErrorResponse(error));
}
this.handleClose();
},
handleReplyTo() {
@@ -0,0 +1,156 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
const { t } = useI18n();
const route = useRoute();
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
});
stats.value = data;
} catch {
stats.value = null;
}
};
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
const resolveTrendGood = (trendValue, direction) => {
if (direction === 'neutral' || trendValue === 0) return null;
return direction === 'up' ? trendValue > 0 : trendValue < 0;
};
// Trend units mirror the backend pack mode: a relative percent change ('%') for
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
// :point, and a plain number for :absolute counts like conversation depth.
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
// Hours-saved is reported in hours, but large values read better as days. Past
// 100h we switch the unit so the card stays legible.
const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
return {
value: formatValue(data.current),
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
trendGood: resolveTrendGood(data.trend, direction),
};
};
const metrics = computed(() => [
{
key: 'handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'reopen',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
...metricFor(
'conversation_depth',
v => v.toFixed(1),
'neutral',
'absolute'
),
},
]);
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
:is-empty="false"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
>
<template #headerActions>
<RangeSelector v-model="selectedRange" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
>
<MetricCard
v-for="metric in metrics"
:key="metric.key"
:label="metric.label"
:value="metric.value"
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks />
</div>
</template>
</PageLayout>
</template>
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -36,6 +37,12 @@ const metaV2 = {
};
const assistantRoutes = [
{
path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
component: AssistantOverviewIndex,
name: 'captain_assistants_overview_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -129,7 +136,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
navigationPath: 'captain_assistants_responses_index',
navigationPath: 'captain_assistants_overview_index',
...to.params,
},
};
@@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => {
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_responses_index';
: 'captain_assistants_overview_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList();
const { agentsList } = useAgentsList(true, { includeAgentBots: true });
return {
agentsList,
};
@@ -81,18 +81,27 @@ export default {
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
const assignee = this.currentChat.meta.assignee;
return (
assignee && {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
}
);
},
set(agent) {
const agentId = agent ? agent.id : null;
const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -152,7 +161,10 @@ export default {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
if (
this.assignedAgent.id !== this.currentUser.id ||
(this.assignedAgent.assignee_type || 'User') !== 'User'
) {
return true;
}
return false;
@@ -183,7 +195,11 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
if (
this.assignedAgent?.id === selectedItem.id &&
(this.assignedAgent?.assignee_type || 'User') ===
(selectedItem.assignee_type || 'User')
) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -1,4 +1,5 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -13,6 +14,7 @@ export function useChannelConfig() {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
@@ -6,6 +6,13 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary WhatsApp kill switch so these specs keep covering
// the credential-based gating it short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
@@ -15,6 +15,8 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import { getCurrencyConfig } from 'dashboard/constants/billing';
import { useI18n } from 'vue-i18n';
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
@@ -29,6 +31,7 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const { t } = useI18n();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
@@ -36,6 +39,10 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
// Currency selection shown to new accounts whose locale supports a non-USD currency.
const currencySelectionRequired = ref(false);
const currencyOptions = ref([]);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -61,6 +68,13 @@ const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
const billingCurrency = computed(() => {
if (!customAttributes.value.billing_currency) return '';
return t(
getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey
);
});
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
@@ -78,7 +92,9 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
const data = await store.dispatch('accounts/subscription');
currencySelectionRequired.value = !!data?.currency_selection_required;
currencyOptions.value = data?.currency_options || [];
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
@@ -97,6 +113,9 @@ const handleBillingPageLogic = async () => {
// If cloud user, fetch account details first
await fetchAccountDetails();
// Waiting on the user to pick a billing currency — don't auto-refresh.
if (currencySelectionRequired.value) return;
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
@@ -118,6 +137,13 @@ const handleBillingPageLogic = async () => {
}
};
const onSelectCurrency = async code => {
await store.dispatch('accounts/selectBillingCurrency', code);
currencySelectionRequired.value = false;
// Currency stored and customer creation kicked off — resume the standard wait flow.
await handleBillingPageLogic();
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -148,7 +174,9 @@ onMounted(handleBillingPageLogic);
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-found="
!hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired
"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -160,7 +188,30 @@ onMounted(handleBillingPageLogic);
/>
</template>
<template #body>
<section class="grid gap-4">
<section v-if="currencySelectionRequired" class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.CURRENCY.SELECT.TITLE')"
:description="$t('BILLING_SETTINGS.CURRENCY.SELECT.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
v-for="code in currencyOptions"
:key="code"
sm
solid
blue
:is-loading="uiFlags.isCheckoutInProcess"
:disabled="uiFlags.isCheckoutInProcess"
@click="onSelectCurrency(code)"
>
{{ $t(getCurrencyConfig(code).i18nLabelKey) }}
</ButtonV4>
</div>
</template>
</BillingCard>
</section>
<section v-else class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
@@ -188,6 +239,11 @@ onMounted(handleBillingPageLogic);
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
/>
<DetailItem
v-if="billingCurrency"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
:value="billingCurrency"
/>
</div>
</BillingCard>
<BillingCard
@@ -1,4 +1,6 @@
<script setup>
import { formatCurrencyAmount } from 'dashboard/constants/billing';
defineProps({
credits: {
type: Number,
@@ -33,11 +35,7 @@ const formatCredits = credits => {
};
const formatAmount = (amount, currency) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
};
</script>
@@ -4,20 +4,18 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
import {
formatCurrencyAmount,
DEFAULT_BILLING_CURRENCY,
} from 'dashboard/constants/billing';
const emit = defineEmits(['close', 'success']);
const emit = defineEmits(['success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
@@ -27,16 +25,20 @@ const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
// Topup packages come from the backend for the account's billing currency.
const topupOptions = ref([]);
const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY);
const isFetchingOptions = ref(false);
const fetchError = ref(false);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
return topupOptions.value.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
const { amount, currency } = selectedOption.value;
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
});
const formattedCredits = computed(() => {
@@ -64,24 +66,44 @@ const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
const selectDefaultOption = () => {
const popularOption = topupOptions.value.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
selectedCredits.value =
popularOption?.credits || topupOptions.value[0]?.credits || null;
};
const fetchOptions = async () => {
isFetchingOptions.value = true;
fetchError.value = false;
try {
const { data } = await EnterpriseAccountAPI.getTopupOptions();
topupOptions.value = data.options ?? [];
optionsCurrency.value = (
data.currency || DEFAULT_BILLING_CURRENCY
).toLowerCase();
selectDefaultOption();
} catch {
fetchError.value = true;
topupOptions.value = [];
} finally {
isFetchingOptions.value = false;
}
};
const open = () => {
currentStep.value = STEP_SELECT;
isLoading.value = false;
selectedCredits.value = null;
dialogRef.value?.open();
fetchOptions();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
@@ -127,32 +149,58 @@ defineExpose({ open, close });
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
<template v-if="currentStep === STEP_SELECT">
<div
v-if="isFetchingOptions"
class="flex items-center justify-center gap-2 py-10"
>
<Spinner />
<span class="text-sm text-n-slate-11">{{
$t('BILLING_SETTINGS.TOPUP.LOADING')
}}</span>
</div>
<div
v-else-if="fetchError"
class="flex flex-col items-center justify-center gap-3 py-10"
>
<p class="text-sm text-n-slate-11">
{{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
</p>
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.RETRY')"
@click="fetchOptions"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in topupOptions"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
</template>
<!-- Step 2: Confirm Purchase -->
@@ -178,7 +226,7 @@ defineExpose({ open, close });
<template #footer>
<!-- Step 1 Footer -->
<div
v-if="currentStep === 'select'"
v-if="currentStep === STEP_SELECT"
class="flex items-center justify-between w-full gap-3"
>
<Button
@@ -192,7 +240,7 @@ defineExpose({ open, close });
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
:disabled="!selectedCredits || isFetchingOptions || fetchError"
@click="goToConfirmStep"
/>
</div>
@@ -149,9 +149,5 @@ export default {
:deep(.ProseMirror-woot-style) {
@apply min-h-[12.5rem];
p {
@apply text-base;
}
}
</style>
@@ -153,9 +153,5 @@ export default {
:deep(.ProseMirror-woot-style) {
@apply min-h-[12.5rem];
p {
@apply text-base;
}
}
</style>

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