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
@@ -123,7 +123,7 @@ RSpec.describe 'Conversations API', type: :request do
end
after do
Conversations::UnreadCounts::Store.clear_account!(account.id)
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
end
context 'when conversation unread counts feature is enabled' do
@@ -144,7 +144,42 @@ RSpec.describe 'Conversations API', type: :request do
'all_count' => 1,
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
'teams' => {},
'mentions_count' => 0,
'participating_count' => 0,
'unattended_count' => 1,
'folders' => {}
)
end
it 'returns unread counts for mentions, participating conversations, unattended conversations, and folders' do
mentioned_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
participating_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
resolved_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
resolved_conversation.update!(status: :resolved)
custom_filter = create(:custom_filter, account: account, user: agent, filter_type: :conversation, query: {
payload: [{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['resolved'],
query_operator: nil,
custom_attribute_type: ''
}]
})
create(:mention, account: account, conversation: mentioned_conversation, user: agent)
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to include(
'mentions_count' => 1,
'participating_count' => 1,
'unattended_count' => 2,
'folders' => { custom_filter.id.to_s => 1 }
)
end
@@ -862,7 +897,7 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
ensure
Conversations::UnreadCounts::Store.clear_account!(account.id)
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
end
it 'updates both if one timestamp is old even when the other is recent' do
@@ -949,7 +984,7 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 1)
ensure
Conversations::UnreadCounts::Store.clear_account!(account.id)
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
end
end
end
@@ -33,7 +33,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
end
after do
Conversations::UnreadCounts::Store.clear_account!(account.id)
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
end
context 'when it is an unauthenticated user' do
@@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe ConversationFinder do
describe '#perform' do
it 'returns participant-only conversations for custom roles with participating permission' do
account = create(:account)
agent = create(:user, account: account, role: :agent)
other_agent = create(:user, account: account, role: :agent)
inbox = create(:inbox, account: account, enable_auto_assignment: false)
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
participating_conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
create(:inbox_member, user: agent, inbox: inbox)
create(:inbox_member, user: other_agent, inbox: inbox)
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
account.account_users.find_by!(user_id: agent.id).update!(custom_role: custom_role)
Current.account = account
result = described_class.new(agent, { status: 'open', conversation_type: 'participating' }).perform
expect(result[:conversations].map(&:id)).to include(participating_conversation.id)
end
end
end
@@ -29,6 +29,24 @@ RSpec.describe AccountUser, type: :model do
end
end
describe 'unread filter count invalidation' do
it 'notifies when the assigned custom role changes' do
account = create(:account)
custom_role = create(:custom_role, account: account)
account_user = create(:account_user, account: account)
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
account_user.update!(custom_role: custom_role)
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
account: account,
user: account_user.user
)
expect(notifier).to have_received(:perform)
end
end
describe 'audit log' do
context 'when account user is created' do
it 'has associated audit log created' do
@@ -9,4 +9,38 @@ RSpec.describe CustomRole, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
end
describe 'unread filter count invalidation' do
it 'notifies assigned users when permissions change' do
account = create(:account)
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
account_user = create(:account_user, account: account, custom_role: custom_role)
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
custom_role.update!(permissions: ['conversation_manage'])
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
account: account,
user: account_user.user
)
expect(notifier).to have_received(:perform)
end
it 'notifies assigned users when the custom role is destroyed' do
account = create(:account)
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
account_user = create(:account_user, account: account, custom_role: custom_role)
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
custom_role.destroy!
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
account: account,
user: account_user.user
)
expect(notifier).to have_received(:perform)
end
end
end
@@ -16,7 +16,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
end
after do
store.clear_account!(account.id)
store.clear_all_account!(account.id)
end
it 'uses base counts for custom roles with conversation_manage permission' do
@@ -26,10 +26,16 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
expect(result).to eq(
all_count: 2,
inboxes: { inbox.id.to_s => 2 },
labels: { label.id.to_s => 2 },
teams: { team.id.to_s => 2 },
mentions_count: 0,
participating_count: 0,
unattended_count: 2,
folders: {}
)
expect(store.assignment_ready?(account.id)).to be(false)
end
@@ -37,14 +43,21 @@ RSpec.describe Conversations::UnreadCounts::Counter do
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']))
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
other_assigned_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
create(:conversation_participant, account: account, conversation: other_assigned_conversation, user: agent)
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
expect(result).to eq(
all_count: 2,
inboxes: { inbox.id.to_s => 2 },
labels: { label.id.to_s => 2 },
teams: { team.id.to_s => 2 },
mentions_count: 0,
participating_count: 0,
unattended_count: 2,
folders: {}
)
expect(store.assignment_ready?(account.id)).to be(true)
end
@@ -52,13 +65,21 @@ RSpec.describe Conversations::UnreadCounts::Counter do
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_participating_manage']))
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
participating_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(1)
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
expect(result[:labels]).to eq(label.id.to_s => 1)
expect(result[:teams]).to eq(team.id.to_s => 1)
expect(result).to eq(
all_count: 1,
inboxes: { inbox.id.to_s => 1 },
labels: { label.id.to_s => 1 },
teams: { team.id.to_s => 1 },
mentions_count: 0,
participating_count: 1,
unattended_count: 1,
folders: {}
)
expect(store.assignment_ready?(account.id)).to be(true)
end
@@ -68,7 +89,16 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
expect(result).to eq(
all_count: 0,
inboxes: {},
labels: {},
teams: {},
mentions_count: 0,
participating_count: 0,
unattended_count: 0,
folders: {}
)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
end
+19
View File
@@ -208,6 +208,25 @@ describe ConversationFinder do
end
end
context 'with participating conversation type' do
let(:params) { { status: 'open', conversation_type: 'participating' } }
it 'does not return participating conversations from inboxes where the agent is no longer a member' do
visible_conversation = create(:conversation, account: account, inbox: inbox)
inaccessible_conversation = create(:conversation, account: account, inbox: restricted_inbox)
create(:inbox_member, user: user_1, inbox: restricted_inbox)
create(:conversation_participant, account: account, conversation: visible_conversation, user: user_1)
create(:conversation_participant, account: account, conversation: inaccessible_conversation, user: user_1)
InboxMember.find_by!(user: user_1, inbox: restricted_inbox).destroy!
result = conversation_finder.perform
conversation_ids = result[:conversations].map(&:id)
expect(conversation_ids).to include(visible_conversation.id)
expect(conversation_ids).not_to include(inaccessible_conversation.id)
end
end
context 'without source' do
let(:params) { {} }
@@ -294,5 +294,19 @@ describe ActionCableListener do
listener.conversation_unread_count_changed(event)
end
it 'supports user-scoped unread count refresh events' do
event = Events::Base.new(event_name, Time.zone.now, account: account, user: agent)
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
a_collection_containing_exactly(agent.pubsub_token),
'conversation.unread_count_changed',
{
account_id: account.id
}
)
listener.conversation_unread_count_changed(event)
end
end
end
+52 -11
View File
@@ -53,38 +53,79 @@ RSpec.describe Account do
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user) }
let(:store) { Conversations::UnreadCounts::Store }
let(:inbox_key) { store.inbox_key(account.id, inbox.id) }
after do
store.clear_account!(account.id)
let(:filter_keys) do
[
store.user_mentions_key(account.id, user.id),
store.user_participating_key(account.id, user.id),
store.user_unattended_key(account.id, user.id),
store.user_folder_key(account.id, user.id, 1)
]
end
it 'clears unread count cache when the feature is enabled' do
after do
store.clear_all_account!(account.id)
end
it 'clears all unread count cache when the feature is enabled' do
build_unread_count_cache
account.enable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
expect_unread_count_cache_cleared
end
it 'clears unread count cache when the feature is disabled' do
it 'clears all unread count cache when the feature is disabled' do
account.enable_features!(:conversation_unread_counts)
build_unread_count_cache
account.disable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
expect_unread_count_cache_cleared
end
it 'clears all unread count cache when account cache keys are reset' do
build_unread_count_cache
account.reset_cache_keys
expect_unread_count_cache_cleared
end
def expect_unread_count_cache_cleared
expect(unread_count_ready_markers).to all(be(false))
expect(store.counts_for_keys(unread_count_keys).values).to all(eq(0))
end
def unread_count_ready_markers
[
store.base_ready?(account.id),
store.assignment_ready?(account.id),
store.filters_ready?(account.id, user.id)
]
end
def unread_count_keys
[inbox_key] + filter_keys
end
def build_unread_count_cache
store.mark_base_ready!(account.id)
store.mark_assignment_ready!(account.id)
store.mark_filters_ready!(account.id, user.id)
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
store.add_filter_memberships(
account_id: account.id,
user_id: user.id,
filters: {
mentions: [1],
participating: [2],
unattended: [3]
},
folders: { 1 => [4] }
)
end
end
+28
View File
@@ -42,4 +42,32 @@ RSpec.describe AccountUser do
expect(user.assigned_conversations.count).to eq(0)
end
end
describe 'unread filter count invalidation' do
let(:notifier) { instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true) }
before do
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
end
it 'notifies when the account role changes' do
account_user.update!(role: :administrator)
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
account: account_user.account,
user: account_user.user
)
expect(notifier).to have_received(:perform)
end
it 'notifies when account access is removed' do
expect(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).with(
account: account_user.account,
user: account_user.user
).and_return(notifier)
expect(notifier).to receive(:perform)
account_user.destroy!
end
end
end
+26
View File
@@ -18,4 +18,30 @@ RSpec.describe InboxMember do
end
end
end
describe 'unread filter count invalidation' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account, role: :agent) }
let(:notifier) { instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true) }
before do
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
end
it 'notifies when inbox access is added' do
create(:inbox_member, inbox: inbox, user: user)
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(account: account, user: user)
expect(notifier).to have_received(:perform)
end
it 'notifies when inbox access is removed' do
inbox_member = create(:inbox_member, inbox: inbox, user: user)
expect(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).with(account: account, user: user).and_return(notifier)
expect(notifier).to receive(:perform)
inbox_member.destroy!
end
end
end
@@ -9,7 +9,7 @@ RSpec.describe Conversations::UnreadCounts::Builder do
let(:store) { Conversations::UnreadCounts::Store }
after do
store.clear_account!(account.id)
store.clear_all_account!(account.id)
end
describe '#build_base!' do
@@ -75,6 +75,173 @@ RSpec.describe Conversations::UnreadCounts::Builder do
end
end
describe '#build_filters_for!' do
before do
create(:inbox_member, user: assignee, inbox: inbox)
end
it 'stores unread open conversations by mentions and participating dimensions' do
mentioned_conversation = create_unread_conversation(account: account, inbox: inbox)
participating_conversation = create_unread_conversation(account: account, inbox: inbox)
resolved_mentioned_conversation = create_unread_conversation(account: account, inbox: inbox)
inaccessible_conversation = create_unread_conversation(account: account, inbox: create(:inbox, account: account))
resolved_mentioned_conversation.update!(status: :resolved)
create(:mention, account: account, conversation: mentioned_conversation, user: assignee)
create(:mention, account: account, conversation: resolved_mentioned_conversation, user: assignee)
create(:mention, account: account, conversation: inaccessible_conversation, user: assignee)
create(:conversation_participant, account: account, conversation: participating_conversation, user: assignee)
described_class.new(account).build_filters_for!(assignee)
expect(store.filters_ready?(account.id, assignee.id)).to be(true)
expect(redis_set_members(store.user_mentions_key(account.id, assignee.id))).to contain_exactly(mentioned_conversation.id.to_s)
expect(redis_set_members(store.user_participating_key(account.id, assignee.id))).to contain_exactly(participating_conversation.id.to_s)
end
it 'excludes participating conversations that are no longer visible to the user' do
participating_conversation = create_unread_conversation(account: account, inbox: inbox)
create(:conversation_participant, account: account, conversation: participating_conversation, user: assignee)
InboxMember.find_by!(user: assignee, inbox: inbox).destroy!
described_class.new(account).build_filters_for!(assignee)
expect(redis_set_members(store.user_participating_key(account.id, assignee.id))).to be_empty
end
it 'stores visible unread open unattended conversations' do
no_first_reply_conversation = create_unread_conversation(account: account, inbox: inbox)
waiting_conversation = create_unread_conversation(account: account, inbox: inbox)
attended_conversation = create_unread_conversation(account: account, inbox: inbox)
inaccessible_conversation = create_unread_conversation(account: account, inbox: create(:inbox, account: account))
resolved_conversation = create_unread_conversation(account: account, inbox: inbox)
create_read_conversation
waiting_conversation.update!(first_reply_created_at: 5.minutes.ago)
attended_conversation.update!(first_reply_created_at: 5.minutes.ago, waiting_since: nil)
inaccessible_conversation.update!(first_reply_created_at: nil)
resolved_conversation.update!(status: :resolved)
described_class.new(account).build_filters_for!(assignee)
expect(redis_set_members(store.user_unattended_key(account.id, assignee.id))).to contain_exactly(
no_first_reply_conversation.id.to_s,
waiting_conversation.id.to_s
)
end
it 'stores folder memberships using the saved filter status conditions' do
resolved_conversation = create_unread_conversation(account: account, inbox: inbox)
resolved_conversation.update!(status: :resolved)
create_unread_conversation(account: account, inbox: inbox, assignee: assignee)
custom_filter = create(
:custom_filter, account: account, user: assignee, filter_type: :conversation, query: filter_query('status', ['resolved'])
)
described_class.new(account).build_filters_for!(assignee)
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, custom_filter.id))).to contain_exactly(resolved_conversation.id.to_s)
end
it 'loads folder filters after taking the invalidation version snapshot' do
create_unread_conversation(account: account, inbox: inbox)
resolved_conversation = create_unread_conversation(account: account, inbox: inbox)
resolved_conversation.update!(status: :resolved)
custom_filter = create(
:custom_filter, account: account, user: assignee, filter_type: :conversation, query: filter_query('status', ['open'])
)
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
filter_updated = false
allow(store).to receive(:filter_version_snapshot).and_wrap_original do |method, *args|
method.call(*args).tap do
next if filter_updated
filter_updated = true
custom_filter.update!(query: filter_query('status', ['resolved']))
end
end
described_class.new(account).build_filters_for!(assignee)
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, custom_filter.id))).to contain_exactly(resolved_conversation.id.to_s)
end
it 'expires relative-date folder caches at the next date boundary' do
create(
:custom_filter,
account: account,
user: assignee,
filter_type: :conversation,
query: filter_query('created_at', [7], filter_operator: 'days_before')
)
allow(store).to receive(:mark_filters_ready_if_current!).and_call_original
expected_ttl = nil
travel_to Time.zone.local(2026, 1, 1, 9, 30, 0) do
expected_ttl = (Time.zone.tomorrow.beginning_of_day - Time.current).ceil
described_class.new(account).build_filters_for!(assignee)
end
expect(store).to have_received(:mark_filters_ready_if_current!).with(
account.id,
assignee.id,
version_snapshot: kind_of(Hash),
expires_in: expected_ttl
)
end
it 'does not mark filters ready when user filters are invalidated during the build' do
conversation = create_unread_conversation(account: account, inbox: inbox)
create(:mention, account: account, conversation: conversation, user: assignee)
clear_user_filters_after_membership_write
described_class.new(account).build_filters_for!(assignee)
expect(store.filters_ready?(account.id, assignee.id)).to be(false)
expect(redis_set_members(store.user_mentions_key(account.id, assignee.id))).to be_empty
end
it 'does not mark filters ready when account filters are invalidated during the build' do
conversation = create_unread_conversation(account: account, inbox: inbox)
create(:mention, account: account, conversation: conversation, user: assignee)
clear_filter_caches_after_membership_write
described_class.new(account).build_filters_for!(assignee)
expect(store.filters_ready?(account.id, assignee.id)).to be(false)
expect(redis_set_members(store.user_mentions_key(account.id, assignee.id))).to be_empty
end
it 'skips invalid folder filters and still marks the user filter cache ready' do
create_unread_conversation(account: account, inbox: inbox)
invalid_filter = create(
:custom_filter, account: account, user: assignee, filter_type: :conversation, query: filter_query('missing_attribute', ['open'])
)
described_class.new(account).build_filters_for!(assignee)
expect(store.filters_ready?(account.id, assignee.id)).to be(true)
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, invalid_filter.id))).to be_empty
end
it 'skips folder filters that fail when the SQL query is executed' do
conversation = create_unread_conversation(account: account, inbox: inbox)
invalid_filter = create(
:custom_filter,
account: account,
user: assignee,
filter_type: :conversation,
query: filter_query('display_id', [conversation.display_id.to_s], filter_operator: 'contains')
)
expect { described_class.new(account).build_filters_for!(assignee) }.not_to raise_error
expect(store.filters_ready?(account.id, assignee.id)).to be(true)
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, invalid_filter.id))).to be_empty
end
end
def create_read_conversation
conversation = create(:conversation, account: account, inbox: inbox, agent_last_seen_at: 1.minute.from_now)
create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming)
@@ -90,4 +257,32 @@ RSpec.describe Conversations::UnreadCounts::Builder do
def redis_set_members(key)
Redis::Alfred.pipelined { |pipeline| pipeline.smembers(key) }.first
end
def clear_user_filters_after_membership_write
allow(store).to receive(:add_filter_memberships).and_wrap_original do |method, *args, **kwargs|
method.call(*args, **kwargs)
store.clear_user_filters!(account.id, assignee.id)
end
end
def clear_filter_caches_after_membership_write
allow(store).to receive(:add_filter_memberships).and_wrap_original do |method, *args, **kwargs|
method.call(*args, **kwargs)
store.clear_filter_caches!(account.id)
end
end
def filter_query(attribute_key, values, filter_operator: 'equal_to')
{
payload: [
{
attribute_key: attribute_key,
filter_operator: filter_operator,
values: values,
query_operator: nil,
custom_attribute_type: ''
}
]
}
end
end
@@ -17,7 +17,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
end
after do
store.clear_account!(account.id)
store.clear_all_account!(account.id)
end
it 'builds the base cache on demand' do
@@ -32,6 +32,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
lock_key = "UNREAD_CONVERSATIONS::V1::ACCOUNT::#{account.id}::BUILD_LOCK::BASE"
lock_manager = instance_double(Redis::LockManager)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(lock_manager).to receive(:with_lock).and_yield.and_return(true)
allow(lock_manager).to receive(:with_lock).with(lock_key, described_class::BUILD_LOCK_TTL).and_yield.and_return(true)
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
@@ -46,13 +47,33 @@ RSpec.describe Conversations::UnreadCounts::Counter do
counter = described_class.new(account: account, user: agent)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(counter).to receive(:wait_for_cache_ready) { store.mark_base_ready!(account.id) }
allow(counter).to receive(:wait_for_cache_ready) do
store.mark_base_ready!(account.id)
store.mark_filters_ready!(account.id, agent.id)
end
expect(Conversations::UnreadCounts::Builder).not_to receive(:new)
counter.perform
expect(counter).to have_received(:wait_for_cache_ready)
expect(store.base_ready?(account.id)).to be(true)
expect(store.filters_ready?(account.id, agent.id)).to be(true)
end
it 'retries when a build finishes without marking the cache ready' do
builder = instance_double(Conversations::UnreadCounts::Builder)
attempts = 0
allow(Conversations::UnreadCounts::Builder).to receive(:new).and_return(builder)
allow(builder).to receive(:build_base!) do
attempts += 1
store.mark_base_ready!(account.id) if attempts == 2
end
allow(builder).to receive(:build_filters_for!) { store.mark_filters_ready!(account.id, agent.id) }
described_class.new(account: account, user: agent).perform
expect(builder).to have_received(:build_base!).twice
expect(store.base_ready?(account.id)).to be(true)
end
it 'counts unread conversations only across inboxes visible to a normal agent' do
@@ -65,7 +86,11 @@ RSpec.describe Conversations::UnreadCounts::Counter do
all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: { label.id.to_s => 1 },
teams: { visible_team.id.to_s => 1 }
teams: { visible_team.id.to_s => 1 },
mentions_count: 0,
participating_count: 0,
unattended_count: 1,
folders: {}
)
end
@@ -79,7 +104,11 @@ RSpec.describe Conversations::UnreadCounts::Counter do
all_count: 2,
inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
labels: { label.id.to_s => 2 },
teams: { visible_team.id.to_s => 2 }
teams: { visible_team.id.to_s => 2 },
mentions_count: 0,
participating_count: 0,
unattended_count: 2,
folders: {}
)
end
@@ -92,7 +121,46 @@ RSpec.describe Conversations::UnreadCounts::Counter do
all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: {},
teams: { visible_team.id.to_s => 1 }
teams: { visible_team.id.to_s => 1 },
mentions_count: 0,
participating_count: 0,
unattended_count: 1,
folders: {}
)
end
it 'returns mention, participating, unattended, and valid folder unread counts for the user' do
mentioned_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
participating_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
resolved_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
resolved_conversation.update!(status: :resolved)
valid_folder = create(:custom_filter, account: account, user: agent, filter_type: :conversation, query: filter_query('status', ['resolved']))
invalid_folder = create(:custom_filter, account: account, user: agent, filter_type: :conversation, query: filter_query('unknown', ['open']))
create(:mention, account: account, conversation: mentioned_conversation, user: agent)
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
result = described_class.new(account: account, user: agent).perform
expect(result[:mentions_count]).to eq(1)
expect(result[:participating_count]).to eq(1)
expect(result[:unattended_count]).to eq(2)
expect(result[:folders]).to eq(valid_folder.id.to_s => 1)
expect(result[:folders]).not_to have_key(invalid_folder.id.to_s)
expect(store.filters_ready?(account.id, agent.id)).to be(true)
end
def filter_query(attribute_key, values)
{
payload: [
{
attribute_key: attribute_key,
filter_operator: 'equal_to',
values: values,
query_operator: nil,
custom_attribute_type: ''
}
]
}
end
end
@@ -21,13 +21,22 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
it 'ignores outgoing message creation' do
it 'clears user filter counts when a non-incoming message updates last activity' do
account.enable_features!(:conversation_unread_counts)
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
event = Events::Base.new('message.created', Time.zone.now, message: message)
allow(store).to receive(:clear_filter_caches!).and_return(true)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
listener.message_created(event)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
expect(store).to have_received(:clear_filter_caches!).with(account.id)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'ignores incoming message creation when conversation unread counts are disabled' do
@@ -52,6 +61,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
end
it 'refreshes unread counts when labels change' do
account.enable_features!(:conversation_unread_counts)
changed_attributes = { label_list: [%w[old], %w[new]] }
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -61,8 +71,43 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
it 'ignores conversation updates unrelated to unread count dimensions' do
it 'clears user filter counts when a folder filter dimension changes' do
account.enable_features!(:conversation_unread_counts)
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] })
allow(store).to receive(:clear_filter_caches!).and_return(true)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
listener.conversation_updated(event)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
expect(store).to have_received(:clear_filter_caches!).with(account.id)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'clears user filter counts when the conversation contact changes' do
account.enable_features!(:conversation_unread_counts)
event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation)
allow(store).to receive(:clear_filter_caches!).and_return(true)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
listener.conversation_contact_changed(event)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
expect(store).to have_received(:clear_filter_caches!).with(account.id)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'ignores conversation updates without changed attributes' do
account.enable_features!(:conversation_unread_counts)
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: {})
listener.conversation_updated(event)
@@ -128,7 +173,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
conversation_data: conversation_data.stringify_keys
)
ensure
store.clear_account!(account.id)
store.clear_all_account!(account.id)
end
def deleted_conversation_data(conversation)
@@ -29,6 +29,18 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
it 'dispatches unread count changed event when user filter caches were cleared' do
allow(Conversations::UnreadCounts::Store).to receive(:clear_filter_caches!).and_return(true)
described_class.new(conversation).perform
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
end
context 'when conversation unread counts feature is disabled' do
@@ -12,7 +12,7 @@ RSpec.describe Conversations::UnreadCounts::Refresher do
let(:store) { Conversations::UnreadCounts::Store }
after do
store.clear_account!(account.id)
store.clear_all_account!(account.id)
end
it 'does not update redis when unread caches are not ready' do
@@ -7,9 +7,10 @@ RSpec.describe Conversations::UnreadCounts::Store do
let(:user_id) { 4 }
let(:conversation_id) { 5 }
let(:team_id) { 6 }
let(:other_user_id) { 8 }
after do
described_class.clear_account!(account_id)
described_class.clear_all_account!(account_id)
end
describe 'key builders' do
@@ -45,20 +46,69 @@ RSpec.describe Conversations::UnreadCounts::Store do
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::TEAM::6::INBOX::2::ASSIGNEE::4'
)
end
it 'builds user filter keys using the Redis key naming convention' do
expect(described_class.user_mentions_key(account_id, user_id)).to eq(
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::MENTIONS'
)
expect(described_class.user_participating_key(account_id, user_id)).to eq(
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::PARTICIPATING'
)
expect(described_class.user_unattended_key(account_id, user_id)).to eq(
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::UNATTENDED'
)
expect(described_class.user_folder_key(account_id, user_id, 7)).to eq(
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::FOLDER::7'
)
end
end
describe 'ready markers' do
it 'tracks base and assignment readiness independently' do
it 'starts with all ready markers missing' do
expect(described_class.base_ready?(account_id)).to be(false)
expect(described_class.assignment_ready?(account_id)).to be(false)
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
end
it 'tracks base, assignment, and user filter readiness independently' do
described_class.mark_base_ready!(account_id)
described_class.mark_assignment_ready!(account_id)
described_class.mark_filters_ready!(account_id, user_id)
expect(described_class.base_ready?(account_id)).to be(true)
expect(described_class.assignment_ready?(account_id)).to be(true)
expect(described_class.filters_ready?(account_id, user_id)).to be(true)
expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::READY::BASE')).to be_within(5).of(Conversations::UnreadCounts::READY_TTL)
expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::READY::ASSIGNMENT')).to be_within(5).of(Conversations::UnreadCounts::READY_TTL)
expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::READY::FILTERS')).to be_within(5).of(
Conversations::UnreadCounts::READY_TTL
)
end
it 'tracks filter invalidation versions independently' do
expect(described_class.filter_version_snapshot(account_id, user_id)).to eq(account: 0, user: 0)
expect(described_class.clear_user_filters!(account_id, user_id)).to be(false)
expect(described_class.filter_version_snapshot(account_id, user_id)).to eq(account: 0, user: 1)
expect(ttl_for(user_filter_version_key)).to be_within(5).of(Conversations::UnreadCounts::SET_TTL)
expect(described_class.clear_filter_caches!(account_id)).to be(false)
expect(described_class.filter_version_snapshot(account_id, user_id)).to eq(account: 1, user: 1)
expect(ttl_for(account_filter_version_key)).to be_within(5).of(Conversations::UnreadCounts::SET_TTL)
end
it 'marks filter caches ready only when the invalidation version is current' do
version_snapshot = described_class.filter_version_snapshot(account_id, user_id)
expect(described_class.mark_filters_ready_if_current!(account_id, user_id, version_snapshot: version_snapshot)).to be_truthy
expect(described_class.filters_ready?(account_id, user_id)).to be(true)
described_class.clear_user_filters!(account_id, user_id)
expect(described_class.mark_filters_ready_if_current!(account_id, user_id, version_snapshot: version_snapshot)).to be(false)
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
end
end
@@ -150,9 +200,94 @@ RSpec.describe Conversations::UnreadCounts::Store do
expect(base_keys.map { |key| ttl_for(key) }).to all(be_within(5).of(Conversations::UnreadCounts::SET_TTL))
end
it 'clears all account memberships' do
it 'adds, counts, and clears user filter memberships' do
described_class.add_filter_memberships(
account_id: account_id,
user_id: user_id,
filters: {
mentions: [conversation_id],
participating: [conversation_id],
unattended: [conversation_id]
},
folders: { 7 => [conversation_id] }
)
described_class.mark_filters_ready!(account_id, user_id)
expect(described_class.counts_for_keys(user_filter_keys)).to eq(
described_class.user_mentions_key(account_id, user_id) => 1,
described_class.user_participating_key(account_id, user_id) => 1,
described_class.user_unattended_key(account_id, user_id) => 1,
described_class.user_folder_key(account_id, user_id, 7) => 1
)
expect(user_filter_keys.map { |key| ttl_for(key) }).to all(be_within(5).of(Conversations::UnreadCounts::SET_TTL))
expect(described_class.clear_user_filters!(account_id, user_id)).to be(true)
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
expect(described_class.counts_for_keys(user_filter_keys).values).to all(eq(0))
end
it 'preserves the user filter build lock when clearing one user filter cache' do
described_class.add_filter_memberships(
account_id: account_id,
user_id: user_id,
filters: {
mentions: [conversation_id],
participating: [conversation_id],
unattended: [conversation_id]
},
folders: { 7 => [conversation_id] }
)
described_class.mark_filters_ready!(account_id, user_id)
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
expect(described_class.clear_user_filters!(account_id, user_id)).to be(true)
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(true)
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
expect(described_class.counts_for_keys(user_filter_keys).values).to all(eq(0))
end
it 'preserves user filter build locks when clearing all account filter caches' do
described_class.add_filter_memberships(
account_id: account_id,
user_id: user_id,
filters: {
mentions: [conversation_id],
participating: [],
unattended: []
},
folders: {}
)
described_class.add_filter_memberships(
account_id: account_id,
user_id: other_user_id,
filters: {
mentions: [conversation_id],
participating: [],
unattended: []
},
folders: {}
)
described_class.mark_filters_ready!(account_id, user_id)
described_class.mark_filters_ready!(account_id, other_user_id)
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
Redis::Alfred.set(user_filter_build_lock_key(other_user_id), 'locked')
expect(described_class.clear_filter_caches!(account_id)).to be(true)
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(true)
expect(Redis::Alfred.exists?(user_filter_build_lock_key(other_user_id))).to be(true)
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
expect(described_class.filters_ready?(account_id, other_user_id)).to be(false)
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, user_id)]).values).to all(eq(0))
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, other_user_id)]).values).to all(eq(0))
end
it 'clears account memberships without clearing user filter memberships' do
described_class.mark_base_ready!(account_id)
described_class.mark_assignment_ready!(account_id)
described_class.mark_filters_ready!(account_id, user_id)
described_class.add_base_membership(
account_id: account_id,
inbox_id: inbox_id,
@@ -168,13 +303,69 @@ RSpec.describe Conversations::UnreadCounts::Store do
team_id: team_id,
conversation_id: conversation_id
)
described_class.add_filter_memberships(
account_id: account_id,
user_id: user_id,
filters: {
mentions: [conversation_id],
participating: [],
unattended: []
},
folders: {}
)
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
described_class.clear_account!(account_id)
expect(described_class.base_ready?(account_id)).to be(false)
expect(described_class.assignment_ready?(account_id)).to be(false)
expect(described_class.filters_ready?(account_id, user_id)).to be(true)
expect(described_class.counts_for_keys(base_keys).values).to all(eq(0))
expect(described_class.counts_for_keys(assignment_keys).values).to all(eq(0))
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, user_id)]).values).to all(eq(1))
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(true)
end
it 'clears all account unread count keys' do
described_class.mark_base_ready!(account_id)
described_class.mark_assignment_ready!(account_id)
described_class.mark_filters_ready!(account_id, user_id)
described_class.add_base_membership(
account_id: account_id,
inbox_id: inbox_id,
label_ids: [label_id],
team_id: team_id,
conversation_id: conversation_id
)
described_class.add_assignment_membership(
account_id: account_id,
inbox_id: inbox_id,
label_ids: [label_id],
assignee_id: user_id,
team_id: team_id,
conversation_id: conversation_id
)
described_class.add_filter_memberships(
account_id: account_id,
user_id: user_id,
filters: {
mentions: [conversation_id],
participating: [],
unattended: []
},
folders: {}
)
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
described_class.clear_all_account!(account_id)
expect(described_class.base_ready?(account_id)).to be(false)
expect(described_class.assignment_ready?(account_id)).to be(false)
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
expect(described_class.counts_for_keys(base_keys).values).to all(eq(0))
expect(described_class.counts_for_keys(assignment_keys).values).to all(eq(0))
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, user_id)]).values).to all(eq(0))
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(false)
end
end
@@ -194,6 +385,27 @@ RSpec.describe Conversations::UnreadCounts::Store do
]
end
def user_filter_keys(filter_user_id = user_id)
[
described_class.user_mentions_key(account_id, filter_user_id),
described_class.user_participating_key(account_id, filter_user_id),
described_class.user_unattended_key(account_id, filter_user_id),
described_class.user_folder_key(account_id, filter_user_id, 7)
]
end
def user_filter_build_lock_key(filter_user_id = user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account_id, user_id: filter_user_id)
end
def account_filter_version_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
end
def user_filter_version_key(filter_user_id = user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: filter_user_id)
end
def ttl_for(key)
Redis::Alfred.ttl(key)
end
@@ -0,0 +1,37 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::UserFilterNotifier do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:store) { Conversations::UnreadCounts::Store }
after do
store.clear_all_account!(account.id)
end
it 'clears the user filter cache and dispatches an unread count refresh event' do
account.enable_features!(:conversation_unread_counts)
store.mark_filters_ready!(account.id, user.id)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
described_class.new(account: account, user: user).perform
expect(store.filters_ready?(account.id, user.id)).to be(false)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
account: account,
user: user
)
end
it 'does nothing when conversation unread counts are disabled' do
store.mark_filters_ready!(account.id, user.id)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
described_class.new(account: account, user: user).perform
expect(store.filters_ready?(account.id, user.id)).to be(true)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end