diff --git a/app/javascript/dashboard/api/conversations.js b/app/javascript/dashboard/api/conversations.js
index 876103694..1de9aee29 100644
--- a/app/javascript/dashboard/api/conversations.js
+++ b/app/javascript/dashboard/api/conversations.js
@@ -13,6 +13,10 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
}
+
+ getUnreadCounts() {
+ return axios.get(`${this.url}/unread_counts`);
+ }
}
export default new ConversationApi();
diff --git a/app/javascript/dashboard/api/specs/conversations.spec.js b/app/javascript/dashboard/api/specs/conversations.spec.js
index 7ae4eb774..686db4098 100644
--- a/app/javascript/dashboard/api/specs/conversations.spec.js
+++ b/app/javascript/dashboard/api/specs/conversations.spec.js
@@ -11,6 +11,7 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
+ expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -47,5 +48,12 @@ describe('#ConversationApi', () => {
}
);
});
+
+ it('#getUnreadCounts', () => {
+ conversationsAPI.getUnreadCounts();
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/conversations/unread_counts'
+ );
+ });
});
});
diff --git a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
index 83d1d4e20..a995cf510 100644
--- a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
+import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: {
@@ -17,6 +18,10 @@ const props = defineProps({
type: Object,
required: true,
},
+ badgeCount: {
+ type: [Number, String],
+ default: 0,
+ },
});
const reauthorizationRequired = computed(() => {
@@ -29,6 +34,7 @@ const reauthorizationRequired = computed(() => {
-import { h, ref, computed, onMounted } from 'vue';
+import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,6 +61,24 @@ const hasAdvancedAssignment = computed(() => {
);
});
+const hasConversationUnreadCounts = computed(() => {
+ return isFeatureEnabledonAccount.value(
+ accountId.value,
+ FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
+ );
+});
+
+const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
+ if (!currentAccountId) return;
+
+ if (!isEnabled) {
+ store.dispatch('conversationUnreadCounts/clear');
+ return;
+ }
+
+ store.dispatch('conversationUnreadCounts/get');
+};
+
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -157,6 +175,15 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
+const getInboxUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getInboxUnreadCount'
+);
+const getLabelUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getLabelUnreadCount'
+);
+const getTeamUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getTeamUnreadCount'
+);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -173,6 +200,10 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
+watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
+ immediate: true,
+});
+
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
@@ -270,6 +301,7 @@ const menuItems = computed(() => {
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
+ badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -281,6 +313,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
+ badgeCount: getInboxUnreadCount.value(inbox.id),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -288,6 +321,7 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
+ badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -299,6 +333,7 @@ const menuItems = computed(() => {
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
+ badgeCount: getLabelUnreadCount.value(label.id),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
index 1beea47df..18e1aea23 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue
@@ -5,6 +5,7 @@ import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
+import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -166,6 +167,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ subChild.label }}
+
@@ -188,6 +190,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
{{ child.label }}
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
index b1b8dd740..31e41a6c2 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
@@ -3,6 +3,7 @@ import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider';
+import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -10,6 +11,7 @@ const props = defineProps({
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
component: { type: Function, default: null },
+ badgeCount: { type: [Number, String], default: 0 },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
{{ label }}
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
new file mode 100644
index 000000000..d9329fdaf
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue
@@ -0,0 +1,27 @@
+
+
+
+
+ {{ displayCount }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
new file mode 100644
index 000000000..03d963134
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js
@@ -0,0 +1,38 @@
+import { mount } from '@vue/test-utils';
+import ChannelLeaf from '../ChannelLeaf.vue';
+
+const mountChannelLeaf = props =>
+ mount(ChannelLeaf, {
+ props: {
+ label: 'Website',
+ inbox: { reauthorization_required: false },
+ ...props,
+ },
+ global: {
+ mocks: {
+ $t: key => key,
+ },
+ stubs: {
+ ChannelIcon: true,
+ Icon: true,
+ },
+ },
+ });
+
+describe('ChannelLeaf', () => {
+ it('renders unread badge when count is present', () => {
+ const wrapper = mountChannelLeaf({ badgeCount: 3 });
+ const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
+
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe('3');
+ });
+
+ it('does not render unread badge when count is zero', () => {
+ const wrapper = mountChannelLeaf({ badgeCount: 0 });
+
+ expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
+ false
+ );
+ });
+});
diff --git a/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
new file mode 100644
index 000000000..79b62d191
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
@@ -0,0 +1,76 @@
+import { mount } from '@vue/test-utils';
+import { h } from 'vue';
+import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
+
+vi.mock('../provider', () => ({
+ useSidebarContext: () => ({
+ resolvePermissions: () => [],
+ resolveFeatureFlag: () => '',
+ }),
+}));
+
+const PolicyStub = {
+ props: ['as', 'permissions', 'featureFlag'],
+ template: '
',
+};
+
+const RouterLinkStub = {
+ props: ['to'],
+ template: '
',
+};
+
+const mountLeaf = props =>
+ mount(SidebarGroupLeaf, {
+ props: {
+ label: 'Support',
+ to: '/support',
+ ...props,
+ },
+ global: {
+ stubs: {
+ Icon: true,
+ Policy: PolicyStub,
+ RouterLink: RouterLinkStub,
+ },
+ },
+ });
+
+describe('SidebarGroupLeaf', () => {
+ it('renders unread badge when count is present', () => {
+ const wrapper = mountLeaf({ badgeCount: 7 });
+ const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
+
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe('7');
+ });
+
+ it('does not render unread badge when count is zero', () => {
+ const wrapper = mountLeaf({ badgeCount: 0 });
+
+ expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
+ false
+ );
+ });
+
+ it('caps large unread counts', () => {
+ const wrapper = mountLeaf({ badgeCount: 120 });
+
+ expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
+ '99+'
+ );
+ });
+
+ it('passes unread count to custom leaf components', () => {
+ const wrapper = mountLeaf({
+ badgeCount: 4,
+ component: leafProps =>
+ h(
+ 'span',
+ { 'data-test-id': 'custom-leaf-count' },
+ leafProps.badgeCount
+ ),
+ });
+
+ expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
+ });
+});
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index b97e30984..00a79763b 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
+ CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index 6feebb35d..74b82105c 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -4,14 +4,18 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
+const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
+ this.lastUnreadCountsFetchAt = null;
+ this.unreadCountsFetchTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -32,6 +36,8 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
+ 'conversation.unread_count_changed':
+ this.onConversationUnreadCountChanged,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
@@ -120,6 +126,56 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
+ onConversationUnreadCountChanged = () => {
+ this.throttledFetchConversationUnreadCounts();
+ };
+
+ throttledFetchConversationUnreadCounts = () => {
+ const now = Date.now();
+ const elapsedTime = now - this.lastUnreadCountsFetchAt;
+
+ if (
+ this.lastUnreadCountsFetchAt === null ||
+ elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
+ ) {
+ this.clearUnreadCountsFetchTimer();
+ this.fetchConversationUnreadCounts();
+ return;
+ }
+
+ if (this.unreadCountsFetchTimer) return;
+
+ this.unreadCountsFetchTimer = setTimeout(() => {
+ this.unreadCountsFetchTimer = null;
+ this.fetchConversationUnreadCounts();
+ }, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
+ };
+
+ clearUnreadCountsFetchTimer = () => {
+ if (!this.unreadCountsFetchTimer) return;
+
+ clearTimeout(this.unreadCountsFetchTimer);
+ this.unreadCountsFetchTimer = null;
+ };
+
+ fetchConversationUnreadCounts = () => {
+ if (!this.isConversationUnreadCountsEnabled()) return;
+
+ this.lastUnreadCountsFetchAt = Date.now();
+ this.app.$store.dispatch('conversationUnreadCounts/get');
+ };
+
+ isConversationUnreadCountsEnabled = () => {
+ const accountId = this.app.$store.getters.getCurrentAccountId;
+ const isFeatureEnabled =
+ this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
+
+ return isFeatureEnabled?.(
+ accountId,
+ FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
+ );
+ };
+
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js
index 4ad8a52c6..8ba411a5f 100644
--- a/app/javascript/dashboard/helper/specs/actionCable.spec.js
+++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js
@@ -1,4 +1,4 @@
-import { describe, it, beforeEach, expect, vi } from 'vitest';
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,12 +30,17 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
+ 'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
@@ -64,4 +69,95 @@ describe('ActionCableConnector - Copilot Tests', () => {
);
});
});
+
+ describe('conversation unread count event handlers', () => {
+ it('should register the conversation.unread_count_changed event handler', () => {
+ expect(Object.keys(actionCable.events)).toContain(
+ 'conversation.unread_count_changed'
+ );
+ expect(actionCable.events['conversation.unread_count_changed']).toBe(
+ actionCable.onConversationUnreadCountChanged
+ );
+ });
+
+ it('should refetch unread counts when unread count changes', () => {
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
+ });
+
+ it('does not refetch unread counts when unread count feature is disabled', () => {
+ store.$store.getters[
+ 'accounts/isFeatureEnabledonAccount'
+ ].mockReturnValue(false);
+
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+ });
+
+ it('should throttle unread count refetches for repeated events', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(4999);
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(1);
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch).toHaveBeenLastCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+ });
+
+ it('clears pending unread count refetch before immediate refetch', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ vi.advanceTimersByTime(1000);
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+
+ vi.advanceTimersByTime(4000);
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index d56958eb5..054a823c6 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -25,6 +25,7 @@ import conversations from './modules/conversations';
import conversationSearch from './modules/conversationSearch';
import conversationStats from './modules/conversationStats';
import conversationTypingStatus from './modules/conversationTypingStatus';
+import conversationUnreadCounts from './modules/conversationUnreadCounts';
import conversationWatchers from './modules/conversationWatchers';
import csat from './modules/csat';
import customRole from './modules/customRole';
@@ -88,6 +89,7 @@ export default createStore({
conversationSearch,
conversationStats,
conversationTypingStatus,
+ conversationUnreadCounts,
conversationWatchers,
csat,
customRole,
diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
new file mode 100644
index 000000000..0503c0806
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
@@ -0,0 +1,70 @@
+import ConversationAPI from '../../api/conversations';
+import types from '../mutation-types';
+
+export const state = {
+ inboxes: {},
+ labels: {},
+ teams: {},
+};
+
+const normalizeCounts = counts => {
+ return Object.entries(counts || {}).reduce((result, [id, count]) => {
+ const parsedCount = Number(count);
+ if (Number.isFinite(parsedCount) && parsedCount > 0) {
+ result[String(id)] = parsedCount;
+ }
+
+ return result;
+ }, {});
+};
+
+export const getters = {
+ getInboxUnreadCount: $state => inboxId => {
+ return $state.inboxes[String(inboxId)] || 0;
+ },
+ getLabelUnreadCount: $state => labelId => {
+ return $state.labels[String(labelId)] || 0;
+ },
+ getTeamUnreadCount: $state => teamId => {
+ return $state.teams[String(teamId)] || 0;
+ },
+ getInboxUnreadCounts($state) {
+ return $state.inboxes;
+ },
+ getLabelUnreadCounts($state) {
+ return $state.labels;
+ },
+ getTeamUnreadCounts($state) {
+ return $state.teams;
+ },
+};
+
+export const actions = {
+ get: async function getUnreadCounts({ commit }) {
+ try {
+ const response = await ConversationAPI.getUnreadCounts();
+ commit(types.SET_CONVERSATION_UNREAD_COUNTS, response.data.payload);
+ } catch (error) {
+ // Ignore errors so the sidebar can continue rendering without badges.
+ }
+ },
+ clear({ commit }) {
+ commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
+ },
+};
+
+export const mutations = {
+ [types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
+ $state.inboxes = normalizeCounts(payload.inboxes);
+ $state.labels = normalizeCounts(payload.labels);
+ $state.teams = normalizeCounts(payload.teams);
+ },
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
new file mode 100644
index 000000000..3100cdd10
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
@@ -0,0 +1,53 @@
+import axios from 'axios';
+import { actions } from '../../conversationUnreadCounts';
+import types from '../../../mutation-types';
+
+const commit = vi.fn();
+global.axios = axios;
+vi.mock('axios');
+
+describe('#actions', () => {
+ beforeEach(() => {
+ commit.mockClear();
+ axios.get.mockReset();
+ });
+
+ describe('#get', () => {
+ it('commits unread counts when API is successful', async () => {
+ const payload = {
+ inboxes: { 1: '2' },
+ labels: { 3: 4 },
+ teams: { 5: 6 },
+ };
+ axios.get.mockResolvedValue({ data: { payload } });
+
+ await actions.get({ commit });
+
+ expect(axios.get).toHaveBeenCalledWith(
+ '/api/v1/conversations/unread_counts'
+ );
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONVERSATION_UNREAD_COUNTS, payload],
+ ]);
+ });
+
+ it('does not commit when API fails', async () => {
+ axios.get.mockRejectedValue({ message: 'Incorrect header' });
+
+ await actions.get({ commit });
+
+ expect(commit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('#clear', () => {
+ it('clears unread counts', () => {
+ actions.clear({ commit });
+
+ expect(commit).toHaveBeenCalledWith(
+ types.SET_CONVERSATION_UNREAD_COUNTS,
+ {}
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
new file mode 100644
index 000000000..a3e74fc37
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
@@ -0,0 +1,51 @@
+import { getters } from '../../conversationUnreadCounts';
+
+describe('#getters', () => {
+ it('returns inbox unread count by id', () => {
+ const state = {
+ inboxes: { 1: 2 },
+ labels: {},
+ teams: {},
+ };
+
+ expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
+ expect(getters.getInboxUnreadCount(state)('1')).toBe(2);
+ expect(getters.getInboxUnreadCount(state)(2)).toBe(0);
+ });
+
+ it('returns label unread count by id', () => {
+ const state = {
+ inboxes: {},
+ labels: { 3: 4 },
+ teams: {},
+ };
+
+ expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
+ expect(getters.getLabelUnreadCount(state)('3')).toBe(4);
+ expect(getters.getLabelUnreadCount(state)(4)).toBe(0);
+ });
+
+ it('returns team unread count by id', () => {
+ const state = {
+ inboxes: {},
+ labels: {},
+ teams: { 5: 6 },
+ };
+
+ expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
+ expect(getters.getTeamUnreadCount(state)('5')).toBe(6);
+ expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
+ });
+
+ it('returns unread count maps', () => {
+ const state = {
+ inboxes: { 1: 2 },
+ labels: { 3: 4 },
+ teams: { 5: 6 },
+ };
+
+ expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
+ expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
+ expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
new file mode 100644
index 000000000..3f7e2b1ec
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
@@ -0,0 +1,48 @@
+import types from '../../../mutation-types';
+import { mutations } from '../../conversationUnreadCounts';
+
+describe('#mutations', () => {
+ describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
+ it('normalizes unread count payload', () => {
+ const state = { inboxes: {}, labels: {}, teams: {} };
+
+ mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
+ inboxes: {
+ 1: '2',
+ 2: 0,
+ 3: 'invalid',
+ },
+ labels: {
+ 4: 5,
+ 5: -1,
+ },
+ teams: {
+ 6: '7',
+ 7: 0,
+ },
+ });
+
+ expect(state).toEqual({
+ inboxes: { 1: 2 },
+ labels: { 4: 5 },
+ teams: { 6: 7 },
+ });
+ });
+
+ it('clears counts when payload is empty', () => {
+ const state = {
+ inboxes: { 1: 2 },
+ labels: { 4: 5 },
+ teams: { 6: 7 },
+ };
+
+ mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
+
+ expect(state).toEqual({
+ inboxes: {},
+ labels: {},
+ teams: {},
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 82858b6b6..1597b7ea6 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -187,6 +187,9 @@ export default {
CLEAR_SELECTED_CONVERSATION_IDS: 'CLEAR_SELECTED_CONVERSATION_IDS',
REMOVE_SELECTED_CONVERSATION_IDS: 'REMOVE_SELECTED_CONVERSATION_IDS',
+ // Conversation Unread Counts
+ SET_CONVERSATION_UNREAD_COUNTS: 'SET_CONVERSATION_UNREAD_COUNTS',
+
// Reports
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
diff --git a/app/models/account.rb b/app/models/account.rb
index 9b52b998c..79f305c1f 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -170,6 +170,11 @@ class Account < ApplicationRecord
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
end
+ def reset_cache_keys
+ super
+ clear_unread_conversation_counts_cache
+ end
+
private
def notify_creation
diff --git a/app/services/conversations/unread_counts/refresher.rb b/app/services/conversations/unread_counts/refresher.rb
index 9456cab55..9e49ecd28 100644
--- a/app/services/conversations/unread_counts/refresher.rb
+++ b/app/services/conversations/unread_counts/refresher.rb
@@ -114,7 +114,7 @@ class Conversations::UnreadCounts::Refresher
end
def affected_assignee_ids
- return [conversation.assignee_id].compact unless changed_attribute?(:assignee_id)
+ return [conversation.assignee_id] unless changed_attribute?(:assignee_id)
[previous_value_for(:assignee_id), conversation.assignee_id].uniq
end
diff --git a/app_store_inbox.md b/app_store_inbox.md
new file mode 100644
index 000000000..441a1f165
--- /dev/null
+++ b/app_store_inbox.md
@@ -0,0 +1,429 @@
+# App Store Reviews Inbox Plan
+
+## Goal
+
+Add Apple App Store reviews as a Chatwoot inbox, similar to the Google Play Reviews inbox, so agents can read App Store reviews in Chatwoot and post developer responses from the conversation reply box.
+
+## API Findings
+
+- Use the official App Store Connect API, not public RSS/iTunes review feeds.
+- App Store Connect API uses API keys and ES256 JWT bearer tokens, not OAuth user sign-in.
+- Required credentials:
+ - Issuer ID
+ - Key ID
+ - `.p8` private key
+- JWTs should be short-lived. Apple generally rejects App Store Connect API tokens with expiration more than 20 minutes in the future.
+- Reviews can be fetched from:
+ - `GET /v1/apps/{id}/customerReviews`
+ - `GET /v1/appStoreVersions/{id}/customerReviews`
+- Review fields include:
+ - `rating`
+ - `title`
+ - `body`
+ - `reviewerNickname`
+ - `createdDate`
+ - `territory`
+ - `response`
+- Developer responses can be included with `include=response`.
+- Developer replies are created or updated through:
+ - `POST /v1/customerReviewResponses`
+- A review can have at most one developer response. Posting another response updates/replaces the existing one.
+- Apple says responses can take up to 24 hours to appear publicly.
+- Required App Store Connect role for responding: Account Holder, Admin, or Customer Support.
+- No Apple equivalent of Google Play's 7-day review fetch limit was found. Do not add a 7-day reply window unless real API testing proves one exists.
+
+References:
+
+- https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests
+- https://developer.apple.com/documentation/appstoreconnectapi/customer-review-responses
+- https://developer.apple.com/documentation/appstoreconnectapi/post-v1-customerreviewresponses
+- https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app_store_version
+- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/respond-to-reviews/
+
+## Product Shape
+
+The App Store inbox should be a new channel type, separate from Google Play:
+
+- Channel name: App Store Reviews
+- One inbox per App Store Connect app per Chatwoot account.
+- Setup should be credential-form based, not OAuth redirect based.
+- Agents should see each review as a conversation.
+- Agents should be able to reply once; later replies update the App Store response.
+- Existing developer responses from App Store Connect should be mirrored as outgoing messages.
+- Review title, body, rating, territory, and reviewer nickname should be visible in the conversation.
+
+## Data Model
+
+Add `channel_app_store`.
+
+Suggested fields:
+
+- `account_id`
+- `app_id` - App Store Connect app resource ID
+- `bundle_id`
+- `app_name`
+- `provider_config` - non-secret metadata only, if needed
+- `issuer_id`
+- `key_id`
+- `private_key`
+- `last_synced_at`
+- timestamps
+
+Indexes:
+
+- unique index on `[:account_id, :app_id]`
+
+Security:
+
+- Treat `.p8` private key as a sensitive credential.
+- Prefer explicit encrypted columns for `issuer_id`, `key_id`, and `private_key`.
+- Avoid storing the private key inside unencrypted JSONB.
+- Follow existing `Chatwoot.encryption_configured?` patterns used by channel credentials.
+
+Model wiring:
+
+- Add `Channel::AppStore`.
+- Add `Account#app_store_channels`.
+- Add `Inbox#app_store?`.
+- Add API serialization for `app_id`, `bundle_id`, `app_name`, and `last_synced_at`.
+- Add `SendReplyJob` mapping to `AppStore::SendOnAppStoreService`.
+
+## Backend Services
+
+### `AppStoreConnect::TokenService`
+
+Responsibilities:
+
+- Build an ES256 JWT using the channel credentials.
+- Use existing `jwt` gem.
+- Parse private key with `OpenSSL::PKey`.
+- Set JWT header:
+ - `alg: ES256`
+ - `kid: key_id`
+ - `typ: JWT`
+- Set JWT payload:
+ - `iss: issuer_id`
+ - `iat`
+ - `exp`
+ - `aud: appstoreconnect-v1`
+- Cache token per channel until close to expiry.
+
+### `AppStoreConnect::Client`
+
+Responsibilities:
+
+- Add bearer token auth header.
+- Fetch reviews with pagination.
+- Fetch included developer responses.
+- Post developer responses.
+- Raise clear errors for:
+ - 401 invalid credentials
+ - 403 missing permissions
+ - 404 app/review not found
+ - 409/422 invalid response payload
+ - 429 rate limited
+ - 5xx Apple errors
+
+Suggested methods:
+
+- `list_reviews(app_id, cursor: nil)`
+- `reply_to_review(review_id, response_body)`
+- `fetch_app(app_id)` or `validate_app_access(app_id)`
+
+## Import Pipeline
+
+Add jobs:
+
+- `Inboxes::FetchAppStoreReviewInboxesJob`
+- `Inboxes::FetchAppStoreReviewsJob`
+
+Polling behavior:
+
+- Scheduled polling similar to Google Play.
+- Skip suspended accounts.
+- Use `last_synced_at` to avoid excessive polling.
+- Page through review results using `links.next`.
+- Consider sorting by `-createdDate`.
+
+Add `AppStore::ReviewBuilder`.
+
+Mapping:
+
+- Apple review ID maps to `ContactInbox#source_id`.
+- One review maps to one conversation.
+- Review edits should be handled idempotently.
+- Incoming message source ID can be based on review ID plus `createdDate` or a stable edit/version field if Apple exposes one.
+- Existing developer response maps to an outgoing message.
+- Developer response message source ID should use Apple response ID if present.
+
+Message content:
+
+- Include star rating.
+- Include title.
+- Include body.
+- Include a compact footer with territory and reviewer nickname when useful.
+
+Message timestamps:
+
+- Use Apple `createdDate` as `created_at` and `updated_at` for imported review messages.
+- Do not use import time for review messages.
+
+Metadata:
+
+Store under `content_attributes[:app_store]`:
+
+- `rating`
+- `title`
+- `territory`
+- `reviewer_nickname`
+- `created_date`
+- `response_state`
+- `response_id`
+
+## Reply Pipeline
+
+Add `AppStore::SendOnAppStoreService`.
+
+Behavior:
+
+- Use `conversation.contact_inbox.source_id` as the Apple review ID.
+- Call `POST /v1/customerReviewResponses`.
+- On success:
+ - Update `message.source_id` with Apple response ID if returned.
+ - Mark message as sent/delivered through `Messages::StatusUpdateService`.
+- On failure:
+ - Mark message as failed through `Messages::StatusUpdateService`.
+ - Store `external_error`.
+
+Constraints:
+
+- Disable attachments.
+- Disable rich-text formatting.
+- Confirm Apple's response length limit during implementation. Do not guess a hard cap unless verified.
+
+## Frontend
+
+Add App Store Reviews to inbox creation.
+
+Setup form fields:
+
+- Inbox name
+- App Store Connect app ID
+- Bundle ID, optional if app ID is enough
+- Issuer ID
+- Key ID
+- Private key `.p8`
+
+Backend should validate credentials before creating the inbox by calling App Store Connect.
+
+Frontend wiring:
+
+- Add `INBOX_TYPES.APP_STORE`.
+- Add `isAnAppStoreChannel` / equivalent composable and mixin helpers.
+- Add channel icon.
+- Add i18n strings in `en.json` only.
+- Add channel to inbox list and channel factory.
+- Add API client for creating/validating App Store channel.
+- Add plain text editor config for `Channel::AppStore`.
+- Add reply max length only after Apple limit is confirmed.
+
+Unsupported settings:
+
+- Hide bots.
+- Hide business hours.
+- Hide CSAT.
+- Hide help center.
+- Hide channel preferences that do not apply.
+- Disable attachments.
+
+## Tests
+
+Backend specs:
+
+- `Channel::AppStore` validations and associations.
+- JWT generation with generated EC key.
+- Client review pagination.
+- Client reply request body.
+- Client error handling.
+- Inbox creation credential validation.
+- Review builder creates contact, conversation, incoming message.
+- Review builder idempotency.
+- Review builder mirrors existing developer response.
+- Send service success and failure.
+- Polling job skips suspended accounts.
+- Polling job respects sync interval.
+
+Frontend specs:
+
+- Channel detection helper/composable.
+- Inbox type icon/readable label.
+- Setup form validation.
+- Reply box behavior for unsupported attachments/formatting.
+
+## Resolved Open Questions
+
+### App-level reviews vs version-level reviews
+
+Use app-level reviews as the primary fetch path:
+
+- `GET /v1/apps/{id}/customerReviews`
+
+Reasoning:
+
+- Chatwoot inboxes should map to an app, not to a specific App Store version.
+- Apple documents app-level customer reviews as the endpoint for getting reviews for a specific app.
+- Version-level reviews are still useful for narrower workflows, but they would make inbox setup more complicated and could fragment one app's support queue across several inboxes.
+
+Implementation decision:
+
+- Store the App Store Connect app resource ID on `Channel::AppStore`.
+- Fetch app-level reviews by default.
+- Keep version-level support out of MVP.
+- Add `platform` or `app_store_version_id` later only if real API testing shows app-level reviews mix platforms in a way agents cannot work with.
+
+Still needs real API validation:
+
+- Confirm whether app-level reviews include all platforms and all versions for a multi-platform app.
+- Confirm whether app-level review payload includes enough context to identify platform/version. The documented review attributes include `rating`, `title`, `body`, `reviewerNickname`, `createdDate`, and `territory`, but not platform/version.
+
+### Response payload shape
+
+Use JSON:API format for `POST /v1/customerReviewResponses`.
+
+Request body:
+
+```json
+{
+ "data": {
+ "type": "customerReviewResponses",
+ "attributes": {
+ "responseBody": "Thanks for the feedback."
+ },
+ "relationships": {
+ "review": {
+ "data": {
+ "type": "customerReviews",
+ "id": "CUSTOMER_REVIEW_ID"
+ }
+ }
+ }
+ }
+}
+```
+
+Expected successful response:
+
+- HTTP `201 Created`.
+- Response resource type: `customerReviewResponses`.
+- Response fields can include:
+ - `responseBody`
+ - `lastModifiedDate`
+ - `state`
+ - `review`
+
+Implementation decision:
+
+- Use the returned customer review response ID as outgoing message `source_id`.
+- Store `state` and `lastModifiedDate` in `content_attributes[:app_store]` when present.
+
+### Review edits and idempotency
+
+Apple's documented `CustomerReview.Attributes` include `createdDate`, but not an update timestamp for the customer review itself.
+
+Implementation decision:
+
+- Use the Apple review ID as the stable incoming message source ID.
+- Create one incoming message per review.
+- If a fetched review with the same ID has changed title/body/rating, update the existing message content and metadata instead of creating a new message.
+- Do not append edit history in MVP because the API docs do not expose a review edit timestamp.
+
+Still needs real API validation:
+
+- Confirm how Apple represents a reviewer editing an existing review in API responses.
+- Confirm whether edited reviews preserve the same review ID.
+
+### Response body length
+
+No official customer review response length limit was found in the App Store Connect API docs checked.
+
+Implementation decision:
+
+- Do not hardcode a special App Store response length limit in MVP.
+- Use Chatwoot's general reply validation on the frontend.
+- Let Apple return `409` or `422` for invalid response payloads and surface the error through `external_error`.
+
+Still needs real API validation:
+
+- Check whether App Store Connect applies a hidden maximum length for `responseBody`.
+
+### Rate limits and retries
+
+Apple documents rate limits through the `X-Rate-Limit` response header.
+
+Header shape:
+
+```text
+user-hour-lim:3500;user-hour-rem:500;
+```
+
+Behavior:
+
+- Limits apply to requests using the same API key.
+- The window is a rolling hour.
+- Exceeding the limit returns HTTP `429` with `RATE_LIMIT_EXCEEDED`.
+
+Implementation decision:
+
+- Parse and log `X-Rate-Limit` headers in the client.
+- On `429`, do not mark the inbox broken.
+- Re-enqueue the fetch job later with backoff.
+- Keep polling conservative, similar to Google Play, and page with `limit=200`.
+
+### Inbox scope for multiple platforms and versions
+
+Implementation decision:
+
+- MVP scope is one inbox per App Store Connect app resource ID per Chatwoot account.
+- Do not create separate inboxes by platform or app version.
+- Store `platform` only if we can reliably derive it during setup or fetch.
+
+Reasoning:
+
+- This matches the way agents think about supporting one app.
+- It avoids forcing customers to know App Store version resource IDs.
+- It keeps parity with Google Play's one-app-per-inbox model.
+
+Still needs real API validation:
+
+- Confirm if app-level reviews for multi-platform apps are agent-friendly without platform separation.
+
+## Remaining Risks
+
+- Credential storage must be handled carefully because `.p8` private keys are highly sensitive.
+- Apple responses can remain pending for up to 24 hours, so Chatwoot send status and public App Store visibility are not the same.
+- A real App Store Connect app and API key are required before finalizing response-length handling, review-edit behavior, and multi-platform behavior.
+
+## Suggested Implementation Order
+
+1. Add model, migration, associations, and inbox serialization.
+2. Add JWT token service and low-level App Store Connect client.
+3. Add backend channel creation/validation endpoint.
+4. Add review fetch job and review builder.
+5. Add send service and `SendReplyJob` mapping.
+6. Add frontend inbox setup and channel helpers.
+7. Add settings/reply-box restrictions.
+8. Add specs.
+9. Manually verify with a real App Store Connect app and API key.
+10. Revisit shared abstractions with Google Play after both integrations work.
+
+## Potential Shared Abstraction Later
+
+After Google Play and App Store are both implemented, consider extracting shared store-review behavior:
+
+- Store review polling orchestration.
+- Review-to-conversation builder conventions.
+- Reply status handling.
+- Unsupported inbox settings.
+- Plain-text reply channel behavior.
+
+Do not extract this upfront. Let both implementations settle first.
diff --git a/spec/services/conversations/unread_counts/refresher_spec.rb b/spec/services/conversations/unread_counts/refresher_spec.rb
index 9b22c2186..5f361e7aa 100644
--- a/spec/services/conversations/unread_counts/refresher_spec.rb
+++ b/spec/services/conversations/unread_counts/refresher_spec.rb
@@ -119,6 +119,43 @@ RSpec.describe Conversations::UnreadCounts::Refresher do
expect(store).to have_received(:remove_assignment_membership).with(hash_including(assignee_ids: [assignee.id]))
end
+ it 'moves assignment-aware unassigned label membership when labels change' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update_labels([new_label.title])
+ result = described_class.new(
+ conversation.reload,
+ changed_attributes: { label_list: [[label.title], [new_label.title]] }
+ ).perform
+
+ expect(result).to be(true)
+ expect(store.counts_for_keys([
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id),
+ store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id)
+ ])).to eq(
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0,
+ store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id) => 1
+ )
+ end
+
+ it 'removes assignment-aware unassigned membership when conversation is resolved' do
+ conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title])
+ Conversations::UnreadCounts::Builder.new(account).build_assignment!
+
+ conversation.update!(status: :resolved)
+ result = described_class.new(conversation.reload, changed_attributes: { status: %w[open resolved] }).perform
+
+ expect(result).to be(true)
+ expect(store.counts_for_keys([
+ store.inbox_unassigned_key(account.id, inbox.id),
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id)
+ ])).to eq(
+ store.inbox_unassigned_key(account.id, inbox.id) => 0,
+ store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0
+ )
+ end
+
it 'moves assignment-aware team membership when team changes' do
create(:team_member, user: assignee, team: new_team)
conversation = create_unread_conversation(account: account, inbox: inbox, assignee: assignee, team: team)