})
);
+const unreadBadgeTooltips = computed(() => ({
+ ALL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.ALL'),
+ INBOX: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.INBOX'),
+ LABEL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.LABEL'),
+ TEAM: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.TEAM'),
+ FOLDER: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.FOLDER'),
+ MENTIONS: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.MENTIONS'),
+ PARTICIPATING: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.PARTICIPATING'),
+ UNATTENDED: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.UNATTENDED'),
+}));
+
+const unreadBadgeTooltip = type => unreadBadgeTooltips.value[type];
+
const closeMobileSidebar = () => {
if (!props.isMobileSidebarOpen) return;
emit('closeMobileSidebar');
@@ -335,12 +360,15 @@ const menuItems = computed(() => {
label: t('SIDEBAR.ALL_CONVERSATIONS'),
icon: 'i-lucide-inbox',
badgeCount: allUnreadCount.value,
+ badgeTooltip: unreadBadgeTooltip('ALL'),
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
+ badgeCount: mentionsUnreadCount.value,
+ badgeTooltip: unreadBadgeTooltip('MENTIONS'),
icon: 'i-lucide-at-sign',
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
@@ -348,6 +376,8 @@ const menuItems = computed(() => {
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
+ badgeCount: participatingUnreadCount.value,
+ badgeTooltip: unreadBadgeTooltip('PARTICIPATING'),
icon: 'i-lucide-user-round-check',
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
@@ -355,6 +385,8 @@ const menuItems = computed(() => {
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
+ badgeCount: unattendedUnreadCount.value,
+ badgeTooltip: unreadBadgeTooltip('UNATTENDED'),
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('conversation_unattended'),
@@ -370,6 +402,8 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
+ badgeCount: getFolderUnreadCount.value(view.id),
+ badgeTooltip: unreadBadgeTooltip('FOLDER'),
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -385,6 +419,7 @@ const menuItems = computed(() => {
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
+ badgeTooltip: unreadBadgeTooltip('TEAM'),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -400,6 +435,7 @@ const menuItems = computed(() => {
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
+ badgeTooltip: unreadBadgeTooltip('INBOX'),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -408,6 +444,7 @@ const menuItems = computed(() => {
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
+ badgeTooltip: leafProps.badgeTooltip,
}),
})),
},
@@ -423,6 +460,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
+ badgeTooltip: unreadBadgeTooltip('LABEL'),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
index 2e431eb7e..8f1de246e 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
@@ -197,7 +197,10 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ subChild.label }}
-
+
@@ -220,7 +223,10 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ child.label }}
-
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
index 1d23ad0ed..404bbe3f3 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
@@ -12,6 +12,7 @@ const props = defineProps({
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
+ badgeTooltip: { type: String, default: '' },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
});
@@ -52,14 +53,14 @@ const TREE_CONNECTOR =
{{ label }}
-
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
index 95d95236a..2a91bfcb4 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
@@ -3,6 +3,7 @@ import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
+ tooltip: { type: String, default: '' },
});
const normalizedCount = computed(() => {
@@ -18,6 +19,7 @@ const displayCount = computed(() =>
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
index 03d963134..c2ee5219f 100644
--- a/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
+++ b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
+import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
@@ -28,6 +29,17 @@ describe('ChannelLeaf', () => {
expect(badge.text()).toBe('3');
});
+ it('passes tooltip copy to unread badge', () => {
+ const wrapper = mountChannelLeaf({
+ badgeCount: 3,
+ badgeTooltip: 'Total unread conversations in this inbox',
+ });
+
+ expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
+ 'Total unread conversations in this inbox'
+ );
+ });
+
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
index 79b62d191..b63ca901c 100644
--- a/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
+++ b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
+import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
@@ -44,6 +45,17 @@ describe('SidebarGroupLeaf', () => {
expect(badge.text()).toBe('7');
});
+ it('passes tooltip copy to unread badge', () => {
+ const wrapper = mountLeaf({
+ badgeCount: 7,
+ badgeTooltip: 'Total unread conversations in this label',
+ });
+
+ expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
+ 'Total unread conversations in this label'
+ );
+ });
+
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
@@ -63,14 +75,17 @@ describe('SidebarGroupLeaf', () => {
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
+ badgeTooltip: 'Total unread conversations in this inbox',
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
- leafProps.badgeCount
+ `${leafProps.badgeCount}:${leafProps.badgeTooltip}`
),
});
- expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
+ expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe(
+ '4:Total unread conversations in this inbox'
+ );
});
});
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index 5e2543698..9afe4f963 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -316,6 +316,16 @@
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",
"UNATTENDED_CONVERSATIONS": "Unattended",
+ "UNREAD_COUNT_TOOLTIP": {
+ "ALL": "Total unread conversations",
+ "INBOX": "Total unread conversations in this inbox",
+ "LABEL": "Total unread conversations in this label",
+ "TEAM": "Total unread conversations in this team",
+ "FOLDER": "Total unread conversations in this folder",
+ "MENTIONS": "Unread conversations where you were mentioned",
+ "PARTICIPATING": "Unread conversations you're participating in",
+ "UNATTENDED": "Unread unattended conversations"
+ },
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
index c03249311..03bbb31eb 100644
--- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
+++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
@@ -6,6 +6,10 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
+ mentionsCount: 0,
+ participatingCount: 0,
+ unattendedCount: 0,
+ folders: {},
};
const normalizeCount = count => {
@@ -37,6 +41,18 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
+ getMentionsUnreadCount($state) {
+ return $state.mentionsCount;
+ },
+ getParticipatingUnreadCount($state) {
+ return $state.participatingCount;
+ },
+ getUnattendedUnreadCount($state) {
+ return $state.unattendedCount;
+ },
+ getFolderUnreadCount: $state => folderId => {
+ return $state.folders[String(folderId)] || 0;
+ },
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -46,6 +62,9 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
+ getFolderUnreadCounts($state) {
+ return $state.folders;
+ },
};
export const actions = {
@@ -68,6 +87,10 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
+ $state.mentionsCount = normalizeCount(payload.mentions_count);
+ $state.participatingCount = normalizeCount(payload.participating_count);
+ $state.unattendedCount = normalizeCount(payload.unattended_count);
+ $state.folders = normalizeCounts(payload.folders);
},
};
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
index 9fe19e22d..311aed95e 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
@@ -57,10 +57,32 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
+ folders: { 8: 9 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
+ expect(getters.getFolderUnreadCounts(state)).toEqual({ 8: 9 });
+ });
+
+ it('returns mentions, participating, unattended, and folder unread counts', () => {
+ const state = {
+ allCount: 0,
+ inboxes: {},
+ labels: {},
+ teams: {},
+ mentionsCount: 3,
+ participatingCount: 4,
+ unattendedCount: 5,
+ folders: { 7: 8 },
+ };
+
+ expect(getters.getMentionsUnreadCount(state)).toBe(3);
+ expect(getters.getParticipatingUnreadCount(state)).toBe(4);
+ expect(getters.getUnattendedUnreadCount(state)).toBe(5);
+ expect(getters.getFolderUnreadCount(state)(7)).toBe(8);
+ expect(getters.getFolderUnreadCount(state)('7')).toBe(8);
+ expect(getters.getFolderUnreadCount(state)(8)).toBe(0);
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
index 8941d7430..aa36ed904 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
@@ -21,6 +21,13 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
+ mentions_count: '8',
+ participating_count: 9,
+ unattended_count: '10',
+ folders: {
+ 11: '12',
+ 12: 0,
+ },
});
expect(state).toEqual({
@@ -28,6 +35,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
+ mentionsCount: 8,
+ participatingCount: 9,
+ unattendedCount: 10,
+ folders: { 11: 12 },
});
});
@@ -37,6 +48,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
+ mentionsCount: 8,
+ participatingCount: 9,
+ unattendedCount: 10,
+ folders: { 10: 11 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -46,6 +61,10 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
+ mentionsCount: 0,
+ participatingCount: 0,
+ unattendedCount: 0,
+ folders: {},
});
});
diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb
index 3bc221504..12b0a9017 100644
--- a/app/listeners/action_cable_listener.rb
+++ b/app/listeners/action_cable_listener.rb
@@ -91,10 +91,10 @@ class ActionCableListener < BaseListener
end
def conversation_unread_count_changed(event)
- account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
+ account, inbox_members, include_admins = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
- tokens = user_tokens(account, inbox_members)
+ tokens = include_admins ? user_tokens(account, inbox_members) : inbox_members.pluck(:pubsub_token)
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
end
diff --git a/app/models/account.rb b/app/models/account.rb
index 667058a2f..98da91b67 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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
diff --git a/app/models/account_user.rb b/app/models/account_user.rb
index bbcb0e010..4f135f508 100644
--- a/app/models/account_user.rb
+++ b/app/models/account_user.rb
@@ -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')
diff --git a/app/models/concerns/cache_keys.rb b/app/models/concerns/cache_keys.rb
index b37d7faa6..3ad9bbadc 100644
--- a/app/models/concerns/cache_keys.rb
+++ b/app/models/concerns/cache_keys.rb
@@ -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
diff --git a/app/models/conversation_participant.rb b/app/models/conversation_participant.rb
index 830eb7baa..d45719378 100644
--- a/app/models/conversation_participant.rb
+++ b/app/models/conversation_participant.rb
@@ -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
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 6d64c0447..9234f9600 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -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
diff --git a/app/models/inbox_member.rb b/app/models/inbox_member.rb
index d4a36ccc8..d0f553a98 100644
--- a/app/models/inbox_member.rb
+++ b/app/models/inbox_member.rb
@@ -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')
diff --git a/app/models/mention.rb b/app/models/mention.rb
index 0e0fe762a..5d3539078 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -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
diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb
index db2892c31..79527ac2f 100644
--- a/app/services/conversations/filter_service.rb
+++ b/app/services/conversations/filter_service.rb
@@ -7,8 +7,7 @@ class Conversations::FilterService < FilterService
end
def perform
- validate_query_operator
- @conversations = query_builder(@filters['conversations'])
+ @conversations = filtered_relation
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
@@ -23,6 +22,13 @@ class Conversations::FilterService < FilterService
}
end
+ def filtered_relation
+ validate_query_operator
+ return base_relation if @params[:payload].blank?
+
+ query_builder(@filters['conversations'])
+ end
+
def base_relation
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
diff --git a/app/services/conversations/unread_counts/broadcast_scope.rb b/app/services/conversations/unread_counts/broadcast_scope.rb
index 8e47050e3..ea43c1e8e 100644
--- a/app/services/conversations/unread_counts/broadcast_scope.rb
+++ b/app/services/conversations/unread_counts/broadcast_scope.rb
@@ -6,13 +6,23 @@ class Conversations::UnreadCounts::BroadcastScope
end
def perform
- return [conversation.account, conversation.inbox.members] if conversation.present?
+ return user_scope if user.present?
+ return [conversation.account, conversation.inbox.members, true] if conversation.present?
deleted_conversation_scope
end
private
+ def user
+ event.data[:user]
+ end
+
+ def user_scope
+ account = event.data[:account] || user.account
+ [account, [user], false]
+ end
+
def conversation
event.data[:conversation]
end
@@ -24,7 +34,7 @@ class Conversations::UnreadCounts::BroadcastScope
account = Account.find_by(id: conversation_data[:account_id])
return if account.blank?
- [account, inbox_members_for(account, conversation_data[:inbox_id])]
+ [account, inbox_members_for(account, conversation_data[:inbox_id]), true]
end
def inbox_members_for(account, inbox_id)
diff --git a/app/services/conversations/unread_counts/build_lock_keys.rb b/app/services/conversations/unread_counts/build_lock_keys.rb
new file mode 100644
index 000000000..06b922c88
--- /dev/null
+++ b/app/services/conversations/unread_counts/build_lock_keys.rb
@@ -0,0 +1,15 @@
+module Conversations::UnreadCounts::BuildLockKeys
+ private
+
+ 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 filters_build_lock_key
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account.id, user_id: user.id)
+ end
+end
diff --git a/app/services/conversations/unread_counts/builder.rb b/app/services/conversations/unread_counts/builder.rb
index 54e466dbd..e35d14b7c 100644
--- a/app/services/conversations/unread_counts/builder.rb
+++ b/app/services/conversations/unread_counts/builder.rb
@@ -1,5 +1,14 @@
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
@@ -24,10 +33,37 @@ class Conversations::UnreadCounts::Builder
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.in_batches(of: BATCH_SIZE) do |relation|
+ 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|
{
@@ -43,14 +79,99 @@ class Conversations::UnreadCounts::Builder
end
end
- def unread_conversations
- account.conversations
- .open
- .joins(:messages)
- .merge(Message.incoming.reorder(nil))
- .where(messages: { account_id: account.id })
- .where(unread_since_last_seen_condition)
- .distinct
+ 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
diff --git a/app/services/conversations/unread_counts/counter.rb b/app/services/conversations/unread_counts/counter.rb
index b1ba5ddb0..08a6c1072 100644
--- a/app/services/conversations/unread_counts/counter.rb
+++ b/app/services/conversations/unread_counts/counter.rb
@@ -1,4 +1,7 @@
class Conversations::UnreadCounts::Counter
+ include ::Conversations::UnreadCounts::BuildLockKeys
+ include ::Conversations::UnreadCounts::FilterCounter
+
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
@@ -18,31 +21,38 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
+ ensure_filters_cache!
inbox_counts = unread_inbox_counts
+ filter_counts = unread_filter_counts
{
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
- teams: unread_team_counts
+ teams: unread_team_counts,
+ mentions_count: filter_counts[:mentions_count],
+ participating_count: filter_counts[:participating_count],
+ unattended_count: filter_counts[:unattended_count],
+ folders: filter_counts[:folders]
}
end
private
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! }
+ ensure_cache_ready!(ready: -> { store.base_ready?(account.id) }, lock_key: base_build_lock_key) { builder.build_base! }
end
def ensure_assignment_cache!
+ ensure_cache_ready!(ready: -> { store.assignment_ready?(account.id) }, lock_key: assignment_build_lock_key) { builder.build_assignment! }
+ end
+
+ def ensure_filters_cache!
ensure_cache_ready!(
- ready: -> { store.assignment_ready?(account.id) },
- lock_key: assignment_build_lock_key
- ) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
+ ready: -> { store.filters_ready?(account.id, user.id) },
+ lock_key: filters_build_lock_key
+ ) { builder.build_filters_for!(user) }
end
def ensure_cache_ready!(ready:, lock_key:)
@@ -51,9 +61,10 @@ class Conversations::UnreadCounts::Counter
loop do
return if ready.call
- return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
+ lock_acquired = lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
+ return if ready.call
- wait_for_cache_ready(ready)
+ wait_for_cache_ready(ready) unless lock_acquired
end
end
@@ -62,14 +73,6 @@ class Conversations::UnreadCounts::Counter
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
@@ -194,10 +197,14 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
- { all_count: 0, inboxes: {}, labels: {}, teams: {} }
+ { all_count: 0, inboxes: {}, labels: {}, teams: {}, mentions_count: 0, participating_count: 0, unattended_count: 0, folders: {} }
end
def store
::Conversations::UnreadCounts::Store
end
+
+ def builder
+ @builder ||= ::Conversations::UnreadCounts::Builder.new(account)
+ end
end
diff --git a/app/services/conversations/unread_counts/filter_counter.rb b/app/services/conversations/unread_counts/filter_counter.rb
new file mode 100644
index 000000000..79f376d87
--- /dev/null
+++ b/app/services/conversations/unread_counts/filter_counter.rb
@@ -0,0 +1,40 @@
+module Conversations::UnreadCounts::FilterCounter
+ private
+
+ def unread_filter_counts
+ keys = user_filter_keys
+ counts_by_key = store.counts_for_keys(keys.values + folder_keys.values)
+
+ {
+ mentions_count: counts_by_key[keys[:mentions]].to_i,
+ participating_count: counts_by_key[keys[:participating]].to_i,
+ unattended_count: counts_by_key[keys[:unattended]].to_i,
+ folders: folder_counts(counts_by_key)
+ }
+ end
+
+ def user_filter_keys
+ {
+ mentions: store.user_mentions_key(account.id, user.id),
+ participating: store.user_participating_key(account.id, user.id),
+ unattended: store.user_unattended_key(account.id, user.id)
+ }
+ end
+
+ def conversation_folder_ids
+ @conversation_folder_ids ||= account.custom_filters.where(user: user, filter_type: :conversation).pluck(:id)
+ end
+
+ def folder_keys
+ @folder_keys ||= conversation_folder_ids.index_with do |custom_filter_id|
+ store.user_folder_key(account.id, user.id, custom_filter_id)
+ end
+ end
+
+ def folder_counts(counts_by_key)
+ folder_keys.each_with_object({}) do |(custom_filter_id, key), result|
+ count = counts_by_key[key].to_i
+ result[custom_filter_id.to_s] = count if count.positive?
+ end
+ end
+end
diff --git a/app/services/conversations/unread_counts/listener.rb b/app/services/conversations/unread_counts/listener.rb
index 28792d884..28bbf2ac3 100644
--- a/app/services/conversations/unread_counts/listener.rb
+++ b/app/services/conversations/unread_counts/listener.rb
@@ -3,10 +3,13 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
- return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
- refresh(message.conversation)
+ if message.incoming?
+ refresh(message.conversation)
+ else
+ notify_filter_counts_changed(message.conversation)
+ end
end
def conversation_status_changed(event)
@@ -15,10 +18,21 @@ class Conversations::UnreadCounts::Listener < BaseListener
end
def conversation_updated(event)
- return unless label_changed?(event.data[:changed_attributes])
-
conversation, = extract_conversation_and_account(event)
- refresh(conversation, event.data[:changed_attributes])
+ return unless conversation.account.feature_enabled?('conversation_unread_counts')
+
+ if label_changed?(event.data[:changed_attributes])
+ refresh(conversation, event.data[:changed_attributes])
+ elsif folder_filter_changed?(event.data[:changed_attributes])
+ notify_filter_counts_changed(conversation)
+ end
+ end
+
+ def conversation_contact_changed(event)
+ conversation, = extract_conversation_and_account(event)
+ return unless conversation.account.feature_enabled?('conversation_unread_counts')
+
+ notify_filter_counts_changed(conversation)
end
def assignee_changed(event)
@@ -37,7 +51,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
- return unless remove_deleted_conversation(account, conversation_data)
+
+ filters_cleared = store.clear_filter_caches!(account.id)
+ memberships_removed = remove_deleted_conversation(account, conversation_data)
+ return unless memberships_removed || filters_cleared
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
end
@@ -90,6 +107,16 @@ class Conversations::UnreadCounts::Listener < BaseListener
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
end
+ def folder_filter_changed?(changed_attributes)
+ changed_attributes.present?
+ end
+
+ def notify_filter_counts_changed(conversation)
+ return unless store.clear_filter_caches!(conversation.account_id)
+
+ Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
+ end
+
def store
::Conversations::UnreadCounts::Store
end
diff --git a/app/services/conversations/unread_counts/notifier.rb b/app/services/conversations/unread_counts/notifier.rb
index 652fbde3a..aac4af7c0 100644
--- a/app/services/conversations/unread_counts/notifier.rb
+++ b/app/services/conversations/unread_counts/notifier.rb
@@ -11,7 +11,9 @@ class Conversations::UnreadCounts::Notifier
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
- return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
+ filters_cleared = ::Conversations::UnreadCounts::Store.clear_filter_caches!(conversation.account_id)
+ memberships_refreshed = ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
+ return false unless memberships_refreshed || filters_cleared
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
true
diff --git a/app/services/conversations/unread_counts/store.rb b/app/services/conversations/unread_counts/store.rb
index e51fc5c09..6aa9cef05 100644
--- a/app/services/conversations/unread_counts/store.rb
+++ b/app/services/conversations/unread_counts/store.rb
@@ -1,5 +1,6 @@
class Conversations::UnreadCounts::Store
extend ::Conversations::UnreadCounts::StoreKeys
+ extend ::Conversations::UnreadCounts::UserFilterStore
class << self
def base_ready?(account_id)
@@ -19,6 +20,10 @@ class Conversations::UnreadCounts::Store
end
def clear_account!(account_id)
+ account_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
+ end
+
+ def clear_all_account!(account_id)
delete_matching("#{account_prefix(account_id)}::*")
end
@@ -178,9 +183,11 @@ class Conversations::UnreadCounts::Store
end
def delete_matching(pattern)
+ deleted = 0
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
- Redis::Alfred.delete(key)
+ deleted += 1 if Redis::Alfred.delete(key)
end
+ deleted.positive?
end
def assignment_key_patterns(account_id)
@@ -195,5 +202,16 @@ class Conversations::UnreadCounts::Store
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
]
end
+
+ def account_key_patterns(account_id)
+ prefix = account_prefix(account_id)
+ [
+ base_ready_key(account_id),
+ assignment_ready_key(account_id),
+ "#{prefix}::INBOX::*",
+ "#{prefix}::LABEL::*::INBOX::*",
+ "#{prefix}::TEAM::*::INBOX::*"
+ ]
+ end
end
end
diff --git a/app/services/conversations/unread_counts/store_keys.rb b/app/services/conversations/unread_counts/store_keys.rb
index bbb6d101a..a5b8c43ec 100644
--- a/app/services/conversations/unread_counts/store_keys.rb
+++ b/app/services/conversations/unread_counts/store_keys.rb
@@ -40,4 +40,20 @@ module Conversations::UnreadCounts::StoreKeys
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
end
+
+ def user_mentions_key(account_id, user_id)
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_MENTIONS, account_id: account_id, user_id: user_id)
+ end
+
+ def user_participating_key(account_id, user_id)
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_PARTICIPATING, account_id: account_id, user_id: user_id)
+ end
+
+ def user_unattended_key(account_id, user_id)
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_UNATTENDED, account_id: account_id, user_id: user_id)
+ end
+
+ def user_folder_key(account_id, user_id, custom_filter_id)
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FOLDER, account_id: account_id, user_id: user_id, custom_filter_id: custom_filter_id)
+ end
end
diff --git a/app/services/conversations/unread_counts/user_filter_notifier.rb b/app/services/conversations/unread_counts/user_filter_notifier.rb
new file mode 100644
index 000000000..94dd95a0f
--- /dev/null
+++ b/app/services/conversations/unread_counts/user_filter_notifier.rb
@@ -0,0 +1,19 @@
+class Conversations::UnreadCounts::UserFilterNotifier
+ include Events::Types
+
+ attr_reader :account, :user
+
+ def initialize(account:, user:)
+ @account = account
+ @user = user
+ end
+
+ def perform
+ return false if account.blank? || user.blank?
+ return false unless account.feature_enabled?('conversation_unread_counts')
+
+ ::Conversations::UnreadCounts::Store.clear_user_filters!(account.id, user.id)
+ Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, account: account, user: user)
+ true
+ end
+end
diff --git a/app/services/conversations/unread_counts/user_filter_store.rb b/app/services/conversations/unread_counts/user_filter_store.rb
new file mode 100644
index 000000000..3533acb56
--- /dev/null
+++ b/app/services/conversations/unread_counts/user_filter_store.rb
@@ -0,0 +1,100 @@
+module Conversations::UnreadCounts::UserFilterStore
+ USER_FILTER_KEY_SUFFIXES = [
+ 'READY::FILTERS',
+ 'MENTIONS',
+ 'PARTICIPATING',
+ 'UNATTENDED',
+ 'FOLDER::*'
+ ].freeze
+
+ def filters_ready?(account_id, user_id)
+ Redis::Alfred.exists?(filters_ready_key(account_id, user_id))
+ end
+
+ def mark_filters_ready!(account_id, user_id, expires_in: Conversations::UnreadCounts::READY_TTL)
+ Redis::Alfred.set(filters_ready_key(account_id, user_id), Time.current.to_i, ex: expires_in)
+ end
+
+ def mark_filters_ready_if_current!(account_id, user_id, version_snapshot:, expires_in: Conversations::UnreadCounts::READY_TTL)
+ return false unless filter_version_snapshot(account_id, user_id) == version_snapshot
+
+ mark_filters_ready!(account_id, user_id, expires_in: expires_in)
+ end
+
+ def filter_version_snapshot(account_id, user_id)
+ {
+ account: filter_version(account_filter_version_key(account_id)),
+ user: filter_version(user_filter_version_key(account_id, user_id))
+ }
+ end
+
+ def clear_filter_caches!(account_id)
+ bump_filter_version(account_filter_version_key(account_id))
+ delete_user_filter_patterns("#{account_prefix(account_id)}::USER::*")
+ end
+
+ def clear_user_filters!(account_id, user_id)
+ bump_filter_version(user_filter_version_key(account_id, user_id))
+ delete_user_filter_patterns(user_filter_prefix(account_id, user_id))
+ end
+
+ def add_filter_memberships(account_id:, user_id:, filters:, folders:)
+ memberships = {
+ user_mentions_key(account_id, user_id) => filters[:mentions],
+ user_participating_key(account_id, user_id) => filters[:participating],
+ user_unattended_key(account_id, user_id) => filters[:unattended]
+ }
+ folders.each do |custom_filter_id, conversation_ids|
+ memberships[user_folder_key(account_id, user_id, custom_filter_id)] = conversation_ids
+ end
+
+ write_membership_sets(memberships)
+ end
+
+ private
+
+ def filters_ready_key(account_id, user_id)
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_READY, account_id: account_id, user_id: user_id)
+ end
+
+ def account_filter_version_key(account_id)
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
+ end
+
+ def user_filter_version_key(account_id, user_id)
+ format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: user_id)
+ end
+
+ def user_filter_prefix(account_id, user_id)
+ "#{account_prefix(account_id)}::USER::#{user_id}"
+ end
+
+ def filter_version(key)
+ Redis::Alfred.get(key).to_i
+ end
+
+ def bump_filter_version(key)
+ Redis::Alfred.incr(key).tap { Redis::Alfred.expire(key, Conversations::UnreadCounts::SET_TTL) }
+ end
+
+ def delete_user_filter_patterns(prefix)
+ deleted = false
+ USER_FILTER_KEY_SUFFIXES.each do |suffix|
+ deleted = delete_matching("#{prefix}::#{suffix}") || deleted
+ end
+ deleted
+ end
+
+ def write_membership_sets(memberships)
+ memberships = memberships.transform_values { |conversation_ids| Array(conversation_ids).compact_blank }
+ memberships = memberships.select { |_key, conversation_ids| conversation_ids.present? }
+ return if memberships.blank?
+
+ Redis::Alfred.pipelined do |pipeline|
+ memberships.each do |key, conversation_ids|
+ conversation_ids.each { |conversation_id| pipeline.sadd(key, conversation_id) }
+ pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
+ end
+ end
+ end
+end
diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb
index 25f118d48..33ca04680 100644
--- a/app/services/filter_service.rb
+++ b/app/services/filter_service.rb
@@ -11,7 +11,7 @@ class FilterService
}.with_indifferent_access
def initialize(params, user)
- @params = params
+ @params = normalize_params(params)
@user = user
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@@ -140,6 +140,16 @@ class FilterService
private
+ def normalize_params(params)
+ return params unless params.respond_to?(:with_indifferent_access)
+
+ normalized_params = params.with_indifferent_access
+ normalized_params[:payload] = Array(normalized_params[:payload]).map do |condition|
+ condition.respond_to?(:with_indifferent_access) ? condition.with_indifferent_access : condition
+ end
+ normalized_params
+ end
+
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
diff --git a/enterprise/app/finders/enterprise/conversation_finder.rb b/enterprise/app/finders/enterprise/conversation_finder.rb
index 24e6b493d..f1f2e5514 100644
--- a/enterprise/app/finders/enterprise/conversation_finder.rb
+++ b/enterprise/app/finders/enterprise/conversation_finder.rb
@@ -1,5 +1,31 @@
module Enterprise::ConversationFinder
+ def filter_by_conversation_type
+ return super unless params[:conversation_type] == 'participating' && custom_role_participating_permission?
+
+ @conversations = participating_visible_conversations.where(
+ id: current_user.participating_conversations.where(account_id: current_account.id).select(:id)
+ )
+ end
+
def conversations_base_query
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
end
+
+ private
+
+ def participating_visible_conversations
+ conversations = current_account.conversations
+ conversations = conversations.where(inbox_id: @inbox_ids) if params[:inbox_id]
+ return conversations if account_user&.administrator?
+
+ conversations.where(inbox: current_user.inboxes.where(account_id: current_account.id))
+ end
+
+ def custom_role_participating_permission?
+ account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?('conversation_participating_manage')
+ end
+
+ def account_user
+ @account_user ||= current_account.account_users.find_by(user_id: current_user.id)
+ end
end
diff --git a/enterprise/app/models/custom_role.rb b/enterprise/app/models/custom_role.rb
index 666f91378..4085000b1 100644
--- a/enterprise/app/models/custom_role.rb
+++ b/enterprise/app/models/custom_role.rb
@@ -28,6 +28,9 @@ class CustomRole < ApplicationRecord
belongs_to :account
has_many :account_users, dependent: :nullify
+ before_destroy :cache_users_for_unread_filter_notification, prepend: true
+ after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
+
PERMISSIONS = %w[
conversation_manage
conversation_unassigned_manage
@@ -39,4 +42,24 @@ class CustomRole < ApplicationRecord
validates :name, presence: true
validates :permissions, inclusion: { in: PERMISSIONS }
+
+ private
+
+ def unread_filter_access_changed?
+ destroyed? || previous_changes.key?('permissions')
+ end
+
+ def cache_users_for_unread_filter_notification
+ @users_for_unread_filter_notification = account_users.includes(:user).map(&:user)
+ end
+
+ def users_for_unread_filter_notification
+ @users_for_unread_filter_notification || account_users.includes(:user).map(&:user)
+ end
+
+ def notify_unread_filter_counts_changed
+ users_for_unread_filter_notification.each do |user|
+ ::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
+ end
+ end
end
diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb
index bc7fb551e..381cafb98 100644
--- a/lib/redis/redis_keys.rb
+++ b/lib/redis/redis_keys.rb
@@ -14,8 +14,15 @@ module Redis::RedisKeys
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d'.freeze
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::READY::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::READY::ASSIGNMENT'.freeze
+ UNREAD_CONVERSATIONS_USER_FILTERS_READY =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::READY::FILTERS'.freeze
+ UNREAD_CONVERSATIONS_FILTERS_VERSION = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::VERSION::FILTERS'.freeze
+ UNREAD_CONVERSATIONS_USER_FILTERS_VERSION =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::VERSION::FILTERS'.freeze
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::BUILD_LOCK::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::BUILD_LOCK::ASSIGNMENT'.freeze
+ UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::BUILD_LOCK::FILTERS'.freeze
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::INBOX::%d'.freeze
UNREAD_CONVERSATIONS_LABEL_INBOX =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::LABEL::%d::INBOX::%d'.freeze
@@ -33,6 +40,14 @@ module Redis::RedisKeys
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::UNASSIGNED'.freeze
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::ASSIGNEE::%d'.freeze
+ UNREAD_CONVERSATIONS_USER_MENTIONS =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::MENTIONS'.freeze
+ UNREAD_CONVERSATIONS_USER_PARTICIPATING =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::PARTICIPATING'.freeze
+ UNREAD_CONVERSATIONS_USER_UNATTENDED =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::UNATTENDED'.freeze
+ UNREAD_CONVERSATIONS_USER_FOLDER =
+ 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::FOLDER::%d'.freeze
## User Keys
# SSO Auth Tokens
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index bdb8117ac..de3cc4a3c 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -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
diff --git a/spec/controllers/super_admin/accounts_controller_spec.rb b/spec/controllers/super_admin/accounts_controller_spec.rb
index e4ff81a08..917499cd5 100644
--- a/spec/controllers/super_admin/accounts_controller_spec.rb
+++ b/spec/controllers/super_admin/accounts_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/finders/conversation_finder_spec.rb b/spec/enterprise/finders/conversation_finder_spec.rb
new file mode 100644
index 000000000..f8b9fce58
--- /dev/null
+++ b/spec/enterprise/finders/conversation_finder_spec.rb
@@ -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
diff --git a/spec/enterprise/models/account_user_spec.rb b/spec/enterprise/models/account_user_spec.rb
index fb572e86a..e157d9acd 100644
--- a/spec/enterprise/models/account_user_spec.rb
+++ b/spec/enterprise/models/account_user_spec.rb
@@ -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
diff --git a/spec/enterprise/models/custom_role_spec.rb b/spec/enterprise/models/custom_role_spec.rb
index f63f3c2dd..553a5a33a 100644
--- a/spec/enterprise/models/custom_role_spec.rb
+++ b/spec/enterprise/models/custom_role_spec.rb
@@ -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
diff --git a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
index e8f850e78..86a93bd8c 100644
--- a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
@@ -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
diff --git a/spec/finders/conversation_finder_spec.rb b/spec/finders/conversation_finder_spec.rb
index 64f134fe2..31cbceb6a 100644
--- a/spec/finders/conversation_finder_spec.rb
+++ b/spec/finders/conversation_finder_spec.rb
@@ -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) { {} }
diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb
index cdb9a93cb..7a774dbd5 100644
--- a/spec/listeners/action_cable_listener_spec.rb
+++ b/spec/listeners/action_cable_listener_spec.rb
@@ -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
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 38ca9694a..d77a16fdb 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -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
diff --git a/spec/models/account_user_spec.rb b/spec/models/account_user_spec.rb
index e5a560fe9..7acfc19cd 100644
--- a/spec/models/account_user_spec.rb
+++ b/spec/models/account_user_spec.rb
@@ -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
diff --git a/spec/models/inbox_member_spec.rb b/spec/models/inbox_member_spec.rb
index b081c546c..df569c695 100644
--- a/spec/models/inbox_member_spec.rb
+++ b/spec/models/inbox_member_spec.rb
@@ -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
diff --git a/spec/services/conversations/unread_counts/builder_spec.rb b/spec/services/conversations/unread_counts/builder_spec.rb
index 4b3aec230..fe1bdb6fa 100644
--- a/spec/services/conversations/unread_counts/builder_spec.rb
+++ b/spec/services/conversations/unread_counts/builder_spec.rb
@@ -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
diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb
index 723f2d437..c3c338355 100644
--- a/spec/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/services/conversations/unread_counts/counter_spec.rb
@@ -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
diff --git a/spec/services/conversations/unread_counts/listener_spec.rb b/spec/services/conversations/unread_counts/listener_spec.rb
index fbb0a0835..0cf31bd3c 100644
--- a/spec/services/conversations/unread_counts/listener_spec.rb
+++ b/spec/services/conversations/unread_counts/listener_spec.rb
@@ -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)
diff --git a/spec/services/conversations/unread_counts/notifier_spec.rb b/spec/services/conversations/unread_counts/notifier_spec.rb
index 1b35d37f6..645c2bc17 100644
--- a/spec/services/conversations/unread_counts/notifier_spec.rb
+++ b/spec/services/conversations/unread_counts/notifier_spec.rb
@@ -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
diff --git a/spec/services/conversations/unread_counts/refresher_spec.rb b/spec/services/conversations/unread_counts/refresher_spec.rb
index 5f361e7aa..e961c21c3 100644
--- a/spec/services/conversations/unread_counts/refresher_spec.rb
+++ b/spec/services/conversations/unread_counts/refresher_spec.rb
@@ -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
diff --git a/spec/services/conversations/unread_counts/store_spec.rb b/spec/services/conversations/unread_counts/store_spec.rb
index fcf21c985..9e1311727 100644
--- a/spec/services/conversations/unread_counts/store_spec.rb
+++ b/spec/services/conversations/unread_counts/store_spec.rb
@@ -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
diff --git a/spec/services/conversations/unread_counts/user_filter_notifier_spec.rb b/spec/services/conversations/unread_counts/user_filter_notifier_spec.rb
new file mode 100644
index 000000000..1d898b103
--- /dev/null
+++ b/spec/services/conversations/unread_counts/user_filter_notifier_spec.rb
@@ -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
From c70a57407d4722e878beeccc9ad756fbb2d75810 Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Wed, 17 Jun 2026 00:05:29 +0530
Subject: [PATCH 17/26] fix: label Chatwoot Mobile sessions instead of 'Unknown
Device' (#14753)
## Description
Sessions created by logins from the Chatwoot Mobile app currently render
as "Unknown Device" in the dashboard sessions UI. The mobile app's HTTP
layer sends:
- Android: `User-Agent: okhttp/4.9.2`
- iOS: `User-Agent: Chatwoot/ CFNetwork/ Darwin/`
Neither pattern is classifiable by the `browser` gem, so
\`browser_name\`, \`platform_name\`, and \`device_name\` all end up
"Unknown".
This change adds a backend-only fallback in
\`UserSessionTrackingService\`. When \`Browser.new(ua)\` returns
"Unknown Browser" and the UA matches a known Chatwoot Mobile pattern,
the labels are overridden to \`Chatwoot Mobile\` + \`Android\` /
\`iPhone\`. The Vue sessions list (\`ActiveSessions.vue\`) then renders
"Chatwoot Mobile on Android" or "Chatwoot Mobile on iPhone" with the
smartphone icon.
This is the immediate floor. A follow-up will add structured
\`X-Chatwoot-*\` headers from the mobile app so we can render full
version + device model.
Fixes INF-75.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Added specs
## 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
- [ ] I have made corresponding changes to the documentation
- [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] Any dependent changes have been merged and published in downstream
modules
---
app/services/user_session_tracking_service.rb | 26 ++++++-
.../user_session_tracking_service_spec.rb | 69 +++++++++++++++++++
2 files changed, 94 insertions(+), 1 deletion(-)
diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb
index 28f272a18..d7e6f6efb 100644
--- a/app/services/user_session_tracking_service.rb
+++ b/app/services/user_session_tracking_service.rb
@@ -1,4 +1,11 @@
class UserSessionTrackingService
+ # CFNetwork UAs cannot distinguish iPhone from iPad; both get labelled iPhone here.
+ LEGACY_MOBILE_UAS = [
+ { match: %r{\Aokhttp/}, platform: 'Android', device: 'Android' },
+ { match: %r{\AChatwoot/.*CFNetwork.*Darwin}, platform: 'iPhone', device: 'iPhone' }
+ ].freeze
+ private_constant :LEGACY_MOBILE_UAS
+
def initialize(user:, request:, client_id:)
@user = user
@request = request
@@ -26,7 +33,7 @@ class UserSessionTrackingService
def session_attributes
browser = Browser.new(@request.user_agent)
- {
+ attrs = {
ip_address: @request.remote_ip,
user_agent: @request.user_agent,
browser_name: browser.name,
@@ -35,5 +42,22 @@ class UserSessionTrackingService
platform_name: browser.platform.name,
platform_version: browser.platform.version
}
+
+ patch_for_legacy_mobile(attrs)
+ end
+
+ def patch_for_legacy_mobile(attrs)
+ return attrs unless attrs[:browser_name] == 'Unknown Browser'
+
+ hit = LEGACY_MOBILE_UAS.find { |m| @request.user_agent.to_s.match?(m[:match]) }
+ return attrs unless hit
+
+ attrs.merge(
+ browser_name: 'Chatwoot Mobile',
+ browser_version: nil,
+ platform_name: hit[:platform],
+ platform_version: nil,
+ device_name: hit[:device]
+ )
end
end
diff --git a/spec/services/user_session_tracking_service_spec.rb b/spec/services/user_session_tracking_service_spec.rb
index 71b6d5924..2b98b95bf 100644
--- a/spec/services/user_session_tracking_service_spec.rb
+++ b/spec/services/user_session_tracking_service_spec.rb
@@ -47,6 +47,75 @@ RSpec.describe UserSessionTrackingService do
expect(existing.reload.ip_address).to eq('8.8.8.8')
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
end
+
+ context 'with a Chatwoot Mobile legacy User-Agent' do
+ let(:request) do
+ instance_double(
+ ActionDispatch::Request,
+ user_agent: ua,
+ remote_ip: '8.8.8.8'
+ )
+ end
+
+ context 'when the UA is okhttp (Android Chatwoot Mobile)' do
+ let(:ua) { 'okhttp/4.9.2' }
+
+ it 'labels the session as Chatwoot Mobile on Android', :aggregate_failures do
+ service.create_or_update!
+
+ session = user.user_sessions.last
+ expect(session.browser_name).to eq('Chatwoot Mobile')
+ expect(session.browser_version).to be_nil
+ expect(session.platform_name).to eq('Android')
+ expect(session.platform_version).to be_nil
+ expect(session.device_name).to eq('Android')
+ expect(session.user_agent).to eq(ua)
+ end
+ end
+
+ context 'when the UA is CFNetwork (iOS Chatwoot Mobile)' do
+ let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' }
+
+ it 'labels the session as Chatwoot Mobile on iPhone', :aggregate_failures do
+ service.create_or_update!
+
+ session = user.user_sessions.last
+ expect(session.browser_name).to eq('Chatwoot Mobile')
+ expect(session.browser_version).to be_nil
+ expect(session.platform_name).to eq('iPhone')
+ expect(session.platform_version).to be_nil
+ expect(session.device_name).to eq('iPhone')
+ expect(session.user_agent).to eq(ua)
+ end
+ end
+
+ context 'when the UA is a real browser (Firefox on Linux)' do
+ let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' }
+
+ it 'does not override the Browser-derived metadata', :aggregate_failures do
+ service.create_or_update!
+
+ session = user.user_sessions.last
+ expect(session.browser_name).to eq('Firefox')
+ expect(session.platform_name).to eq('Generic Linux')
+ expect(session.device_name).not_to eq('Android')
+ expect(session.device_name).not_to eq('iPhone')
+ end
+ end
+
+ context 'when the UA is unknown but does not match any mobile pattern' do
+ let(:ua) { 'curl/8.4.0' }
+
+ it 'leaves the Unknown labels untouched', :aggregate_failures do
+ service.create_or_update!
+
+ session = user.user_sessions.last
+ expect(session.browser_name).to eq('Unknown Browser')
+ expect(session.platform_name).to eq('Unknown')
+ expect(session.device_name).to eq('Unknown')
+ end
+ end
+ end
end
describe '#update_activity!' do
From 124782938ddbbca065296d77115bb8d9b2057c69 Mon Sep 17 00:00:00 2001
From: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Date: Wed, 17 Jun 2026 00:10:47 +0530
Subject: [PATCH 18/26] Bump version to 4.15.0
---
VERSION_CW | 2 +-
config/app.yml | 2 +-
package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/VERSION_CW b/VERSION_CW
index 0fb7a35b6..5c517bf11 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.14.2
+4.15.0
diff --git a/config/app.yml b/config/app.yml
index c2494befa..c2c192018 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.14.2'
+ version: '4.15.0'
development:
<<: *shared
diff --git a/package.json b/package.json
index fc4598bc4..b8ab60839 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.14.2",
+ "version": "4.15.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
From f66b551c7d6778b7984041832cbba70bc833cde3 Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Wed, 17 Jun 2026 16:28:48 +0530
Subject: [PATCH 19/26] revert: Sidebar unread counts for filters (CW-7262)
(#14769)
## Description
Reverts [#14726](https://github.com/chatwoot/chatwoot/pull/14726)
(\"feat: Add sidebar unread counts for filters (CW-7262)\"), which
shipped in 4.15.0.
After 4.15.0 rolled out to prod the unread-counts-for-filters code path
caused a cascading incident:
- `Counter#ensure_filters_cache!` fires on every `/unread_counts/index`
and `update_last_seen` request.
- On cache miss it calls `Builder#build_filters_for!`, which:
- invokes `store.clear_user_filters!` -> `delete_matching` -> a Redis
`SCAN_each` over a per-user pattern keyspace, and
- runs 4 fresh SQL passes per user (mentions, participating, unattended,
and per-folder `Conversations::FilterService` queries).
- Threads blocked in the SCAN held their DB connections, the connection
pool exhausted, Sidekiq jobs were discarded with
`ActiveJob::DeserializationError: could not obtain a connection from the
pool`, and the enqueued queue blew past 200K.
Related:
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] 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
## How Has This Been Tested?
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [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
- [ ] Any dependent changes have been merged and published in downstream
modules
---
app/finders/conversation_finder.rb | 2 +-
.../components-next/sidebar/ChannelLeaf.vue | 6 +-
.../components-next/sidebar/Sidebar.vue | 38 ---
.../sidebar/SidebarCollapsedPopover.vue | 10 +-
.../sidebar/SidebarGroupLeaf.vue | 5 +-
.../sidebar/SidebarUnreadBadge.vue | 2 -
.../sidebar/specs/ChannelLeaf.spec.js | 12 -
.../sidebar/specs/SidebarGroupLeaf.spec.js | 19 +-
.../dashboard/i18n/locale/en/settings.json | 10 -
.../store/modules/conversationUnreadCounts.js | 23 --
.../conversationUnreadCounts/getters.spec.js | 22 --
.../mutations.spec.js | 19 --
app/listeners/action_cable_listener.rb | 4 +-
app/models/account.rb | 2 +-
app/models/account_user.rb | 9 -
app/models/concerns/cache_keys.rb | 1 +
app/models/conversation_participant.rb | 5 -
app/models/custom_filter.rb | 9 -
app/models/inbox_member.rb | 12 -
app/models/mention.rb | 5 -
app/services/conversations/filter_service.rb | 10 +-
.../unread_counts/broadcast_scope.rb | 14 +-
.../unread_counts/build_lock_keys.rb | 15 --
.../conversations/unread_counts/builder.rb | 139 +----------
.../conversations/unread_counts/counter.rb | 45 ++--
.../unread_counts/filter_counter.rb | 40 ----
.../conversations/unread_counts/listener.rb | 39 +---
.../conversations/unread_counts/notifier.rb | 4 +-
.../conversations/unread_counts/store.rb | 20 +-
.../conversations/unread_counts/store_keys.rb | 16 --
.../unread_counts/user_filter_notifier.rb | 19 --
.../unread_counts/user_filter_store.rb | 100 --------
app/services/filter_service.rb | 12 +-
.../finders/enterprise/conversation_finder.rb | 26 ---
enterprise/app/models/custom_role.rb | 23 --
lib/redis/redis_keys.rb | 15 --
.../accounts/conversations_controller_spec.rb | 43 +---
.../super_admin/accounts_controller_spec.rb | 2 +-
.../finders/conversation_finder_spec.rb | 24 --
spec/enterprise/models/account_user_spec.rb | 18 --
spec/enterprise/models/custom_role_spec.rb | 34 ---
.../unread_counts/counter_spec.rb | 60 ++---
spec/finders/conversation_finder_spec.rb | 19 --
spec/listeners/action_cable_listener_spec.rb | 14 --
spec/models/account_spec.rb | 59 +----
spec/models/account_user_spec.rb | 28 ---
spec/models/inbox_member_spec.rb | 26 ---
.../unread_counts/builder_spec.rb | 197 +---------------
.../unread_counts/counter_spec.rb | 78 +------
.../unread_counts/listener_spec.rb | 51 +---
.../unread_counts/notifier_spec.rb | 12 -
.../unread_counts/refresher_spec.rb | 2 +-
.../conversations/unread_counts/store_spec.rb | 218 +-----------------
.../user_filter_notifier_spec.rb | 37 ---
54 files changed, 95 insertions(+), 1579 deletions(-)
delete mode 100644 app/services/conversations/unread_counts/build_lock_keys.rb
delete mode 100644 app/services/conversations/unread_counts/filter_counter.rb
delete mode 100644 app/services/conversations/unread_counts/user_filter_notifier.rb
delete mode 100644 app/services/conversations/unread_counts/user_filter_store.rb
delete mode 100644 spec/enterprise/finders/conversation_finder_spec.rb
delete mode 100644 spec/services/conversations/unread_counts/user_filter_notifier_spec.rb
diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb
index 9d53ec97e..74bf903f5 100644
--- a/app/finders/conversation_finder.rb
+++ b/app/finders/conversation_finder.rb
@@ -142,7 +142,7 @@ class ConversationFinder
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'participating'
- @conversations = @conversations.where(id: current_user.participating_conversations.where(account_id: current_account.id).select(:id))
+ @conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.unattended
end
diff --git a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
index 234737008..a995cf510 100644
--- a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
@@ -22,10 +22,6 @@ const props = defineProps({
type: [Number, String],
default: 0,
},
- badgeTooltip: {
- type: String,
- default: '',
- },
});
const reauthorizationRequired = computed(() => {
@@ -38,7 +34,7 @@ const reauthorizationRequired = computed(() => {
{{ label }}
-
+
})
);
-const unreadBadgeTooltips = computed(() => ({
- ALL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.ALL'),
- INBOX: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.INBOX'),
- LABEL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.LABEL'),
- TEAM: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.TEAM'),
- FOLDER: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.FOLDER'),
- MENTIONS: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.MENTIONS'),
- PARTICIPATING: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.PARTICIPATING'),
- UNATTENDED: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.UNATTENDED'),
-}));
-
-const unreadBadgeTooltip = type => unreadBadgeTooltips.value[type];
-
const closeMobileSidebar = () => {
if (!props.isMobileSidebarOpen) return;
emit('closeMobileSidebar');
@@ -360,15 +335,12 @@ const menuItems = computed(() => {
label: t('SIDEBAR.ALL_CONVERSATIONS'),
icon: 'i-lucide-inbox',
badgeCount: allUnreadCount.value,
- badgeTooltip: unreadBadgeTooltip('ALL'),
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
- badgeCount: mentionsUnreadCount.value,
- badgeTooltip: unreadBadgeTooltip('MENTIONS'),
icon: 'i-lucide-at-sign',
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
@@ -376,8 +348,6 @@ const menuItems = computed(() => {
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
- badgeCount: participatingUnreadCount.value,
- badgeTooltip: unreadBadgeTooltip('PARTICIPATING'),
icon: 'i-lucide-user-round-check',
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
@@ -385,8 +355,6 @@ const menuItems = computed(() => {
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
- badgeCount: unattendedUnreadCount.value,
- badgeTooltip: unreadBadgeTooltip('UNATTENDED'),
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('conversation_unattended'),
@@ -402,8 +370,6 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
- badgeCount: getFolderUnreadCount.value(view.id),
- badgeTooltip: unreadBadgeTooltip('FOLDER'),
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -419,7 +385,6 @@ const menuItems = computed(() => {
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
- badgeTooltip: unreadBadgeTooltip('TEAM'),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -435,7 +400,6 @@ const menuItems = computed(() => {
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
- badgeTooltip: unreadBadgeTooltip('INBOX'),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -444,7 +408,6 @@ const menuItems = computed(() => {
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
- badgeTooltip: leafProps.badgeTooltip,
}),
})),
},
@@ -460,7 +423,6 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
- badgeTooltip: unreadBadgeTooltip('LABEL'),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
index 8f1de246e..2e431eb7e 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
@@ -197,10 +197,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ subChild.label }}
-
+
@@ -223,10 +220,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ child.label }}
-
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
index 404bbe3f3..1d23ad0ed 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
@@ -12,7 +12,6 @@ const props = defineProps({
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
- badgeTooltip: { type: String, default: '' },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
});
@@ -53,14 +52,14 @@ const TREE_CONNECTOR =
{{ label }}
-
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
index 2a91bfcb4..95d95236a 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
@@ -3,7 +3,6 @@ import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
- tooltip: { type: String, default: '' },
});
const normalizedCount = computed(() => {
@@ -19,7 +18,6 @@ const displayCount = computed(() =>
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
index c2ee5219f..03d963134 100644
--- a/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
+++ b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
-import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
@@ -29,17 +28,6 @@ describe('ChannelLeaf', () => {
expect(badge.text()).toBe('3');
});
- it('passes tooltip copy to unread badge', () => {
- const wrapper = mountChannelLeaf({
- badgeCount: 3,
- badgeTooltip: 'Total unread conversations in this inbox',
- });
-
- expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
- 'Total unread conversations in this inbox'
- );
- });
-
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
index b63ca901c..79b62d191 100644
--- a/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
+++ b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
@@ -1,7 +1,6 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
-import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
@@ -45,17 +44,6 @@ describe('SidebarGroupLeaf', () => {
expect(badge.text()).toBe('7');
});
- it('passes tooltip copy to unread badge', () => {
- const wrapper = mountLeaf({
- badgeCount: 7,
- badgeTooltip: 'Total unread conversations in this label',
- });
-
- expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
- 'Total unread conversations in this label'
- );
- });
-
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
@@ -75,17 +63,14 @@ describe('SidebarGroupLeaf', () => {
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
- badgeTooltip: 'Total unread conversations in this inbox',
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
- `${leafProps.badgeCount}:${leafProps.badgeTooltip}`
+ leafProps.badgeCount
),
});
- expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe(
- '4:Total unread conversations in this inbox'
- );
+ expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
});
});
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index 9afe4f963..5e2543698 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -316,16 +316,6 @@
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",
"UNATTENDED_CONVERSATIONS": "Unattended",
- "UNREAD_COUNT_TOOLTIP": {
- "ALL": "Total unread conversations",
- "INBOX": "Total unread conversations in this inbox",
- "LABEL": "Total unread conversations in this label",
- "TEAM": "Total unread conversations in this team",
- "FOLDER": "Total unread conversations in this folder",
- "MENTIONS": "Unread conversations where you were mentioned",
- "PARTICIPATING": "Unread conversations you're participating in",
- "UNATTENDED": "Unread unattended conversations"
- },
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
index 03bbb31eb..c03249311 100644
--- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
+++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
@@ -6,10 +6,6 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
- mentionsCount: 0,
- participatingCount: 0,
- unattendedCount: 0,
- folders: {},
};
const normalizeCount = count => {
@@ -41,18 +37,6 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
- getMentionsUnreadCount($state) {
- return $state.mentionsCount;
- },
- getParticipatingUnreadCount($state) {
- return $state.participatingCount;
- },
- getUnattendedUnreadCount($state) {
- return $state.unattendedCount;
- },
- getFolderUnreadCount: $state => folderId => {
- return $state.folders[String(folderId)] || 0;
- },
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -62,9 +46,6 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
- getFolderUnreadCounts($state) {
- return $state.folders;
- },
};
export const actions = {
@@ -87,10 +68,6 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
- $state.mentionsCount = normalizeCount(payload.mentions_count);
- $state.participatingCount = normalizeCount(payload.participating_count);
- $state.unattendedCount = normalizeCount(payload.unattended_count);
- $state.folders = normalizeCounts(payload.folders);
},
};
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
index 311aed95e..9fe19e22d 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
@@ -57,32 +57,10 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
- folders: { 8: 9 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
- expect(getters.getFolderUnreadCounts(state)).toEqual({ 8: 9 });
- });
-
- it('returns mentions, participating, unattended, and folder unread counts', () => {
- const state = {
- allCount: 0,
- inboxes: {},
- labels: {},
- teams: {},
- mentionsCount: 3,
- participatingCount: 4,
- unattendedCount: 5,
- folders: { 7: 8 },
- };
-
- expect(getters.getMentionsUnreadCount(state)).toBe(3);
- expect(getters.getParticipatingUnreadCount(state)).toBe(4);
- expect(getters.getUnattendedUnreadCount(state)).toBe(5);
- expect(getters.getFolderUnreadCount(state)(7)).toBe(8);
- expect(getters.getFolderUnreadCount(state)('7')).toBe(8);
- expect(getters.getFolderUnreadCount(state)(8)).toBe(0);
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
index aa36ed904..8941d7430 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
@@ -21,13 +21,6 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
- mentions_count: '8',
- participating_count: 9,
- unattended_count: '10',
- folders: {
- 11: '12',
- 12: 0,
- },
});
expect(state).toEqual({
@@ -35,10 +28,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
- mentionsCount: 8,
- participatingCount: 9,
- unattendedCount: 10,
- folders: { 11: 12 },
});
});
@@ -48,10 +37,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
- mentionsCount: 8,
- participatingCount: 9,
- unattendedCount: 10,
- folders: { 10: 11 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -61,10 +46,6 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
- mentionsCount: 0,
- participatingCount: 0,
- unattendedCount: 0,
- folders: {},
});
});
diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb
index 12b0a9017..3bc221504 100644
--- a/app/listeners/action_cable_listener.rb
+++ b/app/listeners/action_cable_listener.rb
@@ -91,10 +91,10 @@ class ActionCableListener < BaseListener
end
def conversation_unread_count_changed(event)
- account, inbox_members, include_admins = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
+ account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
- tokens = include_admins ? user_tokens(account, inbox_members) : inbox_members.pluck(:pubsub_token)
+ tokens = user_tokens(account, inbox_members)
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
end
diff --git a/app/models/account.rb b/app/models/account.rb
index 98da91b67..667058a2f 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -181,7 +181,7 @@ class Account < ApplicationRecord
end
def clear_unread_conversation_counts_cache
- ::Conversations::UnreadCounts::Store.clear_all_account!(id)
+ ::Conversations::UnreadCounts::Store.clear_account!(id)
end
trigger.after(:insert).for_each(:row) do
diff --git a/app/models/account_user.rb b/app/models/account_user.rb
index 4f135f508..bbcb0e010 100644
--- a/app/models/account_user.rb
+++ b/app/models/account_user.rb
@@ -39,7 +39,6 @@ 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 }
@@ -80,14 +79,6 @@ 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')
diff --git a/app/models/concerns/cache_keys.rb b/app/models/concerns/cache_keys.rb
index 3ad9bbadc..b37d7faa6 100644
--- a/app/models/concerns/cache_keys.rb
+++ b/app/models/concerns/cache_keys.rb
@@ -30,6 +30,7 @@ module CacheKeys
update_cache_key_for_account(id, model.name.underscore)
end
+ ::Conversations::UnreadCounts::Store.clear_account!(id)
dispatch_cache_update_event
end
diff --git a/app/models/conversation_participant.rb b/app/models/conversation_participant.rb
index d45719378..830eb7baa 100644
--- a/app/models/conversation_participant.rb
+++ b/app/models/conversation_participant.rb
@@ -28,7 +28,6 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
- after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -39,8 +38,4 @@ 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
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 9234f9600..6d64c0447 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -22,19 +22,10 @@ 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
diff --git a/app/models/inbox_member.rb b/app/models/inbox_member.rb
index d0f553a98..d4a36ccc8 100644
--- a/app/models/inbox_member.rb
+++ b/app/models/inbox_member.rb
@@ -23,9 +23,7 @@ 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
@@ -36,16 +34,6 @@ 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')
diff --git a/app/models/mention.rb b/app/models/mention.rb
index 5d3539078..0e0fe762a 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -32,7 +32,6 @@ 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) }
@@ -56,8 +55,4 @@ 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
diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb
index 79527ac2f..db2892c31 100644
--- a/app/services/conversations/filter_service.rb
+++ b/app/services/conversations/filter_service.rb
@@ -7,7 +7,8 @@ class Conversations::FilterService < FilterService
end
def perform
- @conversations = filtered_relation
+ validate_query_operator
+ @conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
@@ -22,13 +23,6 @@ class Conversations::FilterService < FilterService
}
end
- def filtered_relation
- validate_query_operator
- return base_relation if @params[:payload].blank?
-
- query_builder(@filters['conversations'])
- end
-
def base_relation
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
diff --git a/app/services/conversations/unread_counts/broadcast_scope.rb b/app/services/conversations/unread_counts/broadcast_scope.rb
index ea43c1e8e..8e47050e3 100644
--- a/app/services/conversations/unread_counts/broadcast_scope.rb
+++ b/app/services/conversations/unread_counts/broadcast_scope.rb
@@ -6,23 +6,13 @@ class Conversations::UnreadCounts::BroadcastScope
end
def perform
- return user_scope if user.present?
- return [conversation.account, conversation.inbox.members, true] if conversation.present?
+ return [conversation.account, conversation.inbox.members] if conversation.present?
deleted_conversation_scope
end
private
- def user
- event.data[:user]
- end
-
- def user_scope
- account = event.data[:account] || user.account
- [account, [user], false]
- end
-
def conversation
event.data[:conversation]
end
@@ -34,7 +24,7 @@ class Conversations::UnreadCounts::BroadcastScope
account = Account.find_by(id: conversation_data[:account_id])
return if account.blank?
- [account, inbox_members_for(account, conversation_data[:inbox_id]), true]
+ [account, inbox_members_for(account, conversation_data[:inbox_id])]
end
def inbox_members_for(account, inbox_id)
diff --git a/app/services/conversations/unread_counts/build_lock_keys.rb b/app/services/conversations/unread_counts/build_lock_keys.rb
deleted file mode 100644
index 06b922c88..000000000
--- a/app/services/conversations/unread_counts/build_lock_keys.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-module Conversations::UnreadCounts::BuildLockKeys
- private
-
- 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 filters_build_lock_key
- format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account.id, user_id: user.id)
- end
-end
diff --git a/app/services/conversations/unread_counts/builder.rb b/app/services/conversations/unread_counts/builder.rb
index e35d14b7c..54e466dbd 100644
--- a/app/services/conversations/unread_counts/builder.rb
+++ b/app/services/conversations/unread_counts/builder.rb
@@ -1,14 +1,5 @@
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
@@ -33,37 +24,10 @@ class Conversations::UnreadCounts::Builder
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|
+ unread_conversations.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|
{
@@ -79,99 +43,14 @@ class Conversations::UnreadCounts::Builder
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)
+ def unread_conversations
+ account.conversations
+ .open
+ .joins(:messages)
+ .merge(Message.incoming.reorder(nil))
+ .where(messages: { account_id: account.id })
+ .where(unread_since_last_seen_condition)
+ .distinct
end
def unread_since_last_seen_condition
diff --git a/app/services/conversations/unread_counts/counter.rb b/app/services/conversations/unread_counts/counter.rb
index 08a6c1072..b1ba5ddb0 100644
--- a/app/services/conversations/unread_counts/counter.rb
+++ b/app/services/conversations/unread_counts/counter.rb
@@ -1,7 +1,4 @@
class Conversations::UnreadCounts::Counter
- include ::Conversations::UnreadCounts::BuildLockKeys
- include ::Conversations::UnreadCounts::FilterCounter
-
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
@@ -21,38 +18,31 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
- ensure_filters_cache!
inbox_counts = unread_inbox_counts
- filter_counts = unread_filter_counts
{
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
- teams: unread_team_counts,
- mentions_count: filter_counts[:mentions_count],
- participating_count: filter_counts[:participating_count],
- unattended_count: filter_counts[:unattended_count],
- folders: filter_counts[:folders]
+ teams: unread_team_counts
}
end
private
def ensure_base_cache!
- ensure_cache_ready!(ready: -> { store.base_ready?(account.id) }, lock_key: base_build_lock_key) { builder.build_base! }
+ 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) { builder.build_assignment! }
- end
-
- def ensure_filters_cache!
ensure_cache_ready!(
- ready: -> { store.filters_ready?(account.id, user.id) },
- lock_key: filters_build_lock_key
- ) { builder.build_filters_for!(user) }
+ 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:)
@@ -61,10 +51,9 @@ class Conversations::UnreadCounts::Counter
loop do
return if ready.call
- lock_acquired = lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
- return if ready.call
+ return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
- wait_for_cache_ready(ready) unless lock_acquired
+ wait_for_cache_ready(ready)
end
end
@@ -73,6 +62,14 @@ class Conversations::UnreadCounts::Counter
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
@@ -197,14 +194,10 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
- { all_count: 0, inboxes: {}, labels: {}, teams: {}, mentions_count: 0, participating_count: 0, unattended_count: 0, folders: {} }
+ { all_count: 0, inboxes: {}, labels: {}, teams: {} }
end
def store
::Conversations::UnreadCounts::Store
end
-
- def builder
- @builder ||= ::Conversations::UnreadCounts::Builder.new(account)
- end
end
diff --git a/app/services/conversations/unread_counts/filter_counter.rb b/app/services/conversations/unread_counts/filter_counter.rb
deleted file mode 100644
index 79f376d87..000000000
--- a/app/services/conversations/unread_counts/filter_counter.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-module Conversations::UnreadCounts::FilterCounter
- private
-
- def unread_filter_counts
- keys = user_filter_keys
- counts_by_key = store.counts_for_keys(keys.values + folder_keys.values)
-
- {
- mentions_count: counts_by_key[keys[:mentions]].to_i,
- participating_count: counts_by_key[keys[:participating]].to_i,
- unattended_count: counts_by_key[keys[:unattended]].to_i,
- folders: folder_counts(counts_by_key)
- }
- end
-
- def user_filter_keys
- {
- mentions: store.user_mentions_key(account.id, user.id),
- participating: store.user_participating_key(account.id, user.id),
- unattended: store.user_unattended_key(account.id, user.id)
- }
- end
-
- def conversation_folder_ids
- @conversation_folder_ids ||= account.custom_filters.where(user: user, filter_type: :conversation).pluck(:id)
- end
-
- def folder_keys
- @folder_keys ||= conversation_folder_ids.index_with do |custom_filter_id|
- store.user_folder_key(account.id, user.id, custom_filter_id)
- end
- end
-
- def folder_counts(counts_by_key)
- folder_keys.each_with_object({}) do |(custom_filter_id, key), result|
- count = counts_by_key[key].to_i
- result[custom_filter_id.to_s] = count if count.positive?
- end
- end
-end
diff --git a/app/services/conversations/unread_counts/listener.rb b/app/services/conversations/unread_counts/listener.rb
index 28bbf2ac3..28792d884 100644
--- a/app/services/conversations/unread_counts/listener.rb
+++ b/app/services/conversations/unread_counts/listener.rb
@@ -3,13 +3,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
+ return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
- if message.incoming?
- refresh(message.conversation)
- else
- notify_filter_counts_changed(message.conversation)
- end
+ refresh(message.conversation)
end
def conversation_status_changed(event)
@@ -18,21 +15,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
end
def conversation_updated(event)
+ return unless label_changed?(event.data[:changed_attributes])
+
conversation, = extract_conversation_and_account(event)
- return unless conversation.account.feature_enabled?('conversation_unread_counts')
-
- if label_changed?(event.data[:changed_attributes])
- refresh(conversation, event.data[:changed_attributes])
- elsif folder_filter_changed?(event.data[:changed_attributes])
- notify_filter_counts_changed(conversation)
- end
- end
-
- def conversation_contact_changed(event)
- conversation, = extract_conversation_and_account(event)
- return unless conversation.account.feature_enabled?('conversation_unread_counts')
-
- notify_filter_counts_changed(conversation)
+ refresh(conversation, event.data[:changed_attributes])
end
def assignee_changed(event)
@@ -51,10 +37,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
-
- filters_cleared = store.clear_filter_caches!(account.id)
- memberships_removed = remove_deleted_conversation(account, conversation_data)
- return unless memberships_removed || filters_cleared
+ return unless remove_deleted_conversation(account, conversation_data)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
end
@@ -107,16 +90,6 @@ class Conversations::UnreadCounts::Listener < BaseListener
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
end
- def folder_filter_changed?(changed_attributes)
- changed_attributes.present?
- end
-
- def notify_filter_counts_changed(conversation)
- return unless store.clear_filter_caches!(conversation.account_id)
-
- Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
- end
-
def store
::Conversations::UnreadCounts::Store
end
diff --git a/app/services/conversations/unread_counts/notifier.rb b/app/services/conversations/unread_counts/notifier.rb
index aac4af7c0..652fbde3a 100644
--- a/app/services/conversations/unread_counts/notifier.rb
+++ b/app/services/conversations/unread_counts/notifier.rb
@@ -11,9 +11,7 @@ class Conversations::UnreadCounts::Notifier
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
- filters_cleared = ::Conversations::UnreadCounts::Store.clear_filter_caches!(conversation.account_id)
- memberships_refreshed = ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
- return false unless memberships_refreshed || filters_cleared
+ return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
true
diff --git a/app/services/conversations/unread_counts/store.rb b/app/services/conversations/unread_counts/store.rb
index 6aa9cef05..e51fc5c09 100644
--- a/app/services/conversations/unread_counts/store.rb
+++ b/app/services/conversations/unread_counts/store.rb
@@ -1,6 +1,5 @@
class Conversations::UnreadCounts::Store
extend ::Conversations::UnreadCounts::StoreKeys
- extend ::Conversations::UnreadCounts::UserFilterStore
class << self
def base_ready?(account_id)
@@ -20,10 +19,6 @@ class Conversations::UnreadCounts::Store
end
def clear_account!(account_id)
- account_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
- end
-
- def clear_all_account!(account_id)
delete_matching("#{account_prefix(account_id)}::*")
end
@@ -183,11 +178,9 @@ class Conversations::UnreadCounts::Store
end
def delete_matching(pattern)
- deleted = 0
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
- deleted += 1 if Redis::Alfred.delete(key)
+ Redis::Alfred.delete(key)
end
- deleted.positive?
end
def assignment_key_patterns(account_id)
@@ -202,16 +195,5 @@ class Conversations::UnreadCounts::Store
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
]
end
-
- def account_key_patterns(account_id)
- prefix = account_prefix(account_id)
- [
- base_ready_key(account_id),
- assignment_ready_key(account_id),
- "#{prefix}::INBOX::*",
- "#{prefix}::LABEL::*::INBOX::*",
- "#{prefix}::TEAM::*::INBOX::*"
- ]
- end
end
end
diff --git a/app/services/conversations/unread_counts/store_keys.rb b/app/services/conversations/unread_counts/store_keys.rb
index a5b8c43ec..bbb6d101a 100644
--- a/app/services/conversations/unread_counts/store_keys.rb
+++ b/app/services/conversations/unread_counts/store_keys.rb
@@ -40,20 +40,4 @@ module Conversations::UnreadCounts::StoreKeys
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
end
-
- def user_mentions_key(account_id, user_id)
- format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_MENTIONS, account_id: account_id, user_id: user_id)
- end
-
- def user_participating_key(account_id, user_id)
- format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_PARTICIPATING, account_id: account_id, user_id: user_id)
- end
-
- def user_unattended_key(account_id, user_id)
- format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_UNATTENDED, account_id: account_id, user_id: user_id)
- end
-
- def user_folder_key(account_id, user_id, custom_filter_id)
- format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FOLDER, account_id: account_id, user_id: user_id, custom_filter_id: custom_filter_id)
- end
end
diff --git a/app/services/conversations/unread_counts/user_filter_notifier.rb b/app/services/conversations/unread_counts/user_filter_notifier.rb
deleted file mode 100644
index 94dd95a0f..000000000
--- a/app/services/conversations/unread_counts/user_filter_notifier.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-class Conversations::UnreadCounts::UserFilterNotifier
- include Events::Types
-
- attr_reader :account, :user
-
- def initialize(account:, user:)
- @account = account
- @user = user
- end
-
- def perform
- return false if account.blank? || user.blank?
- return false unless account.feature_enabled?('conversation_unread_counts')
-
- ::Conversations::UnreadCounts::Store.clear_user_filters!(account.id, user.id)
- Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, account: account, user: user)
- true
- end
-end
diff --git a/app/services/conversations/unread_counts/user_filter_store.rb b/app/services/conversations/unread_counts/user_filter_store.rb
deleted file mode 100644
index 3533acb56..000000000
--- a/app/services/conversations/unread_counts/user_filter_store.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-module Conversations::UnreadCounts::UserFilterStore
- USER_FILTER_KEY_SUFFIXES = [
- 'READY::FILTERS',
- 'MENTIONS',
- 'PARTICIPATING',
- 'UNATTENDED',
- 'FOLDER::*'
- ].freeze
-
- def filters_ready?(account_id, user_id)
- Redis::Alfred.exists?(filters_ready_key(account_id, user_id))
- end
-
- def mark_filters_ready!(account_id, user_id, expires_in: Conversations::UnreadCounts::READY_TTL)
- Redis::Alfred.set(filters_ready_key(account_id, user_id), Time.current.to_i, ex: expires_in)
- end
-
- def mark_filters_ready_if_current!(account_id, user_id, version_snapshot:, expires_in: Conversations::UnreadCounts::READY_TTL)
- return false unless filter_version_snapshot(account_id, user_id) == version_snapshot
-
- mark_filters_ready!(account_id, user_id, expires_in: expires_in)
- end
-
- def filter_version_snapshot(account_id, user_id)
- {
- account: filter_version(account_filter_version_key(account_id)),
- user: filter_version(user_filter_version_key(account_id, user_id))
- }
- end
-
- def clear_filter_caches!(account_id)
- bump_filter_version(account_filter_version_key(account_id))
- delete_user_filter_patterns("#{account_prefix(account_id)}::USER::*")
- end
-
- def clear_user_filters!(account_id, user_id)
- bump_filter_version(user_filter_version_key(account_id, user_id))
- delete_user_filter_patterns(user_filter_prefix(account_id, user_id))
- end
-
- def add_filter_memberships(account_id:, user_id:, filters:, folders:)
- memberships = {
- user_mentions_key(account_id, user_id) => filters[:mentions],
- user_participating_key(account_id, user_id) => filters[:participating],
- user_unattended_key(account_id, user_id) => filters[:unattended]
- }
- folders.each do |custom_filter_id, conversation_ids|
- memberships[user_folder_key(account_id, user_id, custom_filter_id)] = conversation_ids
- end
-
- write_membership_sets(memberships)
- end
-
- private
-
- def filters_ready_key(account_id, user_id)
- format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_READY, account_id: account_id, user_id: user_id)
- end
-
- def account_filter_version_key(account_id)
- format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
- end
-
- def user_filter_version_key(account_id, user_id)
- format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: user_id)
- end
-
- def user_filter_prefix(account_id, user_id)
- "#{account_prefix(account_id)}::USER::#{user_id}"
- end
-
- def filter_version(key)
- Redis::Alfred.get(key).to_i
- end
-
- def bump_filter_version(key)
- Redis::Alfred.incr(key).tap { Redis::Alfred.expire(key, Conversations::UnreadCounts::SET_TTL) }
- end
-
- def delete_user_filter_patterns(prefix)
- deleted = false
- USER_FILTER_KEY_SUFFIXES.each do |suffix|
- deleted = delete_matching("#{prefix}::#{suffix}") || deleted
- end
- deleted
- end
-
- def write_membership_sets(memberships)
- memberships = memberships.transform_values { |conversation_ids| Array(conversation_ids).compact_blank }
- memberships = memberships.select { |_key, conversation_ids| conversation_ids.present? }
- return if memberships.blank?
-
- Redis::Alfred.pipelined do |pipeline|
- memberships.each do |key, conversation_ids|
- conversation_ids.each { |conversation_id| pipeline.sadd(key, conversation_id) }
- pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
- end
- end
- end
-end
diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb
index 33ca04680..25f118d48 100644
--- a/app/services/filter_service.rb
+++ b/app/services/filter_service.rb
@@ -11,7 +11,7 @@ class FilterService
}.with_indifferent_access
def initialize(params, user)
- @params = normalize_params(params)
+ @params = params
@user = user
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@@ -140,16 +140,6 @@ class FilterService
private
- def normalize_params(params)
- return params unless params.respond_to?(:with_indifferent_access)
-
- normalized_params = params.with_indifferent_access
- normalized_params[:payload] = Array(normalized_params[:payload]).map do |condition|
- condition.respond_to?(:with_indifferent_access) ? condition.with_indifferent_access : condition
- end
- normalized_params
- end
-
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
diff --git a/enterprise/app/finders/enterprise/conversation_finder.rb b/enterprise/app/finders/enterprise/conversation_finder.rb
index f1f2e5514..24e6b493d 100644
--- a/enterprise/app/finders/enterprise/conversation_finder.rb
+++ b/enterprise/app/finders/enterprise/conversation_finder.rb
@@ -1,31 +1,5 @@
module Enterprise::ConversationFinder
- def filter_by_conversation_type
- return super unless params[:conversation_type] == 'participating' && custom_role_participating_permission?
-
- @conversations = participating_visible_conversations.where(
- id: current_user.participating_conversations.where(account_id: current_account.id).select(:id)
- )
- end
-
def conversations_base_query
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
end
-
- private
-
- def participating_visible_conversations
- conversations = current_account.conversations
- conversations = conversations.where(inbox_id: @inbox_ids) if params[:inbox_id]
- return conversations if account_user&.administrator?
-
- conversations.where(inbox: current_user.inboxes.where(account_id: current_account.id))
- end
-
- def custom_role_participating_permission?
- account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?('conversation_participating_manage')
- end
-
- def account_user
- @account_user ||= current_account.account_users.find_by(user_id: current_user.id)
- end
end
diff --git a/enterprise/app/models/custom_role.rb b/enterprise/app/models/custom_role.rb
index 4085000b1..666f91378 100644
--- a/enterprise/app/models/custom_role.rb
+++ b/enterprise/app/models/custom_role.rb
@@ -28,9 +28,6 @@ class CustomRole < ApplicationRecord
belongs_to :account
has_many :account_users, dependent: :nullify
- before_destroy :cache_users_for_unread_filter_notification, prepend: true
- after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
-
PERMISSIONS = %w[
conversation_manage
conversation_unassigned_manage
@@ -42,24 +39,4 @@ class CustomRole < ApplicationRecord
validates :name, presence: true
validates :permissions, inclusion: { in: PERMISSIONS }
-
- private
-
- def unread_filter_access_changed?
- destroyed? || previous_changes.key?('permissions')
- end
-
- def cache_users_for_unread_filter_notification
- @users_for_unread_filter_notification = account_users.includes(:user).map(&:user)
- end
-
- def users_for_unread_filter_notification
- @users_for_unread_filter_notification || account_users.includes(:user).map(&:user)
- end
-
- def notify_unread_filter_counts_changed
- users_for_unread_filter_notification.each do |user|
- ::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
- end
- end
end
diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb
index 381cafb98..bc7fb551e 100644
--- a/lib/redis/redis_keys.rb
+++ b/lib/redis/redis_keys.rb
@@ -14,15 +14,8 @@ module Redis::RedisKeys
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d'.freeze
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::READY::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::READY::ASSIGNMENT'.freeze
- UNREAD_CONVERSATIONS_USER_FILTERS_READY =
- 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::READY::FILTERS'.freeze
- UNREAD_CONVERSATIONS_FILTERS_VERSION = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::VERSION::FILTERS'.freeze
- UNREAD_CONVERSATIONS_USER_FILTERS_VERSION =
- 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::VERSION::FILTERS'.freeze
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::BUILD_LOCK::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::BUILD_LOCK::ASSIGNMENT'.freeze
- UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK =
- 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::BUILD_LOCK::FILTERS'.freeze
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::INBOX::%d'.freeze
UNREAD_CONVERSATIONS_LABEL_INBOX =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::LABEL::%d::INBOX::%d'.freeze
@@ -40,14 +33,6 @@ module Redis::RedisKeys
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::UNASSIGNED'.freeze
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::ASSIGNEE::%d'.freeze
- UNREAD_CONVERSATIONS_USER_MENTIONS =
- 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::MENTIONS'.freeze
- UNREAD_CONVERSATIONS_USER_PARTICIPATING =
- 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::PARTICIPATING'.freeze
- UNREAD_CONVERSATIONS_USER_UNATTENDED =
- 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::UNATTENDED'.freeze
- UNREAD_CONVERSATIONS_USER_FOLDER =
- 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::USER::%d::FOLDER::%d'.freeze
## User Keys
# SSO Auth Tokens
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index de3cc4a3c..bdb8117ac 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -123,7 +123,7 @@ RSpec.describe 'Conversations API', type: :request do
end
after do
- Conversations::UnreadCounts::Store.clear_all_account!(account.id)
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
end
context 'when conversation unread counts feature is enabled' do
@@ -144,42 +144,7 @@ RSpec.describe 'Conversations API', type: :request do
'all_count' => 1,
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
- '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 }
+ 'teams' => {}
)
end
@@ -897,7 +862,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_all_account!(account.id)
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
end
it 'updates both if one timestamp is old even when the other is recent' do
@@ -984,7 +949,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_all_account!(account.id)
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
end
end
end
diff --git a/spec/controllers/super_admin/accounts_controller_spec.rb b/spec/controllers/super_admin/accounts_controller_spec.rb
index 917499cd5..e4ff81a08 100644
--- a/spec/controllers/super_admin/accounts_controller_spec.rb
+++ b/spec/controllers/super_admin/accounts_controller_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
end
after do
- Conversations::UnreadCounts::Store.clear_all_account!(account.id)
+ Conversations::UnreadCounts::Store.clear_account!(account.id)
end
context 'when it is an unauthenticated user' do
diff --git a/spec/enterprise/finders/conversation_finder_spec.rb b/spec/enterprise/finders/conversation_finder_spec.rb
deleted file mode 100644
index f8b9fce58..000000000
--- a/spec/enterprise/finders/conversation_finder_spec.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-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
diff --git a/spec/enterprise/models/account_user_spec.rb b/spec/enterprise/models/account_user_spec.rb
index e157d9acd..fb572e86a 100644
--- a/spec/enterprise/models/account_user_spec.rb
+++ b/spec/enterprise/models/account_user_spec.rb
@@ -29,24 +29,6 @@ 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
diff --git a/spec/enterprise/models/custom_role_spec.rb b/spec/enterprise/models/custom_role_spec.rb
index 553a5a33a..f63f3c2dd 100644
--- a/spec/enterprise/models/custom_role_spec.rb
+++ b/spec/enterprise/models/custom_role_spec.rb
@@ -9,38 +9,4 @@ 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
diff --git a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
index 86a93bd8c..e8f850e78 100644
--- a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
end
after do
- store.clear_all_account!(account.id)
+ store.clear_account!(account.id)
end
it 'uses base counts for custom roles with conversation_manage permission' do
@@ -26,16 +26,10 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
- 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(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(store.assignment_ready?(account.id)).to be(false)
end
@@ -43,21 +37,14 @@ 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)
- 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)
+ create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
result = described_class.new(account: account, user: agent).perform
- 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(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(store.assignment_ready?(account.id)).to be(true)
end
@@ -65,21 +52,13 @@ 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).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(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(store.assignment_ready?(account.id)).to be(true)
end
@@ -89,16 +68,7 @@ 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: {},
- mentions_count: 0,
- participating_count: 0,
- unattended_count: 0,
- folders: {}
- )
+ expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
end
diff --git a/spec/finders/conversation_finder_spec.rb b/spec/finders/conversation_finder_spec.rb
index 31cbceb6a..64f134fe2 100644
--- a/spec/finders/conversation_finder_spec.rb
+++ b/spec/finders/conversation_finder_spec.rb
@@ -208,25 +208,6 @@ 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) { {} }
diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb
index 7a774dbd5..cdb9a93cb 100644
--- a/spec/listeners/action_cable_listener_spec.rb
+++ b/spec/listeners/action_cable_listener_spec.rb
@@ -294,19 +294,5 @@ 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
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index d77a16fdb..38ca9694a 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -53,79 +53,38 @@ 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) }
- 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
after do
- store.clear_all_account!(account.id)
+ store.clear_account!(account.id)
end
- it 'clears all unread count cache when the feature is enabled' do
+ it 'clears unread count cache when the feature is enabled' do
build_unread_count_cache
account.enable_features!(:conversation_unread_counts)
- expect_unread_count_cache_cleared
+ 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)
end
- it 'clears all unread count cache when the feature is disabled' do
+ it 'clears 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_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
+ 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)
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
diff --git a/spec/models/account_user_spec.rb b/spec/models/account_user_spec.rb
index 7acfc19cd..e5a560fe9 100644
--- a/spec/models/account_user_spec.rb
+++ b/spec/models/account_user_spec.rb
@@ -42,32 +42,4 @@ 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
diff --git a/spec/models/inbox_member_spec.rb b/spec/models/inbox_member_spec.rb
index df569c695..b081c546c 100644
--- a/spec/models/inbox_member_spec.rb
+++ b/spec/models/inbox_member_spec.rb
@@ -18,30 +18,4 @@ 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
diff --git a/spec/services/conversations/unread_counts/builder_spec.rb b/spec/services/conversations/unread_counts/builder_spec.rb
index fe1bdb6fa..4b3aec230 100644
--- a/spec/services/conversations/unread_counts/builder_spec.rb
+++ b/spec/services/conversations/unread_counts/builder_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Conversations::UnreadCounts::Builder do
let(:store) { Conversations::UnreadCounts::Store }
after do
- store.clear_all_account!(account.id)
+ store.clear_account!(account.id)
end
describe '#build_base!' do
@@ -75,173 +75,6 @@ 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)
@@ -257,32 +90,4 @@ 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
diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb
index c3c338355..723f2d437 100644
--- a/spec/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/services/conversations/unread_counts/counter_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
end
after do
- store.clear_all_account!(account.id)
+ store.clear_account!(account.id)
end
it 'builds the base cache on demand' do
@@ -32,7 +32,6 @@ 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)
@@ -47,33 +46,13 @@ 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) do
- store.mark_base_ready!(account.id)
- store.mark_filters_ready!(account.id, agent.id)
- end
+ allow(counter).to receive(:wait_for_cache_ready) { store.mark_base_ready!(account.id) }
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
@@ -86,11 +65,7 @@ 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 },
- mentions_count: 0,
- participating_count: 0,
- unattended_count: 1,
- folders: {}
+ teams: { visible_team.id.to_s => 1 }
)
end
@@ -104,11 +79,7 @@ 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 },
- mentions_count: 0,
- participating_count: 0,
- unattended_count: 2,
- folders: {}
+ teams: { visible_team.id.to_s => 2 }
)
end
@@ -121,46 +92,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: {},
- teams: { visible_team.id.to_s => 1 },
- mentions_count: 0,
- participating_count: 0,
- unattended_count: 1,
- folders: {}
+ teams: { visible_team.id.to_s => 1 }
)
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
diff --git a/spec/services/conversations/unread_counts/listener_spec.rb b/spec/services/conversations/unread_counts/listener_spec.rb
index 0cf31bd3c..fbb0a0835 100644
--- a/spec/services/conversations/unread_counts/listener_spec.rb
+++ b/spec/services/conversations/unread_counts/listener_spec.rb
@@ -21,22 +21,13 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
- it 'clears user filter counts when a non-incoming message updates last activity' do
- account.enable_features!(:conversation_unread_counts)
+ it 'ignores outgoing message creation' do
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
@@ -61,7 +52,6 @@ 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)
@@ -71,43 +61,8 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
- it 'clears user filter counts when a folder filter dimension changes' do
- account.enable_features!(:conversation_unread_counts)
+ it 'ignores conversation updates unrelated to unread count dimensions' do
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)
@@ -173,7 +128,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
conversation_data: conversation_data.stringify_keys
)
ensure
- store.clear_all_account!(account.id)
+ store.clear_account!(account.id)
end
def deleted_conversation_data(conversation)
diff --git a/spec/services/conversations/unread_counts/notifier_spec.rb b/spec/services/conversations/unread_counts/notifier_spec.rb
index 645c2bc17..1b35d37f6 100644
--- a/spec/services/conversations/unread_counts/notifier_spec.rb
+++ b/spec/services/conversations/unread_counts/notifier_spec.rb
@@ -29,18 +29,6 @@ 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
diff --git a/spec/services/conversations/unread_counts/refresher_spec.rb b/spec/services/conversations/unread_counts/refresher_spec.rb
index e961c21c3..5f361e7aa 100644
--- a/spec/services/conversations/unread_counts/refresher_spec.rb
+++ b/spec/services/conversations/unread_counts/refresher_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Conversations::UnreadCounts::Refresher do
let(:store) { Conversations::UnreadCounts::Store }
after do
- store.clear_all_account!(account.id)
+ store.clear_account!(account.id)
end
it 'does not update redis when unread caches are not ready' do
diff --git a/spec/services/conversations/unread_counts/store_spec.rb b/spec/services/conversations/unread_counts/store_spec.rb
index 9e1311727..fcf21c985 100644
--- a/spec/services/conversations/unread_counts/store_spec.rb
+++ b/spec/services/conversations/unread_counts/store_spec.rb
@@ -7,10 +7,9 @@ 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_all_account!(account_id)
+ described_class.clear_account!(account_id)
end
describe 'key builders' do
@@ -46,69 +45,20 @@ 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 'starts with all ready markers missing' do
+ it 'tracks base and assignment readiness independently' 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
@@ -200,94 +150,9 @@ 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 '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
+ it 'clears all account 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,
@@ -303,69 +168,13 @@ 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
@@ -385,27 +194,6 @@ 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
diff --git a/spec/services/conversations/unread_counts/user_filter_notifier_spec.rb b/spec/services/conversations/unread_counts/user_filter_notifier_spec.rb
deleted file mode 100644
index 1d898b103..000000000
--- a/spec/services/conversations/unread_counts/user_filter_notifier_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-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
From 5ce6e00730d6b0f0ec6eb1b6c68178b2cfdd2078 Mon Sep 17 00:00:00 2001
From: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Date: Wed, 17 Jun 2026 18:06:42 +0530
Subject: [PATCH 20/26] Bump version to 4.15.1
---
VERSION_CW | 2 +-
config/app.yml | 2 +-
package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/VERSION_CW b/VERSION_CW
index 5c517bf11..fb0557132 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.15.0
+4.15.1
diff --git a/config/app.yml b/config/app.yml
index c2c192018..a55b621f6 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.15.0'
+ version: '4.15.1'
development:
<<: *shared
diff --git a/package.json b/package.json
index b8ab60839..65dd48d27 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.15.0",
+ "version": "4.15.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
From ac153394566de1aed26a56fb9aaa8ff72019659f Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:16:47 +0530
Subject: [PATCH 21/26] fix: Resolve Firefox input issues and persist advanced
filters (#14781)
# Pull Request Template
## Description
This PR fixes a few issues in Global Search and removes a non-functional
control.
* Fixes an issue in Firefox where characters could be dropped while
typing in the search input. The search now uses the latest input value
directly, preventing searches from running one character behind.
* Removes a stale query sync in `SearchHeader` that could overwrite
recently typed characters during the debounce window, causing the input
to appear out of sync.
* Fixes advanced search filters being removed from the URL on page
reload. The search page now waits for account data to load before
parsing URL parameters, ensuring agent, inbox, and date range filters
are preserved.
* Removes the non-functional "Sort by relevance" button from the search
tabs bar, as it was disabled and had no effect.
Fixes https://github.com/chatwoot/chatwoot/issues/14684
[CW-7305](https://linear.app/chatwoot/issue/CW-7305/global-search-drops-characters-while-typing-in-firefox-query-truncated)
[CW-7370](https://linear.app/chatwoot/issue/CW-7370/remove-non-functional-relevance-placeholder-button-from-global-search)
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screencast
**Before**
https://github.com/user-attachments/assets/48d72a4e-20f4-4f24-91f4-2c9a9c065eba
**After**
https://github.com/user-attachments/assets/0e41a803-b4fd-410d-a739-549f72d418d6
## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] 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
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../search/components/SearchHeader.vue | 12 +------
.../modules/search/components/SearchInput.vue | 9 +++--
.../modules/search/components/SearchTabs.vue | 13 --------
.../modules/search/components/SearchView.vue | 33 ++++++++++++-------
4 files changed, 28 insertions(+), 39 deletions(-)
diff --git a/app/javascript/dashboard/modules/search/components/SearchHeader.vue b/app/javascript/dashboard/modules/search/components/SearchHeader.vue
index 08d4c0c7a..1de642fbe 100644
--- a/app/javascript/dashboard/modules/search/components/SearchHeader.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchHeader.vue
@@ -1,5 +1,5 @@
diff --git a/app/javascript/dashboard/modules/search/components/SearchInput.vue b/app/javascript/dashboard/modules/search/components/SearchInput.vue
index 3edd79fe0..de2db176f 100644
--- a/app/javascript/dashboard/modules/search/components/SearchInput.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchInput.vue
@@ -30,10 +30,13 @@ const debouncedEmit = debounce(
500
);
-const onInput = () => {
- debouncedEmit(searchQuery.value);
+const onInput = e => {
+ // Use the DOM value, not searchQuery.value: the defineModel ref updates a tick
+ // later, so reading it back here lags one character behind.
+ const value = e.target.value;
+ debouncedEmit(value);
- if (searchQuery.value.trim()) {
+ if (value.trim()) {
showRecentSearches.value = false;
} else if (isInputFocused.value) {
showRecentSearches.value = true;
diff --git a/app/javascript/dashboard/modules/search/components/SearchTabs.vue b/app/javascript/dashboard/modules/search/components/SearchTabs.vue
index 86e40df57..1a0fea65c 100644
--- a/app/javascript/dashboard/modules/search/components/SearchTabs.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchTabs.vue
@@ -1,8 +1,6 @@
@@ -39,12 +59,21 @@ const localeCount = computed(() => props.locales?.length);
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
-
+
+
+
+