Files
chatwoot/app/services/conversations/unread_counts/counter.rb
Sony MathewandGitHub 66cfb26c77 feat: Add unread count filters feature flag (1/6) (#14885)
## Description

Adds the account-level `unread_count_for_filters` feature flag as the
dark-launch gate for filtered sidebar unread counts. This reuses the
deprecated `quoted_email_reply` flag slot, resets the reused bit for
existing accounts, and removes stale defaults so new accounts do not
reference the old flag.

This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders.

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

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] 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
2026-07-08 23:50:40 +05:30

214 lines
6.2 KiB
Ruby

class Conversations::UnreadCounts::Counter
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
BUILD_LOCK_TTL = 15.minutes.to_i
BUILD_WAIT_TIMEOUT = 30.seconds.to_i
BUILD_WAIT_INTERVAL = 0.1.seconds
attr_reader :account, :user
def initialize(account:, user:)
@account = account
@user = user
end
def perform
return with_filtered_counts(empty_counts, build: false) if permission_mode == :none
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
inbox_counts = unread_inbox_counts
{
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
}.then { |counts| with_filtered_counts(counts) }
end
private
def with_filtered_counts(counts, build: true)
return counts unless account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
counts.merge(build ? filtered_counter.perform : ::Conversations::UnreadCounts::FilteredCounter.empty_counts)
end
def ensure_base_cache!
ensure_cache_ready!(
ready: -> { store.base_ready?(account.id) },
lock_key: base_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_base! }
end
def ensure_assignment_cache!
ensure_cache_ready!(
ready: -> { store.assignment_ready?(account.id) },
lock_key: assignment_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
end
def ensure_cache_ready!(ready:, lock_key:)
lock_manager = Redis::LockManager.new
loop do
return if ready.call
return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
wait_for_cache_ready(ready)
end
end
def wait_for_cache_ready(ready)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BUILD_WAIT_TIMEOUT
sleep BUILD_WAIT_INTERVAL until ready.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
end
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def unread_inbox_counts
counts_for_grouped_keys(visible_inbox_ids.index_with { |inbox_id| inbox_keys_for_mode(inbox_id) })
end
def unread_label_counts
keys_by_id = Hash.new { |hash, key| hash[key] = [] }
sidebar_label_ids.each do |label_id|
visible_inbox_ids.each do |inbox_id|
keys_by_id[label_id].concat(label_inbox_keys_for_mode(label_id, inbox_id))
end
end
counts_for_grouped_keys(keys_by_id)
end
def unread_team_counts
keys_by_id = Hash.new { |hash, key| hash[key] = [] }
visible_team_ids.each do |team_id|
visible_inbox_ids.each do |inbox_id|
keys_by_id[team_id].concat(team_inbox_keys_for_mode(team_id, inbox_id))
end
end
counts_for_grouped_keys(keys_by_id)
end
def inbox_keys_for_mode(inbox_id)
case permission_mode
when :base
[store.inbox_key(account.id, inbox_id)]
when :unassigned_and_mine
[store.inbox_unassigned_key(account.id, inbox_id), store.inbox_assignee_key(account.id, inbox_id, user.id)]
when :mine
[store.inbox_assignee_key(account.id, inbox_id, user.id)]
end
end
def label_inbox_keys_for_mode(label_id, inbox_id)
case permission_mode
when :base
[store.label_inbox_key(account.id, label_id, inbox_id)]
when :unassigned_and_mine
[
store.label_inbox_unassigned_key(account.id, label_id, inbox_id),
store.label_inbox_assignee_key(account.id, label_id, inbox_id, user.id)
]
when :mine
[store.label_inbox_assignee_key(account.id, label_id, inbox_id, user.id)]
end
end
def team_inbox_keys_for_mode(team_id, inbox_id)
case permission_mode
when :base
[store.team_inbox_key(account.id, team_id, inbox_id)]
when :unassigned_and_mine
[
store.team_inbox_unassigned_key(account.id, team_id, inbox_id),
store.team_inbox_assignee_key(account.id, team_id, inbox_id, user.id)
]
when :mine
[store.team_inbox_assignee_key(account.id, team_id, inbox_id, user.id)]
end
end
def counts_for_grouped_keys(keys_by_id)
counts_by_key = store.counts_for_keys(keys_by_id.values.flatten)
keys_by_id.each_with_object({}) do |(id, keys), result|
count = keys.sum { |key| counts_by_key[key].to_i }
result[id.to_s] = count if count.positive?
end
end
def assignment_mode?
%i[unassigned_and_mine mine].include?(permission_mode)
end
def permission_mode
@permission_mode ||=
if !custom_role_agent? || permissions.include?(MANAGE_ALL_PERMISSION)
:base
elsif permissions.include?(UNASSIGNED_PERMISSION)
:unassigned_and_mine
elsif permissions.include?(PARTICIPATING_PERMISSION)
:mine
else
:none
end
end
def custom_role_agent?
account_user&.agent? && account_user.custom_role_id.present?
end
def permissions
account_user&.permissions || []
end
def account_user
@account_user ||= account.account_users.find_by(user_id: user.id)
end
def visible_inbox_ids
@visible_inbox_ids ||= if account_user&.administrator?
account.inboxes.pluck(:id)
else
user.inboxes.where(account_id: account.id).pluck(:id)
end
end
def sidebar_label_ids
@sidebar_label_ids ||= account.labels.where(show_on_sidebar: true).pluck(:id)
end
def visible_team_ids
@visible_team_ids ||= if account_user&.administrator?
account.teams.pluck(:id)
else
user.teams.where(account_id: account.id).pluck(:id)
end
end
def empty_counts
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
end
def store
::Conversations::UnreadCounts::Store
end
def filtered_counter
@filtered_counter ||= ::Conversations::UnreadCounts::FilteredCounter.new(account: account, user: user)
end
end