## 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>
197 lines
6.2 KiB
Ruby
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
|