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(() => {
{{ label }}
+
-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(() => { 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 @@ + + + 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)