Compare commits

..
Author SHA1 Message Date
Shivam MishraandGitHub c440df9b18 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-16 19:19:24 +05:30
Shivam MishraandGitHub 68da47753e Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-16 19:19:14 +05:30
Shivam Mishra d054a60a65 fix: spec flag name 2026-03-16 19:18:49 +05:30
Shivam Mishra 8b7def05dc fix: feature flag 2026-03-16 19:16:51 +05:30
Shivam MishraandGitHub f126230f73 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-16 19:14:03 +05:30
Shivam MishraandGitHub e42b0be530 Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-16 19:13:55 +05:30
Shivam Mishra 33b3a56d4c fix: feature flag 2026-03-16 19:06:56 +05:30
Shivam Mishra 6693a4d6f7 fix: rollup 2026-03-16 19:05:45 +05:30
Shivam Mishra 69a44ccf4d refactor: wrap rollup action in a rescue block 2026-03-16 18:53:38 +05:30
Muhsin KelothandGitHub 95727bc608 Merge branch 'develop' into feat/rollup/1-model-write-path 2026-03-16 12:28:41 +04:00
a5c50354fc feat: trigger assignment on resolve (#13780)
## Description

When an agent resolves or snoozes a conversation, their capacity frees
up — but under Assignment V2, no new conversation was assigned until the
next event triggered the assignment job. This meant agents could sit
idle despite having queued conversations waiting. This change triggers
the AssignmentJob immediately on resolve/snooze so freed capacity is
utilized right away.

## Type of change

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

## How Has This Been Tested?

- Enable auto assignment v2 on an inbox
- Create a conversation (gets auto-assigned to an available agent)
- Resolve the conversation
- Verify that another unassigned conversation in the inbox gets assigned
to the freed-up agent

## 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: tds-1 <tds-1@users.noreply.github.com>
2026-03-16 13:13:37 +05:30
a452ce9e84 feat(whatsapp): add webhook registration and status endpoints (#13551)
## Description

Adds webhook configuration management for WhatsApp Cloud API channels,
allowing administrators to check webhook status and register webhooks
directly from Chatwoot without accessing Meta Business Manager.

## Type of change

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


## Screenshots

<img width="1130" height="676" alt="Screenshot 2026-03-05 at 7 04 18 PM"
src="https://github.com/user-attachments/assets/f5dcd9dd-8827-42c5-a52b-1024012703c2"
/>
<img width="1101" height="651" alt="Screenshot 2026-03-05 at 7 04 29 PM"
src="https://github.com/user-attachments/assets/e0bd59f9-2a90-4f24-87c0-b79f21e721ee"
/>



## 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-03-16 12:48:16 +05:30
Shivam Mishra 2fab77d7c7 refactor: split large examples in backfill service spec 2026-03-16 12:45:26 +05:30
Shivam Mishra 7ad8011a1b style: fix ABC size 2026-03-16 12:39:52 +05:30
Shivam Mishra c83546e67a style: split specs 2026-03-16 12:37:47 +05:30
Shivam Mishra 1154f9ccb4 chore: remove prompt enable outside resuce 2026-03-16 12:31:45 +05:30
Shivam Mishra e7d5924ca1 feat: make the rollup backfill atomic 2026-03-16 12:26:38 +05:30
Shivam Mishra 89e53e24db fix: timezone offset parsing 2026-03-16 12:04:29 +05:30
Shivam MishraandGitHub 47ecd349cc Merge branch 'develop' into feat/rollup/1-model-write-path 2026-03-16 11:35:49 +05:30
Tanmay Deep SharmaandGitHub 28bf9fa5f9 fix: upgrade markdown-it to 14.1.1 to remediate CVE-2026-2327 (#13782)
## Linear tickets
-
https://linear.app/chatwoot/issue/CW-6607/vanta-remediate-medium-vulnerabilities-identified-in-packages-are
-
https://linear.app/chatwoot/issue/CW-6612/vanta-remediate-medium-vulnerabilities-identified-in-packages-are

## Description

Upgrades markdown-it from 13.0.2 to 14.1.1 to remediate CVE-2026-2327

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

- Sanity testing of golden flows via 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
2026-03-16 11:11:19 +05:30
Shivam MishraandGitHub 73a90f2841 feat: update bunny video support in HC (#13815)
Bunny Video has added a new URL player.mediadelivery.net, this PR adds
support for the new URL
2026-03-16 11:04:27 +05:30
Aakash BakhleandGitHub a90ffe6264 feat: Add force legacy auto-resolve flag (#13804)
# Pull Request Template

## Description

Add account setting and store_accessor for
`captain_force_legacy_auto_resolve`.
Enterprise job now skips LLM evaluation when this flag is true and falls
back to legacy time-based resolution. Add spec to cover the fallback.


## Type of change

We recently rolled out Captain deciding if a conversation is resolved or
not. While it is an improvement for majority of customers, some still
prefer the old way of auto-resolving based on inactivity. This PR adds a
check.

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

legacy_auto_resolve = true

<img width="1282" height="848" alt="CleanShot 2026-03-13 at 19 55 55@2x"
src="https://github.com/user-attachments/assets/dfdcc5d5-6d21-462b-87a6-a5e1b1290a8b"
/>


legacy_auto_resolve = false
<img width="1268" height="864" alt="CleanShot 2026-03-13 at 20 00 50@2x"
src="https://github.com/user-attachments/assets/f4719ec6-922a-4c3b-bc45-7b29eaced565"
/>



## 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-03-13 15:04:58 -07:00
Shivam Mishra b8543c09fb refactor: unify backfill service to per-dimension query pattern
Replace the two-path aggregation (group-then-fan-out for standard events,
per-dimension for distinct-count) with a single per-dimension loop for both.
This removes the bug-prone fan-out logic and the conceptual split.

Strengthen specs to cover all three dimensions (account, agent, inbox),
assert total rollup row counts, and add distinct-count deduplication test.
2026-03-13 15:17:46 +05:30
Alexander UdovichenkoandGitHub 412b72db7c fix: Delete double hmac check (#12464)
## Description

When hmac identity check is enabled according to
[this](https://www.chatwoot.com/hc/user-guide/articles/1677587479-how-to-enable-identity-validation-in-chatwoot)
I found out, that it checked twice.

If `should_verify_hmac? -> true` then hmac checked in `before_action`
and we don't need to do it again later.

This perfomance related and PR fixes this.
2026-03-13 02:30:17 -07:00
Shivam MishraandGitHub e37e9fd814 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-13 13:55:24 +05:30
Shivam MishraandGitHub e6161d2878 Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-13 13:55:18 +05:30
Shivam Mishra 4e2924538f fix: distinct counts in backfill 2026-03-13 13:48:45 +05:30
Shivam MishraandGitHub e71bda7112 Merge branch 'develop' into feat/rollup/1-model-write-path 2026-03-13 13:18:06 +05:30
Shivam Mishra 31e3e9c1ff chore: add comment 2026-03-13 13:17:49 +05:30
Aakash BakhleandGitHub 8aa49f69d2 fix: prefer system API key for completion service (#13799)
# Pull Request Template

## Description

Add an api_key override so internal conversation completions prefers
using the system API key and do not consume customer OpenAI credits.
## 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.

specs and 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-03-13 13:10:10 +05:30
Shivam Mishra 10f1c23742 refactor: use the new registry 2026-03-13 12:46:12 +05:30
Shivam MishraandGitHub 3fd14942f1 Merge branch 'feat/rollup/2-report-data-source' into feat/rollup/3-rollup-read-path 2026-03-13 12:45:20 +05:30
Shivam Mishra 7661104262 refactor: better defaults for metric 2026-03-13 12:44:46 +05:30
Shivam Mishra 38b5402623 refactor: use report metric registry 2026-03-13 12:37:23 +05:30
Shivam MishraandGitHub 3dd29080b5 Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-13 12:36:23 +05:30
Shivam Mishra e56b4b2539 refactor: metric registry 2026-03-13 12:35:09 +05:30
Shivam MishraandGitHub 5ede10c6df Merge branch 'feat/rollup/1-model-write-path' into feat/rollup/2-report-data-source 2026-03-13 12:29:30 +05:30
Shivam Mishra 8a4c1ec07e chore: add comments 2026-03-13 12:24:21 +05:30
Shivam MishraandGitHub 550b408656 fix: restrict existing user sign-in to account members (#13793)
SAML sign-in now only links an existing user when that user already
belongs to the account that initiated SSO. New users can still be
created for SAML-enabled accounts, and invited members can continue to
sign in through their IdP, but SAML will no longer auto-attach an
unrelated existing user record during login.

**What changed**
- Added an account-membership check before SAML reuses an existing user
by email.
- Kept first-time SAML user creation unchanged for valid new users.
- Added builder and request specs covering the allowed and rejected
login paths.
2026-03-13 12:22:25 +05:30
Shivam Mishra ac7a02067e chore: flatten name 2026-03-13 12:15:55 +05:30
Shivam Mishra 2e591afaf6 fix: use IANA 2026-03-13 11:51:28 +05:30
Shivam Mishra 32e923dba0 feat: add rollup data source and route eligible reports through it 2026-03-13 11:02:12 +05:30
Shivam Mishra b824f8ba47 refactor: pass timezone param through report controllers 2026-03-13 10:59:43 +05:30
Shivam Mishra 57d40c373d refactor: route summary builders through DataSource 2026-03-13 10:59:25 +05:30
Shivam Mishra 3c5cd46d2e refactor: replace count and average timeseries builders with unified ReportBuilder 2026-03-13 10:58:57 +05:30
Shivam Mishra d34da0cbf8 feat: extract raw report queries into DataSource abstraction 2026-03-13 10:58:40 +05:30
Shivam Mishra 32b1e16e74 refactor: add timezone_name_from_params to TimezoneHelper 2026-03-13 10:55:32 +05:30
Shivam Mishra 7e14153784 feat: add rake tasks for rollup backfill and timezone setup 2026-03-13 10:55:19 +05:30
Shivam Mishra 914e70568f feat: add backfill service for historical rollup data 2026-03-13 10:55:10 +05:30
Shivam Mishra 13860b9163 feat: write rollups from reporting event listener 2026-03-13 10:54:43 +05:30
Shivam Mishra 42d7f1768c feat: add rollup service to aggregate events in real time 2026-03-13 10:53:37 +05:30
Shivam Mishra 911f211427 feat: add metric registry mapping events to rollup columns 2026-03-13 10:53:29 +05:30
Shivam Mishra ea5779a712 feat: add reporting_timezone setting to account 2026-03-13 10:53:19 +05:30
Shivam Mishra 9ecf5f2504 feat: add reporting_events_rollup table and model 2026-03-13 10:52:57 +05:30
b103747584 fix: skip Enter key submission during IME composition in AI inputs (#13779)
# Pull Request Template

## Description

CJK language users (Chinese, Japanese, Korean, etc.) use IME where Enter
confirms character selection. AI input components were intercepting
Enter unconditionally, making them unusable for IME users.

Add `event.isComposing` check to CopilotEditor, CopilotInput, and
AssistantPlayground so Enter during active IME composition is ignored.


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

Before:

Add a Japenese keyboard, then go to AI follow-ups, type some word,
selecting it with enter submits the follow up. So CJK users cannot use
follow-ups.



https://github.com/user-attachments/assets/53517432-d97b-47fc-a802-81675e31d5c9



After:

Type a word, press enter to choose it, press enter again to unselect it
and enter again to send


https://github.com/user-attachments/assets/6c2a420b-7ee6-4c71-82a6-d9f1d7bbf31a



## 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:26:51 +05:30
d6d38cdd7d feat: captain decides if conversation should be resolved or kept open (#13336)
# Pull Request Template

## Description

captain decides if conversation should be resolved or open

Fixes
https://linear.app/chatwoot/issue/AI-91/make-captain-resolution-time-configurable

Update: Added 2 entries in reporting events:
`conversation_captain_handoff` and `conversation_captain_resolved`

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

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

LLM call decides that conversation is resolved, drops a private note
<img width="1228" height="438" alt="image"
src="https://github.com/user-attachments/assets/fb2cf1e9-4b2b-458b-a1e2-45c53d6a0158"
/>

LLM call decides conversation is still open as query was not resolved
<img width="1215" height="573" alt="image"
src="https://github.com/user-attachments/assets/2d1d5322-f567-487e-954e-11ab0798d11c"
/>


## 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: Sojan Jose <sojan@pepalo.com>
2026-03-13 10:03:58 +05:30
Sojan JoseandGitHub 199dcd382e fix: Skip redundant contact saves in ContactIdentifyAction (#13778)
When the SDK sends identify calls with identical payloads (common on
every page load), `save!` fires even though no attributes changed. While
Rails skips the actual UPDATE SQL, it still opens a transaction, runs
all callbacks (including validation queries like `Contact Exists?`), and
triggers `after_commit` hooks — all for a no-op.

This adds a `changed?` guard before `save!` to skip it entirely when no
attributes have actually changed.

**How to test**

- Trigger an identify call via the SDK with a contact's existing
attributes (same name, email, custom_attributes, etc.)
- The contact should not fire a save (no transaction, no callbacks)
- Trigger an identify call with a changed attribute — save should work
normally

**What changed**

- `ContactIdentifyAction#update_contact`: guard `save!` with `changed?`
check
- Added specs to verify `save!` is skipped for unchanged params and
avatar job still enqueues independently
2026-03-11 21:40:38 -07:00
PranavandGitHub c6f82783ba chore: Remove message touch:true, use combined update query (#13770)
Removes touch: true from the belongs_to :conversation association on
Message and consolidates the conversation timestamp update into the
existing set_conversation_activity callback.

Previously, every message save triggered two separate UPDATE queries on
the conversation — one from Rails' touch (updating updated_at) and
another from set_conversation_activity (updating last_activity_at). This
combines both into a single update_columns call, reducing write load on
the conversations table on every message creation.

### What changed
- Removed touch: true from belongs_to :conversation in Message
- Added updated_at: created_at to the existing update_columns call in
set_conversation_activity
2026-03-11 07:31:46 -07:00
Tanmay Deep SharmaandGitHub 9b3f0029a4 fix: override minimatch to patch ReDoS vulnerability (#13769)
## Description

Remediates high severity ReDoS vulnerability in minimatch
(CVE-2026-27903) flagged by Vanta/Dependabot.
minimatch is a transitive dev-only dependency (via eslint and
tailwindcss build tooling) — not shipped to production. Added pnpm
overrides to force patched versions:
- minimatch@<4 → 3.1.5
- minimatch@>=9.0.0 <9.0.7 → 9.0.9

Closes:
https://linear.app/chatwoot/issue/CW-6595/vanta-remediate-high-vulnerabilities-identified-in-packages-are

## Type of change

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

## How Has This Been Tested?

- No production impact — minimatch is only used in dev tooling, not at
runtime
- pnpm install completes successfully

## 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-03-11 16:48:48 +05:30
6e46be36c8 fix: Add fix to only allow confirmed agents to used in Agent Assingments at Macros/Automations (#13225)
# Pull Request Template

## Description

Unconfirmed agents (pending email verification) were incorrectly
appearing in the "assign agent" dropdown for macros and automations.
This fix filters out unconfirmed agents from these dropdowns and adds
backend validation to prevent assignment of unconfirmed agents.

Fixes #13223

## Type of change

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

## How Has This Been Tested?

**Backend tests:**
```bash
docker compose run --rm rails bundle exec rspec spec/services/action_service_spec.rb
```
- Added tests for confirmed agent assignment (should succeed)
- Added tests for unconfirmed agent assignment (should be skipped)

**Frontend tests:**
```bash
docker compose run --rm rails pnpm test app/javascript/dashboard/composables/spec/useMacros.spec.js
```
- Updated mocks to use `getVerifiedAgents` getter

**Manual testing:**
1. Create an unconfirmed agent via platform
2. Navigate to Settings → Macros → New Macro → Add "Assign Agent" action
3. Verify unconfirmed agent does NOT appear in dropdown
4. Navigate to Settings → Automations → New Automation → Add "Assign
Agent" action
5. Verify unconfirmed agent does NOT appear in dropdown

## 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: Sojan Jose <sojan@pepalo.com>
2026-03-11 02:01:53 -07:00
Aakash BakhleandGitHub 87f5af4caa fix: playground captain v2 scenarios (#13747)
# Pull Request Template

## Description

Playground now uses v2. It was only wired to use v1. Traces get `source:
playground` on langfuse when playground has been used.

## 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
<img width="1806" height="1276" alt="image"
src="https://github.com/user-attachments/assets/41ef4eb3-52b1-4b8e-9a4f-e8510c90cb39"
/>


## 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-03-11 14:05:16 +05:30
dbe35252bc fix: Use handoff_key for scenarios (#13755)
# Pull Request Template

## Description

Ensure agent function names stay within OpenAI's 64-char limit
(ai-agents prepends "handoff_to_").

Add HANDOFF_TITLE_SLUG_MAX_LENGTH and
handoff_key generation: persisted records use `scenario_{id}_agent`; new
records use a truncated title slug.
Assistant scenario keys and agent_name now reference the generated
handoff key.

fixes :

`Invalid 'messages[9].tool_calls[0].function.name': string too long.
Expected a string with maximum length 64, but got a string with length
95 instead.`

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

Tested locally
<img width="1806" height="1044" alt="image"
src="https://github.com/user-attachments/assets/40cd7a3d-3d97-43a8-bd56-d3f5d63abbda"
/>

## 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: Shivam Mishra <scm.mymail@gmail.com>
2026-03-11 14:01:25 +05:30
Tanmay Deep SharmaandGitHub de8aa48b83 feat: make assignment_v2 feature available to all accounts (#13764)
## Description

Makes the assignment_v2 feature flag available to all installations by
removing the chatwoot_internal restriction. Previously this feature was
hidden from self-hosted installations; this change surfaces it in the
feature flags UI so any Chatwoot instance can enable it.

## Type of change

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

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-11 13:46:53 +05:30
141 changed files with 7249 additions and 1327 deletions
+256
View File
@@ -0,0 +1,256 @@
module.exports = {
extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended',
// use recommended-legacy when upgrading the plugin to v4
'plugin:@intlify/vue-i18n/recommended',
],
overrides: [
{
files: ['**/*.spec.{j,t}s?(x)'],
env: {
'vitest-globals/env': true,
},
},
{
files: ['**/*.story.vue'],
rules: {
'vue/no-undef-components': [
'error',
{
ignorePatterns: ['Variant', 'Story'],
},
],
// Story files can have static strings, it doesn't need to handle i18n always.
'vue/no-bare-strings-in-template': 'off',
'no-console': 'off',
},
},
],
plugins: ['html', 'prettier'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'prettier/prettier': ['error'],
camelcase: 'off',
'no-param-reassign': 'off',
'import/no-extraneous-dependencies': 'off',
'import/prefer-default-export': 'off',
'import/no-named-as-default': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/label-has-associated-control': 'off',
'jsx-a11y/label-has-for': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'import/no-unresolved': 'off',
'vue/html-indent': 'off',
'vue/multi-word-component-names': 'off',
'vue/next-tick-style': ['error', 'callback'],
'vue/block-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
},
],
'vue/component-options-name-casing': ['error', 'PascalCase'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-emits-declaration': ['error'],
'vue/define-macros-order': [
'error',
{
order: ['defineProps', 'defineEmits'],
defineExposeLast: false,
},
],
'vue/define-props-declaration': ['error', 'runtime'],
'vue/match-component-import-name': ['error'],
'vue/no-bare-strings-in-template': [
'error',
{
allowlist: [
'(',
')',
',',
'.',
'&',
'+',
'-',
'=',
'*',
'/',
'#',
'%',
'!',
'?',
':',
'[',
']',
'{',
'}',
'<',
'>',
'⌘',
'📄',
'🎉',
'🚀',
'💬',
'👥',
'📥',
'🔖',
'❌',
'✅',
'\u00b7',
'\u2022',
'\u2010',
'\u2013',
'\u2014',
'\u2212',
'|',
],
attributes: {
'/.+/': [
'title',
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext',
],
input: ['placeholder'],
},
directives: ['v-text'],
},
],
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-root-v-if': 'warn',
'vue/no-static-inline-styles': [
'error',
{
allowBinding: false,
},
],
'vue/no-template-target-blank': [
'error',
{
allowReferrer: false,
enforceDynamicLinks: 'always',
},
],
'vue/no-required-prop-with-default': [
'error',
{
autofix: false,
},
],
'vue/no-this-in-before-route-enter': 'error',
'vue/no-undef-components': [
'error',
{
ignorePatterns: [
'^woot-',
'^fluent-',
'^multiselect',
'^router-link',
'^router-view',
'^ninja-keys',
'^FormulateForm',
'^FormulateInput',
'^highlightjs',
],
},
],
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/no-useless-v-bind': [
'error',
{
ignoreIncludesComment: false,
ignoreStringEscape: false,
},
],
'vue/no-v-text': 'error',
'vue/padding-line-between-blocks': ['error', 'always'],
'vue/prefer-separate-static-class': 'error',
'vue/require-explicit-slots': 'error',
'vue/require-macro-variable-name': [
'error',
{
defineProps: 'props',
defineEmits: 'emit',
defineSlots: 'slots',
useSlots: 'slots',
useAttrs: 'attrs',
},
],
'vue/no-unused-properties': [
'error',
{
groups: ['props'],
deepData: false,
ignorePublicMembers: false,
unreferencedOptions: [],
},
],
'vue/max-attributes-per-line': [
'error',
{
singleline: {
max: 20,
},
multiline: {
max: 1,
},
},
],
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
'vue/no-v-html': 'off',
'vue/component-definition-name-casing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'],
'no-console': 'error',
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
'@intlify/vue-i18n/no-unused-keys': [
'warn',
{
extensions: ['.js', '.vue'],
},
],
},
settings: {
'vue-i18n': {
localeDir: './app/javascript/*/i18n/**.json',
},
},
env: {
browser: true,
node: true,
},
globals: {
bus: true,
vi: true,
},
};
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
@contact.save! if @contact.changed?
enqueue_avatar_job
end
+1
View File
@@ -1,6 +1,7 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
+29 -32
View File
@@ -1,5 +1,6 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include TimezoneHelper
def build
load_data
@@ -9,37 +10,13 @@ class V2::Reports::BaseSummaryBuilder
private
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
results = data_source.summary
def load_reporting_events_data
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
index_key = group_by_key.to_s.split('.').last
results = reporting_events
.select(
"#{group_by_key} as #{index_key}",
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
)
.group(group_by_key)
.index_by { |record| record.public_send(index_key) }
@resolved_count = results.transform_values(&:resolved_count)
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
@conversations_count = results.transform_values { |data| data[:conversations_count] }
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
end
def group_by_key
@@ -50,7 +27,27 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: nil,
dimension_type: summary_dimension_type,
dimension_id: nil,
scope: nil,
range: range,
group_by: 'day',
timezone: timezone_name_from_params(params[:timezone], params[:timezone_offset]),
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def summary_dimension_type
{
'account_id' => 'account',
'user_id' => 'agent',
'inbox_id' => 'inbox',
'conversations.team_id' => 'team'
}.fetch(group_by_key.to_s)
end
end
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
return unless Reports::ReportMetricRegistry.supported?(metric)
V2::Reports::Timeseries::ReportBuilder
end
def log_invalid_metric
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
@@ -7,8 +7,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
@timezone = timezone_name_from_params(params[:timezone], params[:timezone_offset])
end
# rubocop:enable Lint/MissingSuper
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
@@ -1,48 +0,0 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,12 +1,13 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
case dimension_type.to_sym
when :account
account
when :inbox
@@ -20,6 +21,21 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
end
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: params[:metric],
dimension_type: dimension_type,
dimension_id: params[:id],
scope: scope,
range: range,
group_by: group_by,
timezone: timezone,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
@@ -41,6 +57,12 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
@timezone ||= timezone_name_from_params(params[:timezone], params[:timezone_offset])
end
private
def dimension_type
(params[:type].presence || 'account').to_s
end
end
@@ -1,78 +0,0 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.where(
name: :conversation_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_resolutions_count
scope.reporting_events.where(
name: :conversation_bot_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
account_id: account.id,
created_at: range
).distinct
end
def grouped_count
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
# It converts timestamps to the target timezone before grouping, which means
# the same event can fall into different day buckets depending on timezone
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -0,0 +1,9 @@
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
data_source.timeseries
end
def aggregate_value
data_source.aggregate
end
end
@@ -0,0 +1,55 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
identify_contact(contact)
end
@@ -112,6 +112,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:current][:since],
until: range[:current][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -120,6 +121,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -129,6 +131,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone: params[:timezone],
timezone_offset: params[:timezone_offset]
})
end
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder)
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
end
def label
@@ -36,15 +36,18 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
}
@builder_params[:timezone] = permitted_params[:timezone] if permitted_params[:timezone].present?
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
end
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
def render_report_with(builder_class, type: nil)
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
builder = builder_class.new(account: Current.account, params: builder_params)
render json: builder.build
end
def permitted_params
params.permit(:since, :until, :business_hours)
params.permit(:since, :until, :business_hours, :timezone, :timezone_offset)
end
def date_range_too_long?
+6
View File
@@ -1,4 +1,10 @@
module TimezoneHelper
def timezone_name_from_params(timezone, offset)
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
timezone_name_from_offset(offset)
end
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
@@ -13,6 +13,7 @@ class ApiClient {
return `${this.baseUrl()}/${this.resource}`;
}
// eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() {
const isInsideAccountScopedURLs =
window.location.pathname.includes('/app/accounts');
@@ -8,6 +8,7 @@ class CacheEnabledApiClient extends ApiClient {
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
@@ -24,10 +25,12 @@ class CacheEnabledApiClient extends ApiClient {
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data.payload;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: { payload: dataToParse } };
}
@@ -7,6 +7,7 @@ class ChangelogApi extends ApiClient {
super('changelog', { apiVersion: 'v1' });
}
// eslint-disable-next-line class-methods-use-this
fetchFromHub() {
return axios.get(CHANGELOG_API_URL);
}
@@ -7,6 +7,7 @@ class VoiceAPI extends ApiClient {
super('voice', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
initiateCall(contactId, inboxId) {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
+1
View File
@@ -72,6 +72,7 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
return axios.post(requestURL, queryPayload);
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
}
export default new InboxHealthAPI();
+1
View File
@@ -6,6 +6,7 @@ class Inboxes extends CacheEnabledApiClient {
super('inboxes', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
}
+1
View File
@@ -5,6 +5,7 @@ class LabelsAPI extends CacheEnabledApiClient {
super('labels', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
}
+6
View File
@@ -2,6 +2,7 @@
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
class ReportsAPI extends ApiClient {
constructor() {
@@ -26,11 +27,13 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
// eslint-disable-next-line default-param-last
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, {
params: {
@@ -40,6 +43,7 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
@@ -104,6 +108,8 @@ class ReportsAPI extends ApiClient {
type: 'account',
group_by: groupBy,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -2,6 +2,8 @@ import reportsAPI from '../reports';
import ApiClient from '../ApiClient';
describe('#Reports API', () => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
it('creates correct instance', () => {
expect(reportsAPI).toBeInstanceOf(ApiClient);
expect(reportsAPI.apiVersion).toBe('/api/v2');
@@ -46,6 +48,7 @@ describe('#Reports API', () => {
since: 1621103400,
until: 1621621800,
type: 'account',
timezone,
timezone_offset: -0,
},
});
@@ -59,6 +62,7 @@ describe('#Reports API', () => {
group_by: undefined,
id: undefined,
since: 1621103400,
timezone,
timezone_offset: -0,
type: 'account',
until: 1621621800,
@@ -140,6 +144,8 @@ describe('#Reports API', () => {
type: 'account',
group_by: 'date',
business_hours: true,
timezone,
timezone_offset: -0,
},
}
);
@@ -0,0 +1,47 @@
import summaryReportsAPI from '../summaryReports';
import ApiClient from '../ApiClient';
describe('#Summary Reports API', () => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
it('creates correct instance', () => {
expect(summaryReportsAPI).toBeInstanceOf(ApiClient);
expect(summaryReportsAPI.apiVersion).toBe('/api/v2');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('includes timezone data in summary report requests', () => {
summaryReportsAPI.getAgentReports({
since: 1621103400,
until: 1621621800,
businessHours: true,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v2/summary_reports/agent',
{
params: {
since: 1621103400,
until: 1621621800,
business_hours: true,
timezone,
timezone_offset: -0,
},
}
);
});
});
});
@@ -1,6 +1,9 @@
/* global axios */
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
@@ -12,6 +15,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -22,6 +27,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -32,6 +39,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
@@ -42,6 +51,8 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone: getTimeZone(),
timezone_offset: getTimeOffset(),
},
});
}
+3
View File
@@ -7,14 +7,17 @@ export class TeamsAPI extends CacheEnabledApiClient {
super('teams', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'team';
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: dataToParse };
}
@@ -56,6 +56,7 @@ const state = reactive({ ...initialState });
const urlValidators = {
shouldBeAValidURLPattern: value => {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch {
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
<EmptyStateLayout
:title="$t('HELP_CENTER.TITLE')"
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
class="bg-n-surface-1"
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px">
@@ -18,10 +18,18 @@ const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
return messages.value.map(message => {
const payload = {
role: message.sender,
content: message.content,
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
};
const resetConversation = () => {
@@ -62,6 +70,7 @@ const sendMessage = async () => {
messages.value.push({
content: data.response,
sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(),
});
} catch (error) {
@@ -71,6 +80,12 @@ const sendMessage = async () => {
isLoading.value = false;
}
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script>
<template>
@@ -104,7 +119,7 @@ const sendMessage = async () => {
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<NextButton
ghost
@@ -29,6 +29,12 @@ const handleInput = () => {
nextTick(adjustHeight);
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => {
nextTick(adjustHeight);
});
@@ -43,7 +49,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -99,6 +99,7 @@ import { useBranding } from 'shared/composables/useBranding';
* @property {number} inboxId - The ID of the inbox to which the message belongs
*/
// eslint-disable-next-line vue/define-macros-order
const props = defineProps({
id: { type: Number, required: true },
messageType: {
@@ -123,12 +124,12 @@ const props = defineProps({
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, default: null },
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false },
additionalAttributes: { type: Object, default: () => ({}) },
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
sender: { type: Object, default: null },
senderId: { type: Number, default: null },
senderType: { type: String, default: null },
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
@@ -18,7 +18,9 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
@@ -35,7 +35,8 @@ export default {
default: '00:00',
},
// inbox prop is used in /mixins/inboxMixin,
// remove this props when refactoring to composable if not neededs
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
inbox: {
type: Object,
default: () => ({}),
@@ -1,6 +1,7 @@
import lamejs from '@breezystack/lamejs';
const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
@@ -28,8 +29,9 @@ const bufferToWav = async (buffer, numChannels, sampleRate) => {
// WAV Data
const offset = 44;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < buffer.length; i++) {
// eslint-disable-next-line no-plusplus
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(
-1,
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'agents/getVerifiedAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
@@ -111,7 +111,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
'agents/getVerifiedAgents': { value: mockAgents },
});
});
@@ -167,7 +167,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
'agents/getVerifiedAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
@@ -20,7 +20,7 @@ import {
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const agents = useMapGetter('agents/getVerifiedAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
@@ -13,7 +13,7 @@ export const useMacros = () => {
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
/**
* Get dropdown values based on the specified type
@@ -37,10 +37,12 @@ class ActionCableConnector extends BaseActionCableConnector {
};
}
// eslint-disable-next-line class-methods-use-this
onReconnect = () => {
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
};
// eslint-disable-next-line class-methods-use-this
onDisconnected = () => {
emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT);
};
@@ -88,6 +90,7 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('updateConversation', data);
};
// eslint-disable-next-line class-methods-use-this
onLogout = () => AuthAPI.logout();
onMessageCreated = data => {
@@ -103,6 +106,7 @@ class ActionCableConnector extends BaseActionCableConnector {
});
};
// eslint-disable-next-line class-methods-use-this
onReload = () => window.location.reload();
onStatusChange = data => {
@@ -157,6 +161,7 @@ class ActionCableConnector extends BaseActionCableConnector {
}, 30000);
};
// eslint-disable-next-line class-methods-use-this
fetchConversationStats = () => {
emitter.emit('fetch_conversation_stats');
};
@@ -212,8 +212,9 @@ export const isCustomAttribute = (attrs, key) => {
};
export const generateCustomAttributes = (
// eslint-disable-next-line default-param-last
conversationAttributes = [],
// eslint-disable-next-line default-param-last
contactAttributes = [],
conversationlabel,
contactlabel
@@ -61,6 +61,7 @@ export class EmailQuoteExtractor {
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
for (const selector of QUOTE_INDICATORS) {
if (tempDiv.querySelector(selector)) {
return true;
@@ -18,6 +18,7 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => {
.register('/sw.js')
.then(registration => callback(registration))
.catch(registrationError => {
// eslint-disable-next-line
console.log('SW registration failed: ', registrationError);
});
};
@@ -68,6 +69,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
onSuccess();
})
.catch(error => {
// eslint-disable-next-line no-console
console.error('Push subscription registration failed:', error);
useAlert('This browser does not support desktop notification');
});
@@ -75,6 +77,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
// eslint-disable-next-line no-console
console.warn('Notification is not supported');
useAlert('This browser does not support desktop notification');
} else if (Notification.permission === 'granted') {
@@ -288,9 +288,10 @@ describe('templateHelper', () => {
});
it('should handle special characters in template variables', () => {
/* eslint-disable no-template-curly-in-string */
const templateText =
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
/* eslint-enable no-template-curly-in-string */
const processedParams = {
body: {
user_name: 'John & Jane',
@@ -685,6 +685,16 @@
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
},
"WEBHOOK": {
"TITLE": "Webhook Configuration",
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
"ACTION_REQUIRED": "Webhook not configured",
"REGISTER_BUTTON": "Register Webhook",
"REGISTER_SUCCESS": "Webhook registered successfully",
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
"CONFIGURED_SUCCESS": "Webhook configured successfully",
"URL_MISMATCH": "Webhook URL mismatch"
}
},
"SETTINGS": "Settings",
@@ -99,6 +99,7 @@ export default {
healthData: null,
isLoadingHealth: false,
healthError: null,
isRegisteringWebhook: false,
widgetBubblePosition: 'right',
widgetBubbleType: 'standard',
widgetBubbleLauncherTitle: '',
@@ -424,6 +425,23 @@ export default {
this.isLoadingHealth = false;
}
},
async registerWebhook() {
if (!this.inbox) return;
try {
this.isRegisteringWebhook = true;
await InboxHealthAPI.registerWebhook(this.inbox.id);
useAlert(this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_SUCCESS'));
await this.fetchHealthData();
} catch (error) {
useAlert(
error.message ||
this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_ERROR')
);
} finally {
this.isRegisteringWebhook = false;
}
},
handleFeatureFlag(e) {
this.selectedFeatureFlags = this.toggleInput(
this.selectedFeatureFlags,
@@ -1162,7 +1180,11 @@ export default {
<BotConfiguration :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'whatsapp-health'">
<AccountHealth :health-data="healthData" />
<AccountHealth
:health-data="healthData"
:is-registering-webhook="isRegisteringWebhook"
@register-webhook="registerWebhook"
/>
</div>
</div>
</section>
@@ -1,4 +1,5 @@
<script>
/* eslint-env browser */
/* global FB */
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
@@ -10,8 +10,14 @@ const props = defineProps({
type: Object,
default: null,
},
isRegisteringWebhook: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['registerWebhook']);
const { t } = useI18n();
const QUALITY_COLORS = {
@@ -133,6 +139,28 @@ const formatModeDisplay = mode =>
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
const showWebhookSection = computed(
() => props.healthData?.webhook_configuration !== undefined
);
const webhookUrl = computed(
() =>
props.healthData?.webhook_configuration?.whatsapp_business_account ||
props.healthData?.webhook_configuration?.application
);
const webhookConfigured = computed(() => !!webhookUrl.value);
const webhookUrlMismatch = computed(
() =>
webhookConfigured.value &&
webhookUrl.value !== props.healthData?.expected_webhook_url
);
const handleRegisterWebhook = () => {
emit('registerWebhook');
};
</script>
<template>
@@ -211,6 +239,55 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
}}</span>
</div>
</div>
<!-- Webhook configuration card -->
<div
v-if="showWebhookSection"
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
>
<div class="flex gap-2 items-center">
<span class="text-body-main font-medium text-n-slate-11">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.TITLE') }}
</span>
<Icon
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.DESCRIPTION')"
icon="i-lucide-info"
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
/>
</div>
<div class="flex items-center justify-between gap-3">
<span
v-if="webhookConfigured && !webhookUrlMismatch"
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
>
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
</span>
<span
v-else
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
>
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
{{
webhookUrlMismatch
? t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.URL_MISMATCH')
: t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.ACTION_REQUIRED')
}}
</span>
<ButtonV4
v-if="!webhookConfigured || webhookUrlMismatch"
sm
solid
blue
:loading="isRegisteringWebhook"
:disabled="isRegisteringWebhook"
class="flex-shrink-0"
@click="handleRegisterWebhook"
>
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
</ButtonV4>
</div>
</div>
</div>
<div v-else class="pt-8">
@@ -10,6 +10,7 @@ export const getAudioContext = () => {
return audioCtx;
};
// eslint-disable-next-line default-param-last
export const getAlertAudio = async (baseUrl = '', requestContext) => {
const audioCtx = getAudioContext();
const playSound = audioBuffer => {
@@ -33,6 +34,7 @@ export const getAlertAudio = async (baseUrl = '', requestContext) => {
.then(response => response.arrayBuffer())
.then(buffer => {
audioCtx.decodeAudioData(buffer).then(playSound);
// eslint-disable-next-line no-promise-executor-return
return new Promise(res => res());
})
.catch(() => {
@@ -74,8 +74,10 @@ class BaseActionCableConnector {
}, RECONNECT_INTERVAL);
};
// eslint-disable-next-line class-methods-use-this
onReconnect = () => {};
// eslint-disable-next-line class-methods-use-this
onDisconnected = () => {};
disconnect() {
@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
export class DuplicateContactException extends Error {
constructor(data) {
super('DUPLICATE_CONTACT');
@@ -48,6 +48,7 @@ export default {
}`;
},
avatarUrl() {
// eslint-disable-next-line
const displayImage = this.useInboxAvatarForBot
? this.inboxAvatarUrl
: '/assets/images/chatwoot_bot.png';
@@ -96,6 +96,7 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('agent/updatePresence', data.users);
};
// eslint-disable-next-line class-methods-use-this
onContactMerge = data => {
const { pubsub_token: pubsubToken } = data;
ActionCableConnector.refreshConnector(pubsubToken);
+2
View File
@@ -1,6 +1,8 @@
const getUuid = () =>
'xxxxxxxx4xxx'.replace(/[xy]/g, c => {
// eslint-disable-next-line
const r = (Math.random() * 16) | 0;
// eslint-disable-next-line
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
+57 -13
View File
@@ -3,23 +3,25 @@ class ReportingEventListener < BaseListener
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
event_end_time = event.timestamp
time_to_resolve = event_end_time.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new(
name: 'conversation_resolved',
value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
conversation.updated_at),
event_end_time),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
create_bot_resolved_event(conversation, reporting_event)
reporting_event.save!
safe_rollup(reporting_event)
end
def first_reply_created(event)
@@ -41,6 +43,7 @@ class ReportingEventListener < BaseListener
)
reporting_event.save!
safe_rollup(reporting_event)
end
def reply_created(event)
@@ -65,45 +68,59 @@ class ReportingEventListener < BaseListener
event_end_time: message.created_at
)
reporting_event.save!
safe_rollup(reporting_event)
end
def conversation_bot_handoff(event)
conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# check if a conversation_bot_handoff event exists for this conversation
# Best-effort guard: raw report reads count bot handoffs with DISTINCT conversation_id,
# while rollup counts assume one conversation_bot_handoff event per conversation.
# That uniqueness is not currently enforced at the database level.
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
return if bot_handoff_event.present?
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
time_to_handoff = event_end_time.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new(
name: 'conversation_bot_handoff',
value: time_to_handoff,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, event_end_time),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
reporting_event.save!
safe_rollup(reporting_event)
end
def conversation_captain_inference_resolved(event)
create_captain_inference_event(event, 'conversation_captain_inference_resolved')
end
def conversation_captain_inference_handoff(event)
create_captain_inference_event(event, 'conversation_captain_inference_handoff')
end
def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# Find the most recent resolved event for this conversation
last_resolved_event = ReportingEvent.where(
conversation_id: conversation.id,
name: 'conversation_resolved'
).order(event_end_time: :desc).first
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
# For first-time openings, value is 0
# For reopenings, calculate time since resolution
if last_resolved_event
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
start_time = last_resolved_event.event_end_time
else
time_since_resolved = 0
@@ -111,12 +128,12 @@ class ReportingEventListener < BaseListener
start_time = conversation.created_at
end
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
end
private
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
reporting_event = ReportingEvent.new(
name: 'conversation_opened',
value: time_since_resolved,
@@ -126,11 +143,27 @@ class ReportingEventListener < BaseListener
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: start_time,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
reporting_event.save!
end
def create_captain_inference_event(event, event_name)
conversation = extract_conversation_and_account(event)[0]
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
ReportingEvent.create!(
name: event_name,
value: time_to_event,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: event.timestamp
)
end
def create_bot_resolved_event(conversation, reporting_event)
return unless conversation.inbox.active_bot?
# We don't want to create a bot_resolved event if there is user interaction on the conversation
@@ -139,5 +172,16 @@ class ReportingEventListener < BaseListener
bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.save!
safe_rollup(bot_resolved_event)
end
def safe_rollup(reporting_event)
# Rollups are derived from the raw reporting event. If a transient rollup write
# failure bubbles out here, Sidekiq retries the dispatcher job and can insert the
# same raw event again. That can temporarily under-report rollups, but the source
# event is preserved and rollup data can be rebuilt or re-applied later.
ReportingEvents::RollupService.perform(reporting_event)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: reporting_event.account).capture_exception
end
end
+11 -2
View File
@@ -41,7 +41,7 @@ class Account < ApplicationRecord
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
@@ -85,13 +85,16 @@ class Account < ApplicationRecord
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
validate :validate_reporting_timezone
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_disable_auto_resolve
store_accessor :settings, :captain_auto_resolve_mode
include AccountCaptainAutoResolve
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -214,6 +217,12 @@ class Account < ApplicationRecord
# method overridden in enterprise module
end
def validate_reporting_timezone
return if reporting_timezone.blank? || ActiveSupport::TimeZone[reporting_timezone].present?
errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid'))
end
def remove_account_sequences
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
@@ -0,0 +1,21 @@
module AccountCaptainAutoResolve
extend ActiveSupport::Concern
VALID_CAPTAIN_AUTO_RESOLVE_MODES = %w[evaluated legacy disabled].freeze
included do
VALID_CAPTAIN_AUTO_RESOLVE_MODES.each do |mode|
define_method("captain_auto_resolve_#{mode}?") do
captain_auto_resolve_mode == mode
end
end
end
def captain_auto_resolve_mode
mode = settings&.[]('captain_auto_resolve_mode')
return mode if VALID_CAPTAIN_AUTO_RESOLVE_MODES.include?(mode)
return 'disabled' if settings&.[]('captain_disable_auto_resolve') == true
feature_enabled?('captain_tasks') ? 'evaluated' : 'legacy'
end
end
+10 -3
View File
@@ -9,9 +9,9 @@ module AutoAssignmentHandler
private
def run_auto_assignment
# Round robin kicks in on conversation create & update
# run it only when conversation status changes to open
return unless conversation_status_changed_to_open?
# Assignment V2: Also trigger assignment when conversation is resolved or snoozed,
# bypassing the open-only condition so the AssignmentJob can redistribute capacity.
return unless conversation_status_changed_to_open? || conversation_status_changed_to_resolved_or_snoozed?
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
@@ -25,6 +25,10 @@ module AutoAssignmentHandler
end
end
def conversation_status_changed_to_resolved_or_snoozed?
inbox.auto_assignment_v2_enabled? && saved_change_to_status? && (resolved? || snoozed?)
end
def team_member_ids_with_capacity
return [] if team.blank? || team.allow_auto_assign.blank?
@@ -33,6 +37,9 @@ module AutoAssignmentHandler
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
# Assignment V2: Resolved/snoozed conversations still have an assignee, so bypass the
# assignee-blank check below. The AssignmentJob needs to run to rebalance assignments.
return true if conversation_status_changed_to_resolved_or_snoozed?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
+19 -13
View File
@@ -81,6 +81,8 @@ class Message < ApplicationRecord
# when you have a temperory id in your frontend and want it echoed back via action cable
attr_accessor :echo_id
# Transient flag used to skip waiting_since clearing for specific bot/system messages.
attr_accessor :preserve_waiting_since
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
enum content_type: {
@@ -125,7 +127,7 @@ class Message < ApplicationRecord
belongs_to :account
belongs_to :inbox
belongs_to :conversation, touch: true
belongs_to :conversation
belongs_to :sender, polymorphic: true, optional: true
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
end
def update_waiting_since
waiting_present = conversation.waiting_since.present?
clear_waiting_since_on_outgoing_response if conversation.waiting_since.present? && !private
set_waiting_since_on_incoming_message
end
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
def clear_waiting_since_on_outgoing_response
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
return
end
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil) if bot_response? && !preserve_waiting_since
end
def set_waiting_since_on_incoming_message
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -429,7 +435,7 @@ class Message < ApplicationRecord
def set_conversation_activity
# rubocop:disable Rails/SkipsModelValidations
conversation.update_columns(last_activity_at: created_at)
conversation.update_columns(last_activity_at: created_at, updated_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
end
+48
View File
@@ -0,0 +1,48 @@
# == Schema Information
#
# Table name: reporting_events_rollups
#
# id :bigint not null, primary key
# count :bigint default(0), not null
# date :date not null
# dimension_id :bigint not null
# dimension_type :string not null
# metric :string not null
# sum_value :float default(0.0), not null
# sum_value_business_hours :float default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_rollup_summary (account_id,dimension_type,date)
# index_rollup_timeseries (account_id,metric,date)
# index_rollup_unique_key (account_id,date,dimension_type,dimension_id,metric) UNIQUE
#
class ReportingEventsRollup < ApplicationRecord
belongs_to :account
# Store string values directly in the database for better readability and debugging
enum :dimension_type, %w[account agent inbox team].index_by(&:itself)
enum :metric, %w[
resolutions_count
first_response
resolution_time
reply_time
bot_resolutions_count
bot_handoffs_count
].index_by(&:itself)
validates :account_id, presence: true
validates :date, presence: true
validates :dimension_type, presence: true
validates :dimension_id, presence: true
validates :metric, presence: true
validates :count, numericality: { greater_than_or_equal_to: 0 }
scope :for_date_range, ->(start_date, end_date) { where(date: start_date..end_date) }
scope :for_dimension, ->(type, id) { where(dimension_type: type, dimension_id: id) }
scope :for_metric, ->(metric) { where(metric: metric) }
end
+3 -1
View File
@@ -47,7 +47,9 @@ class ActionService
@agent = @account.users.find_by(id: agent_ids)
@conversation.update!(assignee_id: @agent.id) if @agent.present?
return unless @agent.present? && @agent.confirmed?
@conversation.update!(assignee_id: @agent.id)
end
def remove_label(labels)
@@ -0,0 +1,142 @@
# frozen_string_literal: true
class ReportingEvents::BackfillService
DIMENSIONS = [
{ type: 'account', group_column: nil },
{ type: 'agent', group_column: :user_id },
{ type: 'inbox', group_column: :inbox_id }
].freeze
# TODO: Move this to EventMetricRegistry when we expand distinct-counting support.
# The live path already guards uniqueness in ReportingEventListener#conversation_bot_handoff,
# but historical duplicates can exist since it's not enforced at the DB level.
# These events are queried per-dimension (not group-then-sum) because COUNT(DISTINCT) is not additive.
DISTINCT_COUNT_EVENTS = %w[conversation_bot_handoff].freeze
DISTINCT_COUNT_SQL = Arel.sql('COUNT(DISTINCT conversation_id)')
def self.backfill_date(account, date)
new(account, date).perform
end
def initialize(account, date)
@account = account
@date = date
end
def perform
start_utc, end_utc = date_boundaries_in_utc
rollup_rows = build_rollup_rows(start_utc, end_utc)
ReportingEventsRollup.transaction do
delete_existing_rollups
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
end
end
private
def delete_existing_rollups
ReportingEventsRollup.where(account_id: @account.id, date: @date).delete_all
end
def date_boundaries_in_utc
tz = ActiveSupport::TimeZone[@account.reporting_timezone]
start_in_tz = tz.parse(@date.to_s)
end_in_tz = start_in_tz + 1.day
[start_in_tz.utc, end_in_tz.utc]
end
def build_rollup_rows(start_utc, end_utc)
aggregates = build_aggregates(start_utc, end_utc)
aggregates.map do |(dimension_type, dimension_id, metric), data|
{
account_id: @account.id,
date: @date,
dimension_type: dimension_type,
dimension_id: dimension_id,
metric: metric,
count: data[:count],
sum_value: data[:sum_value],
sum_value_business_hours: data[:sum_value_business_hours],
created_at: Time.current,
updated_at: Time.current
}
end
end
def build_aggregates(start_utc, end_utc)
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
standard_names = ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS
base = @account.reporting_events.where(created_at: start_utc...end_utc)
DIMENSIONS.each do |dimension|
aggregate_standard_events(aggregates, base.where(name: standard_names), dimension)
aggregate_distinct_events(aggregates, base.where(name: DISTINCT_COUNT_EVENTS), dimension)
end
aggregates
end
def aggregate_standard_events(aggregates, scope, dimension)
group_cols, selects = dimension_groups_and_selects(dimension)
scope.group(*group_cols).pluck(*selects).each do |row|
event_name, dimension_id, count, sum_value, sum_value_business_hours = unpack_row(row, dimension)
next if dimension_id.nil?
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
{ count: count, sum_value: sum_value, sum_value_business_hours: sum_value_business_hours })
end
end
def accumulate_metrics(aggregates, dimension_type, dimension_id, event_name, values)
ReportingEvents::EventMetricRegistry.metrics_for_aggregate(event_name, **values).each do |metric, metric_data|
key = [dimension_type, dimension_id, metric]
aggregates[key][:count] += metric_data[:count]
aggregates[key][:sum_value] += metric_data[:sum_value].to_f
aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f
end
end
def aggregate_distinct_events(aggregates, scope, dimension)
return if DISTINCT_COUNT_EVENTS.empty?
group_cols = dimension[:group_column] ? [:name, dimension[:group_column]] : [:name]
scope.group(*group_cols).pluck(*group_cols, DISTINCT_COUNT_SQL).each do |row|
event_name, dimension_id, count = dimension[:group_column] ? row : [row[0], @account.id, row[1]]
next if dimension_id.nil?
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
{ count: count, sum_value: 0, sum_value_business_hours: 0 })
end
end
def dimension_groups_and_selects(dimension)
agg_selects = [Arel.sql('COUNT(*)'), Arel.sql('COALESCE(SUM(value), 0)'), Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')]
if dimension[:group_column]
[[:name, dimension[:group_column]], [:name, dimension[:group_column], *agg_selects]]
else
[[:name], [:name, *agg_selects]]
end
end
def unpack_row(row, dimension)
if dimension[:group_column]
# [name, dimension_id, count, sum_value, sum_value_business_hours]
row
else
# [name, count, sum_value, sum_value_business_hours] → inject account id
[row[0], @account.id, row[1], row[2], row[3]]
end
end
def bulk_insert_rollups(rollup_rows)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.insert_all(rollup_rows)
# rubocop:enable Rails/SkipsModelValidations
end
end
@@ -0,0 +1,79 @@
module ReportingEvents::EventMetricRegistry
# Describes one rollup metric emitted by a raw reporting event.
# rollup_metric: metric name stored in reporting_events_rollups.
# payload_kind: whether the emitted row carries only a count or a duration payload.
Metric = Data.define(:rollup_metric, :payload_kind)
EVENTS = {
conversation_resolved: [
Metric.new(rollup_metric: :resolutions_count, payload_kind: :count),
Metric.new(rollup_metric: :resolution_time, payload_kind: :duration)
].freeze,
first_response: [
Metric.new(rollup_metric: :first_response, payload_kind: :duration)
].freeze,
reply_time: [
Metric.new(rollup_metric: :reply_time, payload_kind: :duration)
].freeze,
conversation_bot_resolved: [
Metric.new(rollup_metric: :bot_resolutions_count, payload_kind: :count)
].freeze,
conversation_bot_handoff: [
Metric.new(rollup_metric: :bot_handoffs_count, payload_kind: :count)
].freeze
}.freeze
module_function
def event_names
EVENTS.keys.map(&:to_s)
end
def metrics_for(event)
return {} if event.blank?
metrics_for_aggregate(
event.name,
count: 1,
sum_value: event.try(:value),
sum_value_business_hours: event.try(:value_in_business_hours)
)
end
def metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
return {} if event_name.blank?
values = {
count: count.to_i,
sum_value: sum_value.to_f,
sum_value_business_hours: sum_value_business_hours.to_f
}
EVENTS.fetch(event_name.to_sym, []).to_h do |metric|
[metric.rollup_metric, metric_values(metric.payload_kind, values)]
end
end
private_class_method def metric_values(payload_kind, values)
case payload_kind
when :count
count_values(values[:count])
when :duration
duration_values(values)
else
raise ArgumentError, "Unknown metric payload kind: #{payload_kind.inspect}"
end
end
private_class_method def count_values(count)
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
end
private_class_method def duration_values(values)
{
count: values[:count],
sum_value: values[:sum_value],
sum_value_business_hours: values[:sum_value_business_hours]
}
end
end
@@ -0,0 +1,107 @@
# Raw reporting events and rollup rows do not share a single metric namespace; this registry keeps write and read paths aligned.
# TODO: Split this into separate registries for raw event mappings and report metric definitions.
module ReportingEvents::MetricRegistry
# Maps report summary response keys to the metric definitions they read from.
SUMMARY_METRICS = {
resolutions_count: :resolved_conversations_count,
avg_resolution_time: :avg_resolution_time,
avg_first_response_time: :avg_first_response_time,
reply_time: :avg_reply_time
}.freeze
# Expands each raw reporting event into the rollup metric payloads persisted for aggregation.
EVENT_METRICS = {
'conversation_resolved' => lambda do |values|
{
resolutions_count: count_metric(values[:count]),
resolution_time: duration_metric(values)
}
end,
'first_response' => ->(values) { { first_response: duration_metric(values) } },
'reply_time' => ->(values) { { reply_time: duration_metric(values) } },
'conversation_bot_resolved' => ->(values) { { bot_resolutions_count: count_metric(values[:count]) } },
'conversation_bot_handoff' => ->(values) { { bot_handoffs_count: count_metric(values[:count]) } }
}.freeze
# Describes which report metrics are supported and how each one is sourced and aggregated.
REPORT_METRICS = {
conversations_count: { aggregate: :count }.freeze,
incoming_messages_count: { aggregate: :count }.freeze,
outgoing_messages_count: { aggregate: :count }.freeze,
avg_first_response_time: { raw_event_name: :first_response, rollup_metric: :first_response, aggregate: :average }.freeze,
avg_resolution_time: { raw_event_name: :conversation_resolved, rollup_metric: :resolution_time, aggregate: :average }.freeze,
reply_time: { raw_event_name: :reply_time, rollup_metric: :reply_time, aggregate: :average }.freeze,
resolutions_count: { raw_event_name: :conversation_resolved, rollup_metric: :resolutions_count, aggregate: :count }.freeze,
bot_resolutions_count: { raw_event_name: :conversation_bot_resolved, rollup_metric: :bot_resolutions_count, aggregate: :count }.freeze,
bot_handoffs_count: { raw_event_name: :conversation_bot_handoff, rollup_metric: :bot_handoffs_count, aggregate: :count,
raw_count_strategy: :distinct_conversation }.freeze
}.freeze
module_function
def event_metrics_for(event)
return {} if event.blank?
return {} unless EVENT_METRICS.key?(event.name.to_s)
event_metrics_for_aggregate(
event.name,
count: 1,
sum_value: event.try(:value),
sum_value_business_hours: event.try(:value_in_business_hours)
)
end
def event_metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
values = {
count: count.to_i,
sum_value: sum_value.to_f,
sum_value_business_hours: sum_value_business_hours.to_f
}
EVENT_METRICS[event_name.to_s]&.call(values) || {}
end
def report_metric(metric)
return if metric.blank?
REPORT_METRICS[metric.to_sym]
end
def supported_metric?(metric)
report_metric(metric).present?
end
def aggregate_for(metric)
report_metric(metric)&.dig(:aggregate)
end
def rollup_supported_metric?(metric)
rollup_metric_for(metric).present?
end
def rollup_metric_for(metric)
report_metric(metric)&.dig(:rollup_metric)
end
def raw_event_name_for(metric)
report_metric(metric)&.dig(:raw_event_name)
end
def summary_metrics
SUMMARY_METRICS.map do |metric_name, summary_key|
report_metric(metric_name).merge(metric_name: metric_name, summary_key: summary_key)
end
end
private_class_method def count_metric(count)
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
end
private_class_method def duration_metric(values)
{
count: values[:count],
sum_value: values[:sum_value],
sum_value_business_hours: values[:sum_value_business_hours]
}
end
end
@@ -0,0 +1,81 @@
class ReportingEvents::RollupService
def self.perform(reporting_event)
new(reporting_event).perform
end
def initialize(reporting_event)
@reporting_event = reporting_event
@account = reporting_event.account
end
def perform
return unless rollup_enabled?
rows = build_rollup_rows
return if rows.empty?
upsert_rollups(rows)
end
private
# NOTE: This is intentionally not gated by the reporting_events_rollup feature flag.
# Rollup data is collected for all accounts with a valid reporting timezone (soft toggle).
# The feature flag only controls the read path — whether reports query rollups or raw events.
def rollup_enabled?
@account.reporting_timezone.present? && ActiveSupport::TimeZone[@account.reporting_timezone].present?
end
def event_date
@event_date ||= @reporting_event.created_at.in_time_zone(@account.reporting_timezone).to_date
end
def dimensions
{
account: @account.id,
agent: @reporting_event.user_id,
inbox: @reporting_event.inbox_id
}
end
def build_rollup_rows
event_metrics = ReportingEvents::EventMetricRegistry.metrics_for(@reporting_event)
dimensions.each_with_object([]) do |(dimension_type, dimension_id), rows|
next if dimension_id.nil?
event_metrics.each do |metric, metric_data|
rows << rollup_attributes(dimension_type, dimension_id, metric, metric_data)
end
end
end
def upsert_rollups(rows)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.upsert_all(
rows,
unique_by: [:account_id, :date, :dimension_type, :dimension_id, :metric],
on_duplicate: upsert_on_duplicate_sql
)
# rubocop:enable Rails/SkipsModelValidations
end
def rollup_attributes(dimension_type, dimension_id, metric, metric_data)
{
account_id: @account.id, date: event_date,
dimension_type: dimension_type, dimension_id: dimension_id, metric: metric,
count: metric_data[:count], sum_value: metric_data[:sum_value].to_f,
sum_value_business_hours: metric_data[:sum_value_business_hours].to_f,
created_at: Time.current, updated_at: Time.current
}
end
def upsert_on_duplicate_sql
Arel.sql(
'count = reporting_events_rollups.count + EXCLUDED.count, ' \
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
'updated_at = EXCLUDED.updated_at'
)
end
end
+106
View File
@@ -0,0 +1,106 @@
class Reports::DataSource
SUPPORTED_ROLLUP_DIMENSIONS = %w[account agent inbox].freeze
attr_reader :account, :metric, :dimension_type, :dimension_id,
:scope, :range, :group_by, :timezone,
:timezone_offset, :business_hours
class << self
def for(**context)
adapter_class_for(**context).new(**context)
end
def rollup_eligible?(**context)
account = context[:account]
rollup_enabled_for_account?(account) &&
!hourly_grouping?(context[:group_by]) &&
supported_dimension?(context[:dimension_type]) &&
timezone_matches_account?(account, context[:timezone], context[:timezone_offset]) &&
supported_metric?(context[:metric])
end
def timezone_matches_account?(account, timezone, timezone_offset)
return normalized_timezone_identifier(timezone) == normalized_timezone_identifier(account.reporting_timezone) if timezone.present?
return false if timezone_offset.blank?
offset_in_seconds = timezone_offset.to_f * 3600
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
account_zone&.now&.utc_offset == offset_in_seconds
end
private
def adapter_class_for(**context)
rollup_eligible?(**context) ? Reports::RollupDataSource : Reports::RawDataSource
end
def rollup_enabled_for_account?(account)
account.reporting_timezone.present? && account.feature_enabled?(:report_rollup)
end
def hourly_grouping?(group_by)
group_by.to_s == 'hour'
end
def supported_dimension?(dimension_type)
SUPPORTED_ROLLUP_DIMENSIONS.include?((dimension_type.presence || 'account').to_s)
end
def supported_metric?(metric)
metric.blank? || Reports::ReportMetricRegistry.rollup_supported?(metric)
end
def normalized_timezone_identifier(timezone)
ActiveSupport::TimeZone[timezone]&.tzinfo&.name
end
end
def initialize(**context)
@account = context[:account]
@metric = context[:metric]
@dimension_type = (context[:dimension_type].presence || 'account').to_s
@dimension_id = context[:dimension_id]
@scope = context[:scope]
@range = context[:range]
@group_by = context[:group_by].to_s.presence || 'day'
@timezone = context[:timezone]
@timezone_offset = context[:timezone_offset]
@business_hours = context[:business_hours]
end
private
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def average_metric?
report_metric&.average?
end
def count_metric?
!average_metric?
end
def rollup_metric
report_metric&.rollup_metric
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def summary_metrics
@summary_metrics ||= Reports::ReportMetricRegistry.summary_metrics
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(business_hours)
end
end
+156
View File
@@ -0,0 +1,156 @@
class Reports::RawDataSource < Reports::DataSource
def timeseries
average_metric? ? average_timeseries : count_timeseries
end
def aggregate
average_metric? ? average_scope.average(average_value_key) : count_scope.count
end
def summary
metric_results = summary_scope
.select(*summary_select_fields)
.group(summary_group_by_key)
.index_by { |record| record.public_send(summary_index_key) }
merge_summary_results(metric_results, summary_conversation_counts)
end
private
def count_timeseries
grouped_count.map do |event_date, event_count|
{ value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def average_timeseries
grouped_average_time = grouped_average_scope.average(average_value_key)
grouped_event_count = grouped_average_scope.count
grouped_average_time.each_with_object([]) do |(event_date, average_time), results|
results << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def grouped_average_scope
average_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def grouped_count
count_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
def average_scope
scope.reporting_events.where(name: raw_event_name, created_at: range, account_id: account.id)
end
def count_scope
case metric.to_s
when 'conversations_count'
scope.conversations.where(account_id: account.id, created_at: range)
when 'incoming_messages_count'
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
when 'outgoing_messages_count'
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
else
reporting_event_count_scope
end
end
def reporting_event_count_scope
events = scope.reporting_events.where(
name: raw_event_name,
account_id: account.id,
created_at: range
)
return events unless raw_count_strategy == :distinct_conversation
events.joins(:conversation).select(:conversation_id).distinct
end
def summary_scope
scope = account.reporting_events.where(created_at: range)
return scope.joins(:conversation) if dimension_type == 'team'
scope
end
def summary_conversation_counts
account.conversations
.where(created_at: range)
.group(summary_conversation_group_by_key)
.count
end
def merge_summary_results(metric_results, conversation_counts)
(metric_results.keys | conversation_counts.keys).each_with_object({}) do |dimension_id, results|
record = metric_results[dimension_id]
results[dimension_id] = summary_attributes_for(record, conversation_counts[dimension_id])
end
end
def summary_select_fields
["#{summary_group_by_key} as #{summary_index_key}"] + summary_metrics.map { |definition| summary_select_field(definition) }
end
def summary_select_field(definition)
if definition.count?
"COUNT(CASE WHEN name = '#{definition.raw_event_name}' THEN 1 END) as #{definition.summary_key}"
else
"AVG(CASE WHEN name = '#{definition.raw_event_name}' THEN #{average_value_key} END) as #{definition.summary_key}"
end
end
def summary_attributes_for(record, conversations_count = 0)
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
value = record&.public_send(definition.summary_key)
attributes[definition.summary_key] = definition.count? ? value.to_i : value
end
end
def summary_group_by_key
{
'account' => :account_id,
'agent' => :user_id,
'inbox' => :inbox_id,
'team' => 'conversations.team_id'
}[dimension_type]
end
def summary_conversation_group_by_key
{
'account' => :account_id,
'agent' => :assignee_id,
'inbox' => :inbox_id,
'team' => :team_id
}[dimension_type]
end
def summary_index_key
summary_group_by_key.to_s.split('.').last
end
def average_value_key
use_business_hours? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,120 @@
module Reports::ReportMetricRegistry
# Describes one public report metric.
# name: API-facing metric name requested by reports.
# aggregate: whether the metric is a count or average.
# raw_event_name: source reporting_events name for raw queries.
# rollup_metric: source reporting_events_rollups metric for rollup queries.
# summary_key: key used when this metric appears in grouped summary responses.
# raw_count_strategy: optional raw-query counting rule, such as distinct conversations.
Metric = Data.define(
:name,
:aggregate,
:raw_event_name,
:rollup_metric,
:summary_key,
:raw_count_strategy
) do
def initialize(name:, aggregate:, raw_event_name: nil, rollup_metric: nil, summary_key: nil, raw_count_strategy: nil)
super
end
def average?
aggregate == :average
end
def count?
aggregate == :count
end
def rollup_supported?
rollup_metric.present?
end
def summary?
summary_key.present?
end
end
METRICS = {
conversations_count: Metric.new(
name: :conversations_count,
aggregate: :count
),
incoming_messages_count: Metric.new(
name: :incoming_messages_count,
aggregate: :count
),
outgoing_messages_count: Metric.new(
name: :outgoing_messages_count,
aggregate: :count
),
avg_first_response_time: Metric.new(
name: :avg_first_response_time,
aggregate: :average,
raw_event_name: :first_response,
rollup_metric: :first_response,
summary_key: :avg_first_response_time
),
avg_resolution_time: Metric.new(
name: :avg_resolution_time,
aggregate: :average,
raw_event_name: :conversation_resolved,
rollup_metric: :resolution_time,
summary_key: :avg_resolution_time
),
reply_time: Metric.new(
name: :reply_time,
aggregate: :average,
raw_event_name: :reply_time,
rollup_metric: :reply_time,
summary_key: :avg_reply_time
),
resolutions_count: Metric.new(
name: :resolutions_count,
aggregate: :count,
raw_event_name: :conversation_resolved,
rollup_metric: :resolutions_count,
summary_key: :resolved_conversations_count
),
bot_resolutions_count: Metric.new(
name: :bot_resolutions_count,
aggregate: :count,
raw_event_name: :conversation_bot_resolved,
rollup_metric: :bot_resolutions_count
),
bot_handoffs_count: Metric.new(
name: :bot_handoffs_count,
aggregate: :count,
raw_event_name: :conversation_bot_handoff,
rollup_metric: :bot_handoffs_count,
raw_count_strategy: :distinct_conversation
)
}.freeze
SUMMARY_METRIC_NAMES = %i[
resolutions_count
avg_resolution_time
avg_first_response_time
reply_time
].freeze
module_function
def fetch(name)
return if name.blank?
METRICS[name.to_sym]
end
def supported?(name)
fetch(name).present?
end
def rollup_supported?(name)
fetch(name)&.rollup_supported? || false
end
def summary_metrics
SUMMARY_METRIC_NAMES.map { |metric_name| METRICS.fetch(metric_name) }
end
end
+199
View File
@@ -0,0 +1,199 @@
class Reports::RollupDataSource < Reports::DataSource
def timeseries
count_metric? ? count_timeseries : average_timeseries
end
def aggregate
count_metric? ? count_aggregate : average_aggregate
end
def summary
metric_results = summary_rows.index_by(&:dimension_id)
merge_summary_results(metric_results, summary_conversation_counts)
end
private
def count_timeseries
grouped_data = all_periods_in_range.index_with { 0 }
rollup_scope.each do |row|
date_key = normalized_period_key(row.date)
grouped_data[date_key] ||= 0
grouped_data[date_key] += row.count
end
results = grouped_data.map do |date_key, count|
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
end
results.sort_by { |result| result[:timestamp] }
end
def average_timeseries
grouped_data = all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } }
rollup_scope.each { |row| accumulate_average_row(grouped_data, row) }
results = grouped_data.map do |date_key, data|
{
value: data[:count].zero? ? 0 : data[:sum_value] / data[:count],
timestamp: date_key.in_time_zone(timezone).to_i,
count: data[:count]
}
end
results.sort_by { |result| result[:timestamp] }
end
def count_aggregate
rollup_scope.sum(:count).to_i
end
def average_aggregate
result = rollup_scope.pick(Arel.sql("SUM(count), SUM(#{rollup_value_column})"))
return nil if result.blank? || result[0].to_i.zero?
result[1].to_f / result[0].to_i
end
def rollup_scope
ReportingEventsRollup.where(
account_id: account.id,
metric: rollup_metric,
dimension_type: dimension_type,
dimension_id: dimension_id_for_rollup,
date: rollup_date_range
)
end
def summary_rows
ReportingEventsRollup.where(
account_id: account.id,
dimension_type: dimension_type,
date: rollup_date_range
).group(:dimension_id).select(*summary_select_fields)
end
def summary_conversation_counts
account.conversations
.where(created_at: range)
.group(summary_conversation_group_by_key)
.count
end
def merge_summary_results(metric_results, conversation_counts)
(metric_results.keys | conversation_counts.keys).index_with do |dimension_id|
summary_attributes_for(metric_results[dimension_id], conversation_counts[dimension_id])
end
end
def summary_select_fields
['dimension_id'] + summary_metrics.flat_map { |definition| summary_select_fields_for_metric(definition) }
end
def summary_select_fields_for_metric(definition)
return [sum_count_select(definition.rollup_metric, definition.summary_key)] if definition.count?
[
sum_count_select(definition.rollup_metric, summary_count_alias(definition)),
sum_value_select(definition.rollup_metric, summary_sum_alias(definition))
]
end
def sum_count_select(rollup_metric_name, alias_name)
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN count ELSE 0 END) as #{alias_name}"
end
def sum_value_select(rollup_metric_name, alias_name)
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN #{rollup_value_column} ELSE 0 END) as #{alias_name}"
end
def summary_attributes_for(row, conversations_count = 0)
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
attributes[definition.summary_key] = summary_value_for(row, definition)
end
end
def summary_value_for(row, definition)
return row&.public_send(definition.summary_key).to_i if definition.count?
average_from(row&.public_send(summary_sum_alias(definition)), row&.public_send(summary_count_alias(definition)))
end
def summary_count_alias(definition)
"#{definition.summary_key}_count"
end
def summary_sum_alias(definition)
"#{definition.summary_key}_sum_value"
end
def dimension_id_for_rollup
dimension_type == 'account' ? account.id : scope.id
end
def summary_conversation_group_by_key
{
'account' => :account_id,
'agent' => :assignee_id,
'inbox' => :inbox_id,
'team' => :team_id
}[dimension_type]
end
def rollup_value_column
use_business_hours? ? :sum_value_business_hours : :sum_value
end
def rollup_date_range
tz = ActiveSupport::TimeZone[account.reporting_timezone]
start_date = range.first.in_time_zone(tz).to_date
end_date = (range.last - 1.second).in_time_zone(tz).to_date
start_date..end_date
end
def all_periods_in_range
current = normalized_period_key(rollup_date_range.first)
periods = []
while current <= rollup_date_range.last
periods << current
current = advance_period(current)
end
periods
end
def accumulate_average_row(grouped_data, row)
date_key = normalized_period_key(row.date)
grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
grouped_data[date_key][:count] += row.count
grouped_data[date_key][:sum_value] += row.public_send(rollup_value_column)
end
def normalized_period_key(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
def average_from(sum_value, count)
return nil if count.to_i.zero?
sum_value.to_f / count.to_i
end
end
+12 -1
View File
@@ -39,11 +39,11 @@ class Whatsapp::HealthService
def health_fields
%w[
id
quality_rating
messaging_limit_tier
code_verification_status
account_mode
id
display_phone_number
name_status
verified_name
@@ -68,6 +68,7 @@ class Whatsapp::HealthService
def format_health_response(response)
{
id: response['id'],
display_phone_number: response['display_phone_number'],
verified_name: response['verified_name'],
name_status: response['name_status'],
@@ -75,10 +76,20 @@ class Whatsapp::HealthService
messaging_limit_tier: response['messaging_limit_tier'],
account_mode: response['account_mode'],
code_verification_status: response['code_verification_status'],
webhook_configuration: response['webhook_configuration'],
expected_webhook_url: build_expected_webhook_url,
throughput: response['throughput'],
last_onboarded_time: response['last_onboarded_time'],
platform_type: response['platform_type'],
certificate: response['certificate'],
business_id: @channel.provider_config['business_account_id']
}
end
def build_expected_webhook_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
return nil if frontend_url.blank?
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
end
end
@@ -1,9 +1,9 @@
class Whatsapp::WebhookSetupService
def initialize(channel, waba_id, access_token)
def initialize(channel, waba_id = nil, access_token = nil)
@channel = channel
@waba_id = waba_id
@access_token = access_token
@api_client = Whatsapp::FacebookApiClient.new(access_token)
@waba_id = waba_id || channel.provider_config['business_account_id']
@access_token = access_token || channel.provider_config['api_key']
@api_client = Whatsapp::FacebookApiClient.new(@access_token)
end
def perform
@@ -17,6 +17,11 @@ class Whatsapp::WebhookSetupService
setup_webhook
end
def register_callback
validate_parameters!
setup_webhook
end
private
def validate_parameters!
@@ -33,8 +38,6 @@ class Whatsapp::WebhookSetupService
store_pin(pin)
rescue StandardError => e
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
# Continue with webhook setup even if registration fails
# This is just a warning, not a blocking error
end
def fetch_or_create_pin
-1
View File
@@ -191,7 +191,6 @@
- name: assignment_v2
display_name: Assignment V2
enabled: false
chatwoot_internal: true
- name: twilio_content_templates
display_name: Twilio Content Templates
enabled: false
+5
View File
@@ -48,6 +48,9 @@ en:
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
errors:
account:
reporting_timezone:
invalid: is not a valid timezone
validations:
presence: must not be blank
webhook:
@@ -235,8 +238,10 @@ en:
activity:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
resolved_with_reason: 'Conversation was marked resolved by %{user_name} (%{reason})'
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
open: 'Conversation was marked open by %{user_name}'
open_with_reason: 'Conversation was marked open by %{user_name} (%{reason})'
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
agent_bot:
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
+2 -2
View File
@@ -104,11 +104,11 @@ wistia:
</div>
bunny:
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
regex: 'https?://(?:iframe|player)\.mediadelivery\.net/(?:play|embed)/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
template: |
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
src="https://player.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
+1
View File
@@ -218,6 +218,7 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
post :register_webhook, on: :member
if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
@@ -0,0 +1,39 @@
class CreateReportingEventsRollup < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
create_rollups_table
add_rollups_indexes
end
private
def create_rollups_table
create_table :reporting_events_rollups do |t|
t.integer :account_id, null: false
t.date :date, null: false
t.string :dimension_type, null: false
t.bigint :dimension_id, null: false
t.string :metric, null: false
t.bigint :count, default: 0, null: false
t.float :sum_value, default: 0.0, null: false
t.float :sum_value_business_hours, default: 0.0, null: false
t.timestamps
end
end
def add_rollups_indexes
add_index :reporting_events_rollups,
[:account_id, :date, :dimension_type, :dimension_id, :metric],
unique: true, name: 'index_rollup_unique_key', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :metric, :date],
name: 'index_rollup_timeseries', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :dimension_type, :date],
name: 'index_rollup_summary', algorithm: :concurrently
end
end
+16
View File
@@ -1124,6 +1124,22 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
t.index ["user_id"], name: "index_reporting_events_on_user_id"
end
create_table "reporting_events_rollups", force: :cascade do |t|
t.integer "account_id", null: false
t.date "date", null: false
t.string "dimension_type", null: false
t.bigint "dimension_id", null: false
t.string "metric", null: false
t.bigint "count", default: 0, null: false
t.float "sum_value", default: 0.0, null: false
t.float "sum_value_business_hours", default: 0.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "date", "dimension_type", "dimension_id", "metric"], name: "index_rollup_unique_key", unique: true
t.index ["account_id", "dimension_type", "date"], name: "index_rollup_summary"
t.index ["account_id", "metric", "date"], name: "index_rollup_timeseries"
end
create_table "sla_events", force: :cascade do |t|
t.bigint "applied_sla_id", null: false
t.bigint "conversation_id", null: false
+15 -6
View File
@@ -1,4 +1,6 @@
class SamlUserBuilder
class AuthenticationFailed < StandardError; end
def initialize(auth_hash, account_id)
@auth_hash = auth_hash
@account_id = account_id
@@ -16,13 +18,20 @@ class SamlUserBuilder
def find_or_create_user
user = User.from_email(auth_attribute('email'))
if user
confirm_user_if_required(user)
convert_existing_user_to_saml(user)
return user
end
return create_user unless user
return existing_user_for_account(user) if user_belongs_to_account?(user)
create_user
raise AuthenticationFailed, I18n.t('auth.saml.authentication_failed')
end
def existing_user_for_account(user)
confirm_user_if_required(user)
convert_existing_user_to_saml(user)
user
end
def user_belongs_to_account?(user)
user.account_users.exists?(account_id: @account_id)
end
def confirm_user_if_required(user)
@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
additional_message: params[:message_content],
message_history: message_history
)
response = if captain_v2_enabled?
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
message_history: playground_message_history
)
else
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
additional_message: playground_params[:message_content],
message_history: message_history
)
end
render json: response
end
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
end
def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
(playground_params[:message_history] || []).map do |message|
{
role: message[:role],
content: message[:content],
agent_name: message[:agent_name]
}.compact
end
end
def playground_message_history
history = message_history
current_message = playground_params[:message_content]
return history if current_message.blank?
current_user_message = { role: 'user', content: current_message }
return history if history.last == current_user_message
history + [current_user_message]
end
def captain_v2_enabled?
@assistant.account.feature_enabled?('captain_integration_v2')
end
end
@@ -39,7 +39,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
error = params[:message] || 'authentication-failed'
if for_mobile?(relay_state)
redirect_to_mobile_error(error, relay_state)
redirect_to_mobile_error(error)
else
redirect_to login_page_url(error: "saml-#{error}")
end
@@ -51,23 +51,15 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
account_id = extract_saml_account_id
relay_state = saml_relay_state
unless saml_enabled_for_account?(account_id)
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
return redirect_to login_page_url(error: 'saml-not-enabled')
end
return handle_saml_auth_error(relay_state, 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
return sign_in_user_on_mobile if for_mobile?(relay_state)
return sign_in_saml_user(relay_state) if @resource.persisted?
sign_in_user
else
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
redirect_to login_page_url(error: 'saml-authentication-failed')
end
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
rescue SamlUserBuilder::AuthenticationFailed
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
end
def extract_saml_account_id
@@ -82,6 +74,18 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
relay_state.to_s.casecmp('mobile').zero?
end
def sign_in_saml_user(relay_state)
return sign_in_user_on_mobile if for_mobile?(relay_state)
sign_in_user
end
def handle_saml_auth_error(relay_state, error)
return redirect_to_mobile_error(error) if for_mobile?(relay_state)
redirect_to login_page_url(error: error)
end
def redirect_to_mobile_error(error)
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
@@ -5,7 +5,6 @@ module Captain::ChatHelper
def request_chat_completion
log_chat_completion_request
chat = build_chat
add_messages_to_chat(chat)
@@ -86,7 +85,8 @@ module Captain::ChatHelper
temperature: temperature,
metadata: {
assistant_id: @assistant&.id,
channel_type: resolved_channel_type
channel_type: resolved_channel_type,
source: @source
}.compact
}
end
@@ -130,7 +130,6 @@ module Captain::ChatHelper
end
def log_chat_completion_request
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
"for messages #{@messages} with #{@tools&.length || 0} tools")
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
end
end
@@ -1,15 +1,16 @@
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze
CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze
queue_as :low
def perform(inbox)
return if inbox.account.captain_disable_auto_resolve
return if inbox.account.captain_auto_resolve_disabled?
Current.executed_by = inbox.captain_assistant
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
resolvable_conversations.each do |conversation|
create_outgoing_message(conversation, inbox)
conversation.resolved!
if evaluate_conversation_completion?(inbox.account)
perform_with_evaluation(inbox)
else
perform_time_based(inbox)
end
ensure
Current.reset
@@ -17,18 +18,122 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
private
def create_outgoing_message(conversation, inbox)
def evaluate_conversation_completion?(account)
account.feature_enabled?('captain_tasks') && account.captain_auto_resolve_evaluated?
end
def perform_time_based(inbox)
Current.executed_by = inbox.captain_assistant
resolvable_pending_conversations(inbox).each do |conversation|
create_resolution_message(conversation, inbox)
conversation.resolved!
end
end
def perform_with_evaluation(inbox)
Current.executed_by = inbox.captain_assistant
resolvable_pending_conversations(inbox).each do |conversation|
evaluation = evaluate_conversation(conversation, inbox)
next unless still_resolvable_after_evaluation?(conversation)
if evaluation[:complete]
resolve_conversation(conversation, inbox, evaluation[:reason])
else
handoff_conversation(conversation, inbox, evaluation[:reason])
end
end
end
def evaluate_conversation(conversation, inbox)
Captain::ConversationCompletionService.new(
account: inbox.account,
conversation_display_id: conversation.display_id
).perform
end
def resolvable_pending_conversations(inbox)
inbox.conversations.pending
.where('last_activity_at < ?', auto_resolve_cutoff_time)
.limit(Limits::BULK_ACTIONS_LIMIT)
end
def still_resolvable_after_evaluation?(conversation)
conversation.reload
conversation.pending? && conversation.last_activity_at < auto_resolve_cutoff_time
rescue ActiveRecord::RecordNotFound
false
end
def auto_resolve_cutoff_time
Time.now.utc - 1.hour
end
def resolve_conversation(conversation, inbox, reason)
create_private_note(conversation, inbox, "Auto-resolved: #{reason}")
create_resolution_message(conversation, inbox)
conversation.with_captain_activity_context(
reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON,
reason_type: :inference
) { conversation.resolved! }
conversation.dispatch_captain_inference_resolved_event
end
def handoff_conversation(conversation, inbox, reason)
create_private_note(conversation, inbox, "Auto-handoff: #{reason}")
create_handoff_message(conversation, inbox)
conversation.with_captain_activity_context(
reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON,
reason_type: :inference
) { conversation.bot_handoff! }
conversation.dispatch_captain_inference_handoff_event
send_out_of_office_message_if_applicable(conversation.reload)
end
def send_out_of_office_message_if_applicable(conversation)
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def create_private_note(conversation, inbox, content)
conversation.messages.create!(
message_type: :outgoing,
private: true,
sender: inbox.captain_assistant,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: content
)
end
def create_resolution_message(conversation, inbox)
I18n.with_locale(inbox.account.locale) do
resolution_message = inbox.captain_assistant.config['resolution_message']
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
sender: inbox.captain_assistant
}
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
sender: inbox.captain_assistant
)
end
end
def create_handoff_message(conversation, inbox)
handoff_message = inbox.captain_assistant.config['handoff_message']
return if handoff_message.blank?
conversation.messages.create!(
message_type: :outgoing,
sender: inbox.captain_assistant,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: handoff_message,
preserve_waiting_since: true
)
end
end
@@ -12,7 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
inbox = captain_inbox.inbox
next if inbox.email?
next if inbox.account.captain_disable_auto_resolve
next if inbox.account.captain_auto_resolve_disabled?
Captain::InboxPendingConversationsResolutionJob.perform_later(
inbox
+1 -1
View File
@@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
key: scenario.title.parameterize.underscore,
key: scenario.handoff_key,
description: scenario.description
}
end,
+39 -1
View File
@@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
# OpenAI enforces a 64-char limit on function names. The ai-agents gem
# prepends "handoff_to_" (11 chars), so we keep a safety margin and cap
# the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars).
# Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable),
# and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated
# based on the available length budget.
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
HANDOFF_KEY_PREFIX = 'scenario'.freeze
HANDOFF_KEY_SUFFIX = 'agent'.freeze
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length
MAX_HANDOFF_SLUG_LENGTH = 24
self.table_name = 'captain_scenarios'
belongs_to :assistant, class_name: 'Captain::Assistant'
@@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord
before_save :resolve_tool_references
def handoff_key
[handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_')
end
def prompt_context
{
title: title,
@@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord
private
def agent_name
"#{title} Agent".parameterize(separator: '_')
handoff_key
end
def handoff_id_key
return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present?
"#{HANDOFF_KEY_PREFIX}_draft"
end
def compact_handoff_slug
slug = title.to_s.parameterize(separator: '_').presence
return nil if slug.blank?
max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min
return nil if max_slug_length <= 0
slug.first(max_slug_length).sub(/_+\z/, '').presence
end
def dynamic_slug_max_length
# handoff_to_#{scenario_<id>_<slug>_agent}
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
end
def agent_tools
@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
key = captain_activity_key
return unless key
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
I18n.t(key, user_name: Current.executed_by.name, reason: captain_status_reason, locale: locale)
end
private
def captain_status_reason
captain_activity_reason.presence
end
def captain_activity_key
if resolved? && Current.captain_resolve_reason.present?
'conversations.activity.captain.resolved_by_tool'
elsif resolved?
'conversations.activity.captain.resolved'
elsif open?
'conversations.activity.captain.open'
end
return captain_resolved_activity_key if resolved?
return captain_open_activity_key if open?
end
def captain_resolved_activity_key
return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
return 'conversations.activity.captain.resolved_with_reason' if captain_status_reason.present?
'conversations.activity.captain.resolved'
end
def captain_open_activity_key
return 'conversations.activity.captain.open_with_reason' if captain_status_reason.present?
'conversations.activity.captain.open'
end
end
@@ -1,8 +1,30 @@
module Enterprise::Conversation
attr_accessor :captain_activity_reason, :captain_activity_reason_type
def dispatch_captain_inference_resolved_event
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_RESOLVED)
end
def dispatch_captain_inference_handoff_event
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_HANDOFF)
end
def list_of_keys
super + %w[sla_policy_id]
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
self.captain_activity_reason = reason
self.captain_activity_reason_type = reason_type
yield
ensure
self.captain_activity_reason = previous_reason
self.captain_activity_reason_type = previous_reason_type
end
# Include select additional_attributes keys (call related) for update events
def allowed_keys?
return true if super
@@ -13,4 +35,10 @@ module Enterprise::Conversation
changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status])
end
private
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
end
@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
def initialize(assistant:, conversation: nil, callbacks: {})
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
@assistant = assistant
@conversation = conversation
@callbacks = callbacks
@source = source
end
def generate_response(message_history: [])
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
process_agent_result(result)
rescue StandardError => e
# when running the agent runner service in a rake task, the conversation might not have an account associated
# for regular production usage, it will run just fine
# In rake/local runs, conversation may not be present, so account is optional here.
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
assistant_id: @assistant.id,
assistant_config: @assistant.config
}
state[:source] = @source if @source.present?
build_conversation_state(state) if @conversation
state
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
return unless @conversation.contact_inbox
state[:contact_inbox] =
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
end
def build_and_wire_agents
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
}.compact.transform_values(&:to_s)
@@ -1,11 +1,12 @@
class Captain::Llm::AssistantChatService < Llm::BaseAiService
include Captain::ChatHelper
def initialize(assistant: nil, conversation_id: nil)
def initialize(assistant: nil, conversation_id: nil, source: nil)
super()
@assistant = assistant
@conversation_id = conversation_id
@source = source
@messages = [system_message]
@response = ''
@@ -0,0 +1,4 @@
class Captain::ConversationCompletionSchema < RubyLLM::Schema
boolean :complete, description: 'Whether the conversation is complete and can be closed'
string :reason, description: 'Brief explanation of why the conversation is complete or incomplete'
end
@@ -0,0 +1,76 @@
# Evaluates whether a conversation is complete and can be auto-resolved.
# Used by InboxPendingConversationsResolutionJob to determine if inactive
# conversations should be resolved or handed off to human agents.
#
# NOTE: This service intentionally does NOT count toward Captain usage limits.
# The response excludes the :message key that Enterprise::Captain::BaseTaskService
# checks for usage tracking. This is an internal operational evaluation,
# not a customer-facing value-add, so we don't charge for it.
class Captain::ConversationCompletionService < Captain::BaseTaskService
RESPONSE_SCHEMA = Captain::ConversationCompletionSchema
pattr_initialize [:account!, :conversation_display_id!]
def perform
content = format_messages_as_string
return default_incomplete_response('No messages found') if content.blank?
response = make_api_call(
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('conversation_completion') },
{ role: 'user', content: content }
],
schema: RESPONSE_SCHEMA
)
return default_incomplete_response(response[:error]) if response[:error].present?
parse_response(response[:message])
end
private
def prompt_from_file(file_name)
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
end
def format_messages_as_string
messages = conversation_messages(start_from: 0)
messages.map do |msg|
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
"#{sender_type}: #{msg[:content]}"
end.join("\n")
end
def parse_response(message)
return default_incomplete_response('Invalid response format') unless message.is_a?(Hash)
{
complete: message['complete'] == true,
reason: message['reason'] || 'No reason provided'
}
end
def default_incomplete_response(reason)
{ complete: false, reason: reason }
end
# Prefer the system API key over the account's OpenAI hook key.
# This is an internal operational evaluation, not a customer-triggered feature,
# so it should not consume the customer's OpenAI credits on hosted platforms.
# Falls back to the account hook for self-hosted deployments without a system key.
def api_key
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
end
def event_name
'captain.conversation_completion'
end
def build_follow_up_context?
false
end
end
Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService')
@@ -0,0 +1,21 @@
You are evaluating whether a customer support conversation is complete and can be safely closed. When in doubt, keep the conversation OPEN. It is far better to hand off to a human agent unnecessarily than to close a conversation where the customer still needs help.
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
A conversation is INCOMPLETE (keep open) if ANY of these apply:
- The assistant suggested the customer try something or take an action — they may still be attempting it
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
- The assistant asked a question or requested information that the customer hasn't provided
- The customer asked a question that wasn't fully answered
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
- The customer raised multiple questions or issues and not all were addressed
A conversation is COMPLETE only if ALL of these are true:
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
Analyze the conversation and respond with ONLY a JSON object (no other text):
{"complete": true, "reason": "brief explanation"}
or
{"complete": false, "reason": "brief explanation"}
@@ -6,16 +6,11 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved?
return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve
return 'Auto-resolve is disabled for this account' if conversation.account.captain_auto_resolve_disabled?
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
Current.captain_resolve_reason = reason
begin
conversation.resolved!
ensure
Current.captain_resolve_reason = nil
end
conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
end
@@ -0,0 +1,12 @@
# Overrides the quota check from Enterprise::Captain::BaseTaskService
# so that conversation completion evaluation always runs regardless of
# the customer's Captain usage quota. This is an internal operational
# check, not a customer-facing feature — it should never be blocked
# by quota exhaustion.
module Enterprise::Captain::ConversationCompletionService
private
def responses_available?
true
end
end

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