Compare commits

..
Author SHA1 Message Date
Shivam MishraandGitHub 48f85b85cf Merge branch 'develop' into feat/summary-report-aggregate 2025-10-16 15:13:28 +05:30
Sivin VargheseandGitHub 2180edc14a chore: Hide "Learn More" button in feature spotlight for self-hosted (#12675) 2025-10-16 12:04:53 +05:30
Sojan Jose de2a35b617 Merge branch 'release/4.7.0' into develop 2025-10-15 22:11:12 -07:00
Sojan Jose 40577e6a50 Bump version to 4.7.0 2025-10-15 22:05:13 -07:00
Shivam MishraandGitHub ea430bcfe5 Merge branch 'develop' into feat/summary-report-aggregate 2025-10-15 14:02:28 +05:30
368d7c4608 feat: Add support for HTML emails in outgoing messages (#12662)
This PR adds sending custom HTML content in outgoing email messages
through Chatwoot's Email channels, while maintaining backward
compatibility with existing markdown rendering.

###  API Usage

**Endpoint:** `POST
/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages`

```json
  {
   "content": "Fallback text content",
   "email_html_content": "<div><h1>Welcome!</h1><p>This is <strong>custom HTML</strong></p></div>"
  }
```

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
2025-10-15 13:22:23 +05:30
Shivam MishraandGitHub ae342e3293 Merge branch 'develop' into feat/summary-report-aggregate 2025-10-14 13:06:01 +05:30
Shivam Mishra 46f95e849d feat: merge base query 2025-10-14 12:44:06 +05:30
f1f1ce644c feat: Overview heatmap improvements (#12359)
This PR adds inbox filtering to the conversation traffic heatmap,
allowing users to analyze patterns for specific inboxes. Additionally,
it also adds a new resolution count heatmap that shows when support
teams are most active in resolving conversations, using a green color to
distinguish it from the blue conversation heatmap.

The PR also reorganizes heatmap components into a cleaner structure with
a shared `BaseHeatmapContainer` that handles common functionality like
date range selection, inbox filtering, and data fetching. This makes it
easy to add new heatmap metrics in the future - just create a wrapper
component specifying the metric type and color scheme.

<img width="1926" height="1670" alt="CleanShot 2025-10-13 at 14 01
35@2x"
src="https://github.com/user-attachments/assets/67822a34-6170-4d19-9e11-7ad4ded5c388"
/>

<img width="1964" height="1634" alt="CleanShot 2025-10-13 at 14 03
00@2x"
src="https://github.com/user-attachments/assets/e4613c08-64b8-4fa6-91d8-7510946dd75d"
/>


Unrelated change, the data seeder conversation resolution would not work
correctly, we've fixed it.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 19:15:57 +05:30
38f16ba677 feat: Secure external credentials with database encryption (#12648)
## Changelog

- Added conditional Active Record encryption to every external
credential we store (SMTP/IMAP passwords, Twilio tokens,
Slack/OpenAI hook tokens, Facebook/Instagram tokens, LINE/Telegram keys,
Twitter secrets) so new writes are encrypted
whenever Chatwoot.encryption_configured? is true; legacy installs still
receive plaintext until their secrets are
    updated.
- Tuned encryption settings in config/application.rb to allow legacy
reads (support_unencrypted_data) and to extend
deterministic queries so lookups continue to match plaintext rows during
the rollout; added TODOs to retire the
    fallback once encryption becomes mandatory.
- Introduced an MFA-pipeline test suite
(spec/models/external_credentials_encryption_spec.rb) plus shared
examples to
verify each attribute encrypts at rest and that plaintext records
re-encrypt on update, with a dedicated Telegram case.
The existing MFA GitHub workflow now runs these tests using the
preconfigured encryption keys.

fixes:
https://linear.app/chatwoot/issue/CW-5453/encrypt-sensitive-credentials-stored-in-plain-text-in-database

## Testing Instructions

 1. Instance without encryption keys
- Unset ACTIVE_RECORD_ENCRYPTION_* vars (or run in an environment where
they’re absent).
      - Create at least one credentialed channel (e.g., Email SMTP).
- Confirm workflows still function (send/receive mail or a similar
sanity check).
- In the DB you should still see plaintext values—this confirms the
guard prevents encryption when keys are missing.
  2. Instance with encryption keys
      - Configure the three encryption env vars and restart.
- Pick a couple of representative integrations (e.g., Email SMTP +
Twilio SMS).
      - Legacy channel check:
- Use existing records created before enabling keys. Trigger their
workflow (send an email / SMS, or hit the
            webhook) to ensure they still authenticate.
- Inspect the raw column—value remains plaintext until changed.
      - Update legacy channel:
- Edit one legacy channel’s credential (e.g., change SMTP password).
- Verify the operation still works and the stored value is now encrypted
(raw column differs, accessor returns
            original).
      - New channel creation:
- Create a new channel of the same type; confirm functionality and that
the stored credential is encrypted from
            the start.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 18:05:12 +05:30
e7b01d80b3 chore: add script to throttle bulkreindex job creation and increase meta timeouts(#12626)
- scripts to throttle reindex job creation and monitor progress

```

RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb

RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
```

---------
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-10-13 16:21:45 +05:30
ec9a82a017 feat: Open conversation when agent bot webhook fails (#12379)
# Changelog

When an agent bot webhook fails, we now flip any pending conversation
back to an open state so a human agent can pick
it up immediately. There will be an clear activity message giving the
team clear visibility into what went
wrong. This keeps customers from getting stuck in limbo when their
connected bot goes offline.

# Testing instructions 

1. Initial setup: Create an agent bot with a working webhook URL and
connect it to a test inbox. Send a message from a
contact (e.g., via the widget) so a conversation is created; it should
enter the Pending state while the bot handles
     the reply.
2. Introduce failure: Edit that agent bot and swap the webhook URL for a
dummy endpoint that will fail. Have the same
contact send another message in the existing conversation. Because the
webhook call now fails, the conversation should flip from Pending back
to Open, making it visible to agents. Also verify the activity message
3. New conversation check: With the dummy URL still in place, start a
brand-new conversation from a contact. When the
bot tries (and fails) to respond, confirm that the conversation appears
immediately as Open rather than remaining Pending. Also the activity
message is visible
4. Subsequent messages in open conversations will show no change

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 15:59:59 +05:30
KarimandGitHub cdd3b73fc9 fix: Duplicate contacts creating for Argentina numbers (#11173) 2025-10-13 11:07:07 +05:30
dependabot[bot]andGitHub 610495123e chore(deps): bump rack from 3.2.2 to 3.2.3 (#12642)
Bumps rack from 3.2.2 to 3.2.3.
2025-10-11 17:05:38 +05:30
cb65d615ea chore: Add auto-refresh and self-hosted redirect logic to the billing page (#12615)
# Pull Request Template

## Description

This PR includes billing page improvements with the following updates:

### Self-hosted Users

* Automatically redirected to the dashboard when accessing the billing
page.

### Cloud Users – No Billing Plan (First Visit)

* Shows a loading spinner with the `Your billing account is being
configured. Please refresh the page and try again.` message.
* Automatically refreshes the page after 5 seconds to check for billing
setup.

### Cloud Users – No Billing Plan (After Refresh)

* Prevents infinite refresh loops using `sessionStorage` tracking.
* Displays the standard `Your billing account is being configured.
Please refresh the page and try again.` message without further refresh
attempts.
* Cleans up session flags for future visits.

### Cloud Users – With Billing Plan

* Displays the existing billing page normally with no refresh or
redirection logic.


Fixes
https://linear.app/chatwoot/issue/CW-5559/your-billing-page-is-being-set-up-message-on-billing-page-is-confusing

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/d0ea13d6b90b4ab1acbc581b524f6382?sid=d3dd19f3-85aa-4127-9233-7eecb1be0884

## 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
- [ ] 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-09 10:57:30 -07:00
Muhsin KelothandGitHub d8da1f5bf3 fix: Handle video file types in Slack file shares (#12630)
Fixes https://linear.app/chatwoot/issue/CW-5752/fix-nomethoderror-when-processing-video-files-in-slack-integration
#### Problem
When users shared video files (like MP4) through Slack, the `file_type`
method in `SlackMessageHelper` would return `nil` for unsupported file
types. This caused a `NoMethodError (undefined method 'to_sym' for nil)`
when the attachment was being processed, as the system expected a symbol
value for the `file_type` attribute.

#### Solution
- Added video file type support in the `file_type` method case statement
- Added `else` clause to default unknown file types to `:file` instead
of returning `nil`
- This ensures `file_type` always returns a symbol, preventing the
`to_sym` error
2025-10-09 21:05:53 +05:30
Shivam MishraandGitHub 6829328182 fix(filters): correct null matching logic [CW-5741] (#12627)
This PR fixes a bug came from assuming the old null check only mattered
for the `is_not_present` filter.

The fix keeps `not_equal_to` working but lets each operator decide what
to do with `null`. Presence filters look at a shared `isNullish` flag,
text filters still rely on `contains`, and date filters skip
conversations with no timestamp. The new spec covers the null-assignee
scenario for both `equal_to` and `not_equal_to` so we don’t miss this
again.
2025-10-09 18:19:20 +05:30
Sivin VargheseandGitHub 6cc69f444b chore: Include 11:59 PM slot in business hours display (#12610) 2025-10-09 16:51:25 +05:30
Shivam MishraandGitHub f89ed56258 feat: update rack version (#12628)
Fixes CI failing at bundle audit for a [rack
vulnerability](https://github.com/rack/rack/security/advisories/GHSA-wpv5-97wm-hp9c)
2025-10-09 16:50:28 +05:30
Vishnu NarayananandGitHub 7c5bb343c6 fix: Optimize message reindexing to reduce sidekiq job creation (#12618)
Changes searchkick callback behavior to check `should_index?` before
creating reindex jobs, preventing unnecessary job creation for messages
that don't need indexing (activity messages, unpaid accounts, etc.).

Previously, `callbacks: :async` created reindex jobs for all messages
(~5,100/min or 7.3M/day in production), which were then filtered by
`should_index?` inside the job worker - resulting in 98% wasted jobs,
Redis memory pressure, and avoidable p0 alerts.

Now, `should_index?` is checked before job creation via `after_commit`
callback, reducing job creation to actual incoming/outgoing messages
from paid accounts.

  Changes:
  - Disable automatic searchkick callbacks
  - Add manual `after_commit` callback with `should_index?` condition
  - Add specs to verify callback behavior

  Expected impact:
  - 98% reduction in sidekiq job creation (~7.3M → ~150K jobs/day)
  - Reduced redis memory usage
  - Same async indexing behavior for eligible messages
2025-10-09 16:04:50 +05:30
Vinay KeerthiandGitHub 170ea7691f feat: Add company model and API with tests (#12548)
# Pull Request Template

## Description

* add Company model with validations for name, domain, description and
  avatar
* Add database migration fo
* Implement endpoints for company CRUD operations
* Add optional company relationship for contacts
* Add test for models, controllers, factories and policies
* Add authorization policies restricting delete to admins
* support JSON API responses
Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.

Fixes #(cw-5650)

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tests are implemented using `RSpec`

```
$ bundle exec rails db:migrate
$ bundle exec rspec spec/models/company_spec.rb spec/controllers/api/v1/accounts/companies_controller_spec.rb
```

## 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-10-08 07:53:43 -07:00
606adffeeb fix: I18n::MissingInterpolationArgument for assignee activity messages (#12617)
# Pull Request Template

## Description

This PR fixes the following error:
`I18n::MissingInterpolationArgument: missing interpolation argument
:assignee_name in "Asignado a %{assignee_name} por %{user_name}"
({user_name: "Marketing Telpronet"} given)
(I18n::MissingInterpolationArgument)`

**Issue**

In the Spanish locale, an `I18n::MissingInterpolationArgument` error
occurred during bulk assignee operations.
This happened because `assignee&.name` was returning `nil`, and the
`.compact` method removed the `assignee_name` key entirely from the
params.

<img width="1744" height="164" alt="image"
src="https://github.com/user-attachments/assets/3c15ed77-48c0-4938-a7fd-356bdb07da39"
/>


**Solution**

* Always include the `assignee_name` key with an empty string (`''`)
when its value is `nil`.
* Removed the `.compact` method call to ensure the interpolation key is
always present.


Fixes
https://linear.app/chatwoot/issue/CW-5747/i18nmissinginterpolationargument-missing-interpolation-argument

## Type of change

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



## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-08 18:39:51 +05:30
99 changed files with 2117 additions and 681 deletions
+1
View File
@@ -70,6 +70,7 @@ jobs:
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \
--format documentation
env:
+1 -1
View File
@@ -644,7 +644,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.0)
rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
+20 -1
View File
@@ -178,7 +178,13 @@ class Messages::MessageBuilder
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
email_attributes[:html_content] = build_html_content(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(normalized_content)
end
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes
end
@@ -213,4 +219,17 @@ class Messages::MessageBuilder
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
def build_custom_html_content
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
html_content[:full] = @params[:email_html_content]
html_content[:reply] = @params[:email_html_content]
html_content
end
end
+22 -16
View File
@@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
load_reporting_events_data
end
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
@@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
# Override this method
end
@@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
@@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
load_reporting_events_data
end
def fetch_conversations_count
@@ -1,4 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -20,6 +22,7 @@ const onClick = () => {
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
class="mb-8"
:hide-actions="!isOnChatwootCloud"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
@@ -1,4 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -19,6 +21,7 @@ const onClick = () => {
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
learn-more-url="https://chwt.app/captain-document"
:hide-actions="!isOnChatwootCloud"
class="mb-8"
/>
<EmptyStateLayout
@@ -1,4 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -19,6 +21,7 @@ const onClick = () => {
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
:hide-actions="!isOnChatwootCloud"
class="mb-8"
/>
<EmptyStateLayout
@@ -10,6 +10,7 @@ defineProps({
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
});
const imageError = ref(false);
@@ -65,7 +66,7 @@ const openLink = link => {
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8">
<p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
<div class="flex gap-3">
<div v-if="!hideActions" class="flex gap-3">
<slot name="actions">
<Button
v-if="videoUrl"
@@ -13,6 +13,7 @@ defineProps({
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
});
const imageError = ref(false);
@@ -92,7 +93,7 @@ const openLink = link => {
{{ note }}
</p>
<div class="flex gap-3 justify-between w-full">
<div v-if="!hideActions" class="flex gap-3 justify-between w-full">
<slot name="actions">
<Button
v-if="videoUrl"
@@ -1,8 +1,6 @@
<script>
import DatePicker from 'vue-datepicker-next';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { getUnixTime } from 'date-fns';
import { saveLastCustomSnoozeTime } from 'dashboard/helper/customSnoozeStorage';
export default {
components: {
@@ -27,13 +25,7 @@ export default {
this.$emit('close');
},
chooseTime() {
if (this.snoozeTime) {
const unixTimestamp = getUnixTime(this.snoozeTime);
// Save the custom time to localStorage for future use
saveLastCustomSnoozeTime(unixTimestamp);
// The saveLastCustomSnoozeTime function will emit the event automatically
this.$emit('chooseTime', this.snoozeTime);
}
this.$emit('chooseTime', this.snoozeTime);
},
disabledDate(date) {
// Disable all the previous dates
@@ -1,7 +1,6 @@
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getLastCustomSnoozeTime } from 'dashboard/helper/customSnoozeStorage';
import { emitter } from 'shared/helpers/mitt';
import wootConstants from 'dashboard/constants/globals';
import {
@@ -101,17 +100,6 @@ export default {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (
snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME
) {
// Use the saved custom time
const lastCustomTime = getLastCustomSnoozeTime();
if (lastCustomTime) {
this.updateConversations('snoozed', lastCustomTime);
} else {
// Fallback to showing custom modal if no saved time exists
this.showCustomTimeSnoozeModal = true;
}
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
@@ -66,9 +66,8 @@ export function useBulkActionsHotKeys() {
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: typeof action.title === 'string' ? t(action.title) : action.title,
section:
typeof action.section === 'string' ? t(action.section) : action.section,
title: t(action.title),
section: t(action.section),
}));
};
@@ -1,4 +1,4 @@
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
@@ -31,25 +31,22 @@ import {
import {
OPEN_CONVERSATION_ACTIONS,
SNOOZE_CONVERSATION_ACTIONS,
RESOLVED_CONVERSATION_ACTIONS,
SEND_TRANSCRIPT_ACTION,
UNMUTE_ACTION,
MUTE_ACTION,
createSnoozeHandlers,
} from 'dashboard/helper/commandbar/actions';
import {
isAConversationRoute,
isAInboxViewRoute,
} from 'dashboard/helper/routeHelpers';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/events';
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
const prepareActions = (actions, t) => {
return actions.map(action => ({
...action,
title: typeof action.title === 'string' ? t(action.title) : action.title,
section:
typeof action.section === 'string' ? t(action.section) : action.section,
title: t(action.title),
section: t(action.section),
}));
};
@@ -147,14 +144,6 @@ export function useConversationHotKeys() {
const store = useStore();
const route = useRoute();
// Create a reactive trigger for localStorage changes
const localStorageTrigger = ref(0);
// Watch for localStorage changes (we'll trigger this manually when needed)
const forceSnoozeActionsUpdate = () => {
localStorageTrigger.value += 1;
};
const {
activeLabels,
inactiveLabels,
@@ -208,28 +197,6 @@ export function useConversationHotKeys() {
});
};
// Create reactive snooze conversation actions that update when localStorage changes
const snoozeConversationActions = computed(() => {
// This computed will re-run when localStorageTrigger changes
// eslint-disable-next-line no-unused-expressions
localStorageTrigger.value; // Access to make it reactive
return [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(wootConstants.SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_SNOOZE_CONVERSATION,
'snooze_conversation',
'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION'
),
];
});
const statusActions = computed(() => {
const isOpen = currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
const isSnoozed =
@@ -239,10 +206,7 @@ export function useConversationHotKeys() {
let actions = [];
if (isOpen) {
actions = [
...OPEN_CONVERSATION_ACTIONS,
...snoozeConversationActions.value,
];
actions = [...OPEN_CONVERSATION_ACTIONS, ...SNOOZE_CONVERSATION_ACTIONS];
} else if (isResolved || isSnoozed) {
actions = RESOLVED_CONVERSATION_ACTIONS;
}
@@ -430,7 +394,7 @@ export function useConversationHotKeys() {
const conversationHotKeys = computed(() => {
if (shouldShowSnoozeOption.value) {
return prepareActions(snoozeConversationActions.value, t);
return prepareActions(SNOOZE_CONVERSATION_ACTIONS, t);
}
if (isConversationOrInboxRoute.value) {
return getDefaultConversationHotKeys.value;
@@ -440,6 +404,5 @@ export function useConversationHotKeys() {
return {
conversationHotKeys,
forceSnoozeActionsUpdate,
};
}
@@ -45,7 +45,6 @@ export default {
UNTIL_TOMORROW: 'until_tomorrow',
UNTIL_NEXT_WEEK: 'until_next_week',
UNTIL_NEXT_MONTH: 'until_next_month',
UNTIL_LAST_CUSTOM_TIME: 'until_last_custom_time',
UNTIL_CUSTOM_TIME: 'until_custom_time',
},
EXAMPLE_URL: 'example.com',
@@ -32,97 +32,14 @@ export const OPEN_CONVERSATION_ACTIONS = [
];
export const createSnoozeHandlers = (busEventName, parentId, section) => {
// Import the helper functions inline to avoid import issues
const LAST_CUSTOM_SNOOZE_KEY = 'chatwoot_last_custom_snooze_time';
const getLastCustomSnoozeTime = () => {
try {
const stored = localStorage.getItem(LAST_CUSTOM_SNOOZE_KEY);
if (!stored) return null;
const data = JSON.parse(stored);
const now = Date.now();
const nowUnix = Math.floor(now / 1000);
const savedAt = data.savedAt;
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
// Check if the saved time has already passed OR if it was saved more than 7 days ago
const timeHasPassed = data.timestamp <= nowUnix;
const tooOld = now - savedAt > sevenDaysInMs;
if (timeHasPassed || tooOld) {
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
return null;
}
return data.timestamp;
} catch (error) {
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
return null;
}
};
const formatLastCustomSnoozeTime = () => {
const timestamp = getLastCustomSnoozeTime();
if (!timestamp) return null;
try {
const date = new Date(timestamp * 1000);
// Format as "Sat, 23 Aug, 8.16pm"
const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' });
const day = date.getDate();
const month = date.toLocaleDateString('en-US', { month: 'short' });
// Format time as 8.16pm (with dots instead of colons)
let hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
// Convert to 12-hour format
hours %= 12;
if (hours === 0) hours = 12; // 12am/12pm instead of 0am/0pm
const timeString = `${hours}.${minutes}${ampm}`;
return `${dayOfWeek}, ${day} ${month}, ${timeString}`;
} catch (error) {
return null;
}
};
const hasLastCustomSnoozeTime = () => {
return getLastCustomSnoozeTime() !== null;
};
const availableOptions = Object.values(SNOOZE_OPTIONS);
const snoozeOptions = availableOptions.filter(option => {
if (option === SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME) {
return hasLastCustomSnoozeTime();
}
return true;
});
return snoozeOptions.map(option => {
let title = `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`;
// For the last custom time option, show the formatted time instead of using translation
if (option === SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME) {
const formattedTime = formatLastCustomSnoozeTime();
if (formattedTime) {
title = `Last snoozed ${formattedTime}`;
}
}
return {
id: option,
title: title,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
};
});
return Object.values(SNOOZE_OPTIONS).map(option => ({
id: option,
title: `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
}));
};
export const SNOOZE_CONVERSATION_ACTIONS = [
@@ -1,92 +0,0 @@
const LAST_CUSTOM_SNOOZE_KEY = 'chatwoot_last_custom_snooze_time';
export const saveLastCustomSnoozeTime = snoozedUntil => {
if (!snoozedUntil) return;
try {
localStorage.setItem(
LAST_CUSTOM_SNOOZE_KEY,
JSON.stringify({
timestamp: snoozedUntil,
savedAt: Date.now(),
})
);
// Emit event to update command bar
if (typeof window !== 'undefined' && window.emitter) {
window.emitter.emit('CUSTOM_SNOOZE_TIME_CHANGED');
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save custom snooze time:', error);
}
};
export const getLastCustomSnoozeTime = () => {
try {
const stored = localStorage.getItem(LAST_CUSTOM_SNOOZE_KEY);
if (!stored) return null;
const data = JSON.parse(stored);
const now = Date.now();
const nowUnix = Math.floor(now / 1000);
const savedAt = data.savedAt;
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
// Check if the saved time has already passed OR if it was saved more than 7 days ago
const timeHasPassed = data.timestamp <= nowUnix;
const tooOld = now - savedAt > sevenDaysInMs;
if (timeHasPassed || tooOld) {
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
// Emit event when data is cleared due to expiration
if (typeof window !== 'undefined' && window.emitter) {
window.emitter.emit('CUSTOM_SNOOZE_TIME_CHANGED');
}
return null;
}
return data.timestamp;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to get custom snooze time:', error);
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
// Emit event when data is cleared due to error
if (typeof window !== 'undefined' && window.emitter) {
window.emitter.emit('CUSTOM_SNOOZE_TIME_CHANGED');
}
return null;
}
};
export const hasLastCustomSnoozeTime = () => {
return getLastCustomSnoozeTime() !== null;
};
export const formatLastCustomSnoozeTime = () => {
const timestamp = getLastCustomSnoozeTime();
if (!timestamp) return null;
try {
const date = new Date(timestamp * 1000); // Convert Unix timestamp to milliseconds
// Format as "Sat, 23 Aug, 8.16pm"
const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' });
const day = date.getDate();
const month = date.toLocaleDateString('en-US', { month: 'short' });
// Format time as 8.16pm (with dots instead of colons)
let hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
// Convert to 12-hour format
hours %= 12;
if (hours === 0) hours = 12; // 12am/12pm instead of 0am/0pm
const timeString = `${hours}.${minutes}${ampm}`;
return `${dayOfWeek}, ${day} ${month}, ${timeString}`;
} catch (error) {
return null;
}
};
@@ -12,7 +12,6 @@ import {
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import { getLastCustomSnoozeTime } from './customSnoozeStorage';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -52,9 +51,6 @@ export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME) {
// Return the stored timestamp directly (already in Unix format)
return getLastCustomSnoozeTime();
}
return parsedDate ? getUnixTime(parsedDate) : null;
@@ -233,7 +233,6 @@
"UNTIL_TOMORROW": "Until tomorrow",
"UNTIL_NEXT_MONTH": "Until next month",
"AN_HOUR_FROM_NOW": "Until an hour from now",
"UNTIL_LAST_CUSTOM_TIME": "Last snoozed {time}",
"UNTIL_CUSTOM_TIME": "Custom...",
"CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light",
@@ -51,6 +51,7 @@
},
"DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
@@ -266,6 +267,8 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -467,6 +470,13 @@
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"RESOLUTION_HEATMAP": {
"HEADER": "Resolutions",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "{count} conversation",
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents",
"LOADING_MESSAGE": "Loading agent metrics...",
@@ -2,6 +2,8 @@
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
@@ -11,7 +13,8 @@ import CreateAssistantDialog from 'dashboard/components-next/captain/pageCompone
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
import { useRouter } from 'vue-router';
const { isOnChatwootCloud } = useAccount();
const router = useRouter();
@@ -90,6 +93,7 @@ onMounted(() => store.dispatch('captainAssistants/get'));
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -16,6 +17,7 @@ import LimitBanner from 'dashboard/components-next/captain/pageComponents/docume
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
const documents = useMapGetter('captainDocuments/getRecords');
const assistants = useMapGetter('captainAssistants/getRecords');
@@ -121,6 +123,7 @@ onMounted(() => {
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/document-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-popover-dark.svg"
learn-more-url="https://chwt.app/captain-document"
@@ -7,6 +7,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
@@ -25,6 +26,7 @@ import LimitBanner from 'dashboard/components-next/captain/pageComponents/respon
const router = useRouter();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const responseMeta = useMapGetter('captainResponses/getMeta');
@@ -285,6 +287,7 @@ onMounted(() => {
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
@@ -6,7 +6,6 @@ import { useI18n } from 'vue-i18n';
import { useEmitter } from 'dashboard/composables/emitter';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getLastCustomSnoozeTime } from 'dashboard/helper/customSnoozeStorage';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/events';
import wootConstants from 'dashboard/constants/globals';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
@@ -32,17 +31,6 @@ const toggleStatus = async (status, snoozedUntil) => {
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
} else if (
snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME
) {
// Use the saved custom time
const lastCustomTime = getLastCustomSnoozeTime();
if (lastCustomTime) {
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, lastCustomTime);
} else {
// Fallback to showing custom modal if no saved time exists
showCustomSnoozeModal.value = true;
}
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
@@ -1,6 +1,6 @@
<script setup>
import '@chatwoot/ninja-keys';
import { ref, computed, watchEffect, onMounted, onUnmounted } from 'vue';
import { ref, computed, watchEffect, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
@@ -11,7 +11,6 @@ import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkAct
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
import wootConstants from 'dashboard/constants/globals';
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { emitter } from 'shared/helpers/mitt';
const store = useStore();
const { t } = useI18n();
@@ -27,8 +26,7 @@ const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const { inboxHotKeys } = useInboxHotKeys();
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const { conversationHotKeys, forceSnoozeActionsUpdate } =
useConversationHotKeys();
const { conversationHotKeys } = useConversationHotKeys();
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
@@ -80,37 +78,7 @@ watchEffect(() => {
}
});
onMounted(() => {
setCommandBarData();
// Make emitter globally available for storage helpers
window.emitter = emitter;
// Listen for custom snooze time changes to update actions
emitter.on('CUSTOM_SNOOZE_TIME_CHANGED', () => {
if (forceSnoozeActionsUpdate) {
forceSnoozeActionsUpdate();
}
});
// Backward compatibility
emitter.on('CUSTOM_SNOOZE_TIME_SAVED', () => {
if (forceSnoozeActionsUpdate) {
forceSnoozeActionsUpdate();
}
});
});
onUnmounted(() => {
// Clean up event listeners
emitter.off('CUSTOM_SNOOZE_TIME_CHANGED');
emitter.off('CUSTOM_SNOOZE_TIME_SAVED');
// Clean up global reference
if (window.emitter === emitter) {
delete window.emitter;
}
});
onMounted(setCommandBarData);
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
@@ -1,9 +1,11 @@
<script setup>
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
@@ -13,7 +15,8 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
const { currentAccount } = useAccount();
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
captainLimits,
@@ -24,6 +27,12 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -61,11 +70,45 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
store.dispatch('accounts/subscription');
await store.dispatch('accounts/subscription');
fetchLimits();
}
};
const handleBillingPageLogic = async () => {
// If self-hosted, redirect to dashboard
if (!isOnChatwootCloud.value) {
router.push({ name: 'home' });
return;
}
// Check if we've already attempted a refresh for billing setup
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
// If cloud user, fetch account details first
await fetchAccountDetails();
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
if (!billingRefreshAttempted) {
isWaitingForBilling.value = true;
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
setTimeout(() => {
window.location.reload();
}, 5000);
} else {
// We've already tried refreshing, so just show the no billing message
// Clear the flag for future visits
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
} else {
// Billing plan found, clear any existing refresh flag
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -76,14 +119,18 @@ const onToggleChatWindow = () => {
}
};
onMounted(fetchAccountDetails);
onMounted(handleBillingPageLogic);
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!hasABillingPlan"
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
:loading-message="
isWaitingForBilling
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -96,11 +96,12 @@ export default {
return parse(this.toTime, 'hh:mm a', new Date());
},
totalHours() {
if (this.timeSlot.openAllDay) {
return 24;
}
const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
return totalHours;
if (this.timeSlot.openAllDay) return '24h';
const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
},
hasError() {
return !this.timeSlot.valid;
@@ -211,7 +212,7 @@ export default {
v-if="isDayEnabled && !hasError"
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
>
{{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
{{ totalHours }}
</span>
</div>
</div>
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
will be generated by adding `step` minutes to the previous one.
The list is generated by starting with a random day and adding step minutes till end of the same day.
Always includes 11:59 PM as the final slot to complete the day.
*/
const date = new Date(1970, 1, 1);
const slots = [];
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
);
date.setMinutes(date.getMinutes() + step);
}
// Always add 11:59 PM as the final slot if it's not already included
const lastSlot = '11:59 PM';
if (!slots.includes(lastSlot)) {
slots.push(lastSlot);
}
return slots;
};
@@ -7,10 +7,19 @@ import {
} from '../businessHour';
describe('#generateTimeSlots', () => {
it('returns correct number of time slots', () => {
expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
it('returns correct number of time slots for 15-minute intervals', () => {
const slots = generateTimeSlots(15);
// 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
expect(slots.length).toStrictEqual(97);
});
it('returns correct time slots', () => {
it('returns correct number of time slots for 30-minute intervals', () => {
const slots = generateTimeSlots(30);
// 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
expect(slots.length).toStrictEqual(49);
});
it('returns correct time slots for 4-hour intervals', () => {
expect(generateTimeSlots(240)).toStrictEqual([
'12:00 AM',
'04:00 AM',
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
'12:00 PM',
'04:00 PM',
'08:00 PM',
'11:59 PM',
]);
});
it('always starts with 12:00 AM', () => {
expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
});
it('always ends with 11:59 PM', () => {
const slots15 = generateTimeSlots(15);
const slots30 = generateTimeSlots(30);
const slots60 = generateTimeSlots(60);
expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
});
it('includes 11:59 PM even when it would not be in regular intervals', () => {
const slots = generateTimeSlots(30);
expect(slots).toContain('11:59 PM');
expect(slots).toContain('11:30 PM'); // Regular interval
});
it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
// Test with a step that would naturally include 11:59 PM
const slots = generateTimeSlots(1); // 1-minute intervals
const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
expect(count11_59).toStrictEqual(1);
});
it('generates correct time format', () => {
const slots = generateTimeSlots(60);
expect(slots).toContain('01:00 AM');
expect(slots).toContain('12:00 PM');
expect(slots).toContain('01:00 PM');
expect(slots).toContain('11:00 PM');
});
it('handles edge case with very large step', () => {
const slots = generateTimeSlots(1440); // 24 hours
expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
});
});
describe('#getTime', () => {
@@ -1,6 +1,7 @@
<script setup>
import ReportHeader from './components/ReportHeader.vue';
import HeatmapContainer from './components/HeatmapContainer.vue';
import ConversationHeatmapContainer from './components/heatmaps/ConversationHeatmapContainer.vue';
import ResolutionHeatmapContainer from './components/heatmaps/ResolutionHeatmapContainer.vue';
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
@@ -10,7 +11,8 @@ import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vu
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
<div class="flex flex-col gap-4 pb-6">
<StatsLiveReportsContainer />
<HeatmapContainer />
<ConversationHeatmapContainer />
<ResolutionHeatmapContainer />
<AgentLiveReportContainer />
<TeamLiveReportContainer />
</div>
@@ -1,175 +0,0 @@
<script setup>
import { computed } from 'vue';
import format from 'date-fns/format';
import getDay from 'date-fns/getDay';
import { getQuantileIntervals } from '@chatwoot/utils';
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
import { useI18n } from 'vue-i18n';
const props = defineProps({
heatmapData: {
type: Array,
default: () => [],
},
numberOfRows: {
type: Number,
default: 7,
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const processedData = computed(() => {
return groupHeatmapByDay(props.heatmapData);
});
const quantileRange = computed(() => {
const flattendedData = props.heatmapData.map(data => data.value);
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
});
function getCountTooltip(value) {
if (!value) {
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
}
if (value === 1) {
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
count: value,
});
}
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
count: value,
});
}
function formatDate(dateString) {
return format(new Date(dateString), 'MMM d, yyyy');
}
function getDayOfTheWeek(date) {
const dayIndex = getDay(date);
const days = [
t('DAYS_OF_WEEK.SUNDAY'),
t('DAYS_OF_WEEK.MONDAY'),
t('DAYS_OF_WEEK.TUESDAY'),
t('DAYS_OF_WEEK.WEDNESDAY'),
t('DAYS_OF_WEEK.THURSDAY'),
t('DAYS_OF_WEEK.FRIDAY'),
t('DAYS_OF_WEEK.SATURDAY'),
];
return days[dayIndex];
}
function getHeatmapLevelClass(value) {
if (!value) return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50';
let level = [...quantileRange.value, Infinity].findIndex(
range => value <= range && value > 0
);
if (level > 6) level = 5;
if (level === 0) {
return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50';
}
const classes = [
'bg-n-blue-3 dark:outline-n-blue-4',
'bg-n-blue-5 dark:outline-n-blue-6',
'bg-n-blue-7 dark:outline-n-blue-8',
'bg-n-blue-8 dark:outline-n-blue-9',
'bg-n-blue-10 dark:outline-n-blue-8',
'bg-n-blue-11 dark:outline-n-blue-10',
];
return classes[level - 1];
}
</script>
<template>
<div
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
>
<template v-if="isLoading">
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="ii in numberOfRows"
:key="ii"
class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]"
/>
</div>
<div class="grid gap-[5px] w-full min-w-[700px]">
<div
v-for="ii in numberOfRows"
:key="ii"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
>
<div
v-for="jj in 24"
:key="jj"
class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse"
/>
</div>
</div>
<div />
<div
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11"
>
<div
v-for="ii in 24"
:key="ii"
class="flex items-center justify-center"
>
{{ ii - 1 }} – {{ ii }}
</div>
</div>
</template>
<template v-else>
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="dateKey in processedData.keys()"
:key="dateKey"
class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center"
>
{{ getDayOfTheWeek(new Date(dateKey)) }}
<time class="font-normal text-n-slate-11">
{{ formatDate(dateKey) }}
</time>
</div>
</div>
<div class="grid gap-[5px] w-full min-w-[700px]">
<div
v-for="dateKey in processedData.keys()"
:key="dateKey"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
>
<div
v-for="data in processedData.get(dateKey)"
:key="data.timestamp"
v-tooltip.top="getCountTooltip(data.value)"
class="h-8 rounded-sm shadow-inner dark:outline dark:outline-1"
:class="getHeatmapLevelClass(data.value)"
/>
</div>
</div>
<div />
<div
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12"
>
<div
v-for="ii in 24"
:key="ii"
class="flex items-center justify-center"
>
{{ ii - 1 }} – {{ ii }}
</div>
</div>
</template>
</div>
</template>
@@ -1,119 +0,0 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToggle } from '@vueuse/core';
import MetricCard from './overview/MetricCard.vue';
import ReportHeatmap from './Heatmap.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const accountConversationHeatmap = useMapGetter(
'getAccountConversationHeatmapData'
);
const { t } = useI18n();
const menuItems = [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 29,
},
];
const selectedDays = ref(6);
const selectedDayFilter = computed(() =>
menuItems.find(menuItem => menuItem.value === selectedDays.value)
);
const downloadHeatmapData = () => {
const to = endOfDay(new Date());
store.dispatch('downloadAccountConversationHeatmap', {
daysBefore: selectedDays.value,
to: getUnixTime(to),
});
};
const [showDropdown, toggleDropdown] = useToggle();
const fetchHeatmapData = () => {
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
return;
}
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, Number(selectedDays.value)));
store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count',
from: getUnixTime(from),
to: getUnixTime(to),
groupBy: 'hour',
businessHours: false,
});
};
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedDays.value = value;
fetchHeatmapData();
};
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
onMounted(() => {
fetchHeatmapData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
<template #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedDayFilter.label"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
<Button
sm
slate
faded
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
class="rounded-md group-hover:bg-n-alpha-2"
@click="downloadHeatmapData"
/>
</template>
<ReportHeatmap
:heatmap-data="accountConversationHeatmap"
:number-of-rows="selectedDays + 1"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/>
</MetricCard>
</div>
</template>
@@ -0,0 +1,214 @@
<script setup>
import { computed } from 'vue';
import { useMemoize } from '@vueuse/core';
import format from 'date-fns/format';
import getDay from 'date-fns/getDay';
import { getQuantileIntervals } from '@chatwoot/utils';
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
import { useI18n } from 'vue-i18n';
import { useHeatmapTooltip } from './composables/useHeatmapTooltip';
import HeatmapTooltip from './HeatmapTooltip.vue';
const props = defineProps({
heatmapData: {
type: Array,
default: () => [],
},
numberOfRows: {
type: Number,
default: 7,
},
isLoading: {
type: Boolean,
default: false,
},
colorScheme: {
type: String,
default: 'blue',
validator: value => ['blue', 'green'].includes(value),
},
});
const { t } = useI18n();
const dataRows = computed(() => {
const groupedData = groupHeatmapByDay(props.heatmapData);
return Array.from(groupedData.keys()).map(dateKey => {
const rowData = groupedData.get(dateKey);
return {
dateKey,
data: rowData,
dataHash: rowData.map(d => d.value).join(','),
};
});
});
const quantileRange = computed(() => {
const flattendedData = props.heatmapData.map(data => data.value);
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
});
function formatDate(dateString) {
return format(new Date(dateString), 'MMM d, yyyy');
}
const DAYS_OF_WEEK = [
t('DAYS_OF_WEEK.SUNDAY'),
t('DAYS_OF_WEEK.MONDAY'),
t('DAYS_OF_WEEK.TUESDAY'),
t('DAYS_OF_WEEK.WEDNESDAY'),
t('DAYS_OF_WEEK.THURSDAY'),
t('DAYS_OF_WEEK.FRIDAY'),
t('DAYS_OF_WEEK.SATURDAY'),
];
function getDayOfTheWeek(date) {
const dayIndex = getDay(date);
return DAYS_OF_WEEK[dayIndex];
}
const COLOR_SCHEMES = {
blue: [
'bg-n-blue-3 border border-n-blue-4/30',
'bg-n-blue-5 border border-n-blue-6/30',
'bg-n-blue-7 border border-n-blue-8/30',
'bg-n-blue-8 border border-n-blue-9/30',
'bg-n-blue-10 border border-n-blue-8/30',
'bg-n-blue-11 border border-n-blue-10/30',
],
green: [
'bg-n-teal-3 border border-n-teal-4/30',
'bg-n-teal-5 border border-n-teal-6/30',
'bg-n-teal-7 border border-n-teal-8/30',
'bg-n-teal-8 border border-n-teal-9/30',
'bg-n-teal-10 border border-n-teal-8/30',
'bg-n-teal-11 border border-n-teal-10/30',
],
};
// Memoized function to calculate CSS class for heatmap cell intensity levels
const getHeatmapLevelClass = useMemoize(
(value, quantileRangeArray, colorScheme) => {
if (!value)
return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30';
let level = [...quantileRangeArray, Infinity].findIndex(
range => value <= range && value > 0
);
if (level > 6) level = 5;
if (level === 0) {
return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30';
}
return COLOR_SCHEMES[colorScheme][level - 1];
}
);
function getHeatmapClass(value) {
return getHeatmapLevelClass(value, quantileRange.value, props.colorScheme);
}
// Tooltip composable
const tooltip = useHeatmapTooltip();
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
>
<template v-if="isLoading">
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="ii in numberOfRows"
:key="ii"
class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]"
/>
</div>
<div class="grid gap-[5px] w-full min-w-[700px]">
<div
v-for="ii in numberOfRows"
:key="ii"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
>
<div
v-for="jj in 24"
:key="jj"
class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse"
/>
</div>
</div>
<div />
<div
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11"
>
<div
v-for="ii in 24"
:key="ii"
class="flex items-center justify-center"
>
{{ ii - 1 }}
</div>
</div>
</template>
<template v-else>
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="row in dataRows"
:key="row.dateKey"
v-memo="[row.dateKey]"
class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center"
>
{{ getDayOfTheWeek(new Date(row.dateKey)) }}
<time class="font-normal text-n-slate-11">
{{ formatDate(row.dateKey) }}
</time>
</div>
</div>
<div
class="grid gap-[5px] w-full min-w-[700px]"
style="content-visibility: auto"
>
<div
v-for="row in dataRows"
:key="row.dateKey"
v-memo="[row.dataHash, colorScheme]"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
style="content-visibility: auto"
>
<div
v-for="data in row.data"
:key="data.timestamp"
class="h-8 rounded-sm cursor-pointer"
:class="getHeatmapClass(data.value)"
@mouseenter="tooltip.show($event, data.value)"
@mouseleave="tooltip.hide"
/>
</div>
</div>
<div />
<div
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12"
>
<div
v-for="ii in 24"
:key="ii"
class="flex items-center justify-center"
>
{{ ii - 1 }}
</div>
</div>
</template>
<HeatmapTooltip
:visible="tooltip.visible.value"
:x="tooltip.x.value"
:y="tooltip.y.value"
:value="tooltip.value.value"
/>
</div>
</template>
@@ -0,0 +1,265 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToggle } from '@vueuse/core';
import MetricCard from '../overview/MetricCard.vue';
import BaseHeatmap from './BaseHeatmap.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
import format from 'date-fns/format';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { downloadCsvFile } from 'dashboard/helper/downloadHelper';
const props = defineProps({
metric: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
downloadTitle: {
type: String,
required: true,
},
storeGetter: {
type: String,
required: true,
},
storeAction: {
type: String,
required: true,
},
downloadAction: {
type: String,
default: '',
},
uiFlagKey: {
type: String,
required: true,
},
colorScheme: {
type: String,
default: 'blue',
},
});
const store = useStore();
const { t } = useI18n();
const uiFlags = useMapGetter('getOverviewUIFlags');
const heatmapData = useMapGetter(props.storeGetter);
const inboxes = useMapGetter('inboxes/getInboxes');
const menuItems = [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
value: 13,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 29,
},
];
const selectedDays = ref(6);
const selectedInbox = ref(null);
const selectedDayFilter = computed(() =>
menuItems.find(menuItem => menuItem.value === selectedDays.value)
);
const inboxMenuItems = computed(() => {
return [
{
label: t('INBOX_REPORTS.ALL_INBOXES'),
value: null,
action: 'select_inbox',
},
...inboxes.value.map(inbox => ({
label: inbox.name,
value: inbox.id,
action: 'select_inbox',
})),
];
});
const selectedInboxFilter = computed(() => {
if (!selectedInbox.value) {
return { label: t('INBOX_REPORTS.ALL_INBOXES') };
}
return inboxMenuItems.value.find(
item => item.value === selectedInbox.value.id
);
});
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
const downloadHeatmapData = () => {
const to = endOfDay(new Date());
// If no inbox is selected and download action exists, use backend endpoint
if (!selectedInbox.value && props.downloadAction) {
store.dispatch(props.downloadAction, {
daysBefore: selectedDays.value,
to: getUnixTime(to),
});
return;
}
// Generate CSV from store data
if (!heatmapData.value || heatmapData.value.length === 0) {
return;
}
// Create CSV headers
const headers = ['Date', 'Hour', props.title];
const rows = [headers];
// Convert heatmap data to rows
heatmapData.value.forEach(item => {
const date = new Date(item.timestamp * 1000);
const dateStr = format(date, 'yyyy-MM-dd');
const hour = date.getHours();
rows.push([dateStr, `${hour}:00 - ${hour + 1}:00`, item.value]);
});
// Convert to CSV string
const csvContent = rows.map(row => row.join(',')).join('\n');
// Generate filename
const inboxName = selectedInbox.value
? `_${selectedInbox.value.name.replace(/[^a-z0-9]/gi, '_')}`
: '';
const fileName = `${props.downloadTitle}${inboxName}_${format(
new Date(),
'dd-MM-yyyy'
)}.csv`;
// Download the file
downloadCsvFile(fileName, csvContent);
};
const [showDropdown, toggleDropdown] = useToggle();
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
const fetchHeatmapData = () => {
if (isLoading.value) {
return;
}
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, Number(selectedDays.value)));
const params = {
metric: props.metric,
from: getUnixTime(from),
to: getUnixTime(to),
groupBy: 'hour',
businessHours: false,
};
// Add inbox filtering if an inbox is selected
if (selectedInbox.value) {
params.type = 'inbox';
params.id = selectedInbox.value.id;
}
store.dispatch(props.storeAction, params);
};
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedDays.value = value;
fetchHeatmapData();
};
const handleInboxAction = ({ value }) => {
toggleInboxDropdown(false);
selectedInbox.value = value
? inboxes.value.find(inbox => inbox.id === value)
: null;
fetchHeatmapData();
};
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
onMounted(() => {
store.dispatch('inboxes/get');
fetchHeatmapData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="title">
<template #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedDayFilter.label"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
<div
v-on-clickaway="() => toggleInboxDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedInboxFilter.label"
class="rounded-md group-hover:bg-n-alpha-2 max-w-[200px]"
@click="toggleInboxDropdown()"
/>
<DropdownMenu
v-if="showInboxDropdown"
:menu-items="inboxMenuItems"
show-search
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]"
@action="handleInboxAction($event)"
/>
</div>
<Button
sm
slate
faded
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
class="rounded-md group-hover:bg-n-alpha-2"
@click="downloadHeatmapData"
/>
</template>
<BaseHeatmap
:heatmap-data="heatmapData"
:number-of-rows="selectedDays + 1"
:is-loading="isLoading"
:color-scheme="colorScheme"
/>
</MetricCard>
</div>
</template>
@@ -0,0 +1,18 @@
<script setup>
import BaseHeatmapContainer from './BaseHeatmapContainer.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<BaseHeatmapContainer
metric="conversations_count"
:title="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')"
download-title="conversation_heatmap"
store-getter="getAccountConversationHeatmapData"
store-action="fetchAccountConversationHeatmap"
download-action="downloadAccountConversationHeatmap"
ui-flag-key="isFetchingAccountConversationsHeatmap"
/>
</template>
@@ -0,0 +1,57 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
value: {
type: Number,
default: null,
},
});
const { t } = useI18n();
const tooltipText = computed(() => {
if (!props.value) {
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
}
if (props.value === 1) {
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
count: props.value,
});
}
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
count: props.value,
});
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div
class="fixed z-50 px-2 py-1 text-xs font-medium text-n-slate-6 bg-n-slate-12 rounded shadow-lg pointer-events-none transition-[opacity,transform] duration-75"
:class="{ 'opacity-100': visible, 'opacity-0': !visible }"
:style="{
left: `${x}px`,
top: `${y - 15}px`,
transform: 'translateX(-50%) translateZ(0)',
willChange: 'transform, opacity',
}"
>
{{ tooltipText }}
</div>
</template>
@@ -0,0 +1,18 @@
<script setup>
import BaseHeatmapContainer from './BaseHeatmapContainer.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<BaseHeatmapContainer
metric="resolutions_count"
:title="t('OVERVIEW_REPORTS.RESOLUTION_HEATMAP.HEADER')"
download-title="resolution_heatmap"
store-getter="getAccountResolutionHeatmapData"
store-action="fetchAccountResolutionHeatmap"
ui-flag-key="isFetchingAccountResolutionsHeatmap"
color-scheme="green"
/>
</template>
@@ -0,0 +1,34 @@
import { ref } from 'vue';
export function useHeatmapTooltip() {
const visible = ref(false);
const x = ref(0);
const y = ref(0);
const value = ref(null);
let timeoutId = null;
const show = (event, cellValue) => {
clearTimeout(timeoutId);
// Update position immediately for smooth movement
const rect = event.target.getBoundingClientRect();
x.value = rect.left + rect.width / 2;
y.value = rect.top;
// Only delay content update and visibility
timeoutId = setTimeout(() => {
value.value = cellValue;
visible.value = true;
}, 100);
};
const hide = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
visible.value = false;
}, 50);
};
return { visible, x, y, value, show, hide };
}
@@ -26,12 +26,12 @@ const fetchMetaData = async (commit, params) => {
};
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
1500,
10000,
false,
10000
20000
);
export const actions = {
@@ -154,7 +154,10 @@ const equalTo = (filterValue, conversationValue) => {
* It only works with string values and returns false for non-string types.
*/
const contains = (filterValue, conversationValue) => {
if (typeof conversationValue === 'string') {
if (
typeof conversationValue === 'string' &&
typeof filterValue === 'string'
) {
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
}
return false;
@@ -190,10 +193,8 @@ const compareDates = (conversationValue, filterValue, compareFn) => {
const matchesCondition = (conversationValue, filter) => {
const { filter_operator: filterOperator, values } = filter;
// Handle null/undefined values
if (conversationValue === null || conversationValue === undefined) {
return filterOperator === 'is_not_present';
}
const isNullish =
conversationValue === null || conversationValue === undefined;
const filterValue = Array.isArray(values)
? values.map(resolveValue)
@@ -213,10 +214,10 @@ const matchesCondition = (conversationValue, filter) => {
return !contains(filterValue, conversationValue);
case 'is_present':
return true; // We already handled null/undefined above
return !isNullish;
case 'is_not_present':
return false; // We already handled null/undefined above
return isNullish;
case 'is_greater_than':
return compareDates(conversationValue, filterValue, (a, b) => a > b);
@@ -225,6 +226,10 @@ const matchesCondition = (conversationValue, filter) => {
return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': {
if (isNullish) {
return false;
}
const today = new Date();
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
const targetDate = new Date(today.getTime() - daysInMilliseconds);
@@ -192,6 +192,32 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when assignee is null', () => {
const conversation = { meta: { assignee: null } };
const filters = [
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: { id: 1, name: 'John Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with not_equal_to operator when assignee is null', () => {
const conversation = { meta: { assignee: null } };
const filters = [
{
attribute_key: 'assignee_id',
filter_operator: 'not_equal_to',
values: { id: 1, name: 'John Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with is_not_present operator for assignee_id', () => {
const conversation = { meta: { assignee: null } };
const filters = [
@@ -285,6 +311,58 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should not match contains operator when display_id is null', () => {
const conversation = { id: null };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'contains',
values: '234',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should not match contains operator when filter value is null', () => {
const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'contains',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match does_not_contain operator when display_id is null', () => {
const conversation = { id: null };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'does_not_contain',
values: '234',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match does_not_contain operator when filter value is null', () => {
const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'does_not_contain',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with does_not_contain operator when value is not present', () => {
const conversation = { id: '12345' };
const filters = [
@@ -57,11 +57,13 @@ const state = {
uiFlags: {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAccountResolutionsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
accountResolutionHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
@@ -89,6 +91,9 @@ const getters = {
getAccountConversationHeatmapData(_state) {
return _state.overview.accountConversationHeatmap;
},
getAccountResolutionHeatmapData(_state) {
return _state.overview.accountResolutionHeatmap;
},
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
@@ -130,6 +135,16 @@ export const actions = {
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
},
fetchAccountResolutionHeatmap({ commit }, reportObj) {
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true);
Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => {
let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data);
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false);
});
},
fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
@@ -287,6 +302,9 @@ const mutations = {
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountConversationHeatmap = heatmapData;
},
[types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountResolutionHeatmap = heatmapData;
},
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[metric] = value;
},
@@ -299,6 +317,9 @@ const mutations = {
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
},
[types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag;
},
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
_state.accountSummary = summaryData;
},
@@ -187,6 +187,8 @@ export default {
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
SET_RESOLUTION_HEATMAP_DATA: 'SET_RESOLUTION_HEATMAP_DATA',
TOGGLE_RESOLUTION_HEATMAP_LOADING: 'TOGGLE_RESOLUTION_HEATMAP_LOADING',
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
+4
View File
@@ -1,3 +1,7 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
def perform(url, payload, webhook_type = :agent_bot_webhook)
super(url, payload, webhook_type)
end
end
+6
View File
@@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord
AUTHORIZATION_ERROR_THRESHOLD = 10
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :imap_password
encrypts :smtp_password
end
self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
+6
View File
@@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord
include Channelable
include Reauthorizable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :page_access_token
encrypts :user_access_token
end
self.table_name = 'channel_facebook_pages'
validates :page_id, uniqueness: { scope: :account_id }
+3
View File
@@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord
include Reauthorizable
self.table_name = 'channel_instagram'
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :access_token if Chatwoot.encryption_configured?
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :access_token, presence: true
+6
View File
@@ -18,6 +18,12 @@
class Channel::Line < ApplicationRecord
include Channelable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :line_channel_secret
encrypts :line_channel_token
end
self.table_name = 'channel_line'
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
+3
View File
@@ -17,6 +17,9 @@
class Channel::Telegram < ApplicationRecord
include Channelable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured?
self.table_name = 'channel_telegram'
EDITABLE_ATTRS = [:bot_token].freeze
+3
View File
@@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord
self.table_name = 'channel_twilio_sms'
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :auth_token if Chatwoot.encryption_configured?
validates :account_sid, presence: true
# The same parameter is used to store api_key_secret if api_key authentication is opted
validates :auth_token, presence: true
+6
View File
@@ -19,6 +19,12 @@
class Channel::TwitterProfile < ApplicationRecord
include Channelable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :twitter_access_token
encrypts :twitter_access_token_secret
end
self.table_name = 'channel_twitter_profiles'
validates :profile_id, uniqueness: { scope: :account_id }
@@ -106,7 +106,7 @@ module ActivityMessageHandler
end
def generate_assignee_change_activity_content(user_name)
params = { assignee_name: assignee&.name, user_name: user_name }.compact
params = { assignee_name: assignee&.name || '', user_name: user_name }
key = assignee_id ? 'assigned' : 'removed'
key = 'self_assigned' if self_assign? assignee_id
I18n.t("conversations.activity.assignee.#{key}", **params)
+3
View File
@@ -21,6 +21,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# company_id :bigint
#
# Indexes
#
@@ -28,6 +29,7 @@
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
@@ -244,3 +246,4 @@ class Contact < ApplicationRecord
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
end
end
Contact.include_mod_with('Concerns::Contact')
+3
View File
@@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord
before_validation :ensure_hook_type
after_create :trigger_setup_if_crm
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :access_token, deterministic: true if Chatwoot.encryption_configured?
validates :account_id, presence: true
validates :app_id, presence: true
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
+6 -1
View File
@@ -39,7 +39,7 @@
#
class Message < ApplicationRecord
searchkick callbacks: :async if ChatwootApp.advanced_search_allowed?
searchkick callbacks: false if ChatwootApp.advanced_search_allowed?
include MessageFilterHelpers
include Liquidable
@@ -135,6 +135,7 @@ class Message < ApplicationRecord
after_create_commit :execute_after_create_commit_callbacks
after_update_commit :dispatch_update_event
after_commit :reindex_for_search, if: :should_index?, on: [:create, :update]
def channel_token
@token ||= inbox.channel.try(:page_access_token)
@@ -436,6 +437,10 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
def reindex_for_search
reindex(mode: :async)
end
end
Message.prepend_mod_with('Message')
+1 -1
View File
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
+1 -1
View File
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
@@ -47,6 +47,15 @@ module Whatsapp::IncomingMessageServiceHelpers
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
end
def argentina_phone_number?(phone_number)
phone_number.match(/^54/)
end
def normalised_argentina_mobil_number(phone_number)
# Remove 9 before country code
phone_number.sub(/^549/, '54')
end
def processed_waid(waid)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
end
@@ -0,0 +1,18 @@
# Handles Argentina phone number normalization
#
# Argentina phone numbers can appear with or without "9" after country code
# This normalizer removes the "9" when present to create consistent format: 54 + area + number
class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
def normalize(waid)
return waid unless handles_country?(waid)
# Remove "9" after country code if present (549 → 54)
waid.sub(/^549/, '54')
end
private
def country_code_pattern
/^54/
end
end
@@ -1,5 +1,5 @@
# Service to handle phone number normalization for WhatsApp messages
# Currently supports Brazil phone number format variations
# Currently supports Brazil and Argentina phone number format variations
# Designed to be extensible for additional countries in future PRs
#
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
@@ -34,6 +34,7 @@ class Whatsapp::PhoneNumberNormalizationService
end
NORMALIZERS = [
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
].freeze
end
@@ -1,9 +1,11 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
<% elsif @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% end %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% end %>
<% end %>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.6.0'
version: '4.7.0'
development:
<<: *shared
+6
View File
@@ -75,7 +75,11 @@ module Chatwoot
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
# TODO: Remove once encryption is mandatory and legacy plaintext is migrated.
config.active_record.encryption.support_unencrypted_data = true
# Extend deterministic queries so they match both encrypted and plaintext rows
config.active_record.encryption.extend_queries = true
# Store a per-row key reference to support future key rotation
config.active_record.encryption.store_key_references = true
end
end
@@ -94,6 +98,8 @@ module Chatwoot
end
def self.encryption_configured?
# TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out),
# remove this guard and assume encryption is always enabled.
# Check if proper encryption keys are configured
# MFA/2FA features should only be enabled when proper keys are set
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
+5
View File
@@ -76,6 +76,9 @@ en:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
companies:
domain:
invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -199,6 +202,8 @@ en:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
open: 'Conversation was marked open by %{user_name}'
agent_bot:
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
status:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
+1
View File
@@ -153,6 +153,7 @@ Rails.application.routes.draw do
end
end
resources :companies, only: [:index, :show, :create, :update, :destroy]
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
collection do
get :active
+1
View File
@@ -27,6 +27,7 @@
- purgable
- housekeeping
- async_database_migration
- bulk_reindex_low
- active_storage_analysis
- active_storage_purge
- action_mailbox_incineration
@@ -0,0 +1,14 @@
class CreateCompanies < ActiveRecord::Migration[7.1]
def change
create_table :companies do |t|
t.string :name, null: false
t.string :domain
t.text :description
t.references :account, null: false
t.timestamps
end
add_index :companies, [:name, :account_id]
add_index :companies, [:domain, :account_id]
end
end
@@ -0,0 +1,5 @@
class AddCompanyToContacts < ActiveRecord::Migration[7.1]
def change
add_reference :contacts, :company, null: true
end
end
+14
View File
@@ -570,6 +570,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
create_table "companies", force: :cascade do |t|
t.string "name", null: false
t.string "domain"
t.text "description"
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
create_table "contact_inboxes", force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
@@ -602,6 +614,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
@@ -609,6 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
@@ -0,0 +1,40 @@
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_authorization
before_action :fetch_company, only: [:show, :update, :destroy]
def index
@companies = Current.account.companies.ordered_by_name
end
def show; end
def create
@company = Current.account.companies.build(company_params)
@company.save!
end
def update
@company.update!(company_params)
end
def destroy
@company.destroy!
head :ok
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
authorize(Company)
end
def fetch_company
@company = Current.account.companies.find(params[:id])
end
def company_params
params.require(:company).permit(:name, :domain, :description, :avatar)
end
end
+33
View File
@@ -0,0 +1,33 @@
# == Schema Information
#
# Table name: companies
#
# id :bigint not null, primary key
# description :text
# domain :string
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_companies_on_account_id (account_id)
# index_companies_on_domain_and_account_id (domain,account_id)
# index_companies_on_name_and_account_id (name,account_id)
#
class Company < ApplicationRecord
include Avatarable
validates :account_id, presence: true
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
validates :domain, allow_blank: true, format: {
with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
message: I18n.t('errors.companies.domain.invalid')
}
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
belongs_to :account
has_many :contacts, dependent: :nullify
scope :ordered_by_name, -> { order(:name) }
end
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
@@ -0,0 +1,6 @@
module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true
end
end
+21
View File
@@ -0,0 +1,21 @@
class CompanyPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
true
end
def update?
true
end
def destroy?
@account_user.administrator?
end
end
@@ -0,0 +1,7 @@
json.id company.id
json.name company.name
json.domain company.domain
json.description company.description
json.avatar_url company.avatar_url
json.created_at company.created_at
json.updated_at company.updated_at
@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end
@@ -0,0 +1,5 @@
json.payload do
json.array! @companies do |company|
json.partial! 'company', company: company
end
end
@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end
@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end
@@ -70,7 +70,9 @@ module Integrations::Slack::SlackMessageHelper
case attachment[:filetype]
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
:image
when 'pdf'
when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
:video
else
:file
end
end
+2
View File
@@ -6,6 +6,8 @@ module Limits
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
COMPANY_NAME_LENGTH_LIMIT = 100
COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
MAX_CUSTOM_FILTERS_PER_USER = 1000
def self.conversation_message_per_minute_limit
+25 -11
View File
@@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
end
# rubocop:disable Metrics/MethodLength
def create_conversation(created_at:)
conversation = nil
should_resolve = false
resolution_time = nil
ActiveRecord::Base.transaction do
travel_to(created_at) do
@@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator
add_labels_to_conversation(conversation)
create_messages_for_conversation(conversation)
resolve_conversation_if_needed(conversation)
# Determine if should resolve but don't update yet
should_resolve = rand > 0.3
if should_resolve
resolution_delay = rand((30.minutes)..(24.hours))
resolution_time = created_at + resolution_delay
end
end
travel_back
end
# Now resolve outside of time travel if needed
if should_resolve && resolution_time
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:status, :resolved)
conversation.update_column(:updated_at, resolution_time)
# rubocop:enable Rails/SkipsModelValidations
# Trigger the event with proper timestamp
travel_to(resolution_time) do
trigger_conversation_resolved_event(conversation)
end
travel_back
end
conversation
end
# rubocop:enable Metrics/MethodLength
private
@@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator
message_creator.create_messages
end
def resolve_conversation_if_needed(conversation)
return unless rand < 0.7
resolution_delay = rand((30.minutes)..(24.hours))
travel(resolution_delay)
conversation.update!(status: :resolved)
trigger_conversation_resolved_event(conversation)
end
def trigger_conversation_resolved_event(conversation)
event_data = { conversation: conversation }
+23 -4
View File
@@ -31,14 +31,33 @@ class Webhooks::Trigger
end
def handle_error(error)
return unless should_handle_error?
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message
update_message_status(error)
case @webhook_type
when :agent_bot_webhook
conversation = message.conversation
return unless conversation&.pending?
conversation.open!
create_agent_bot_error_activity(conversation)
when :api_inbox_webhook
update_message_status(error)
end
end
def should_handle_error?
@webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
def create_agent_bot_error_activity(conversation)
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
end
def activity_message_params(conversation, content)
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
end
def update_message_status(error)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.6.0",
"version": "4.7.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
+58
View File
@@ -0,0 +1,58 @@
# Bulk reindex all messages with throttling to prevent DB overload
# This creates jobs slowly to avoid overwhelming the database connection pool
# Usage: RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb
JOBS_PER_MINUTE = 50 # Adjust based on your DB capacity
BATCH_SIZE = 1000 # Messages per job
batch_count = 0
total_batches = (Message.count / BATCH_SIZE.to_f).ceil
start_time = Time.zone.now
index_name = Message.searchkick_index.name
puts '=' * 80
puts "Bulk Reindex Started at #{start_time}"
puts '=' * 80
puts "Total messages: #{Message.count}"
puts "Batch size: #{BATCH_SIZE}"
puts "Total batches: #{total_batches}"
puts "Index name: #{index_name}"
puts "Rate: #{JOBS_PER_MINUTE} jobs/minute (#{JOBS_PER_MINUTE * BATCH_SIZE} messages/minute)"
puts "Estimated time: #{(total_batches / JOBS_PER_MINUTE.to_f / 60).round(2)} hours"
puts '=' * 80
puts ''
sleep(15)
Message.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, index|
batch_count += 1
# Enqueue to low priority queue with proper format
Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
class_name: 'Message',
index_name: index_name,
batch_id: index,
record_ids: batch.map(&:id) # Keep as integers like Message.reindex does
)
# Throttle: wait after every N jobs
if (batch_count % JOBS_PER_MINUTE).zero?
elapsed = Time.zone.now - start_time
progress = (batch_count.to_f / total_batches * 100).round(2)
queue_size = Sidekiq::Queue.new('bulk_reindex_low').size
puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}] Progress: #{batch_count}/#{total_batches} (#{progress}%)"
puts " Queue size: #{queue_size}"
puts " Elapsed: #{(elapsed / 3600).round(2)} hours"
puts " ETA: #{((elapsed / batch_count * (total_batches - batch_count)) / 3600).round(2)} hours remaining"
puts ''
sleep(60)
end
end
puts '=' * 80
puts "Done! Created #{batch_count} jobs"
puts "Total time: #{((Time.zone.now - start_time) / 3600).round(2)} hours"
puts '=' * 80
+19
View File
@@ -0,0 +1,19 @@
# Monitor bulk reindex progress
# RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
puts 'Monitoring bulk reindex progress (Ctrl+C to stop)...'
puts ''
loop do
bulk_queue = Sidekiq::Queue.new('bulk_reindex_low')
prod_queue = Sidekiq::Queue.new('async_database_migration')
retry_set = Sidekiq::RetrySet.new
puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}]"
puts " Bulk Reindex Queue: #{bulk_queue.size} jobs"
puts " Production Queue: #{prod_queue.size} jobs"
puts " Retry Queue: #{retry_set.size} jobs"
puts " #{('-' * 60)}"
sleep(30)
end
+58
View File
@@ -0,0 +1,58 @@
# Reindex messages for a single account
# Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]
#account_id = ARGV[0]&.to_i
days_back = (ARGV[1] || 30).to_i
# if account_id.nil? || account_id.zero?
# puts "Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]"
# puts "Example: bundle exec rails runner script/reindex_single_account.rb 93293 30"
# exit 1
# end
# account = Account.find(account_id)
# puts "=" * 80
# puts "Reindexing messages for: #{account.name} (ID: #{account.id})"
# puts "=" * 80
# Enable feature if not already enabled
# unless account.feature_enabled?('advanced_search_indexing')
# puts "Enabling advanced_search_indexing feature..."
# account.enable_features(:advanced_search_indexing)
# account.save!
# end
# Get messages to index
# messages = Message.where(account_id: account.id)
# .where(message_type: [0, 1]) # incoming/outgoing only
# .where('created_at >= ?', days_back.days.ago)
messages = Message.where('created_at >= ?', days_back.days.ago)
puts "Found #{messages.count} messages to index (last #{days_back} days)"
puts ''
sleep(15)
# Create bulk reindex jobs
index_name = Message.searchkick_index.name
batch_count = 0
messages.find_in_batches(batch_size: 1000).with_index do |batch, index|
Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
class_name: 'Message',
index_name: index_name,
batch_id: index,
record_ids: batch.map(&:id)
)
batch_count += 1
print '.'
sleep(0.5) # Small delay
end
puts ''
puts '=' * 80
puts "Done! Created #{batch_count} bulk reindex jobs"
puts 'Messages will be indexed shortly via the bulk_reindex_low queue'
puts '=' * 80
@@ -179,6 +179,63 @@ describe Messages::MessageBuilder do
expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
end
context 'when custom email content is provided' do
before do
account.enable_features('quoted_email_reply')
end
it 'creates message with custom HTML email content' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom <strong>HTML</strong> content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content'
expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
end
it 'does not process custom email content when quoted_email_reply feature is disabled' do
account.disable_features('quoted_email_reply')
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'does not process custom email content for private messages' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>',
private: true
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'falls back to default behavior when no custom email content is provided' do
params = ActionController::Parameters.new({
content: 'Regular **markdown** content'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('<strong>markdown</strong>')
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
end
end
end
end
end
@@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe 'Companies API', type: :request do
let(:account) { create(:account) }
describe 'GET /api/v1/accounts/{account.id}/companies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/companies"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:company1) { create(:company, name: 'Company 1', account: account) }
let!(:company2) { create(:company, account: account) }
it 'returns all companies' do
get "/api/v1/accounts/#{account.id}/companies",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(2)
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'returns the company' do
get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq(company.name)
expect(response_body['payload']['id']).to eq(company.id)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/companies' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:valid_params) do
{
company: {
name: 'New Company',
domain: 'newcompany.com',
description: 'A new company'
}
}
end
it 'creates a new company' do
expect do
post "/api/v1/accounts/#{account.id}/companies",
params: valid_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(1)
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('New Company')
expect(response_body['payload']['domain']).to eq('newcompany.com')
end
it 'returns error for invalid params' do
invalid_params = { company: { name: '' } }
post "/api/v1/accounts/#{account.id}/companies",
params: invalid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
let(:update_params) do
{
company: {
name: 'Updated Company Name',
domain: 'updated.com'
}
}
end
it 'updates the company' do
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
params: update_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('Updated Company Name')
expect(response_body['payload']['domain']).to eq('updated.com')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated administrator' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'deletes the company' do
company
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when it is a regular agent' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:company) { create(:company, account: account) }
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end
+38
View File
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Company, type: :model do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(100) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
describe 'domain validation' do
it { is_expected.to allow_value('example.com').for(:domain) }
it { is_expected.to allow_value('sub.example.com').for(:domain) }
it { is_expected.to allow_value('').for(:domain) }
it { is_expected.to allow_value(nil).for(:domain) }
it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
it { is_expected.not_to allow_value('.example.com').for(:domain) }
end
end
context 'with associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:contacts).dependent(:nullify) }
end
describe 'scopes' do
let(:account) { create(:account) }
let!(:company_b) { create(:company, name: 'B Company', account: account) }
let!(:company_a) { create(:company, name: 'A Company', account: account) }
let!(:company_c) { create(:company, name: 'C Company', account: account) }
describe '.ordered_by_name' do
it 'orders companies by name alphabetically' do
companies = described_class.where(account: account).ordered_by_name
expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
end
end
end
end
@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe CompanyPolicy, type: :policy do
subject(:company_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:company) { create(:company, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :create?, :update? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).to permit(agent_context, company) }
end
end
permissions :destroy? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).not_to permit(agent_context, company) }
end
end
end
+20
View File
@@ -0,0 +1,20 @@
FactoryBot.define do
factory :company do
sequence(:name) { |n| "Company #{n}" }
sequence(:domain) { |n| "company#{n}.com" }
description { 'A sample company description' }
account
trait :without_domain do
domain { nil }
end
trait :with_avatar do
avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
end
trait :with_long_description do
description { 'A' * 500 }
end
end
end
@@ -157,6 +157,19 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.count).to eql(messages_count)
end
it 'handles different file types correctly' do
expect(hook).not_to be_nil
video_attachment_params = message_with_attachments.deep_dup
video_attachment_params[:event][:files][0][:filetype] = 'mp4'
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
builder = described_class.new(video_attachment_params)
allow(builder).to receive(:sender).and_return(nil)
expect { builder.perform }.not_to raise_error
expect(conversation.messages.last.attachments).to be_any
end
end
context 'when link shared' do
+64 -1
View File
@@ -1,6 +1,8 @@
require 'rails_helper'
describe Webhooks::Trigger do
include ActiveJob::TestHelper
subject(:trigger) { described_class }
let!(:account) { create(:account) }
@@ -8,8 +10,18 @@ describe Webhooks::Trigger do
let!(:conversation) { create(:conversation, inbox: inbox) }
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
let!(:webhook_type) { :api_inbox_webhook }
let(:webhook_type) { :api_inbox_webhook }
let!(:url) { 'https://test.com' }
let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
before do
ActiveJob::Base.queue_adapter = :test
end
after do
clear_enqueued_jobs
clear_performed_jobs
end
describe '#execute' do
it 'triggers webhook' do
@@ -54,6 +66,57 @@ describe Webhooks::Trigger do
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
end
context 'when webhook type is agent bot' do
let(:webhook_type) { :agent_bot_webhook }
it 'reopens conversation and enqueues activity message if pending' do
conversation.update(status: :pending)
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: 5
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
perform_enqueued_jobs do
trigger.execute(url, payload, webhook_type)
end
end.not_to(change { message.reload.status })
expect(conversation.reload.status).to eq('open')
activity_message = conversation.reload.messages.order(:created_at).last
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to eq(agent_bot_error_content)
end
it 'does not change message status or enqueue activity when conversation is not pending' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: 5
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
trigger.execute(url, payload, webhook_type)
end.not_to(change { message.reload.status })
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
expect(conversation.reload.status).to eq('open')
end
end
end
it 'does not update message status if webhook fails for other events' do
@@ -335,6 +335,118 @@ RSpec.describe ConversationReplyMailer do
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
end
end
context 'with custom email content' do
it 'uses custom HTML content when available and creates multipart email' do
message_with_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
html_content: {
reply: '<p>Custom <strong>HTML</strong> content for email</p>'
},
text_content: {
reply: 'Custom text content for email'
}
}
})
mail = described_class.email_reply(message_with_custom_content).deliver_now
# Check HTML part contains custom HTML content
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<p>Custom <strong>HTML</strong> content for email</p>')
expect(html_part.body.encoded).not_to include('Regular message content')
# Check text part contains custom text content
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content for email')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
it 'falls back to markdown rendering when custom HTML content is not available' do
message_without_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content')
mail = described_class.email_reply(message_without_custom_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles empty custom HTML content gracefully' do
message_with_empty_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: ''
}
}
})
mail = described_class.email_reply(message_with_empty_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles nil custom HTML content gracefully' do
message_with_nil_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: nil
}
}
})
mail = described_class.email_reply(message_with_nil_content).deliver_now
expect(mail.body.encoded).to include('<strong>markdown</strong>')
expect(mail.body.encoded).to include('Regular')
end
it 'uses custom text content in text part when only text is provided' do
message_with_text_only = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
text_content: {
reply: 'Custom text content only'
}
}
})
mail = described_class.email_reply(message_with_text_only).deliver_now
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content only')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
end
end
context 'when smtp enabled for email channel' do
@@ -0,0 +1,113 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ApplicationRecord do
it_behaves_like 'encrypted external credential',
factory: :channel_email,
attribute: :smtp_password,
value: 'smtp-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_email,
attribute: :imap_password,
value: 'imap-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twilio_sms,
attribute: :auth_token,
value: 'twilio-secret'
it_behaves_like 'encrypted external credential',
factory: :integrations_hook,
attribute: :access_token,
value: 'hook-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_facebook_page,
attribute: :page_access_token,
value: 'fb-page-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_facebook_page,
attribute: :user_access_token,
value: 'fb-user-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_instagram,
attribute: :access_token,
value: 'ig-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_line,
attribute: :line_channel_secret,
value: 'line-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_line,
attribute: :line_channel_token,
value: 'line-token-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_telegram,
attribute: :bot_token,
value: 'telegram-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twitter_profile,
attribute: :twitter_access_token,
value: 'twitter-access-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twitter_profile,
attribute: :twitter_access_token_secret,
value: 'twitter-secret-secret'
context 'when backfilling legacy plaintext' do
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
end
it 'reads existing plaintext and encrypts on update' do
account = create(:account)
channel = create(:channel_email, account: account, smtp_password: nil)
# Simulate legacy plaintext by updating the DB directly
sql = ActiveRecord::Base.send(
:sanitize_sql_array,
['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id]
)
ActiveRecord::Base.connection.execute(sql)
legacy_record = Channel::Email.find(channel.id)
expect(legacy_record.smtp_password).to eq('legacy-plain')
legacy_record.update!(smtp_password: 'encrypted-now')
stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password)
expect(stored_value).to be_present
expect(stored_value).not_to include('encrypted-now')
expect(legacy_record.smtp_password).to eq('encrypted-now')
end
end
context 'when looking up telegram legacy records' do
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
end
it 'finds plaintext records via fallback lookup' do
channel = create(:channel_telegram, bot_token: 'legacy-token')
# Simulate legacy plaintext by updating the DB directly
sql = ActiveRecord::Base.send(
:sanitize_sql_array,
['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id]
)
ActiveRecord::Base.connection.execute(sql)
found = Channel::Telegram.find_by(bot_token: 'legacy-token')
expect(found).to eq(channel)
end
end
end
+56
View File
@@ -4,6 +4,12 @@ require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
before do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
end
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
@@ -678,4 +684,54 @@ RSpec.describe Message do
end
end
end
describe '#reindex_for_search callback' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search_indexing')
end
context 'when message should be indexed' do
it 'calls reindex_for_search for incoming message on create' do
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).to receive(:reindex_for_search)
message.save!
end
it 'calls reindex_for_search for outgoing message on update' do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
message = create(:message, conversation: conversation, account: account, message_type: :outgoing)
expect(message).to receive(:reindex_for_search).and_return(true)
message.update!(content: 'Updated content')
end
end
context 'when message should not be indexed' do
it 'does not call reindex_for_search for activity message' do
message = build(:message, conversation: conversation, account: account, message_type: :activity)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search for unpaid account on cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features('advanced_search_indexing')
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search when advanced search is not allowed' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
end
end
end
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
end
end
describe 'When the incoming waid is an Argentine number with 9 after country code' do
let(:wa_id) { '5491123456789' }
it 'creates appropriate conversations, message and contacts if contact does not exist' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
it 'appends to existing contact if contact inbox exists with normalized format' do
# Normalized format removes the 9 after country code
normalized_wa_id = '541123456789'
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
# should use the normalized wa_id from existing contact
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
end
end
describe 'When incoming waid is an Argentine number without 9 after country code' do
let(:wa_id) { '541123456789' }
context 'when a contact inbox exists with the same format' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
end
context 'when a contact inbox does not exist' do
it 'creates contact inbox with the incoming waid' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
end
end
describe 'when message processing is in progress' do
it 'ignores the current message creation request' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
@@ -0,0 +1,21 @@
# frozen_string_literal: true
RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'|
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
if defined?(Facebook::Messenger::Subscriptions)
allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true)
allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true)
end
end
it "encrypts #{attribute} at rest" do
record = create(factory, attribute => value)
raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s
expect(raw_stored_value).to be_present
expect(raw_stored_value).not_to include(value)
expect(record.public_send(attribute)).to eq(value)
expect(record.encrypted_attribute?(attribute)).to be(true)
end
end