Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+48
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user