Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews

This commit is contained in:
Muhsin
2026-05-21 08:29:53 +04:00
22 changed files with 1055 additions and 6 deletions
@@ -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();
@@ -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'
);
});
});
});
@@ -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(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -1,5 +1,5 @@
<script setup>
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 },
@@ -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"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -188,6 +190,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -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(() => {
<component
:is="component"
v-if="shouldRenderComponent"
:label
:icon
:active
v-bind="{ label, icon, active, badgeCount }"
/>
<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" />
</template>
</component>
</Policy>
@@ -0,0 +1,27 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
});
const normalizedCount = computed(() => {
const count = Number(props.count);
return Number.isFinite(count) && count > 0 ? count : 0;
});
const displayCount = computed(() =>
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
);
</script>
<template>
<span
v-if="normalizedCount > 0"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-brand px-1 text-xxs font-medium leading-3 text-white flex-shrink-0"
>
{{ displayCount }}
</span>
<span v-else class="hidden" />
</template>
@@ -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
);
});
});
@@ -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: '<li><slot /></li>',
};
const RouterLinkStub = {
props: ['to'],
template: '<a><slot /></a>',
};
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');
});
});
+1
View File
@@ -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 = [
@@ -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;
@@ -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);
});
});
});
+2
View File
@@ -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,
@@ -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,
};
@@ -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,
{}
);
});
});
});
@@ -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 });
});
});
@@ -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: {},
});
});
});
});
@@ -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',
+5
View File
@@ -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
@@ -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
+429
View File
@@ -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.
@@ -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)