feat: Add sidebar unread counts for filters (CW-7262) (#14726)
## Description Extends the conversation unread-count system so the left sidebar can show unread badges for Mentions, Participating, Unattended, and saved conversation folders. Folder badges reuse the existing `custom_filters` conversation filter semantics, store user-scoped Redis sets lazily, and skip unsupported folder filters so invalid saved folders continue to render without a badge. The Unattended badge counts all visible unread open conversations that match the existing unattended conversation scope. Closes - [CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders) ## What changed - Added user-scoped unread-count Redis keys and cache builders for mentions, participating conversations, unattended conversations, and saved folder filters. - Reused `Conversations::FilterService` through a relation-returning path so folder counts match the folder conversation list behavior. - Invalidated user filter caches from mention, participant, custom-filter, and relevant conversation update events. - Extended the unread-count endpoint payload and sidebar Vuex/sidebar rendering for the new badge counts, including the Unattended sidebar item. - Added Ruby, Enterprise, request, listener, and frontend store coverage for the new unread-count dimensions. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Created local validation folders for `john@acme.inc` and confirmed the unread-count payload includes open, resolved, and high-priority folder badges while excluding the invalid unsupported folder. - Added coverage for the Unattended badge rule: all visible unread open conversations matching `Conversation.unattended`. - Ran focused unread-count Ruby specs, including service, listener, request, and Enterprise counter coverage. - Ran frontend unread-count store specs. - Ran RuboCop on the touched Ruby files. - Ran ESLint through the project script; it completed with warnings in existing unrelated files and no errors. <img width="369" height="525" alt="Screenshot 2026-06-13 at 10 51 39 PM" src="https://github.com/user-attachments/assets/36b1d2c4-dac1-4f6f-9c0e-7ef5a6cc2975" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] Documentation changes are not required for this internal unread-count behavior - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] No dependent downstream changes are required --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
co-authored by
Muhsin Keloth
parent
352f120c6a
commit
fe6368b42e
@@ -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 = current_user.participating_conversations.where(account_id: current_account.id)
|
||||
@conversations = @conversations.where(id: current_user.participating_conversations.where(account_id: current_account.id).select(:id))
|
||||
when 'unattended'
|
||||
@conversations = @conversations.unattended
|
||||
end
|
||||
|
||||
@@ -22,6 +22,10 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
badgeTooltip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
@@ -34,7 +38,7 @@ const reauthorizationRequired = computed(() => {
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
|
||||
@@ -199,6 +199,18 @@ const getLabelUnreadCount = useMapGetter(
|
||||
const getTeamUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getTeamUnreadCount'
|
||||
);
|
||||
const mentionsUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getMentionsUnreadCount'
|
||||
);
|
||||
const participatingUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getParticipatingUnreadCount'
|
||||
);
|
||||
const unattendedUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getUnattendedUnreadCount'
|
||||
);
|
||||
const getFolderUnreadCount = useMapGetter(
|
||||
'conversationUnreadCounts/getFolderUnreadCount'
|
||||
);
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
@@ -280,6 +292,19 @@ const sortedLabels = computed(() =>
|
||||
})
|
||||
);
|
||||
|
||||
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 },
|
||||
|
||||
@@ -197,7 +197,10 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||
<SidebarUnreadBadge :count="subChild.badgeCount" />
|
||||
<SidebarUnreadBadge
|
||||
:count="subChild.badgeCount"
|
||||
:tooltip="subChild.badgeTooltip"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -220,7 +223,10 @@ onMounted(async () => {
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||
<SidebarUnreadBadge :count="child.badgeCount" />
|
||||
<SidebarUnreadBadge
|
||||
:count="child.badgeCount"
|
||||
:tooltip="child.badgeTooltip"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -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 =
|
||||
<component
|
||||
:is="component"
|
||||
v-if="shouldRenderComponent"
|
||||
v-bind="{ label, icon, active, badgeCount }"
|
||||
v-bind="{ label, icon, active, badgeCount, badgeTooltip }"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||
<Icon :icon="icon" class="size-4 inline-block" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||
<SidebarUnreadBadge :count="badgeCount" />
|
||||
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
|
||||
@@ -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(() =>
|
||||
<template>
|
||||
<span
|
||||
v-if="normalizedCount > 0"
|
||||
v-tooltip.top="tooltip || null"
|
||||
data-test-id="sidebar-unread-badge"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+19
@@ -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: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,8 +14,15 @@ module Redis::RedisKeys
|
||||
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FILTERS_READY =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::READY::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_FILTERS_VERSION = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::VERSION::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FILTERS_VERSION =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::VERSION::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::BUILD_LOCK::FILTERS'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
@@ -33,6 +40,14 @@ module Redis::RedisKeys
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_MENTIONS =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::MENTIONS'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_PARTICIPATING =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::PARTICIPATING'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_UNATTENDED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::UNATTENDED'.freeze
|
||||
UNREAD_CONVERSATIONS_USER_FOLDER =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::FOLDER::%<custom_filter_id>d'.freeze
|
||||
|
||||
## User Keys
|
||||
# SSO Auth Tokens
|
||||
|
||||
@@ -123,7 +123,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
|
||||
after do
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
context 'when conversation unread counts feature is enabled' do
|
||||
@@ -144,7 +144,42 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
'all_count' => 1,
|
||||
'inboxes' => { visible_inbox.id.to_s => 1 },
|
||||
'labels' => { label.id.to_s => 1 },
|
||||
'teams' => {}
|
||||
'teams' => {},
|
||||
'mentions_count' => 0,
|
||||
'participating_count' => 0,
|
||||
'unattended_count' => 1,
|
||||
'folders' => {}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns unread counts for mentions, participating conversations, unattended conversations, and folders' do
|
||||
mentioned_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
participating_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
resolved_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
resolved_conversation.update!(status: :resolved)
|
||||
custom_filter = create(:custom_filter, account: account, user: agent, filter_type: :conversation, query: {
|
||||
payload: [{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['resolved'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}]
|
||||
})
|
||||
|
||||
create(:mention, account: account, conversation: mentioned_conversation, user: agent)
|
||||
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to include(
|
||||
'mentions_count' => 1,
|
||||
'participating_count' => 1,
|
||||
'unattended_count' => 2,
|
||||
'folders' => { custom_filter.id.to_s => 1 }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -862,7 +897,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
|
||||
ensure
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
it 'updates both if one timestamp is old even when the other is recent' do
|
||||
@@ -949,7 +984,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 1)
|
||||
ensure
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,7 +33,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
end
|
||||
|
||||
after do
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
Conversations::UnreadCounts::Store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ConversationFinder do
|
||||
describe '#perform' do
|
||||
it 'returns participant-only conversations for custom roles with participating permission' do
|
||||
account = create(:account)
|
||||
agent = create(:user, account: account, role: :agent)
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
inbox = create(:inbox, account: account, enable_auto_assignment: false)
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
participating_conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
|
||||
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
create(:inbox_member, user: other_agent, inbox: inbox)
|
||||
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
|
||||
account.account_users.find_by!(user_id: agent.id).update!(custom_role: custom_role)
|
||||
Current.account = account
|
||||
|
||||
result = described_class.new(agent, { status: 'open', conversation_type: 'participating' }).perform
|
||||
|
||||
expect(result[:conversations].map(&:id)).to include(participating_conversation.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,24 @@ RSpec.describe AccountUser, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'unread filter count invalidation' do
|
||||
it 'notifies when the assigned custom role changes' do
|
||||
account = create(:account)
|
||||
custom_role = create(:custom_role, account: account)
|
||||
account_user = create(:account_user, account: account)
|
||||
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
|
||||
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
|
||||
|
||||
account_user.update!(custom_role: custom_role)
|
||||
|
||||
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
|
||||
account: account,
|
||||
user: account_user.user
|
||||
)
|
||||
expect(notifier).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when account user is created' do
|
||||
it 'has associated audit log created' do
|
||||
|
||||
@@ -9,4 +9,38 @@ RSpec.describe CustomRole, type: :model do
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
end
|
||||
|
||||
describe 'unread filter count invalidation' do
|
||||
it 'notifies assigned users when permissions change' do
|
||||
account = create(:account)
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
account_user = create(:account_user, account: account, custom_role: custom_role)
|
||||
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
|
||||
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
|
||||
|
||||
custom_role.update!(permissions: ['conversation_manage'])
|
||||
|
||||
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
|
||||
account: account,
|
||||
user: account_user.user
|
||||
)
|
||||
expect(notifier).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'notifies assigned users when the custom role is destroyed' do
|
||||
account = create(:account)
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
account_user = create(:account_user, account: account, custom_role: custom_role)
|
||||
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
|
||||
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
|
||||
|
||||
custom_role.destroy!
|
||||
|
||||
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(
|
||||
account: account,
|
||||
user: account_user.user
|
||||
)
|
||||
expect(notifier).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
end
|
||||
|
||||
after do
|
||||
store.clear_account!(account.id)
|
||||
store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
it 'uses base counts for custom roles with conversation_manage permission' do
|
||||
@@ -26,10 +26,16 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:all_count]).to eq(2)
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 2)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 2)
|
||||
expect(result).to eq(
|
||||
all_count: 2,
|
||||
inboxes: { inbox.id.to_s => 2 },
|
||||
labels: { label.id.to_s => 2 },
|
||||
teams: { team.id.to_s => 2 },
|
||||
mentions_count: 0,
|
||||
participating_count: 0,
|
||||
unattended_count: 2,
|
||||
folders: {}
|
||||
)
|
||||
expect(store.assignment_ready?(account.id)).to be(false)
|
||||
end
|
||||
|
||||
@@ -37,14 +43,21 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']))
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
|
||||
other_assigned_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: other_agent, team: team)
|
||||
create(:conversation_participant, account: account, conversation: other_assigned_conversation, user: agent)
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:all_count]).to eq(2)
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 2)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 2)
|
||||
expect(result).to eq(
|
||||
all_count: 2,
|
||||
inboxes: { inbox.id.to_s => 2 },
|
||||
labels: { label.id.to_s => 2 },
|
||||
teams: { team.id.to_s => 2 },
|
||||
mentions_count: 0,
|
||||
participating_count: 0,
|
||||
unattended_count: 2,
|
||||
folders: {}
|
||||
)
|
||||
expect(store.assignment_ready?(account.id)).to be(true)
|
||||
end
|
||||
|
||||
@@ -52,13 +65,21 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_participating_manage']))
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], assignee: agent, team: team)
|
||||
create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
||||
participating_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
||||
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:all_count]).to eq(1)
|
||||
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
|
||||
expect(result[:labels]).to eq(label.id.to_s => 1)
|
||||
expect(result[:teams]).to eq(team.id.to_s => 1)
|
||||
expect(result).to eq(
|
||||
all_count: 1,
|
||||
inboxes: { inbox.id.to_s => 1 },
|
||||
labels: { label.id.to_s => 1 },
|
||||
teams: { team.id.to_s => 1 },
|
||||
mentions_count: 0,
|
||||
participating_count: 1,
|
||||
unattended_count: 1,
|
||||
folders: {}
|
||||
)
|
||||
expect(store.assignment_ready?(account.id)).to be(true)
|
||||
end
|
||||
|
||||
@@ -68,7 +89,16 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
|
||||
expect(result).to eq(
|
||||
all_count: 0,
|
||||
inboxes: {},
|
||||
labels: {},
|
||||
teams: {},
|
||||
mentions_count: 0,
|
||||
participating_count: 0,
|
||||
unattended_count: 0,
|
||||
folders: {}
|
||||
)
|
||||
expect(store.base_ready?(account.id)).to be(false)
|
||||
expect(store.assignment_ready?(account.id)).to be(false)
|
||||
end
|
||||
|
||||
@@ -208,6 +208,25 @@ describe ConversationFinder do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with participating conversation type' do
|
||||
let(:params) { { status: 'open', conversation_type: 'participating' } }
|
||||
|
||||
it 'does not return participating conversations from inboxes where the agent is no longer a member' do
|
||||
visible_conversation = create(:conversation, account: account, inbox: inbox)
|
||||
inaccessible_conversation = create(:conversation, account: account, inbox: restricted_inbox)
|
||||
create(:inbox_member, user: user_1, inbox: restricted_inbox)
|
||||
create(:conversation_participant, account: account, conversation: visible_conversation, user: user_1)
|
||||
create(:conversation_participant, account: account, conversation: inaccessible_conversation, user: user_1)
|
||||
InboxMember.find_by!(user: user_1, inbox: restricted_inbox).destroy!
|
||||
|
||||
result = conversation_finder.perform
|
||||
conversation_ids = result[:conversations].map(&:id)
|
||||
|
||||
expect(conversation_ids).to include(visible_conversation.id)
|
||||
expect(conversation_ids).not_to include(inaccessible_conversation.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without source' do
|
||||
let(:params) { {} }
|
||||
|
||||
|
||||
@@ -294,5 +294,19 @@ describe ActionCableListener do
|
||||
|
||||
listener.conversation_unread_count_changed(event)
|
||||
end
|
||||
|
||||
it 'supports user-scoped unread count refresh events' do
|
||||
event = Events::Base.new(event_name, Time.zone.now, account: account, user: agent)
|
||||
|
||||
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
||||
a_collection_containing_exactly(agent.pubsub_token),
|
||||
'conversation.unread_count_changed',
|
||||
{
|
||||
account_id: account.id
|
||||
}
|
||||
)
|
||||
|
||||
listener.conversation_unread_count_changed(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+52
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,4 +18,30 @@ RSpec.describe InboxMember do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'unread filter count invalidation' do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:user) { create(:user, account: account, role: :agent) }
|
||||
let(:notifier) { instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true) }
|
||||
|
||||
before do
|
||||
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
|
||||
end
|
||||
|
||||
it 'notifies when inbox access is added' do
|
||||
create(:inbox_member, inbox: inbox, user: user)
|
||||
|
||||
expect(Conversations::UnreadCounts::UserFilterNotifier).to have_received(:new).with(account: account, user: user)
|
||||
expect(notifier).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'notifies when inbox access is removed' do
|
||||
inbox_member = create(:inbox_member, inbox: inbox, user: user)
|
||||
expect(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).with(account: account, user: user).and_return(notifier)
|
||||
expect(notifier).to receive(:perform)
|
||||
|
||||
inbox_member.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ RSpec.describe Conversations::UnreadCounts::Builder do
|
||||
let(:store) { Conversations::UnreadCounts::Store }
|
||||
|
||||
after do
|
||||
store.clear_account!(account.id)
|
||||
store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
describe '#build_base!' do
|
||||
@@ -75,6 +75,173 @@ RSpec.describe Conversations::UnreadCounts::Builder do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_filters_for!' do
|
||||
before do
|
||||
create(:inbox_member, user: assignee, inbox: inbox)
|
||||
end
|
||||
|
||||
it 'stores unread open conversations by mentions and participating dimensions' do
|
||||
mentioned_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
participating_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
resolved_mentioned_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
inaccessible_conversation = create_unread_conversation(account: account, inbox: create(:inbox, account: account))
|
||||
resolved_mentioned_conversation.update!(status: :resolved)
|
||||
|
||||
create(:mention, account: account, conversation: mentioned_conversation, user: assignee)
|
||||
create(:mention, account: account, conversation: resolved_mentioned_conversation, user: assignee)
|
||||
create(:mention, account: account, conversation: inaccessible_conversation, user: assignee)
|
||||
create(:conversation_participant, account: account, conversation: participating_conversation, user: assignee)
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(store.filters_ready?(account.id, assignee.id)).to be(true)
|
||||
expect(redis_set_members(store.user_mentions_key(account.id, assignee.id))).to contain_exactly(mentioned_conversation.id.to_s)
|
||||
expect(redis_set_members(store.user_participating_key(account.id, assignee.id))).to contain_exactly(participating_conversation.id.to_s)
|
||||
end
|
||||
|
||||
it 'excludes participating conversations that are no longer visible to the user' do
|
||||
participating_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
create(:conversation_participant, account: account, conversation: participating_conversation, user: assignee)
|
||||
InboxMember.find_by!(user: assignee, inbox: inbox).destroy!
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(redis_set_members(store.user_participating_key(account.id, assignee.id))).to be_empty
|
||||
end
|
||||
|
||||
it 'stores visible unread open unattended conversations' do
|
||||
no_first_reply_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
waiting_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
attended_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
inaccessible_conversation = create_unread_conversation(account: account, inbox: create(:inbox, account: account))
|
||||
resolved_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
create_read_conversation
|
||||
|
||||
waiting_conversation.update!(first_reply_created_at: 5.minutes.ago)
|
||||
attended_conversation.update!(first_reply_created_at: 5.minutes.ago, waiting_since: nil)
|
||||
inaccessible_conversation.update!(first_reply_created_at: nil)
|
||||
resolved_conversation.update!(status: :resolved)
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(redis_set_members(store.user_unattended_key(account.id, assignee.id))).to contain_exactly(
|
||||
no_first_reply_conversation.id.to_s,
|
||||
waiting_conversation.id.to_s
|
||||
)
|
||||
end
|
||||
|
||||
it 'stores folder memberships using the saved filter status conditions' do
|
||||
resolved_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
resolved_conversation.update!(status: :resolved)
|
||||
create_unread_conversation(account: account, inbox: inbox, assignee: assignee)
|
||||
custom_filter = create(
|
||||
:custom_filter, account: account, user: assignee, filter_type: :conversation, query: filter_query('status', ['resolved'])
|
||||
)
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, custom_filter.id))).to contain_exactly(resolved_conversation.id.to_s)
|
||||
end
|
||||
|
||||
it 'loads folder filters after taking the invalidation version snapshot' do
|
||||
create_unread_conversation(account: account, inbox: inbox)
|
||||
resolved_conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
resolved_conversation.update!(status: :resolved)
|
||||
custom_filter = create(
|
||||
:custom_filter, account: account, user: assignee, filter_type: :conversation, query: filter_query('status', ['open'])
|
||||
)
|
||||
notifier = instance_double(Conversations::UnreadCounts::UserFilterNotifier, perform: true)
|
||||
allow(Conversations::UnreadCounts::UserFilterNotifier).to receive(:new).and_return(notifier)
|
||||
filter_updated = false
|
||||
allow(store).to receive(:filter_version_snapshot).and_wrap_original do |method, *args|
|
||||
method.call(*args).tap do
|
||||
next if filter_updated
|
||||
|
||||
filter_updated = true
|
||||
custom_filter.update!(query: filter_query('status', ['resolved']))
|
||||
end
|
||||
end
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, custom_filter.id))).to contain_exactly(resolved_conversation.id.to_s)
|
||||
end
|
||||
|
||||
it 'expires relative-date folder caches at the next date boundary' do
|
||||
create(
|
||||
:custom_filter,
|
||||
account: account,
|
||||
user: assignee,
|
||||
filter_type: :conversation,
|
||||
query: filter_query('created_at', [7], filter_operator: 'days_before')
|
||||
)
|
||||
allow(store).to receive(:mark_filters_ready_if_current!).and_call_original
|
||||
expected_ttl = nil
|
||||
|
||||
travel_to Time.zone.local(2026, 1, 1, 9, 30, 0) do
|
||||
expected_ttl = (Time.zone.tomorrow.beginning_of_day - Time.current).ceil
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
end
|
||||
|
||||
expect(store).to have_received(:mark_filters_ready_if_current!).with(
|
||||
account.id,
|
||||
assignee.id,
|
||||
version_snapshot: kind_of(Hash),
|
||||
expires_in: expected_ttl
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not mark filters ready when user filters are invalidated during the build' do
|
||||
conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
create(:mention, account: account, conversation: conversation, user: assignee)
|
||||
clear_user_filters_after_membership_write
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(store.filters_ready?(account.id, assignee.id)).to be(false)
|
||||
expect(redis_set_members(store.user_mentions_key(account.id, assignee.id))).to be_empty
|
||||
end
|
||||
|
||||
it 'does not mark filters ready when account filters are invalidated during the build' do
|
||||
conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
create(:mention, account: account, conversation: conversation, user: assignee)
|
||||
clear_filter_caches_after_membership_write
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(store.filters_ready?(account.id, assignee.id)).to be(false)
|
||||
expect(redis_set_members(store.user_mentions_key(account.id, assignee.id))).to be_empty
|
||||
end
|
||||
|
||||
it 'skips invalid folder filters and still marks the user filter cache ready' do
|
||||
create_unread_conversation(account: account, inbox: inbox)
|
||||
invalid_filter = create(
|
||||
:custom_filter, account: account, user: assignee, filter_type: :conversation, query: filter_query('missing_attribute', ['open'])
|
||||
)
|
||||
|
||||
described_class.new(account).build_filters_for!(assignee)
|
||||
|
||||
expect(store.filters_ready?(account.id, assignee.id)).to be(true)
|
||||
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, invalid_filter.id))).to be_empty
|
||||
end
|
||||
|
||||
it 'skips folder filters that fail when the SQL query is executed' do
|
||||
conversation = create_unread_conversation(account: account, inbox: inbox)
|
||||
invalid_filter = create(
|
||||
:custom_filter,
|
||||
account: account,
|
||||
user: assignee,
|
||||
filter_type: :conversation,
|
||||
query: filter_query('display_id', [conversation.display_id.to_s], filter_operator: 'contains')
|
||||
)
|
||||
|
||||
expect { described_class.new(account).build_filters_for!(assignee) }.not_to raise_error
|
||||
|
||||
expect(store.filters_ready?(account.id, assignee.id)).to be(true)
|
||||
expect(redis_set_members(store.user_folder_key(account.id, assignee.id, invalid_filter.id))).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
def create_read_conversation
|
||||
conversation = create(:conversation, account: account, inbox: inbox, agent_last_seen_at: 1.minute.from_now)
|
||||
create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming)
|
||||
@@ -90,4 +257,32 @@ RSpec.describe Conversations::UnreadCounts::Builder do
|
||||
def redis_set_members(key)
|
||||
Redis::Alfred.pipelined { |pipeline| pipeline.smembers(key) }.first
|
||||
end
|
||||
|
||||
def clear_user_filters_after_membership_write
|
||||
allow(store).to receive(:add_filter_memberships).and_wrap_original do |method, *args, **kwargs|
|
||||
method.call(*args, **kwargs)
|
||||
store.clear_user_filters!(account.id, assignee.id)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_filter_caches_after_membership_write
|
||||
allow(store).to receive(:add_filter_memberships).and_wrap_original do |method, *args, **kwargs|
|
||||
method.call(*args, **kwargs)
|
||||
store.clear_filter_caches!(account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_query(attribute_key, values, filter_operator: 'equal_to')
|
||||
{
|
||||
payload: [
|
||||
{
|
||||
attribute_key: attribute_key,
|
||||
filter_operator: filter_operator,
|
||||
values: values,
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
end
|
||||
|
||||
after do
|
||||
store.clear_account!(account.id)
|
||||
store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
it 'builds the base cache on demand' do
|
||||
@@ -32,6 +32,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
lock_key = "UNREAD_CONVERSATIONS::V1::ACCOUNT::#{account.id}::BUILD_LOCK::BASE"
|
||||
lock_manager = instance_double(Redis::LockManager)
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
allow(lock_manager).to receive(:with_lock).and_yield.and_return(true)
|
||||
allow(lock_manager).to receive(:with_lock).with(lock_key, described_class::BUILD_LOCK_TTL).and_yield.and_return(true)
|
||||
|
||||
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title], team: visible_team)
|
||||
@@ -46,13 +47,33 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
counter = described_class.new(account: account, user: agent)
|
||||
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
allow(counter).to receive(:wait_for_cache_ready) { store.mark_base_ready!(account.id) }
|
||||
allow(counter).to receive(:wait_for_cache_ready) do
|
||||
store.mark_base_ready!(account.id)
|
||||
store.mark_filters_ready!(account.id, agent.id)
|
||||
end
|
||||
expect(Conversations::UnreadCounts::Builder).not_to receive(:new)
|
||||
|
||||
counter.perform
|
||||
|
||||
expect(counter).to have_received(:wait_for_cache_ready)
|
||||
expect(store.base_ready?(account.id)).to be(true)
|
||||
expect(store.filters_ready?(account.id, agent.id)).to be(true)
|
||||
end
|
||||
|
||||
it 'retries when a build finishes without marking the cache ready' do
|
||||
builder = instance_double(Conversations::UnreadCounts::Builder)
|
||||
attempts = 0
|
||||
allow(Conversations::UnreadCounts::Builder).to receive(:new).and_return(builder)
|
||||
allow(builder).to receive(:build_base!) do
|
||||
attempts += 1
|
||||
store.mark_base_ready!(account.id) if attempts == 2
|
||||
end
|
||||
allow(builder).to receive(:build_filters_for!) { store.mark_filters_ready!(account.id, agent.id) }
|
||||
|
||||
described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(builder).to have_received(:build_base!).twice
|
||||
expect(store.base_ready?(account.id)).to be(true)
|
||||
end
|
||||
|
||||
it 'counts unread conversations only across inboxes visible to a normal agent' do
|
||||
@@ -65,7 +86,11 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
all_count: 1,
|
||||
inboxes: { visible_inbox.id.to_s => 1 },
|
||||
labels: { label.id.to_s => 1 },
|
||||
teams: { visible_team.id.to_s => 1 }
|
||||
teams: { visible_team.id.to_s => 1 },
|
||||
mentions_count: 0,
|
||||
participating_count: 0,
|
||||
unattended_count: 1,
|
||||
folders: {}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -79,7 +104,11 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
all_count: 2,
|
||||
inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
|
||||
labels: { label.id.to_s => 2 },
|
||||
teams: { visible_team.id.to_s => 2 }
|
||||
teams: { visible_team.id.to_s => 2 },
|
||||
mentions_count: 0,
|
||||
participating_count: 0,
|
||||
unattended_count: 2,
|
||||
folders: {}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -92,7 +121,46 @@ RSpec.describe Conversations::UnreadCounts::Counter do
|
||||
all_count: 1,
|
||||
inboxes: { visible_inbox.id.to_s => 1 },
|
||||
labels: {},
|
||||
teams: { visible_team.id.to_s => 1 }
|
||||
teams: { visible_team.id.to_s => 1 },
|
||||
mentions_count: 0,
|
||||
participating_count: 0,
|
||||
unattended_count: 1,
|
||||
folders: {}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns mention, participating, unattended, and valid folder unread counts for the user' do
|
||||
mentioned_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
participating_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
resolved_conversation = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
resolved_conversation.update!(status: :resolved)
|
||||
valid_folder = create(:custom_filter, account: account, user: agent, filter_type: :conversation, query: filter_query('status', ['resolved']))
|
||||
invalid_folder = create(:custom_filter, account: account, user: agent, filter_type: :conversation, query: filter_query('unknown', ['open']))
|
||||
|
||||
create(:mention, account: account, conversation: mentioned_conversation, user: agent)
|
||||
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
|
||||
|
||||
result = described_class.new(account: account, user: agent).perform
|
||||
|
||||
expect(result[:mentions_count]).to eq(1)
|
||||
expect(result[:participating_count]).to eq(1)
|
||||
expect(result[:unattended_count]).to eq(2)
|
||||
expect(result[:folders]).to eq(valid_folder.id.to_s => 1)
|
||||
expect(result[:folders]).not_to have_key(invalid_folder.id.to_s)
|
||||
expect(store.filters_ready?(account.id, agent.id)).to be(true)
|
||||
end
|
||||
|
||||
def filter_query(attribute_key, values)
|
||||
{
|
||||
payload: [
|
||||
{
|
||||
attribute_key: attribute_key,
|
||||
filter_operator: 'equal_to',
|
||||
values: values,
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,13 +21,22 @@ RSpec.describe Conversations::UnreadCounts::Listener do
|
||||
expect(notifier).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'ignores outgoing message creation' do
|
||||
it 'clears user filter counts when a non-incoming message updates last activity' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
|
||||
event = Events::Base.new('message.created', Time.zone.now, message: message)
|
||||
allow(store).to receive(:clear_filter_caches!).and_return(true)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
listener.message_created(event)
|
||||
|
||||
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
|
||||
expect(store).to have_received(:clear_filter_caches!).with(account.id)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
|
||||
it 'ignores incoming message creation when conversation unread counts are disabled' do
|
||||
@@ -52,6 +61,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
|
||||
end
|
||||
|
||||
it 'refreshes unread counts when labels change' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
changed_attributes = { label_list: [%w[old], %w[new]] }
|
||||
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
|
||||
|
||||
@@ -61,8 +71,43 @@ RSpec.describe Conversations::UnreadCounts::Listener do
|
||||
expect(notifier).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'ignores conversation updates unrelated to unread count dimensions' do
|
||||
it 'clears user filter counts when a folder filter dimension changes' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] })
|
||||
allow(store).to receive(:clear_filter_caches!).and_return(true)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
listener.conversation_updated(event)
|
||||
|
||||
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
|
||||
expect(store).to have_received(:clear_filter_caches!).with(account.id)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
|
||||
it 'clears user filter counts when the conversation contact changes' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation)
|
||||
allow(store).to receive(:clear_filter_caches!).and_return(true)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
listener.conversation_contact_changed(event)
|
||||
|
||||
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
|
||||
expect(store).to have_received(:clear_filter_caches!).with(account.id)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
|
||||
it 'ignores conversation updates without changed attributes' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: {})
|
||||
|
||||
listener.conversation_updated(event)
|
||||
|
||||
@@ -128,7 +173,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
|
||||
conversation_data: conversation_data.stringify_keys
|
||||
)
|
||||
ensure
|
||||
store.clear_account!(account.id)
|
||||
store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
def deleted_conversation_data(conversation)
|
||||
|
||||
@@ -29,6 +29,18 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
|
||||
|
||||
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
|
||||
end
|
||||
|
||||
it 'dispatches unread count changed event when user filter caches were cleared' do
|
||||
allow(Conversations::UnreadCounts::Store).to receive(:clear_filter_caches!).and_return(true)
|
||||
|
||||
described_class.new(conversation).perform
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation unread counts feature is disabled' do
|
||||
|
||||
@@ -12,7 +12,7 @@ RSpec.describe Conversations::UnreadCounts::Refresher do
|
||||
let(:store) { Conversations::UnreadCounts::Store }
|
||||
|
||||
after do
|
||||
store.clear_account!(account.id)
|
||||
store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
it 'does not update redis when unread caches are not ready' do
|
||||
|
||||
@@ -7,9 +7,10 @@ RSpec.describe Conversations::UnreadCounts::Store do
|
||||
let(:user_id) { 4 }
|
||||
let(:conversation_id) { 5 }
|
||||
let(:team_id) { 6 }
|
||||
let(:other_user_id) { 8 }
|
||||
|
||||
after do
|
||||
described_class.clear_account!(account_id)
|
||||
described_class.clear_all_account!(account_id)
|
||||
end
|
||||
|
||||
describe 'key builders' do
|
||||
@@ -45,20 +46,69 @@ RSpec.describe Conversations::UnreadCounts::Store do
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::TEAM::6::INBOX::2::ASSIGNEE::4'
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds user filter keys using the Redis key naming convention' do
|
||||
expect(described_class.user_mentions_key(account_id, user_id)).to eq(
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::MENTIONS'
|
||||
)
|
||||
expect(described_class.user_participating_key(account_id, user_id)).to eq(
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::PARTICIPATING'
|
||||
)
|
||||
expect(described_class.user_unattended_key(account_id, user_id)).to eq(
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::UNATTENDED'
|
||||
)
|
||||
expect(described_class.user_folder_key(account_id, user_id, 7)).to eq(
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::FOLDER::7'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'ready markers' do
|
||||
it 'tracks base and assignment readiness independently' do
|
||||
it 'starts with all ready markers missing' do
|
||||
expect(described_class.base_ready?(account_id)).to be(false)
|
||||
expect(described_class.assignment_ready?(account_id)).to be(false)
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
|
||||
end
|
||||
|
||||
it 'tracks base, assignment, and user filter readiness independently' do
|
||||
described_class.mark_base_ready!(account_id)
|
||||
described_class.mark_assignment_ready!(account_id)
|
||||
described_class.mark_filters_ready!(account_id, user_id)
|
||||
|
||||
expect(described_class.base_ready?(account_id)).to be(true)
|
||||
expect(described_class.assignment_ready?(account_id)).to be(true)
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(true)
|
||||
expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::READY::BASE')).to be_within(5).of(Conversations::UnreadCounts::READY_TTL)
|
||||
expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::READY::ASSIGNMENT')).to be_within(5).of(Conversations::UnreadCounts::READY_TTL)
|
||||
expect(ttl_for('UNREAD_CONVERSATIONS::V1::ACCOUNT::1::USER::4::READY::FILTERS')).to be_within(5).of(
|
||||
Conversations::UnreadCounts::READY_TTL
|
||||
)
|
||||
end
|
||||
|
||||
it 'tracks filter invalidation versions independently' do
|
||||
expect(described_class.filter_version_snapshot(account_id, user_id)).to eq(account: 0, user: 0)
|
||||
|
||||
expect(described_class.clear_user_filters!(account_id, user_id)).to be(false)
|
||||
|
||||
expect(described_class.filter_version_snapshot(account_id, user_id)).to eq(account: 0, user: 1)
|
||||
expect(ttl_for(user_filter_version_key)).to be_within(5).of(Conversations::UnreadCounts::SET_TTL)
|
||||
|
||||
expect(described_class.clear_filter_caches!(account_id)).to be(false)
|
||||
|
||||
expect(described_class.filter_version_snapshot(account_id, user_id)).to eq(account: 1, user: 1)
|
||||
expect(ttl_for(account_filter_version_key)).to be_within(5).of(Conversations::UnreadCounts::SET_TTL)
|
||||
end
|
||||
|
||||
it 'marks filter caches ready only when the invalidation version is current' do
|
||||
version_snapshot = described_class.filter_version_snapshot(account_id, user_id)
|
||||
|
||||
expect(described_class.mark_filters_ready_if_current!(account_id, user_id, version_snapshot: version_snapshot)).to be_truthy
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(true)
|
||||
|
||||
described_class.clear_user_filters!(account_id, user_id)
|
||||
|
||||
expect(described_class.mark_filters_ready_if_current!(account_id, user_id, version_snapshot: version_snapshot)).to be(false)
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -150,9 +200,94 @@ RSpec.describe Conversations::UnreadCounts::Store do
|
||||
expect(base_keys.map { |key| ttl_for(key) }).to all(be_within(5).of(Conversations::UnreadCounts::SET_TTL))
|
||||
end
|
||||
|
||||
it 'clears all account memberships' do
|
||||
it 'adds, counts, and clears user filter memberships' do
|
||||
described_class.add_filter_memberships(
|
||||
account_id: account_id,
|
||||
user_id: user_id,
|
||||
filters: {
|
||||
mentions: [conversation_id],
|
||||
participating: [conversation_id],
|
||||
unattended: [conversation_id]
|
||||
},
|
||||
folders: { 7 => [conversation_id] }
|
||||
)
|
||||
described_class.mark_filters_ready!(account_id, user_id)
|
||||
|
||||
expect(described_class.counts_for_keys(user_filter_keys)).to eq(
|
||||
described_class.user_mentions_key(account_id, user_id) => 1,
|
||||
described_class.user_participating_key(account_id, user_id) => 1,
|
||||
described_class.user_unattended_key(account_id, user_id) => 1,
|
||||
described_class.user_folder_key(account_id, user_id, 7) => 1
|
||||
)
|
||||
expect(user_filter_keys.map { |key| ttl_for(key) }).to all(be_within(5).of(Conversations::UnreadCounts::SET_TTL))
|
||||
|
||||
expect(described_class.clear_user_filters!(account_id, user_id)).to be(true)
|
||||
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
|
||||
expect(described_class.counts_for_keys(user_filter_keys).values).to all(eq(0))
|
||||
end
|
||||
|
||||
it 'preserves the user filter build lock when clearing one user filter cache' do
|
||||
described_class.add_filter_memberships(
|
||||
account_id: account_id,
|
||||
user_id: user_id,
|
||||
filters: {
|
||||
mentions: [conversation_id],
|
||||
participating: [conversation_id],
|
||||
unattended: [conversation_id]
|
||||
},
|
||||
folders: { 7 => [conversation_id] }
|
||||
)
|
||||
described_class.mark_filters_ready!(account_id, user_id)
|
||||
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
|
||||
|
||||
expect(described_class.clear_user_filters!(account_id, user_id)).to be(true)
|
||||
|
||||
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(true)
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
|
||||
expect(described_class.counts_for_keys(user_filter_keys).values).to all(eq(0))
|
||||
end
|
||||
|
||||
it 'preserves user filter build locks when clearing all account filter caches' do
|
||||
described_class.add_filter_memberships(
|
||||
account_id: account_id,
|
||||
user_id: user_id,
|
||||
filters: {
|
||||
mentions: [conversation_id],
|
||||
participating: [],
|
||||
unattended: []
|
||||
},
|
||||
folders: {}
|
||||
)
|
||||
described_class.add_filter_memberships(
|
||||
account_id: account_id,
|
||||
user_id: other_user_id,
|
||||
filters: {
|
||||
mentions: [conversation_id],
|
||||
participating: [],
|
||||
unattended: []
|
||||
},
|
||||
folders: {}
|
||||
)
|
||||
described_class.mark_filters_ready!(account_id, user_id)
|
||||
described_class.mark_filters_ready!(account_id, other_user_id)
|
||||
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
|
||||
Redis::Alfred.set(user_filter_build_lock_key(other_user_id), 'locked')
|
||||
|
||||
expect(described_class.clear_filter_caches!(account_id)).to be(true)
|
||||
|
||||
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(true)
|
||||
expect(Redis::Alfred.exists?(user_filter_build_lock_key(other_user_id))).to be(true)
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
|
||||
expect(described_class.filters_ready?(account_id, other_user_id)).to be(false)
|
||||
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, user_id)]).values).to all(eq(0))
|
||||
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, other_user_id)]).values).to all(eq(0))
|
||||
end
|
||||
|
||||
it 'clears account memberships without clearing user filter memberships' do
|
||||
described_class.mark_base_ready!(account_id)
|
||||
described_class.mark_assignment_ready!(account_id)
|
||||
described_class.mark_filters_ready!(account_id, user_id)
|
||||
described_class.add_base_membership(
|
||||
account_id: account_id,
|
||||
inbox_id: inbox_id,
|
||||
@@ -168,13 +303,69 @@ RSpec.describe Conversations::UnreadCounts::Store do
|
||||
team_id: team_id,
|
||||
conversation_id: conversation_id
|
||||
)
|
||||
described_class.add_filter_memberships(
|
||||
account_id: account_id,
|
||||
user_id: user_id,
|
||||
filters: {
|
||||
mentions: [conversation_id],
|
||||
participating: [],
|
||||
unattended: []
|
||||
},
|
||||
folders: {}
|
||||
)
|
||||
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
|
||||
|
||||
described_class.clear_account!(account_id)
|
||||
|
||||
expect(described_class.base_ready?(account_id)).to be(false)
|
||||
expect(described_class.assignment_ready?(account_id)).to be(false)
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(true)
|
||||
expect(described_class.counts_for_keys(base_keys).values).to all(eq(0))
|
||||
expect(described_class.counts_for_keys(assignment_keys).values).to all(eq(0))
|
||||
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, user_id)]).values).to all(eq(1))
|
||||
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(true)
|
||||
end
|
||||
|
||||
it 'clears all account unread count keys' do
|
||||
described_class.mark_base_ready!(account_id)
|
||||
described_class.mark_assignment_ready!(account_id)
|
||||
described_class.mark_filters_ready!(account_id, user_id)
|
||||
described_class.add_base_membership(
|
||||
account_id: account_id,
|
||||
inbox_id: inbox_id,
|
||||
label_ids: [label_id],
|
||||
team_id: team_id,
|
||||
conversation_id: conversation_id
|
||||
)
|
||||
described_class.add_assignment_membership(
|
||||
account_id: account_id,
|
||||
inbox_id: inbox_id,
|
||||
label_ids: [label_id],
|
||||
assignee_id: user_id,
|
||||
team_id: team_id,
|
||||
conversation_id: conversation_id
|
||||
)
|
||||
described_class.add_filter_memberships(
|
||||
account_id: account_id,
|
||||
user_id: user_id,
|
||||
filters: {
|
||||
mentions: [conversation_id],
|
||||
participating: [],
|
||||
unattended: []
|
||||
},
|
||||
folders: {}
|
||||
)
|
||||
Redis::Alfred.set(user_filter_build_lock_key, 'locked')
|
||||
|
||||
described_class.clear_all_account!(account_id)
|
||||
|
||||
expect(described_class.base_ready?(account_id)).to be(false)
|
||||
expect(described_class.assignment_ready?(account_id)).to be(false)
|
||||
expect(described_class.filters_ready?(account_id, user_id)).to be(false)
|
||||
expect(described_class.counts_for_keys(base_keys).values).to all(eq(0))
|
||||
expect(described_class.counts_for_keys(assignment_keys).values).to all(eq(0))
|
||||
expect(described_class.counts_for_keys([described_class.user_mentions_key(account_id, user_id)]).values).to all(eq(0))
|
||||
expect(Redis::Alfred.exists?(user_filter_build_lock_key)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -194,6 +385,27 @@ RSpec.describe Conversations::UnreadCounts::Store do
|
||||
]
|
||||
end
|
||||
|
||||
def user_filter_keys(filter_user_id = user_id)
|
||||
[
|
||||
described_class.user_mentions_key(account_id, filter_user_id),
|
||||
described_class.user_participating_key(account_id, filter_user_id),
|
||||
described_class.user_unattended_key(account_id, filter_user_id),
|
||||
described_class.user_folder_key(account_id, filter_user_id, 7)
|
||||
]
|
||||
end
|
||||
|
||||
def user_filter_build_lock_key(filter_user_id = user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account_id, user_id: filter_user_id)
|
||||
end
|
||||
|
||||
def account_filter_version_key
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
|
||||
end
|
||||
|
||||
def user_filter_version_key(filter_user_id = user_id)
|
||||
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: filter_user_id)
|
||||
end
|
||||
|
||||
def ttl_for(key)
|
||||
Redis::Alfred.ttl(key)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversations::UnreadCounts::UserFilterNotifier do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:store) { Conversations::UnreadCounts::Store }
|
||||
|
||||
after do
|
||||
store.clear_all_account!(account.id)
|
||||
end
|
||||
|
||||
it 'clears the user filter cache and dispatches an unread count refresh event' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
store.mark_filters_ready!(account.id, user.id)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
described_class.new(account: account, user: user).perform
|
||||
|
||||
expect(store.filters_ready?(account.id, user.id)).to be(false)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
account: account,
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
it 'does nothing when conversation unread counts are disabled' do
|
||||
store.mark_filters_ready!(account.id, user.id)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
described_class.new(account: account, user: user).perform
|
||||
|
||||
expect(store.filters_ready?(account.id, user.id)).to be(true)
|
||||
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user