feat: Add sidebar unread counts for filters (CW-7262) (#14726)

## Description

Extends the conversation unread-count system so the left sidebar can
show unread badges for Mentions, Participating, Unattended, and saved
conversation folders. Folder badges reuse the existing `custom_filters`
conversation filter semantics, store user-scoped Redis sets lazily, and
skip unsupported folder filters so invalid saved folders continue to
render without a badge. The Unattended badge counts all visible unread
open conversations that match the existing unattended conversation
scope.

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

## What changed

- Added user-scoped unread-count Redis keys and cache builders for
mentions, participating conversations, unattended conversations, and
saved folder filters.
- Reused `Conversations::FilterService` through a relation-returning
path so folder counts match the folder conversation list behavior.
- Invalidated user filter caches from mention, participant,
custom-filter, and relevant conversation update events.
- Extended the unread-count endpoint payload and sidebar Vuex/sidebar
rendering for the new badge counts, including the Unattended sidebar
item.
- Added Ruby, Enterprise, request, listener, and frontend store coverage
for the new unread-count dimensions.

## Type of change

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

## How Has This Been Tested?

- Created local validation folders for `john@acme.inc` and confirmed the
unread-count payload includes open, resolved, and high-priority folder
badges while excluding the invalid unsupported folder.
- Added coverage for the Unattended badge rule: all visible unread open
conversations matching `Conversation.unattended`.
- Ran focused unread-count Ruby specs, including service, listener,
request, and Enterprise counter coverage.
- Ran frontend unread-count store specs.
- Ran RuboCop on the touched Ruby files.
- Ran ESLint through the project script; it completed with warnings in
existing unrelated files and no errors.

<img width="369" height="525" alt="Screenshot 2026-06-13 at 10 51 39 PM"
src="https://github.com/user-attachments/assets/36b1d2c4-dac1-4f6f-9c0e-7ef5a6cc2975"
/>

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] Documentation changes are not required for this internal
unread-count behavior
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] No dependent downstream changes are required

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sony Mathew
2026-06-16 23:46:49 +05:30
committed by GitHub
co-authored by Muhsin Keloth
parent 352f120c6a
commit fe6368b42e
54 changed files with 1581 additions and 97 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ class Account < ApplicationRecord
end
def clear_unread_conversation_counts_cache
::Conversations::UnreadCounts::Store.clear_account!(id)
::Conversations::UnreadCounts::Store.clear_all_account!(id)
end
trigger.after(:insert).for_each(:row) do
+9
View File
@@ -39,6 +39,7 @@ class AccountUser < ApplicationRecord
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
validates :user_id, uniqueness: { scope: :account_id }
@@ -79,6 +80,14 @@ class AccountUser < ApplicationRecord
def update_presence_in_redis
OnlineStatusTracker.set_status(account.id, user.id, availability)
end
def unread_filter_access_changed?
destroyed? || previous_changes.key?('role') || previous_changes.key?('custom_role_id')
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
AccountUser.prepend_mod_with('AccountUser')
-1
View File
@@ -30,7 +30,6 @@ module CacheKeys
update_cache_key_for_account(id, model.name.underscore)
end
::Conversations::UnreadCounts::Store.clear_account!(id)
dispatch_cache_update_event
end
+5
View File
@@ -28,6 +28,7 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -38,4 +39,8 @@ class ConversationParticipant < ApplicationRecord
def ensure_inbox_access
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
+9
View File
@@ -22,10 +22,19 @@ class CustomFilter < ApplicationRecord
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
after_commit :notify_unread_filter_counts_changed, on: [:create, :update, :destroy]
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
private
def notify_unread_filter_counts_changed
return unless conversation?
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
+12
View File
@@ -23,7 +23,9 @@ class InboxMember < ApplicationRecord
belongs_to :inbox
after_create :add_agent_to_round_robin
before_destroy :cache_unread_filter_notification_context
after_destroy :remove_agent_from_round_robin
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -34,6 +36,16 @@ class InboxMember < ApplicationRecord
def remove_agent_from_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
end
def cache_unread_filter_notification_context
@unread_filter_account = inbox&.account
@unread_filter_user = user
end
def notify_unread_filter_counts_changed
account = @unread_filter_account || inbox&.account
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: @unread_filter_user || user).perform
end
end
InboxMember.include_mod_with('Audit::InboxMember')
+5
View File
@@ -32,6 +32,7 @@ class Mention < ApplicationRecord
belongs_to :user
after_commit :notify_mentioned_user
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
scope :latest, -> { order(mentioned_at: :desc) }
@@ -55,4 +56,8 @@ class Mention < ApplicationRecord
def notify_mentioned_user
Rails.configuration.dispatcher.dispatch(CONVERSATION_MENTIONED, Time.zone.now, user: user, conversation: conversation)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end