Files
chatwoot/app/services/conversations/unread_counts/builder.rb
T
fe6368b42e 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>
2026-06-16 23:46:49 +05:30

197 lines
6.2 KiB
Ruby

class Conversations::UnreadCounts::Builder
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
RELATIVE_DATE_FILTER_OPERATOR = 'days_before'.freeze
BATCH_SIZE = 1000
FILTER_ERRORS = [
ActiveRecord::StatementInvalid,
CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue
].freeze
attr_reader :account
def initialize(account)
@account = account
end
def build_base!
store.clear_account!(account.id)
write_memberships(assignment: false)
store.mark_base_ready!(account.id)
end
def build_assignment!
store.clear_assignment!(account.id)
write_memberships(assignment: true)
store.mark_assignment_ready!(account.id)
end
def build_all!
build_base!
build_assignment!
end
def build_filters_for!(user)
store.clear_user_filters!(account.id, user.id)
version_snapshot = store.filter_version_snapshot(account.id, user.id)
custom_filters = conversation_custom_filters(user).to_a
store.add_filter_memberships(
account_id: account.id,
user_id: user.id,
filters: {
mentions: mentioned_unread_conversation_ids(user),
participating: participating_unread_conversation_ids(user),
unattended: unattended_unread_conversation_ids(user)
},
folders: folder_unread_conversation_ids(custom_filters, user)
)
mark_filters_ready_if_current(user, custom_filters, version_snapshot)
end
private
def mark_filters_ready_if_current(user, custom_filters, version_snapshot)
store.mark_filters_ready_if_current!(
account.id,
user.id,
version_snapshot: version_snapshot,
expires_in: filters_ready_ttl(custom_filters)
)
end
def write_memberships(assignment:)
unread_conversations(open_only: true).in_batches(of: BATCH_SIZE) do |relation|
columns = %i[id inbox_id assignee_id cached_label_list team_id]
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
{
conversation_id: id,
inbox_id: inbox_id,
assignee_id: assignee_id,
team_id: team_id,
label_ids: label_ids_for(cached_label_list)
}
end
store.add_memberships(account_id: account.id, memberships: memberships, assignment: assignment)
end
end
def mentioned_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.joins(:mentions)
.where(mentions: { account_id: account.id, user_id: user.id })
.pluck(:id)
end
def participating_unread_conversation_ids(user)
participating_visible_unread_conversations(user, open_only: true)
.where(id: user.participating_conversations.where(account_id: account.id).select(:id))
.pluck(:id)
end
def unattended_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.unattended
.pluck(:id)
end
def folder_unread_conversation_ids(custom_filters, user)
custom_filters.each_with_object({}) do |custom_filter, result|
result[custom_filter.id] = unread_ids_for_filter(custom_filter, user)
rescue *FILTER_ERRORS
next
end
end
def conversation_custom_filters(user)
account.custom_filters.where(user: user, filter_type: :conversation)
end
def filters_ready_ttl(custom_filters)
return Conversations::UnreadCounts::READY_TTL unless relative_date_filter?(custom_filters)
seconds_until_next_day
end
def relative_date_filter?(custom_filters)
custom_filters.any? do |custom_filter|
Array(custom_filter.query.with_indifferent_access[:payload]).any? do |condition|
condition[:filter_operator] == RELATIVE_DATE_FILTER_OPERATOR
end
end
end
def seconds_until_next_day
[(Time.zone.tomorrow.beginning_of_day - Time.current).ceil, 1].max
end
def unread_ids_for_filter(custom_filter, user)
filter_relation = ::Conversations::FilterService.new(custom_filter.query.with_indifferent_access, user, account).filtered_relation
filter_relation
.where(id: unread_conversations(open_only: false).select(:id))
.reorder(nil)
.distinct
.pluck(:id)
end
def unread_conversations(open_only:)
conversations = account.conversations
conversations = conversations.open if open_only
conversations.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def visible_unread_conversations(user, open_only:)
::Conversations::PermissionFilterService.new(unread_conversations(open_only: open_only), user, account).perform
end
def participating_visible_unread_conversations(user, open_only:)
return inbox_visible_unread_conversations(user, open_only: open_only) if custom_role_participating_permission?(user)
visible_unread_conversations(user, open_only: open_only)
end
def inbox_visible_unread_conversations(user, open_only:)
conversations = unread_conversations(open_only: open_only)
return conversations if account_user_for(user)&.administrator?
conversations.where(inbox: user.inboxes.where(account_id: account.id))
end
def custom_role_participating_permission?(user)
account_user = account_user_for(user)
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?(PARTICIPATING_PERMISSION)
end
def account_user_for(user)
account.account_users.find_by(user_id: user.id)
end
def unread_since_last_seen_condition
conversations = Conversation.arel_table
messages = Message.arel_table
conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
end
def label_ids_for(cached_label_list)
label_titles = cached_label_list.to_s.split(',').map(&:strip).compact_blank
labels_by_title.values_at(*label_titles).compact
end
def labels_by_title
@labels_by_title ||= account.labels.pluck(:title, :id).to_h
end
def store
::Conversations::UnreadCounts::Store
end
end