Compare commits

...
Author SHA1 Message Date
Pranav 8993810af8 Merge remote-tracking branch 'origin/develop' into captain-message-generation-metadata
# Conflicts:
#	config/routes.rb
#	db/schema.rb
#	enterprise/app/models/enterprise/concerns/message.rb
2026-07-02 07:09:59 -07:00
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
Sojan JoseandGitHub d0b1c055e8 chore: Track cloud plan activation conversions (#14834)
## Summary
- track cloud plan activation conversions when an attributed account
moves from the configured default cloud plan to a paid plan
- use the Stripe webhook event time as the activation timestamp so the
30-day signup attribution window reflects the actual upgrade event
- send the Stripe subscription amount and currency for the conversion
value
- mark the account attribution after enqueueing so later plan updates do
not send duplicate activation conversions

## Notes
- Marketing tracker: https://linear.app/chatwoot/issue/MAR-113
- Cloud implementation: https://linear.app/chatwoot/issue/LEA-34
- Stripe billing stays responsible for subscription state and value
calculation.
- Cloud plan activation conversion tracking is handled by a small
dedicated service that owns the activation rule, duplicate marker, and
conversion enqueue.
- Website attribution cookie capture remains separate in the marketing
attribution service.
- There is no frontend change and no new user-facing configuration.
- Conversion upload still no-ops outside Chatwoot Cloud and when
attribution has no supported click identifier.
2026-06-25 18:41:43 -07:00
Sony MathewandGitHub cf134deb37 fix: Preserve Captain LLM defaults (#14858)
# Pull Request Template

## Description

Adjusts the Captain LLM feature defaults after feature routing so the
defaults stay intentional and avoid unintended high-cost model upgrades.
Assistant, copilot, and onboarding content generation now default to
`gpt-4.1`; audio transcription keeps `gpt-4o-mini-transcribe` as the
default while exposing `whisper-1` as an available account override
option.

Related: https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `ruby -ryaml -e 'yaml = YAML.load_file("config/llm.yml"); features =
yaml.fetch("features"); models = yaml.fetch("models"); features.each {
|name, cfg| missing = Array(cfg["models"]) - models.keys; raise
"#{name}: missing #{missing.join(",")}" if missing.any?; raise "#{name}:
default not in models" unless
Array(cfg["models"]).include?(cfg["default"]) }'`
- `node -e
"JSON.parse(require('fs').readFileSync('app/javascript/dashboard/i18n/locale/en/settings.json',
'utf8'))"`
- `pnpm exec prettier --check
app/javascript/dashboard/i18n/locale/en/settings.json`
- `bundle exec rspec spec/lib/llm/feature_router_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/models/concerns/agentable_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb`
- `bundle exec rspec
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb`
- `RUBOCOP_CACHE_ROOT=/private/tmp/rubocop_cache bundle exec rubocop
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_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
- [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-25 21:35:09 +05:30
Sony MathewandGitHub b8b62ad0f1 feat: Harden model override preferences (5/6) (#14846)
## Description

Hardens the Captain model override preferences API so account-level
overrides follow the same feature-router contract used by runtime LLM
calls. The API now permits model and feature keys from `llm.yml`,
removes blank model overrides, rejects invalid saved model combinations,
and returns each feature's effective model, provider, and source for UI
clients.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

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

Verified the account preferences API and account model validation
behavior for valid overrides, invalid model values, unknown feature
keys, blank override removal, and effective model/provider/source
payload metadata.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
app/models/concerns/captain_featurable.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_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
- [x] 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
- [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-25 17:38:11 +05:30
Sony MathewandGitHub 4e26c5b4bb feat: Route system LLM jobs (4/6) (#14843)
## Description

Routes the remaining system-only and legacy-sensitive LLM jobs through
feature-level model configuration, while preserving system credential
usage and usage-accounting behavior. This adds dedicated defaults for
help center article generation, onboarding content generation, query
translation, transcription, and search embeddings so these flows can be
configured per account without falling back to installation-wide model
settings.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

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

Verified the feature routing defaults and account overrides for the
touched Captain/system LLM paths, including the legacy OpenAI
transcription and paginated FAQ services.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/lib/captain/base_task_service_spec.rb spec/lib/llm/models_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
lib/captain/base_task_service.rb
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/captain/llm/article_writer_service.rb
enterprise/app/services/captain/llm/embedding_service.rb
enterprise/app/services/captain/llm/help_center_curation_service.rb
enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
enterprise/app/services/captain/llm/translate_query_service.rb
enterprise/app/services/captain/llm/widget_tagline_service.rb
enterprise/app/services/captain/onboarding/website_analyzer_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/lib/captain/base_task_service_spec.rb`
- `ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml');
%w[document_faq_generation help_center_article_generation
onboarding_content_generation help_center_query_translation
audio_transcription help_center_search].each { |feature| abort(%(missing
#{feature})) unless config.dig('features', feature) }; abort('wrong
article default') unless config.dig('features',
'help_center_article_generation', 'default') == 'gpt-5.2'; puts 'llm.yml
ok'"`
- `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
- [ ] 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-25 17:37:45 +05:30
Sony MathewandGitHub 43e0f8a141 feat: Route Enterprise LLM services (3/6) (#14841)
# Pull Request Template

## Description

Routes Enterprise assistant, copilot, FAQ, contact memory,
action-classifier, and false-promise detector LLM paths through
feature-specific model resolution. `Llm::BaseAiService` now accepts
feature/account context and uses `Llm::FeatureRouter` when that context
is present, while retaining the installation-model fallback for
unmigrated callers. This also adds a `document_faq_generation` feature
default for generative FAQ/document content.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14840

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

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with 112 examples, 0 failures.
- `bundle exec rspec spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/lib/llm/feature_router_spec.rb` passed with 87 examples, 0
failures.
- `bundle exec rubocop enterprise/app/services/llm/base_ai_service.rb
enterprise/app/services/captain/copilot/chat_service.rb
enterprise/app/services/captain/llm/assistant_chat_service.rb
enterprise/app/services/captain/llm/faq_generator_service.rb
enterprise/app/services/captain/llm/conversation_faq_service.rb
enterprise/app/services/captain/llm/contact_notes_service.rb
enterprise/app/services/captain/llm/contact_attributes_service.rb
enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
enterprise/app/services/captain/llm/assistant_false_promise_service.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with no offenses.
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing
document_faq_generation') unless config.dig('features',
'document_faq_generation'); abort('missing default') unless
config.dig('features', 'document_faq_generation', 'default'); puts
'llm.yml ok'"` passed.
- `git diff --check` passed.

## 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-25 17:03:07 +05:30
Sony MathewandGitHub 91d8a4e2a3 feat: Route reply box LLM tasks (2/6) (#14840)
# Pull Request Template

## Description

Routes OSS reply-box and small Captain tasks through feature-specific
LLM model resolution. Rewrite, reply suggestion, summary, follow-up, and
CSAT utility analysis now resolve through the `editor` feature; label
suggestion resolves through `label_suggestion`. Existing credentials,
account OpenAI hook behavior, and instrumentation event names remain
unchanged.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14839

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

- `bundle exec rspec spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with 81 examples, 0
failures.
- `bundle exec rubocop lib/captain/base_task_service.rb
lib/captain/rewrite_service.rb lib/captain/reply_suggestion_service.rb
lib/captain/summary_service.rb lib/captain/label_suggestion_service.rb
lib/captain/csat_utility_analysis_service.rb
lib/captain/follow_up_service.rb
enterprise/lib/enterprise/captain/reply_suggestion_service.rb
spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with no offenses.
- `git diff --check` passed.

## 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-25 17:01:05 +05:30
Sony MathewandGitHub 8b977b35a8 feat: Add LLM feature router (1/6) (#14839)
## Description

Adds the foundation for feature-specific LLM model routing so Captain AI
features can resolve their effective provider/model from code defaults
and account-level overrides. This fixes the provider metadata key in
`config/llm.yml`, adds `Llm::FeatureRouter`, and routes existing
`CaptainFeaturable` model defaults through the shared resolver.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

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

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - 23 examples, 0
failures
- `bundle exec rubocop lib/llm/models.rb lib/llm/feature_router.rb
app/models/concerns/captain_featurable.rb spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - no offenses
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing providers') unless
config['providers']; abort('missing models') unless config['models'];
abort('missing features') unless config['features']; puts 'llm.yml ok'"`
- `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-06-25 17:00:07 +05:30
62cbeae95f feat: onboarding inboxes UI (#14565)
After entering their account details, new admins land on an **Inbox
setup** screen that shows what we've already set up for them and lets
them connect their conversation channels without leaving onboarding. It
surfaces the auto-created live chat widget (and Help Center on
Enterprise), highlights channels detected from their website, and offers
a **View all** dialog to connect any supported channel inline.

### Channel status

| Channel | How it connects | Status | PR |
|---|---|---|---|
| Live chat (Website) | Auto-created during setup |  Done |
https://github.com/chatwoot/chatwoot/pull/14314 |
| WhatsApp | Meta embedded signup |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Facebook | Login + page picker |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Instagram | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14568 |
| TikTok | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14569 |
| LINE | Inline credential form |  Done | — |
| Telegram | Inline credential form |  Done | — |
| Gmail / Outlook | OAuth (email) | ⚠️ Disabled — coming in a follow-up
| https://github.com/chatwoot/chatwoot/pull/14567 |
| SMS / API / Voice / Other email | — |  Unavailable | — |

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-25 14:54:07 +05:30
7c7459b734 fix(whatsapp): override webhook at phone number level (#13817)
## Description

Move WhatsApp webhook callback override from WABA level to phone number
level, allowing multiple phone numbers on the same WABA to have
independent callback URLs.

## Type of change

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

## How Has This Been Tested?

- Connect a WhatsApp Cloud inbox via embedded signup
- Verify webhook setup succeeds and messages are received
- Connect a second phone number on the same WABA — both should receive
messages independently
- Delete an inbox and verify only that phone number's override is
cleared

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

---------

Co-authored-by: tds-1 <tds-1@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-25 14:49:09 +05:30
Sojan JoseandGitHub 0849d2e070 chore: Track cloud signup conversions (#14852)
## Summary
- enqueue `cloud_signup` conversion tracking after website attribution
is stored on a new cloud account
- keep authenticated add-workspace requests out of
attribution/conversion tracking
- reuse the existing marketing conversion tracking service and config
already available on `develop`

## Notes
- This PR only wires the signup path.
- Billing/plan activation conversion tracking is intentionally left out
for a separate rollout.
- The conversion service still no-ops outside Chatwoot Cloud and when
stored attribution has no supported click identifier.
2026-06-24 20:04:42 -07:00
Sojan JoseandGitHub cd1a214d98 chore: Add marketing conversion tracking foundation (#14851)
## Summary

Adds the minimal foundation for Cloud marketing conversion tracking:

- locked internal installation config for conversion tracking
credentials and event mappings
- generic background job and service for uploading conversion events
from stored attribution
- focused coverage for Cloud gating, click-id selection, payload shape,
and optional conversion value

This PR intentionally does not wire signup or plan activation yet. The
next step is to validate the service from Rails console against existing
attributed accounts, then add the event hooks in a follow-up PR.

## Notes

The config remains locked and is surfaced under the Internal settings
group next to the existing Cloud plan configuration. The service assumes
the locked config is present and valid; config mistakes should surface
instead of being silently ignored.
2026-06-24 18:27:02 -07:00
f9cc702030 fix(help-center): documentation layout on custom domain root, locale (#14850)
Help centers using the documentation layout now render correctly in two
cases that previously fell back to the wrong output. Opening a portal at
its custom-domain root (e.g. docs.example.com/ ) now shows the full
documentation home
in place instead of the classic layout, and portals whose locale is a
region variant that Chatwoot doesn't ship a
translation for (e.g. th_TH , fr_ML ) now show their categories in the
sidebar on article pages.

Closes
https://linear.app/chatwoot/issue/CW-7437/portal-layout-is-not-properly-working-in-custom-domain

## How to test

1. Create a portal with the documentation layout and a custom domain
(e.g. example.chat.test ), with at least one
category containing a published article.
2. Visit the custom-domain root ( http://example.chat.test:3000/ ) → it
should show the full documentation home
(sidebar, topbar), not the classic layout, with no redirect.
3. Set the portal's locale to a region variant Chatwoot doesn't
translate, e.g. th_TH , with categories/articles
under that locale.
4. Open an article → the sidebar should list the article's category and
sibling articles. Before the fix the sidebar
was empty for these locales.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:12:55 -07:00
74db16158d feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-24 11:04:08 +04:00
Sivin VargheseandGitHub e8edc9ebf5 feat: allow users to set an icon or emoji for teams (#14767) 2026-06-23 20:53:31 +05:30
Vishnu NarayananandGitHub e785620921 feat: read structured X-Chatwoot-* headers for mobile session metadata (#14762)
Phase 2 of [INF-75](https://linear.app/chatwoot/issue/INF-75). Follows
up on [#14753](https://github.com/chatwoot/chatwoot/pull/14753) (Phase 1
UA pattern fallback, already merged).

When the request carries an \`X-Chatwoot-Client-Name\` header,
\`UserSessionTrackingService\` now prefers the five structured
\`X-Chatwoot-*\` headers over the User-Agent for populating the
\`user_sessions\` row:

| Header | Mapped column |
|---|---|
| \`X-Chatwoot-Client-Name\` | \`browser_name\` |
| \`X-Chatwoot-Client-Version\` | \`browser_version\` |
| \`X-Chatwoot-Device-Model\` | \`platform_name\` |
| \`X-Chatwoot-Platform-Version\` | \`platform_version\` |
| \`X-Chatwoot-Platform\` (+ model) | \`device_name\` (\`iPhone\` /
\`iPad\` / \`Android\`) |

When the header is absent or blank, the existing \`Browser.new\` path
runs, with the Phase 1 legacy UA fallback still acting as the floor.
Real browsers are untouched.

A follow-up PR in
[chatwoot-mobile-app](https://github.com/chatwoot/chatwoot-mobile-app)
will start sending these headers from the React Native build. Until that
ships, this PR is a no-op for production traffic, so it can land
independently.

Fixes INF-75.
2026-06-23 20:08:27 +05:30
Aakash BakhleandGitHub 0af9900be2 fix: model for false promise harness (#14824)
fixes model for false promise harness
2026-06-23 18:42:34 +05:30
Aakash BakhleandGitHub 84fda8a323 fix(captain): move false promise harness setting to account (#14823)
# Pull Request Template

## Description

moves the harness to account settings rather than assistant settings

## Type of change
refactor

## 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-06-23 16:55:42 +05:30
Aakash BakhleandGitHub 1c7e60880d fix: add harness on false promises (#14672)
# Pull Request Template

## Description


https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?

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

Against negative cases identified by evals

## 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-06-23 16:30:14 +05:30
Aakash BakhleandGitHub 8e27ca4282 fix(firecrawl): get url in case sourceUrl is not available (#14820)
# Pull Request Template

## Description

Fixes inconsistency by adding a fallback to use `url` instead of
`sourceUrl` when Firecrawl returns empty `sourceUrl` in production.

We switched to v2 endpoints of Firecrawl in
https://github.com/chatwoot/chatwoot/pull/14624 and it worked locally,
but seems to fail for some cases in production.

## Type of change

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
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-23 13:40:31 +05:30
ee5e20551d fix(facebook): handle messenger post attachment type (#14813)
Facebook Messenger messages that share a post no longer break message
ingestion. Following the recent sticker change, Meta now sends a shared
post as a new `post` attachment type. Chatwoot didn't recognise `post`
as a valid attachment file type, so the webhook job crashed and the
message — and any others in the same batch — failed to sync. Shared
posts now appear in the conversation as a fallback link (title + URL),
the same way `share` attachments already do.

This is the same class of bug as #14793 (sticker), just a different
attachment type.

#### Root cause

`post` was neither skipped as unsupported nor mapped to a valid
`file_type`, so `attachment_params` produced `file_type: :post`. The
`Attachment#file_type` enum has no `post` value, so it raised
`ArgumentError: 'post' is not a valid file_type`, crashing
`Webhooks::FacebookEventsJob`.

#### Fix

The Facebook builder already maps `share -> :fallback` because shared
posts point to facebook.com page URLs rather than downloadable media. A
`post` is the same kind of attachment, so map both `share` and `post` to
`fallback` (stores the title/link, no download attempt). Also read the
fallback title from `payload.title`, since the post payload nests it
there rather than at the top level.

## How to reproduce

1. Connect a Facebook Page inbox.
2. Share a Facebook post into Messenger to that page.
3. Before this change: `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'post' is not a valid file_type` and the message is
dropped.
4. After this change: the shared post shows up in the conversation as a
fallback link.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:04 +04:00
df79c0bbde feat: exclude stale conversations via assignment policy age threshold (#14766)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents

## Description

Assignment policies now skip stale unassigned conversations
automatically. Each policy carries an age threshold (defaults to 7
days), so auto-assignment only picks up recent backlog instead of
draining very old, forgotten conversations. The threshold is
configurable per policy and can be cleared to assign conversations
regardless of age. Previously this control existed only on Enterprise
capacity policies (`exclude_older_than_hours`); it now lives on the
assignment policy itself, so every V2 inbox benefits without needing a
capacity policy.

## Type of change

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

## How Has This Been Tested?

- Local UI flows

## Screenshots? 

<img width="645" height="822" alt="image"
src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077"
/>


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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:45 +05:30
647cfc2d83 feat: distinguish agent-declined calls with a rejected status (#14783)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7374/agent-declined-voice-calls-miscounted-as-failed

## Description

Agent rejections were stored with status: failed, so call reports lumped
deliberate declines together with real technical failure.

Fix: give declines their own terminal rejected status (Twilio + WhatsApp
paths), keeping end_reason: agent_rejected. Genuine provider/network
failures stay failed. Frontend renders declines exactly as before;
existing rows backfilled via migration.

## Type of change

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

## How Has This Been Tested?

- Tested on UI

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:29 +05:30
Sivin VargheseandGitHub 961ec86ba9 chore: support custom variables in auto-resolution message (#14782)
# Pull Request Template

## Description

This PR adds support for custom variables in auto-resolution messages
within Conversation Workflows.

Agents can insert variables (e.g. `{{contact.name}}`) into the custom
auto-resolution message using the variable picker by typing `{{`. These
variables are resolved with actual conversation data when the
auto-resolution message is sent.

The field uses the existing editor with the formatting toolbar hidden,
since auto-resolution messages are delivered across all channels,
including SMS, where formatting isn't supported. Existing message
content is preserved as-is, and typing `{{` opens the variable picker
for quick insertion.

Fixes
https://linear.app/chatwoot/issue/CW-7369/support-custom-variables-in-conversations-workflow-text-fields

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1262" height="764" alt="image"
src="https://github.com/user-attachments/assets/e43be22c-f86e-436a-9fdd-5bcb73c61ca3"
/>



## 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-23 11:35:52 +05:30
PranavandClaude Opus 4.8 eb203bb144 feat(captain): capture and surface how a reply was generated
Records reasoning, citations (the FAQs the assistant found), the tool
path, and the model for each V1 Captain reply in a new
captain_message_generations table. The assistant reports the source IDs
it actually used so cited FAQs can be flagged, and handoff messages
capture the transfer reason.

Agents can expand any Captain message in the conversation (a sparkle
toggle on the timestamp line) to see this breakdown, fetched on demand
from a dedicated endpoint. Model is shown only in development.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:10:55 -07:00
319 changed files with 12861 additions and 1014 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
+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 -3
View File
@@ -43,13 +43,18 @@
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
+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)
@@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
return :fallback if [:share, :post].include?(type.to_sym)
super
end
@@ -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
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
@@ -47,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
)
end
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
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
)
end
@@ -1,17 +1,19 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@account = Current.account
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
render 'api/v1/accounts/update', format: :json
end
@@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
end
def website
custom_attributes_params[:website]
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
end
def account_params
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign)
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
end
end
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact
@conversation.contact,
@message
)
render_response(response)
end
@@ -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,
@@ -0,0 +1,29 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
+5
View File
@@ -1,5 +1,6 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -63,6 +64,10 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale])
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
I18n.with_locale(@locale, &)
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def switch_locale_with_article(&)
@@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def allow_iframe_requests
@@ -1,4 +1,6 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
+4 -1
View File
@@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
attrs = super + [limits: {}]
attrs = super + [limits: {}, captain_models: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class MessageGenerations extends ApiClient {
constructor() {
super('captain/message_generations', { accountScoped: true });
}
}
export default new MessageGenerations();
+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`, {
@@ -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 || [],
});
};
@@ -20,6 +20,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -28,13 +29,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = 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';
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
@@ -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')"
@@ -168,6 +168,7 @@ const selectEmoji = emoji => {
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
type="button"
variant="ghost"
color="slate"
size="sm"
@@ -0,0 +1,178 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from './provider.js';
import { ORIENTATION } from './constants';
import MessageGenerationsAPI from 'dashboard/api/captain/messageGenerations';
const props = defineProps({
messageId: { type: Number, required: true },
});
const { t } = useI18n();
const { orientation } = useMessageContext();
const isExpanded = ref(false);
const isLoading = ref(false);
const hasFetched = ref(false);
const generation = ref(null);
const reasoning = computed(() => generation.value?.reasoning);
const citations = computed(() => generation.value?.citations || []);
const generationPath = computed(() => generation.value?.generationPath || []);
const tools = computed(() =>
generationPath.value.map(step => step?.tool).filter(Boolean)
);
// Model is only surfaced in development to aid debugging.
const model = computed(() =>
import.meta.env.DEV ? generation.value?.model : null
);
const searchQuery = computed(() => {
const step = generationPath.value.find(
s => s?.tool === 'search_documentation'
);
return step?.arguments?.query || '';
});
const hasUsedCitation = computed(() => citations.value.some(c => c.used));
const sourcesSummary = computed(() => {
const summary = t('CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY', {
count: citations.value.length,
});
if (!searchQuery.value) return summary;
const searched = t('CONVERSATION.CAPTAIN_GENERATION.SEARCHED_FOR', {
query: searchQuery.value,
});
return `${summary} · ${searched}`;
});
// Surface the FAQ(s) Captain actually used in the reply ahead of the rest.
const sortedCitations = computed(() =>
[...citations.value].sort((a, b) => Number(b.used) - Number(a.used))
);
const hasDetails = computed(
() =>
Boolean(reasoning.value) ||
citations.value.length > 0 ||
tools.value.length > 0
);
const rowAlignClass = computed(() =>
orientation.value === ORIENTATION.LEFT ? 'justify-start' : 'justify-end'
);
const fetchGeneration = async () => {
if (hasFetched.value) return;
isLoading.value = true;
try {
const { data } = await MessageGenerationsAPI.show(props.messageId);
generation.value = useCamelCase(data, { deep: true });
} catch (error) {
generation.value = null;
} finally {
hasFetched.value = true;
isLoading.value = false;
}
};
const toggle = () => {
isExpanded.value = !isExpanded.value;
if (isExpanded.value) fetchGeneration();
};
</script>
<template>
<div class="flex flex-col gap-2">
<Transition
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isExpanded"
class="flex flex-col gap-3 p-3 text-xs rounded-lg bg-n-alpha-black1"
>
<span v-if="isLoading">
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
</span>
<span v-else-if="!hasDetails">
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
</span>
<template v-else>
<div v-if="reasoning" class="flex flex-col gap-1">
<span class="font-medium opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
</span>
<p class="m-0 whitespace-pre-line">{{ reasoning }}</p>
</div>
<div v-if="citations.length" class="flex flex-col gap-1.5">
<span class="font-medium opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
</span>
<p class="m-0 opacity-70">{{ sourcesSummary }}</p>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li
v-for="(citation, index) in sortedCitations"
:key="index"
:class="[
{ 'opacity-50': hasUsedCitation && !citation.used },
citation.used ? 'font-medium' : '',
]"
>
<a
v-if="citation.source"
:href="citation.source"
target="_blank"
rel="noopener noreferrer"
class="text-n-blue-11 hover:underline"
>
{{ citation.title || citation.source }}
</a>
<span v-else>{{ citation.title }}</span>
<span
v-if="citation.used"
class="px-1 ml-1 rounded bg-n-alpha-2 text-n-teal-10"
>
{{ t('CONVERSATION.CAPTAIN_GENERATION.USED') }}
</span>
</li>
</ul>
</div>
<div v-if="tools.length" class="flex flex-col gap-1">
<span class="font-medium opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.TOOLS') }}
</span>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li v-for="(tool, index) in tools" :key="index">{{ tool }}</li>
</ul>
</div>
<span v-if="model" class="opacity-70">
{{ t('CONVERSATION.CAPTAIN_GENERATION.MODEL', { model }) }}
</span>
</template>
</div>
</Transition>
<div class="flex items-center gap-1.5" :class="rowAlignClass">
<slot name="meta" />
<button
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
type="button"
class="inline-flex items-center justify-center bg-transparent border-0 cursor-pointer text-n-slate-10 hover:text-n-slate-11"
:class="isExpanded ? 'text-n-slate-11' : ''"
@click="toggle"
>
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
</button>
</div>
</div>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const {
variant,
orientation,
inReplyTo,
shouldGroupWithNext,
id,
sender,
senderType,
} = useMessageContext();
const { t } = useI18n();
const isCaptainMessage = computed(
() =>
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
);
const metaColorClass = computed(() =>
variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11'
);
const emailMetaClass = computed(() =>
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
);
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
/>
</div>
<slot />
<MessageMeta
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
<template v-if="shouldShowMeta">
<CaptainGenerationDetails
v-if="isCaptainMessage"
:message-id="id"
class="mt-2"
>
<template #meta>
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
</template>
</CaptainGenerationDetails>
<MessageMeta
v-else
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
class="mt-2"
/>
</template>
</div>
</template>
@@ -34,6 +34,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
@@ -81,7 +82,11 @@ const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
REJECTED: 'rejected',
};
export const VOICE_CALL_DIRECTION = {
@@ -123,6 +123,10 @@ onMounted(() => {
a {
@apply p-4;
}
.ProseMirror a {
@apply p-0;
}
}
}
}
@@ -31,12 +31,22 @@ export default {
}
},
methods: {
createErrorMessage(error) {
const responseError = error?.response?.data?.error;
if (typeof responseError === 'string') return responseError;
return (
responseError?.error?.message ||
responseError?.message ||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
);
},
async onClick() {
this.isLoading = true;
try {
await DyteAPI.createAMeeting(this.conversationId);
} catch (error) {
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
useAlert(this.createErrorMessage(error));
} finally {
this.isLoading = false;
}
@@ -57,7 +57,9 @@ const showMetaSection = computed(() => {
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
@@ -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 {
@@ -21,6 +21,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -29,13 +30,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = computed(() => {
@@ -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 || [],
});
};
@@ -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);
});
});
@@ -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);
+21 -1
View File
@@ -163,6 +163,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Context::NoToolbar': {
marks: ['strong', 'em', 'link'],
nodes: ['bulletList', 'orderedList'],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -183,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.
*
@@ -259,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
],
@@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
});
+18
View File
@@ -13,6 +13,24 @@ export const INBOX_TYPES = {
TIKTOK: 'Channel::Tiktok',
};
// Short channel-type slugs used to identify a channel without leaning on its
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
export const CHANNEL_TYPES = {
WEBSITE: 'website',
WHATSAPP: 'whatsapp',
FACEBOOK: 'facebook',
INSTAGRAM: 'instagram',
TIKTOK: 'tiktok',
TELEGRAM: 'telegram',
LINE: 'line',
GMAIL: 'gmail',
OUTLOOK: 'outlook',
SMS: 'sms',
API: 'api',
VOICE: 'voice',
EMAIL: 'email',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
@@ -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,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);
});
});
@@ -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,
});
});
});
});
});
+11 -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';
@@ -7,6 +10,7 @@ export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'rejected',
'no-answer',
'canceled',
'missed',
@@ -100,6 +104,7 @@ export function handleVoiceCallCreated(
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
@@ -107,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,
@@ -70,6 +70,18 @@
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CAPTAIN_GENERATION": {
"TITLE": "How was this reply generated?",
"LOADING": "Loading details…",
"EMPTY": "No generation details available for this message.",
"REASONING": "Reasoning",
"SOURCES": "Knowledge base",
"SOURCES_SUMMARY": "Found {count} results",
"SEARCHED_FOR": "Searched for “{query}”",
"USED": "Used in reply",
"TOOLS": "Tools used",
"MODEL": "Generated with {model}"
},
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels",
@@ -494,10 +494,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",
@@ -30,5 +30,59 @@
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
},
"ONBOARDING_INBOX_SETUP": {
"GREETING": "Let's set up a few things",
"SUBTITLE": "This will give you head-start in your workspace",
"CONTINUE": "Continue",
"SKIP": "Skip",
"ERROR": "Something went wrong. Please try again.",
"WHATSAPP_CONNECTED": "WhatsApp connected successfully",
"FACEBOOK_CONNECTED": "Facebook connected successfully",
"CREATED_FOR_YOU": {
"TITLE": "We've created the following for you",
"LIVE_CHAT": "Live Chat widget",
"LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
"LIVE_CHAT_STATUS": "Almost done…",
"LIVE_CHAT_READY": "Ready",
"HELP_CENTER": "Help Center",
"HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
"HELP_CENTER_GENERATING": "Creating your help center…",
"HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
"HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
"HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
"HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
"HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
"HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
},
"CHANNELS": {
"TITLE": "Connect all your conversation channels",
"HEADER": "We found a few channels you can connect",
"CONNECT": "Connect",
"CONNECTED": "Connected",
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
"MORE_CHANNELS_EMAIL": "Email",
"MORE_CHANNELS_VOICE": "Voice",
"VIEW_ALL": "View all",
"GMAIL": "Gmail",
"OUTLOOK": "Outlook",
"OTHER_EMAIL": "Other Email Providers"
},
"CHANNELS_DIALOG": {
"TITLE": "Connect all your channels instantly",
"SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
"NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
"CONNECT_TITLE": "Connect your {name} account",
"CONNECT_SUBTITLE": "Fill out these quick details",
"CONNECT": "Connect",
"BACK": "Back",
"SETUP_LATER": "Setup later in app",
"FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
"FACEBOOK_LAUNCH": "Continue with Facebook",
"FACEBOOK_SELECT_PAGE": "Select a Page to connect",
"FACEBOOK_LOADING": "Loading your Facebook Pages…",
"FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
"FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
}
}
}
@@ -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"
@@ -440,7 +440,9 @@
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
"MODEL_TITLE": "Audio Transcription Model",
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
@@ -798,10 +800,14 @@
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"EXCLUDE_OLDER_THAN": {
"LABEL": "Skip stale conversations",
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
@@ -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() {
@@ -252,6 +252,7 @@ export default {
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
show-emoji-icon
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
@@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
export default {
routes: [
@@ -40,6 +41,14 @@ export default {
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
name: 'onboarding_inbox_setup',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingInboxSetup,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -0,0 +1,158 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import InboxChannelsDialog from './inbox-setup/InboxChannelsDialog.vue';
import InboxChannelsFooter from './inbox-setup/InboxChannelsFooter.vue';
import ChannelRow from './inbox-setup/ChannelRow.vue';
import WebWidgetCreationStatus from './inbox-setup/WebWidgetCreationStatus.vue';
import HelpCenterCreationStatus from './inbox-setup/HelpCenterCreationStatus.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './inbox-setup/useChannelConnect';
import { useDetectedChannels } from './inbox-setup/useDetectedChannels';
import { DIALOG_CHANNELS } from './inbox-setup/constants';
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { isEnterprise } = useConfig();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const helpCenterGenerationId = computed(
() => currentAccount.value?.custom_attributes?.help_center_generation_id
);
const isSubmitting = ref(false);
const inboxes = useMapGetter('inboxes/getInboxes');
const {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
} = useDetectedChannels();
const channelsDialogRef = ref(null);
// The initial inboxes fetch happens in WebWidgetCreationStatus, which polls
// `inboxes/get` from its own mount — no need to dispatch it here too.
onMounted(() => {
useTrack(ONBOARDING_EVENTS.INBOX_SETUP_VISITED);
});
const completeOnboarding = async event => {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
// Declare the step we're completing so the controller only clears it when
// the stored step still matches (idempotent). setUser then refreshes the
// auth store so the router guard sees the cleared step and lets us in.
await finishOnboarding({ onboarding_step: 'inbox_setup' });
useTrack(event);
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isSubmitting.value = false;
}
};
const handleContinue = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_COMPLETED);
const handleSkip = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_SKIPPED);
const openChannelsDialog = () => channelsDialogRef.value?.open();
const refetchInboxes = () => store.dispatch('inboxes/get');
// WhatsApp connects via Meta's embedded-signup popup; the DIALOG_CHANNELS open
// the channels dialog preselected to their in-dialog step; the rest go through
// the redirect OAuth flow (Gmail/Outlook keyed by email provider, Instagram by
// channel type).
const connectChannel = channel => {
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
if (DIALOG_CHANNELS.includes(channel.type)) {
channelsDialogRef.value?.open(channel.type);
return;
}
connectViaOAuth(channel.inbox?.provider || channel.type);
};
</script>
<template>
<div>
<OnboardingLayout
:greeting="t('ONBOARDING_INBOX_SETUP.GREETING')"
:subtitle="t('ONBOARDING_INBOX_SETUP.SUBTITLE')"
:continue-label="t('ONBOARDING_INBOX_SETUP.CONTINUE')"
:skip-label="t('ONBOARDING_INBOX_SETUP.SKIP')"
:is-loading="isSubmitting"
@continue="handleContinue"
@skip="handleSkip"
>
<template #greeting-icon>
<Icon icon="i-lucide-wrench" class="size-4 text-n-slate-7" />
</template>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.TITLE')"
icon="i-lucide-sparkles"
>
<div class="divide-y divide-n-weak">
<WebWidgetCreationStatus />
<HelpCenterCreationStatus
v-if="isEnterprise && helpCenterGenerationId"
/>
</div>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CHANNELS.TITLE')"
icon="i-lucide-inbox"
>
<div
v-if="hasDetectedChannels"
class="flex items-center gap-2 p-3 border-b border-dashed border-n-strong"
>
<Icon
icon="i-lucide-lightbulb"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="flex-1 min-w-0 text-body-main text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
</span>
</div>
<ChannelRow
v-for="channel in displayedChannels"
:key="channel.type"
:channel="channel"
:connected-inbox="connectedInbox(channel)"
@connect="connectChannel"
/>
<InboxChannelsFooter
:remaining-channels="remainingChannels"
@view-all="openChannelsDialog"
/>
</OnboardingSection>
</OnboardingLayout>
<InboxChannelsDialog
ref="channelsDialogRef"
:inboxes="inboxes"
@connected="refetchInboxes"
/>
</div>
</template>
@@ -1,21 +1,22 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { ref, computed, nextTick, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import OnboardingLayout from './OnboardingLayout.vue';
import OnboardingSection from './OnboardingSection.vue';
import OnboardingFormRow from './OnboardingFormRow.vue';
import OnboardingFormSelect from './OnboardingFormSelect.vue';
import OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
@@ -23,12 +24,16 @@ import {
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './constants';
} from './shared/constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, finishOnboarding } = useAccount();
// Where each onboarding cursor routes. The backend owns which steps run where;
// the frontend just follows the cursor it advanced us to (no deployment checks).
const STEP_ROUTES = { inbox_setup: 'onboarding_inbox_setup' };
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -46,7 +51,10 @@ const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
website: {},
// Website is required: the onboarding web-widget inbox can't be created
// without a URL (Channel::WebWidget validates presence), so a blank value
// would leave the "Live Chat widget" status polling forever.
website: { required },
locale: {},
timezone: {},
companySize: {},
@@ -67,12 +75,15 @@ const v$ = useVuelidate(validationRules, {
const userName = computed(() => currentUser.value?.name || '');
const userEmail = computed(() => currentUser.value?.email || '');
const accountName = computed(() => currentAccount.value?.name || '');
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
const { isEnriching, getChangedFields } = useAccountEnrichment({
locale,
website,
timezone,
companySize,
industry,
referralSource,
});
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
@@ -98,94 +109,10 @@ const timezoneOptions = computed(() => {
}
});
// Best-effort match browser language to enabled Chatwoot locales.
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
// falls back to account locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of auto-populated values to detect user edits at submit time
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const account = currentAccount.value;
const attrs = account?.custom_attributes || {};
const brandInfo = attrs.brand_info;
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = attrs.website || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
if (!companySize.value) companySize.value = attrs.company_size || '';
if (!industry.value) {
industry.value =
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
}
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, 30000);
};
onMounted(() => {
populateFormFields();
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, newVal => {
if (newVal) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
const enableWebsiteEditing = () => {
isEditingWebsite.value = true;
nextTick(() => websiteInput.value?.focus());
@@ -204,8 +131,10 @@ const normalizeWebsiteUrl = raw => {
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
// onboarding_step and persist incomplete data.
if (isEnriching.value) return;
// onboarding_step and persist incomplete data. Also guard against
// re-entry while a submit is in flight (double-click/Enter), which would
// fire parallel requests that can duplicate the auto-created inbox/portal.
if (isEnriching.value || isSubmitting.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
@@ -214,22 +143,15 @@ const handleSubmit = async () => {
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
// The website field is read-only until edited; open it so a required-but-
// empty value is immediately fixable rather than just shaking a locked field.
if (v$.value.website.$error) enableWebsiteEditing();
return;
}
// Detect which enrichable fields the user actually edited *before*
// normalizing — otherwise an untouched auto-filled domain
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
// and gets falsely reported as changed, skewing onboarding telemetry.
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
const fieldsChanged = Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key);
// Capture which enrichable fields the user edited *before* normalizing the
// website, so an untouched auto-filled domain isn't falsely flagged.
const fieldsChanged = getChangedFields();
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
@@ -246,6 +168,7 @@ const handleSubmit = async () => {
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
onboarding_step: 'account_details',
});
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
@@ -260,8 +183,19 @@ const handleSubmit = async () => {
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
// Follow the cursor the backend advanced us to. A next step routes there; no
// next step means onboarding is complete, so refresh the user (so the router
// guard sees the cleared cursor) and head to the dashboard.
const nextStep = currentAccount.value?.custom_attributes?.onboarding_step;
if (STEP_ROUTES[nextStep]) {
router.push({
name: STEP_ROUTES[nextStep],
params: { accountId: accountId.value },
});
} else {
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
}
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
@@ -0,0 +1,136 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
const ENRICHMENT_TIMEOUT = 30000;
// Manages the post-signup enrichment lifecycle for the account-details form.
// After signup the account is enriched asynchronously (onboarding_step ===
// 'enrichment'); this fills empty form fields from the enriched
// custom_attributes/brand_info as it arrives — idempotently, so it never
// clobbers a value the user has already typed — waits the step out (with a
// timeout fallback), and tracks which enrichable fields the user edited.
//
// `fields` is the set of form refs to populate, owned by the component so it can
// still wire them to validation and the template.
export function useAccountEnrichment(fields) {
const { currentAccount } = useAccount();
const { enabledLanguages } = useConfig();
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
// Best-effort match browser language to enabled Chatwoot locales: exact match
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account
// locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of the auto-populated values, used to detect user edits at submit.
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const {
website,
timezone,
company_size: companySize,
industry,
referral_source: referralSource,
brand_info: brandInfo,
} = currentAccount.value?.custom_attributes || {};
const fillIfEmpty = (field, value) => {
if (!field.value) field.value = value || '';
};
fillIfEmpty(fields.locale, detectBestLocale());
fillIfEmpty(fields.website, website || brandInfo?.domain);
fillIfEmpty(
fields.timezone,
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
);
fillIfEmpty(fields.companySize, companySize);
fillIfEmpty(
fields.industry,
industry || brandInfo?.industries?.[0]?.industry
);
fillIfEmpty(fields.referralSource, referralSource);
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, ENRICHMENT_TIMEOUT);
};
onMounted(() => {
populateFormFields();
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, enriching => {
if (enriching) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
// Enrichable fields the user actually edited since they were auto-filled.
// Compare against the snapshot *before* the caller normalizes any values —
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
// compares unequal and gets falsely reported as changed.
const getChangedFields = () => {
const init = initialValues.value;
const current = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
return Object.entries(current)
.filter(([key, value]) => value !== init[key])
.map(([key]) => key);
};
return { isEnriching, getChangedFields };
}
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
const props = defineProps({
channel: { type: Object, required: true },
connectedInbox: { type: Object, default: null },
});
defineEmits(['connect']);
const { t } = useI18n();
const connected = computed(() => Boolean(props.connectedInbox));
// Prefer the real connected account's name over the detected handle — the user
// may have connected a different account than the one we detected.
const connectedName = computed(
() =>
props.connectedInbox?.name ||
props.channel.handle ||
t(props.channel.labelKey)
);
</script>
<template>
<div class="flex items-center gap-2 p-3 border-b border-n-weak">
<div class="size-4 rounded overflow-hidden flex-shrink-0">
<ChannelIcon
:inbox="channel.inbox"
use-brand-icon
class="size-4"
:class="{ grayscale: !connected }"
/>
</div>
<span class="flex-1 min-w-0 truncate text-body-main text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<div
v-if="connected"
class="flex items-center gap-2 flex-shrink-0 text-body-main text-n-slate-11"
>
<span class="whitespace-nowrap">{{ connectedName }}</span>
<span class="w-px h-3 bg-n-weak" />
<span class="whitespace-nowrap">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
</span>
</div>
<button
v-else
type="button"
class="flex items-center flex-shrink-0 h-7 px-2 rounded-lg outline outline-1 outline-n-container bg-n-button-color text-button text-n-blue-11"
@click="$emit('connect', channel)"
>
<span class="truncate">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
</span>
</button>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// Presentational shell for the "Created for you" rows. The owning component
// drives `ready` from its own lifecycle (widget polling, help-center events);
// row separation is handled by the parent's `divide-y`.
defineProps({
ready: { type: Boolean, default: false },
title: { type: String, required: true },
description: { type: String, default: '' },
status: { type: String, default: '' },
});
</script>
<template>
<div class="flex items-center justify-between gap-3 px-3 py-3">
<div class="flex items-center gap-2 min-w-0">
<Icon
v-if="ready"
icon="i-lucide-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
<Spinner v-else :size="16" class="text-n-slate-9 flex-shrink-0" />
<span class="text-sm font-medium text-n-slate-12 flex-shrink-0">
{{ title }}
</span>
<span class="w-px h-4 bg-n-weak flex-shrink-0" />
<span class="text-sm text-n-slate-11 truncate">{{ description }}</span>
</div>
<span class="text-sm text-n-slate-11 flex-shrink-0">{{ status }}</span>
</div>
</template>
@@ -0,0 +1,116 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import OnboardingAPI from 'dashboard/api/onboarding';
import CreationStatusRow from './CreationStatusRow.vue';
const { t } = useI18n();
const POLL_INTERVAL = 5000;
const generation = ref({
generation_id: null,
state: null,
articles_count: 0,
categories_count: 0,
});
// Before the first article arrives, advance through phases so the spinner
// label doesn't sit on a single line through the whole curation step.
const generatingPhases = computed(() => [
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_GENERATING'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ANALYZING_WEBSITE'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SETTING_UP_CATEGORIES'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CURATING_ARTICLES'),
]);
const PHASE_DELAY_BASE = 4000;
const PHASE_DELAY_JITTER = 2000;
const phaseIndex = ref(0);
let phaseTimer = null;
// Advance one phase after a jittered delay; stop once we reach the last line
// (it then holds until the first article arrives).
const scheduleNextPhase = () => {
if (phaseIndex.value >= generatingPhases.value.length - 1) return;
const delay = PHASE_DELAY_BASE + Math.random() * PHASE_DELAY_JITTER;
phaseTimer = setTimeout(() => {
phaseIndex.value += 1;
scheduleNextPhase();
}, delay);
};
const status = computed(
() =>
generation.value.state?.status ||
(generation.value.generation_id ? 'generating' : 'not_started')
);
const isCompleted = computed(() => status.value === 'completed');
const isSkipped = computed(() => status.value === 'skipped');
const isNotStarted = computed(() => status.value === 'not_started');
const isTerminal = computed(
() => isCompleted.value || isSkipped.value || isNotStarted.value
);
const articlesCount = computed(() => generation.value.articles_count || 0);
const categoriesCount = computed(() => generation.value.categories_count || 0);
// Poll the generation status until it reaches a terminal state. useTimeoutPoll
// waits for each request to settle before scheduling the next (so requests never
// overlap), fires immediately on mount, and stops on unmount.
const { pause: stopPolling } = useTimeoutPoll(
async () => {
try {
const { data } = await OnboardingAPI.getHelpCenterGeneration();
generation.value = data;
if (isTerminal.value) stopPolling();
} catch {
// Keep polling; transient network failures should not strand onboarding.
}
},
POLL_INTERVAL,
{ immediate: true }
);
onMounted(scheduleNextPhase);
onBeforeUnmount(() => clearTimeout(phaseTimer));
const articlesText = computed(() =>
t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ARTICLES',
{ count: articlesCount.value },
articlesCount.value
)
);
const statusText = computed(() => {
if (isCompleted.value) {
const categories = t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CATEGORIES',
{ count: categoriesCount.value },
categoriesCount.value
);
return t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SUMMARY',
{ count: articlesCount.value, categories },
articlesCount.value
);
}
if (articlesCount.value > 0) return articlesText.value;
return generatingPhases.value[phaseIndex.value];
});
const isVisible = computed(() => !isSkipped.value && !isNotStarted.value);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<CreationStatusRow
v-if="isVisible"
:ready="isCompleted"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_DESCRIPTION')
"
:status="statusText"
/>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
channel: { type: Object, required: true },
});
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
// Per-channel field list + payload shape, mirroring the standalone settings
// forms (channels/Line.vue, channels/Telegram.vue). Labels/placeholders reuse
// the existing INBOX_MGMT translations so there's nothing new to localize.
const FORMS = {
line: {
fields: [
{
key: 'lineChannelId',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER'
),
},
{
key: 'lineChannelSecret',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER'
),
type: 'password',
},
{
key: 'lineChannelToken',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER'
),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'),
// Inbox name is mandatory for Line; prefill it from the channel label
// rather than asking, to keep the form to just the credentials.
buildPayload: values => ({
name: t(props.channel.labelKey),
channel: {
type: 'line',
line_channel_id: values.lineChannelId,
line_channel_secret: values.lineChannelSecret,
line_channel_token: values.lineChannelToken,
},
}),
},
telegram: {
fields: [
{
key: 'botToken',
label: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.LABEL'),
placeholder: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.PLACEHOLDER'),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE'),
buildPayload: values => ({
channel: { type: 'telegram', bot_token: values.botToken },
}),
},
};
const config = FORMS[props.channel.type];
const values = reactive(
Object.fromEntries(config.fields.map(field => [field.key, '']))
);
const isCreating = ref(false);
const isValid = computed(() =>
config.fields.every(field => values[field.key].trim())
);
const submit = async () => {
if (!isValid.value || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createChannel', config.buildPayload(values));
emit('created');
} catch (error) {
useAlert(error?.message || config.errorMessage);
} finally {
isCreating.value = false;
}
};
</script>
<!-- This sits inside the dialog's <form>; intercept Enter so it submits the
channel here instead of bubbling up and completing onboarding. -->
<template>
<div class="flex flex-col gap-4" @keydown.enter.prevent="submit">
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
<template v-for="field in config.fields" :key="field.key">
<label :for="field.key" class="text-sm font-medium text-n-slate-12">
{{ field.label }}
</label>
<Input
:id="field.key"
v-model="values[field.key]"
:type="field.type || 'text'"
:placeholder="field.placeholder"
/>
</template>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!isValid"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="submit"
/>
</div>
</div>
</template>
@@ -0,0 +1,210 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './useChannelConnect';
import { useChannelConfig } from './useChannelConfig';
import { CHANNEL_LIST } from './constants';
import { isChannelConnected } from './channelMatchers';
import InboxChannelForm from './InboxChannelForm.vue';
import InboxFacebookForm from './InboxFacebookForm.vue';
const props = defineProps({
inboxes: { type: Array, default: () => [] },
});
const emit = defineEmits(['connected']);
const { t } = useI18n();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const { isConfigured } = useChannelConfig();
// Maps the dialog's display types to the OAuth client key the flow expects.
// Types without an entry (manual-setup channels) are no-ops for now.
const OAUTH_PROVIDERS = {
[CHANNEL_TYPES.GMAIL]: 'google',
[CHANNEL_TYPES.OUTLOOK]: 'microsoft',
[CHANNEL_TYPES.INSTAGRAM]: 'instagram',
[CHANNEL_TYPES.TIKTOK]: 'tiktok',
};
// A card's availability — what the user can do with it right now:
// available — usable now (configured, not deferred)
// setupLater — deferred to in-app setup (SMS/API/Voice/Email cards)
// Channels whose installation OAuth credentials are missing are hidden entirely
// (see channelCards), so they never reach this state.
// `connected` (a real inbox already backs it) is orthogonal and tracked
// separately, since a connected channel can still be in any of these states.
const channelAvailability = channel =>
channel.setupLater ? 'setupLater' : 'available';
const CARD_CLASS = {
available: 'bg-n-solid-1 hover:outline-n-slate-6 cursor-pointer',
setupLater: 'bg-n-slate-2 cursor-not-allowed',
};
// Decorate the catalog with per-render state so the template reads plain fields
// rather than calling predicates for each card. Channels needing an absent
// installation credential are dropped so they don't show at all; deferred
// (setupLater) channels stay since they aren't a configuration problem.
const channelCards = computed(() =>
CHANNEL_LIST.filter(
channel => channel.setupLater || isConfigured(channel.type)
).map(channel => {
const connected = isChannelConnected(props.inboxes, channel.inbox);
// Website inboxes are only auto-created during onboarding — there is no
// manual creation path, so an unconnected Website card defers rather than
// offering a click that can't do anything.
const availability =
channel.type === CHANNEL_TYPES.WEBSITE && !connected
? 'setupLater'
: channelAvailability(channel);
return { ...channel, availability, connected };
})
);
const dialogRef = ref(null);
// Credential-form channels (Line, Telegram) and Facebook swap the grid for an
// inline view; OAuth channels redirect; the rest are no-ops for now.
const selectedChannel = ref(null);
// An inbox was created by an in-dialog form (Line/Telegram credentials or the
// Facebook page picker); close the dialog (its @close resets the form view) and
// let the parent refetch so the connected state and real channel icons update.
const onCreated = () => {
dialogRef.value?.close();
emit('connected');
};
const onCardClick = channel => {
if (channel.availability !== 'available') return;
if (channel.form) {
selectedChannel.value = channel;
return;
}
// WhatsApp uses Meta's embedded-signup popup, not the redirect OAuth flow.
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
// Facebook swaps to an in-dialog page picker (FB.login → choose a Page).
if (channel.type === CHANNEL_TYPES.FACEBOOK) {
selectedChannel.value = channel;
return;
}
connectViaOAuth(OAUTH_PROVIDERS[channel.type]);
};
const dialogTitle = computed(() =>
selectedChannel.value
? t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_TITLE', {
name: t(selectedChannel.value.labelKey),
})
: t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.TITLE')
);
const dialogDescription = computed(() => {
if (!selectedChannel.value) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SUBTITLE');
}
if (selectedChannel.value.type === CHANNEL_TYPES.FACEBOOK) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SUBTITLE');
}
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_SUBTITLE');
});
const open = preselectType => {
const entry = preselectType
? channelCards.value.find(channel => channel.type === preselectType)
: null;
// Only jump straight into a channel's view when it's actually usable;
// otherwise show the grid (with its muted card) rather than launching SDK
// auth with a missing credential.
selectedChannel.value = entry?.availability === 'available' ? entry : null;
dialogRef.value?.open();
};
const close = () => dialogRef.value?.close();
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="dialogTitle"
:description="dialogDescription"
width="lg"
:show-confirm-button="false"
:show-cancel-button="false"
@close="selectedChannel = null"
>
<InboxFacebookForm
v-if="selectedChannel?.type === CHANNEL_TYPES.FACEBOOK"
@back="selectedChannel = null"
@created="onCreated"
/>
<InboxChannelForm
v-else-if="selectedChannel"
:channel="selectedChannel"
@back="selectedChannel = null"
@created="onCreated"
/>
<template v-else>
<div class="grid grid-cols-2 gap-3">
<button
v-for="channel in channelCards"
:key="channel.type"
type="button"
:disabled="channel.availability !== 'available'"
class="flex items-center gap-3 p-3 rounded-xl outline outline-1 outline-n-weak shadow-[0px_1px_2px_0px_rgba(27,28,29,0.036)] transition-colors text-start"
:class="CARD_CLASS[channel.availability]"
@click="onCardClick(channel)"
>
<div
class="size-9 rounded-[10px] outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
>
<ChannelIcon
v-if="channel.inbox"
:inbox="channel.inbox"
use-brand-icon
class="size-5 text-n-slate-11"
/>
<Icon
v-else
:icon="channel.fallbackIcon"
class="size-4 text-n-slate-11"
/>
</div>
<div class="flex-1 min-w-0">
<span class="block text-sm font-medium text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<span
v-if="channel.availability === 'setupLater'"
class="block text-xs text-n-slate-11"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SETUP_LATER') }}
</span>
</div>
<Icon
v-if="channel.connected"
icon="i-lucide-circle-check"
class="size-5 text-n-teal-11"
/>
<Icon
v-else-if="channel.availability === 'available'"
icon="i-lucide-chevron-right"
class="size-5 text-n-slate-9"
/>
</button>
</div>
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.NOTE') }}
</p>
</template>
</Dialog>
</template>
@@ -0,0 +1,70 @@
<script setup>
import { computed } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { FALLBACK_PREVIEW_CHANNELS } from './constants';
import { useChannelConfig } from './useChannelConfig';
const props = defineProps({
remainingChannels: { type: Array, default: () => [] },
});
defineEmits(['viewAll']);
const { t } = useI18n();
const { isConfigured } = useChannelConfig();
// Icons shown next to "View all". Defaults to the unconnected socials, but when
// everything detected is already connected we still want a hint of what's
// behind the dialog — fall back to a representative trio. Either way, drop
// channels whose installation credentials are missing so we never preview an
// icon the dialog itself hides.
const previewChannels = computed(() =>
(props.remainingChannels.length
? props.remainingChannels
: FALLBACK_PREVIEW_CHANNELS
).filter(channel => isConfigured(channel.type))
);
</script>
<template>
<div class="flex items-center gap-2 px-3 pt-3 pb-4">
<Icon icon="i-lucide-info" class="size-4 text-n-slate-9 flex-shrink-0" />
<I18nT
keypath="ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_NOTE"
tag="span"
class="flex-1 min-w-0 text-body-main text-n-slate-11"
>
<template #email>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
</span>
</template>
<template #voice>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
</span>
</template>
</I18nT>
<div class="flex items-center gap-2 flex-shrink-0">
<ChannelIcon
v-for="channel in previewChannels"
:key="channel.type"
:inbox="channel.inbox"
use-brand-icon
class="size-5"
/>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ps-1">
<span class="w-px h-3 bg-n-weak" />
<button
type="button"
class="text-button text-n-slate-12 hover:underline"
@click="$emit('viewAll')"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,157 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
const { isAuthenticating, preloadSdk, loginAndFetchPages } =
useFacebookPageConnect();
// null until the login + page fetch completes.
const pages = ref(null);
const userAccessToken = ref('');
const selectedPageId = ref('');
const errored = ref(false);
const isCreating = ref(false);
const hasFetched = computed(() => pages.value !== null);
// Pages already connected as inboxes come back with `exists: true`.
const pageOptions = computed(() =>
(pages.value || [])
.filter(page => !page.exists)
.map(page => ({ value: page.id, label: page.name }))
);
// Warm the SDK on open so the login click opens its popup within the gesture's
// activation window (see useFacebookPageConnect).
onMounted(preloadSdk);
const connect = async () => {
errored.value = false;
try {
const result = await loginAndFetchPages();
if (!result) return; // cancelled — stay on the connect prompt
userAccessToken.value = result.userAccessToken;
pages.value = result.pages;
} catch {
errored.value = true;
}
};
const createInbox = async () => {
const page = (pages.value || []).find(p => p.id === selectedPageId.value);
if (!page || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createFBChannel', {
user_access_token: userAccessToken.value,
page_access_token: page.access_token,
page_id: page.id,
inbox_name: page.name,
});
useAlert(t('ONBOARDING_INBOX_SETUP.FACEBOOK_CONNECTED'));
emit('created');
} catch (error) {
useAlert(parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isCreating.value = false;
}
};
</script>
<template>
<div class="flex flex-col gap-4">
<div
v-if="isAuthenticating"
class="flex items-center justify-center gap-3 py-8"
>
<Spinner :size="16" class="text-n-blue-10" />
<span class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
</span>
</div>
<template v-else-if="hasFetched && pageOptions.length">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE') }}
</label>
<ComboBox
v-model="selectedPageId"
:options="pageOptions"
:placeholder="
t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE')
"
/>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!selectedPageId"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="createInbox"
/>
</div>
</template>
<template v-else-if="hasFetched">
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
</p>
<div class="flex">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
</div>
</template>
<template v-else>
<p v-if="errored" class="text-sm text-n-ruby-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
</p>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH')"
@click="connect"
/>
</div>
</template>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import CreationStatusRow from './CreationStatusRow.vue';
const POLL_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
// The web widget inbox is created asynchronously during account setup. We poll
// the inboxes endpoint until it shows up, then stop — much simpler than the
// event-driven help center flow.
const websiteInboxes = useMapGetter('inboxes/getWebsiteInboxes');
const isReady = computed(() => websiteInboxes.value.length > 0);
// useTimeoutPoll waits for each fetch to settle before scheduling the next (so
// requests never overlap), fires immediately on mount, and stops on unmount.
const { pause } = useTimeoutPoll(
async () => {
await store.dispatch('inboxes/get');
if (isReady.value) pause();
},
POLL_INTERVAL,
{ immediate: true }
);
</script>
<template>
<CreationStatusRow
:ready="isReady"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_DESCRIPTION')
"
:status="
isReady
? t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_READY')
: t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_STATUS')
"
/>
</template>
@@ -0,0 +1,18 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
// A detected channel maps to a real inbox when they share a channel_type. Gmail
// and Outlook both use Channel::Email, so for email we also match on provider.
// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
// Returns the matching inbox (not a boolean) so callers can show the connected
// account's real name rather than the detected handle.
export const findConnectedInbox = (inboxes, stub) =>
inboxes.find(
inbox =>
inbox.channel_type === stub?.channel_type &&
(stub?.channel_type !== INBOX_TYPES.EMAIL ||
inbox.provider === stub?.provider)
);
export const isChannelConnected = (inboxes, stub) =>
Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
@@ -0,0 +1,149 @@
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
// Channels whose connect flow opens the channels dialog preselected to their
// in-dialog step — Facebook (page picker) and the credential-form channels
// (Telegram, Line) — rather than redirecting through OAuth.
export const DIALOG_CHANNELS = [
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Suggested channels (in priority order) to offer as rows when nothing is
// detected, so the step isn't empty. The mainstream OAuth channels show on
// configured installs, while credential-free Telegram/LINE keep the list
// non-empty on a bare self-host.
export const DEFAULT_CHANNEL_TYPES = [
CHANNEL_TYPES.WHATSAPP,
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.INSTAGRAM,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
// exists and falls back to the monochrome glyph otherwise, so no per-channel
// style flag is needed. Entries without a channel type (Voice, Other Email
// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
// inline credential form; `setupLater: true` defers the channel to in-app setup
// for this phase. `labelKey` is an i18n key — most reuse the shared channel
// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
// names translate without duplicating strings; resolve it with `t()` at display.
export const CHANNEL_LIST = [
{
type: 'website',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
inbox: { channel_type: 'Channel::WebWidget' },
},
{
type: 'whatsapp',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
{
type: 'facebook',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
form: true,
},
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
form: true,
},
// Email channels (including Gmail/Outlook OAuth) are set up later in-app for
// this phase; they will be enabled in a future PR.
{
type: 'gmail',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
inbox: { channel_type: 'Channel::Email', provider: 'google' },
setupLater: true,
},
{
type: 'outlook',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
setupLater: true,
},
{
type: 'sms',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
inbox: { channel_type: 'Channel::Sms' },
setupLater: true,
},
{
type: 'api',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
inbox: { channel_type: 'Channel::Api' },
setupLater: true,
},
{
type: 'voice',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
fallbackIcon: 'i-woot-voice',
setupLater: true,
},
{
type: 'email',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
fallbackIcon: 'i-woot-mail',
setupLater: true,
},
];
const channelByType = type =>
CHANNEL_LIST.find(channel => channel.type === type);
// Icons shown next to "View all" when every detected channel is already
// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
// aren't duplicated.
export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
channelByType
);
// Social channels that detected brand_info socials map to, keyed by social type
// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
// identity (label, channel_type) has a single source. Keys mirror
// SocialLinkParser::SOCIAL_DOMAIN_MAP.
const SOCIAL_PLATFORM_TYPES = [
'whatsapp',
'facebook',
'line',
'instagram',
'telegram',
'tiktok',
];
export const SOCIAL_PLATFORMS = Object.fromEntries(
SOCIAL_PLATFORM_TYPES.map(type => {
const { labelKey, inbox } = channelByType(type);
return [type, { labelKey, channelType: inbox.channel_type }];
})
);
// Mailbox providers inferred from the signup domain's MX records, keyed by
// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
export const EMAIL_PROVIDERS = Object.fromEntries(
CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
channel.inbox.provider,
{ labelKey: channel.labelKey },
])
);
@@ -0,0 +1,29 @@
import { useMapGetter } from 'dashboard/composables/store';
// 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
// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
// need no installation credential and are always considered configured.
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
};
const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
return { isConfigured };
}
@@ -0,0 +1,67 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
import instagramClient from 'dashboard/api/channel/instagramClient';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
// Channels that complete via an OAuth redirect. Email channels are keyed by their
// Channel::Email provider, others by channel type. The request is tagged with a
// return hint so the callback brings the user back to onboarding instead of the
// inbox settings page.
const OAUTH_CLIENTS = {
google: googleClient,
microsoft: microsoftClient,
instagram: instagramClient,
tiktok: tiktokClient,
};
export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
try {
const {
data: { url },
} = await client.generateAuthorization({ return_to: 'onboarding' });
window.location.href = url;
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
}
};
// WhatsApp connects via Meta's embedded-signup popup instead of the redirect
// OAuth flow above. Collect the signup credentials, exchange them for an
// inbox, and surface the result inline — then refetch so the connected state
// reflects the freshly created inbox (and renders its real channel icon).
const connectWhatsapp = async () => {
let credentials;
try {
credentials = await runEmbeddedSignup();
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
return;
}
if (!credentials) return; // user dismissed the popup
try {
await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
await store.dispatch('inboxes/get');
useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
} catch (error) {
useAlert(
parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
);
}
};
return { connectViaOAuth, connectWhatsapp };
}
@@ -0,0 +1,130 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import {
SOCIAL_PLATFORMS,
EMAIL_PROVIDERS,
DEFAULT_CHANNEL_TYPES,
} from './constants';
import { findConnectedInbox } from './channelMatchers';
import { useChannelConfig } from './useChannelConfig';
// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
// is config-gated like everything else, then sliced to this limit.
const DISPLAYED_CHANNEL_LIMIT = 3;
// Pull the handle/username out of a detected social URL, formatted per channel.
const extractHandle = ({ type, url }) => {
try {
const { pathname } = new URL(url);
const path = pathname.replace(/^\/+|\/+$/g, '');
if (type === 'whatsapp') {
const digits = path.replace(/\D/g, '');
return digits ? `+${digits}` : '';
}
if (type === 'line') return path;
return path.startsWith('@') ? path : `@${path}`;
} catch {
return '';
}
};
// Derives the channel rows for the inbox-setup step from the account's detected
// brand_info (socials + mailbox provider) and the real connected inboxes,
// keeping InboxSetup.vue focused on layout, connect routing, and completion.
export function useDetectedChannels() {
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const { isConfigured } = useChannelConfig();
const brandSocials = computed(
() => currentAccount.value?.custom_attributes?.brand_info?.socials || []
);
const connectedChannels = computed(() =>
brandSocials.value
.filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
.map(social => ({
type: social.type,
handle: extractHandle(social),
labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
}))
);
const detectedEmailChannel = computed(() => {
const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
const provider = brandInfo?.email_provider;
if (!EMAIL_PROVIDERS[provider]) return null;
return {
type: 'email',
handle: brandInfo?.email || '',
labelKey: EMAIL_PROVIDERS[provider].labelKey,
inbox: { channel_type: 'Channel::Email', provider },
};
});
// The real inbox backing a channel, if one exists — returned (not just a
// boolean) so the row can show the connected account's real name.
const connectedInbox = channel =>
findConnectedInbox(inboxes.value, channel.inbox);
// A channel row built from a social type, with no detected handle — used for
// the default suggestions when nothing was detected.
const toChannelRow = type => ({
type,
handle: '',
labelKey: SOCIAL_PLATFORMS[type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
});
const detectedChannels = computed(() =>
[detectedEmailChannel.value, ...connectedChannels.value]
.filter(Boolean)
// Email channels (including Gmail/Outlook OAuth) are disabled for this
// phase; they will be enabled in a future PR.
.filter(channel => channel.type !== 'email')
// Hide channels whose installation OAuth credentials are missing — their
// connect flow would only error.
.filter(channel => isConfigured(channel.type))
);
const defaultChannels = computed(() =>
DEFAULT_CHANNEL_TYPES.filter(isConfigured)
.slice(0, DISPLAYED_CHANNEL_LIMIT)
.map(toChannelRow)
);
// Show the detected channels, or fall back to the default suggestions so the
// step is never an empty list.
const displayedChannels = computed(() =>
detectedChannels.value.length
? detectedChannels.value
: defaultChannels.value
);
const remainingChannels = computed(() => {
// Exclude whatever is already shown as a row (detected or defaulted) so the
// footer preview doesn't duplicate it.
const shownTypes = new Set(displayedChannels.value.map(c => c.type));
return Object.entries(SOCIAL_PLATFORMS)
.filter(([type]) => !shownTypes.has(type))
.filter(([type]) => isConfigured(type))
.slice(0, 3)
.map(([type, { labelKey, channelType }]) => ({
type,
labelKey,
inbox: { channel_type: channelType },
}));
});
const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
return {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
};
}
@@ -5,11 +5,12 @@ defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
skipLabel: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue']);
defineEmits(['continue', 'skip']);
</script>
<template>
@@ -20,7 +21,7 @@ defineEmits(['continue']);
<div
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
/>
<div class="relative w-full max-w-[580px]">
<div class="relative w-full max-w-[40rem]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
@@ -104,16 +105,30 @@ defineEmits(['continue']);
>
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
</svg>
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
class="w-full justify-center"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
<div class="flex items-center gap-3">
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
:class="
skipLabel ? 'flex-1 justify-center' : 'w-full justify-center'
"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
<NextButton
v-if="skipLabel"
type="button"
slate
outline
class="flex-1 justify-center"
@click="$emit('skip')"
>
{{ skipLabel }}
</NextButton>
</div>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
bare: { type: Boolean, default: false },
});
</script>
@@ -42,8 +43,12 @@ defineProps({
</div>
<!-- Card -->
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<div
v-if="!bare"
class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1"
>
<slot />
</div>
<slot v-else />
</div>
</template>
@@ -0,0 +1,186 @@
import { defineComponent, h, ref } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
vi.mock('vue-router');
const ENABLED_LANGUAGES = [
{ iso_639_1_code: 'en', name: 'English' },
{ iso_639_1_code: 'fr', name: 'French' },
];
// Mounts the composable against a real store and the real useAccount/useConfig
// (only useRoute and the underlying account getter / window config are faked),
// so a change to how those resolve their data is exercised here too. `presets`
// seeds form fields as if the user had already typed them.
const mountComposable = ({
account = {},
enabledLanguages = ENABLED_LANGUAGES,
presets = {},
} = {}) => {
window.chatwootConfig = { enabledLanguages };
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: { getAccount: () => () => account },
},
},
});
const fields = {
locale: ref(presets.locale || ''),
website: ref(presets.website || ''),
timezone: ref(presets.timezone || ''),
companySize: ref(presets.companySize || ''),
industry: ref(presets.industry || ''),
referralSource: ref(presets.referralSource || ''),
};
let api;
const Component = defineComponent({
setup() {
api = useAccountEnrichment(fields);
return () => h('div');
},
});
const wrapper = mount(Component, { global: { plugins: [store] } });
return { ...api, fields, wrapper };
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useAccountEnrichment', () => {
describe('populateFormFields', () => {
it('fills empty fields from the enriched attributes on mount', () => {
const { fields } = mountComposable({
account: {
locale: 'en',
custom_attributes: {
website: 'https://acme.com',
timezone: 'America/New_York',
company_size: '11-50',
industry: 'Technology',
referral_source: 'google',
},
},
});
expect(fields.website.value).toBe('https://acme.com');
expect(fields.timezone.value).toBe('America/New_York');
expect(fields.companySize.value).toBe('11-50');
expect(fields.industry.value).toBe('Technology');
expect(fields.referralSource.value).toBe('google');
});
it('falls back to brand_info for website and industry', () => {
const { fields } = mountComposable({
account: {
custom_attributes: {
brand_info: {
domain: 'acme.com',
industries: [{ industry: 'Retail & E-commerce' }],
},
},
},
});
expect(fields.website.value).toBe('acme.com');
expect(fields.industry.value).toBe('Retail & E-commerce');
});
it('does not clobber fields the user already set', () => {
const { fields } = mountComposable({
presets: { website: 'mysite.com', industry: 'Finance' },
account: {
custom_attributes: {
website: 'https://enriched.com',
industry: 'Technology',
},
},
});
expect(fields.website.value).toBe('mysite.com');
expect(fields.industry.value).toBe('Finance');
});
it('detects the locale from the browser, else the account locale', () => {
// jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
const { fields } = mountComposable({ account: { locale: 'de' } });
expect(fields.locale.value).toBe('en');
// No enabled language matches the browser -> fall back to account locale.
const { fields: other } = mountComposable({
account: { locale: 'de' },
enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
});
expect(other.locale.value).toBe('de');
});
});
describe('isEnriching', () => {
it('is true while the account is on the enrichment step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'enrichment' } },
});
expect(isEnriching.value).toBe(true);
});
it('is false on any other step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'account_details' } },
});
expect(isEnriching.value).toBe(false);
});
it('times out after 30s, flipping to false and populating', () => {
vi.useFakeTimers();
try {
const { isEnriching, fields } = mountComposable({
account: {
custom_attributes: {
onboarding_step: 'enrichment',
company_size: '51-200',
},
},
});
expect(isEnriching.value).toBe(true);
vi.advanceTimersByTime(30000);
expect(isEnriching.value).toBe(false);
expect(fields.companySize.value).toBe('51-200');
} finally {
vi.useRealTimers();
}
});
});
describe('getChangedFields', () => {
it('lists only enrichable fields edited after auto-fill', () => {
const { fields, getChangedFields } = mountComposable({
account: {
custom_attributes: {
website: 'https://acme.com',
company_size: '11-50',
industry: 'Technology',
},
},
});
expect(getChangedFields()).toEqual([]);
fields.industry.value = 'Finance';
expect(getChangedFields()).toEqual(['industry']);
});
});
});
@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils';
import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
import OnboardingAPI from 'dashboard/api/onboarding';
vi.mock('dashboard/api/onboarding', () => ({
default: {
getHelpCenterGeneration: vi.fn(),
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key.endsWith('HELP_CENTER_CATEGORIES')) {
return `${params.count} categories`;
}
if (key.endsWith('HELP_CENTER_SUMMARY')) {
return `${params.count} articles across ${params.categories}`;
}
if (key.endsWith('HELP_CENTER_ARTICLES')) {
return `${params.count} articles`;
}
return key;
},
}),
}));
const mountStatus = () =>
mount(HelpCenterCreationStatus, {
global: {
stubs: {
CreationStatusRow: {
props: ['ready', 'title', 'description', 'status'],
template:
'<div data-test="row" :data-ready="ready">{{ status }}</div>',
},
},
},
});
describe('HelpCenterCreationStatus', () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders completed summary from the status endpoint', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 3,
categories_count: 2,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'3 articles across 2 categories'
);
});
it('hides the row when generation is skipped', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'skipped' },
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
});
it('polls while generating and stops after completion', async () => {
vi.useFakeTimers();
OnboardingAPI.getHelpCenterGeneration
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'generating' },
articles_count: 1,
categories_count: 0,
},
})
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 2,
categories_count: 1,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'2 articles across 1 categories'
);
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ({ value: {} }),
}));
vi.mock('../../inbox-setup/useChannelConnect', () => ({
useChannelConnect: () => ({
connectViaOAuth: vi.fn(),
connectWhatsapp: vi.fn(),
}),
}));
const mountDialog = () =>
mount(InboxChannelsDialog, {
props: { inboxes: [] },
global: {
stubs: {
Dialog: {
template: '<div><slot /></div>',
methods: { open() {}, close() {} },
},
InboxFacebookForm: { template: '<div data-test="fb-form" />' },
InboxChannelForm: { template: '<div data-test="channel-form" />' },
ChannelIcon: true,
Icon: true,
},
},
});
describe('InboxChannelsDialog Facebook gating', () => {
afterEach(() => {
delete window.chatwootConfig;
});
it('opens the Facebook page picker when fbAppId is configured', async () => {
window.chatwootConfig = { fbAppId: 'fb-app' };
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
});
it('shows the grid (not the picker) when fbAppId is missing', async () => {
window.chatwootConfig = {};
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
// The channel grid renders its cards instead.
expect(wrapper.find('button').exists()).toBe(true);
});
});
@@ -0,0 +1,158 @@
import { flushPromises, mount } from '@vue/test-utils';
import { ref, nextTick } from 'vue';
import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('dashboard/store/utils/api', () => ({
parseAPIErrorResponse: vi.fn(),
}));
vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
useFacebookPageConnect: vi.fn(),
}));
const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
}));
const NextButtonStub = {
props: ['label', 'disabled', 'isLoading'],
emits: ['click'],
template: `<button :disabled="disabled" @click="$emit('click')">{{ label }}</button>`,
};
const ComboBoxStub = {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: '<div data-test="combobox" />',
};
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'pt1' },
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
];
const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
let loginAndFetchPages;
let preloadSdk;
const mountForm = () =>
mount(InboxFacebookForm, {
global: {
stubs: {
NextButton: NextButtonStub,
ComboBox: ComboBoxStub,
Spinner: true,
},
},
});
const clickButton = (wrapper, label) =>
wrapper
.findAll('button')
.find(button => button.text() === label)
.trigger('click');
beforeEach(() => {
vi.clearAllMocks();
preloadSdk = vi.fn();
loginAndFetchPages = vi.fn();
useFacebookPageConnect.mockReturnValue({
isAuthenticating: ref(false),
preloadSdk,
loginAndFetchPages,
});
dispatch.mockResolvedValue({ id: 1 });
});
describe('InboxFacebookForm', () => {
it('preloads the SDK on mount', () => {
mountForm();
expect(preloadSdk).toHaveBeenCalled();
});
it('lists only connectable pages and creates an inbox for the selected one', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: PAGES,
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
// p2 is already connected (exists), so only p1 is offered.
const combobox = wrapper.findComponent(ComboBoxStub);
expect(combobox.props('options')).toEqual([
{ value: 'p1', label: 'Page One' },
]);
combobox.vm.$emit('update:modelValue', 'p1');
await nextTick();
await clickButton(wrapper, CONNECT);
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
user_access_token: 'tok',
page_access_token: 'pt1',
page_id: 'p1',
inbox_name: 'Page One',
});
expect(wrapper.emitted('created')).toBeTruthy();
});
it('shows the empty state when every page is already connected', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: [
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
],
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
);
expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
});
it('shows an error when the connection fails', async () => {
loginAndFetchPages.mockRejectedValue(new Error('boom'));
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
expect(dispatch).not.toHaveBeenCalled();
});
it('stays on the connect prompt without an error when cancelled', async () => {
loginAndFetchPages.mockResolvedValue(null);
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).not.toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
// Launch button is still available to retry.
expect(
wrapper.findAll('button').some(button => button.text() === LAUNCH)
).toBe(true);
});
});
@@ -0,0 +1,62 @@
import {
findConnectedInbox,
isChannelConnected,
} from '../../inbox-setup/channelMatchers';
const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
const OUTLOOK = {
id: 3,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
describe('channelMatchers', () => {
describe('findConnectedInbox', () => {
it('returns the inbox sharing the channel type', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(WHATSAPP);
});
it('matches email inboxes on provider', () => {
expect(
findConnectedInbox([OUTLOOK, GMAIL], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBe(GMAIL);
});
it('does not match a different email provider', () => {
expect(
findConnectedInbox([OUTLOOK], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBeUndefined();
});
it('returns undefined when nothing matches', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
).toBeUndefined();
});
});
describe('isChannelConnected', () => {
it('is true when a matching inbox exists', () => {
expect(
isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(true);
});
it('is false when no inbox matches', () => {
expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
});
it('is false for a channel without an inbox stub', () => {
expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
});
});
});
@@ -0,0 +1,300 @@
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// 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
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: {
getAccount: () => () => ({
id: 1,
custom_attributes: { brand_info: brandInfo },
}),
},
},
inboxes: {
namespaced: true,
getters: { getInboxes: () => inboxes },
},
},
});
let result;
const Component = defineComponent({
setup() {
result = useDetectedChannels();
return () => h('div');
},
});
mount(Component, { global: { plugins: [store] } });
return result;
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
// Configure the installation OAuth credentials so detected channels aren't
// hidden by the config gate; individual tests clear this to assert hiding.
window.chatwootConfig = {
fbAppId: 'fb',
instagramAppId: 'ig',
tiktokAppId: 'tt',
whatsappAppId: 'wa',
whatsappConfigurationId: 'wa-config',
};
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useDetectedChannels', () => {
describe('displayedChannels', () => {
it('maps detected socials with a url to channel rows', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
{ type: 'instagram', url: 'https://instagram.com/acme' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '+14155552671',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
handle: '@acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('skips socials without a url or with an unknown type', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'telegram' }, // no url
{ type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
it('uses the raw path for line and falls back to empty on a bad url', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'line', url: 'https://line.me/acme' },
{ type: 'facebook', url: 'not-a-url' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'line',
handle: 'acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
]);
});
it('omits the detected email channel while email is disabled for this phase', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
email_provider: 'google',
email: 'support@acme.com',
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'whatsapp',
]);
});
it('falls back to the default channel suggestions when nothing is detected', () => {
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// The configured mainstream channels, with no detected handle.
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'instagram',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('gates the default suggestions by installation config, keeping the list non-empty', () => {
window.chatwootConfig = {}; // no OAuth credentials configured
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// Only the credential-free defaults survive (Telegram, LINE).
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'telegram',
'line',
]);
});
it('hides detected channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'facebook', url: 'https://facebook.com/acme' },
{ type: 'line', url: 'https://line.me/acme' },
],
},
});
// Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'line',
]);
});
});
describe('remainingChannels', () => {
it('returns the platforms not already shown as default rows', () => {
// Nothing detected → displayed falls back to the defaults (WhatsApp,
// Facebook, Instagram), so the footer previews the remaining platforms.
const { remainingChannels } = mountComposable({ brandInfo: {} });
expect(remainingChannels.value).toEqual([
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
]);
});
it('excludes already-detected socials, preserving order', () => {
const { remainingChannels } = mountComposable({
brandInfo: {
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
'facebook',
'line',
'instagram',
]);
});
it('excludes channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { remainingChannels } = mountComposable({ brandInfo: {} });
// The only configured channels (Telegram, LINE) are shown as default rows,
// and every other platform is gated out — so nothing remains for the footer.
expect(remainingChannels.value).toEqual([]);
});
});
describe('connectedInbox', () => {
it('returns the real inbox sharing the channel type', () => {
const inbox = {
id: 1,
channel_type: 'Channel::Whatsapp',
name: 'WA Biz',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [inbox],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
).toBe(inbox);
});
it('matches email inboxes on provider', () => {
const gmail = {
id: 1,
channel_type: 'Channel::Email',
provider: 'google',
};
const outlook = {
id: 2,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [outlook, gmail],
});
expect(
connectedInbox({
inbox: { channel_type: 'Channel::Email', provider: 'google' },
})
).toBe(gmail);
});
it('returns undefined when nothing matches', () => {
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
).toBeUndefined();
});
});
});
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Editor from 'next/Editor/Editor.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
<Editor
v-model="message"
class="w-full"
channel-type="Context::NoToolbar"
enable-variables
:enable-canned-responses="false"
:show-character-count="false"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Default age threshold for excluding stale unassigned conversations (7 days)
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
@@ -106,6 +106,7 @@ const formData = computed(() => ({
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
}));
const handleDeleteInbox = async inboxId => {
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
@@ -16,6 +18,7 @@ import {
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
@@ -28,6 +31,7 @@ const props = defineProps({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
}),
},
mode: {
@@ -56,7 +60,6 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
@@ -64,6 +67,9 @@ const emit = defineEmits([
'navigateToInbox',
'validationChange',
]);
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
const MIN_EXCLUSION_MINUTES = 60;
const MAX_EXCLUSION_MINUTES = 1438560;
const { t } = useI18n();
const route = useRoute();
@@ -83,12 +89,28 @@ const state = reactive({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
const validationState = ref({
isValid: false,
});
const exclusionUnit = ref(DURATION_UNITS.DAYS);
// DurationInput works in minutes; the policy stores hours, so bridge the two
const excludeOlderThanMinutes = computed({
get() {
return state.excludeOlderThanHours == null
? null
: state.excludeOlderThanHours * 60;
},
set(minutes) {
state.excludeOlderThanHours =
minutes == null ? null : Math.round(minutes / 60);
},
});
const createOption = (
type,
key,
@@ -170,6 +192,7 @@ const resetForm = () => {
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
};
@@ -177,10 +200,17 @@ const handleSubmit = () => {
emit('submit', { ...state });
};
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
const detectExclusionUnit = hours => {
exclusionUnit.value =
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
detectExclusionUnit(newData.excludeOlderThanHours);
},
{ immediate: true, deep: true }
);
@@ -247,6 +277,27 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
</p>
</div>
<div
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<DurationInput
v-model:unit="exclusionUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_EXCLUSION_MINUTES"
:max="MAX_EXCLUSION_MINUTES"
/>
</div>
</div>
</div>
<Button
@@ -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>
@@ -144,8 +144,10 @@ const showWebhookSection = computed(
() => props.healthData?.webhook_configuration !== undefined
);
// Phone-level override takes precedence over WABA-level (application), so prefer it.
const webhookUrl = computed(
() =>
props.healthData?.webhook_configuration?.phone_number ||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
props.healthData?.webhook_configuration?.application
);
@@ -101,6 +101,9 @@ export default {
summary-fetching-key="getBotSummaryFetchingStatus"
:group-by="groupBy"
:report-keys="reportKeys"
:from="from"
:to="to"
:business-hours="businessHours"
/>
</div>
</template>

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