feat(inboxes): add app store reviews channel
This commit is contained in:
@@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def allowed_channel_types
|
||||
%w[web_widget api email line telegram whatsapp sms]
|
||||
%w[web_widget api email line telegram whatsapp sms app_store]
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@@ -179,7 +179,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
'line' => Channel::Line,
|
||||
'telegram' => Channel::Telegram,
|
||||
'whatsapp' => Channel::Whatsapp,
|
||||
'sms' => Channel::Sms
|
||||
'sms' => Channel::Sms,
|
||||
'app_store' => Channel::AppStore
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ module Api::V1::InboxesHelper
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
'sms' => Current.account.sms_channels,
|
||||
'app_store' => Current.account.app_store_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::AppStore': 'i-ri-app-store-fill',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAnAppStoreChannel,
|
||||
isATiktokChannel,
|
||||
} = useInbox();
|
||||
|
||||
@@ -62,7 +63,8 @@ const isSent = computed(() => {
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
@@ -85,7 +87,8 @@ const isDelivered = computed(() => {
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
@@ -110,7 +113,8 @@ const isRead = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ const isActive = computed(() => {
|
||||
'line',
|
||||
'instagram',
|
||||
'tiktok',
|
||||
'app_store',
|
||||
'voice',
|
||||
].includes(key);
|
||||
});
|
||||
@@ -78,7 +79,7 @@ const isComingSoon = computed(() => {
|
||||
});
|
||||
|
||||
const isBeta = computed(() => {
|
||||
return ['tiktok', 'voice'].includes(props.channel.key);
|
||||
return ['tiktok', 'app_store', 'voice'].includes(props.channel.key);
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
@@ -30,6 +31,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
@@ -138,6 +140,10 @@ export const useInbox = (inboxId = null) => {
|
||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||
});
|
||||
|
||||
const isAnAppStoreChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.APP_STORE;
|
||||
});
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
@@ -160,6 +166,7 @@ export const useInbox = (inboxId = null) => {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAnAppStoreChannel,
|
||||
voiceCallEnabled,
|
||||
voiceCallProvider,
|
||||
};
|
||||
|
||||
@@ -114,6 +114,11 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::AppStore': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
// Special contexts (not actual channels)
|
||||
'Context::PrivateNote': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
|
||||
@@ -11,6 +11,7 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
APP_STORE: 'Channel::AppStore',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
@@ -50,6 +51,7 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -65,6 +67,7 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-line',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
||||
@@ -114,6 +117,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.APP_STORE:
|
||||
return 'app_store';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -382,6 +382,39 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the api channel"
|
||||
}
|
||||
},
|
||||
"APP_STORE": {
|
||||
"TITLE": "App Store Reviews",
|
||||
"DESC": "Manage and reply to your App Store reviews from Chatwoot.",
|
||||
"CHANNEL_NAME": {
|
||||
"LABEL": "Inbox Name",
|
||||
"PLACEHOLDER": "Please enter an inbox name",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"APP_ID": {
|
||||
"LABEL": "App Store Connect app ID",
|
||||
"PLACEHOLDER": "Enter your App Store Connect app ID",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"ISSUER_ID": {
|
||||
"LABEL": "Issuer ID",
|
||||
"PLACEHOLDER": "Enter your App Store Connect issuer ID",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"KEY_ID": {
|
||||
"LABEL": "Key ID",
|
||||
"PLACEHOLDER": "Enter your App Store Connect key ID",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"PRIVATE_KEY": {
|
||||
"LABEL": "Private key",
|
||||
"PLACEHOLDER": "Paste the contents of your .p8 private key",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create App Store Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the App Store channel"
|
||||
}
|
||||
},
|
||||
"EMAIL_CHANNEL": {
|
||||
"TITLE": "Email Channel",
|
||||
"DESC": "Integrate your email inbox.",
|
||||
@@ -493,6 +526,10 @@
|
||||
"TITLE": "TikTok",
|
||||
"DESCRIPTION": "Connect your TikTok account"
|
||||
},
|
||||
"APP_STORE": {
|
||||
"TITLE": "App Store Reviews",
|
||||
"DESCRIPTION": "Manage your App Store app reviews"
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice",
|
||||
"DESCRIPTION": "Integrate with Twilio Voice"
|
||||
@@ -1165,6 +1202,7 @@
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram",
|
||||
"TIKTOK": "TikTok",
|
||||
"APP_STORE": "App Store Reviews",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import Telegram from './channels/Telegram.vue';
|
||||
import Instagram from './channels/Instagram.vue';
|
||||
import Tiktok from './channels/Tiktok.vue';
|
||||
import Voice from './channels/Voice.vue';
|
||||
import AppStore from './channels/AppStore.vue';
|
||||
|
||||
const channelViewList = {
|
||||
facebook: Facebook,
|
||||
@@ -25,6 +26,7 @@ const channelViewList = {
|
||||
telegram: Telegram,
|
||||
instagram: Instagram,
|
||||
tiktok: Tiktok,
|
||||
app_store: AppStore,
|
||||
voice: Voice,
|
||||
};
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ const channelList = computed(() => {
|
||||
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
|
||||
icon: 'i-woot-instagram',
|
||||
},
|
||||
{
|
||||
key: 'app_store',
|
||||
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.TITLE'),
|
||||
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.DESCRIPTION'),
|
||||
icon: 'i-ri-app-store-line',
|
||||
},
|
||||
];
|
||||
|
||||
if (hasTiktokConfigured.value) {
|
||||
|
||||
@@ -249,6 +249,13 @@ export default {
|
||||
];
|
||||
}
|
||||
|
||||
if (this.isAnAppStoreChannel) {
|
||||
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
|
||||
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
|
||||
tab => !unsupportedKeys.includes(tab.key)
|
||||
);
|
||||
}
|
||||
|
||||
return visibleToAllChannelTabs;
|
||||
},
|
||||
currentInboxId() {
|
||||
@@ -282,6 +289,9 @@ export default {
|
||||
if (this.isAnEmailChannel) {
|
||||
return `${this.inbox.name} (${this.inbox.email})`;
|
||||
}
|
||||
if (this.isAnAppStoreChannel && this.inbox.app_id) {
|
||||
return `${this.inbox.name} (${this.inbox.app_id})`;
|
||||
}
|
||||
return this.inbox.name;
|
||||
},
|
||||
canLocktoSingleConversation() {
|
||||
@@ -293,6 +303,7 @@ export default {
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isAnAppStoreChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -829,6 +840,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="!isAnAppStoreChannel"
|
||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||
>
|
||||
@@ -1128,6 +1140,7 @@ export default {
|
||||
</SettingsAccordion>
|
||||
|
||||
<SettingsAccordion
|
||||
v-if="!isAnAppStoreChannel"
|
||||
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
|
||||
class="mt-6"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import router from '../../../../index';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
NextButton,
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
channelName: '',
|
||||
appId: '',
|
||||
issuerId: '',
|
||||
keyId: '',
|
||||
privateKey: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'inboxes/getUIFlags',
|
||||
}),
|
||||
},
|
||||
validations: {
|
||||
channelName: { required },
|
||||
appId: { required },
|
||||
issuerId: { required },
|
||||
keyId: { required },
|
||||
privateKey: { required },
|
||||
},
|
||||
methods: {
|
||||
async createChannel() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) return;
|
||||
|
||||
try {
|
||||
const appStoreChannel = await this.$store.dispatch(
|
||||
'inboxes/createChannel',
|
||||
{
|
||||
name: this.channelName?.trim(),
|
||||
channel: {
|
||||
type: 'app_store',
|
||||
app_id: this.appId.trim(),
|
||||
issuer_id: this.issuerId.trim(),
|
||||
key_id: this.keyId.trim(),
|
||||
private_key: this.privateKey.trim(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: appStoreChannel.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message || this.$t('INBOX_MGMT.ADD.APP_STORE.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.APP_STORE.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.APP_STORE.DESC')"
|
||||
/>
|
||||
<form
|
||||
class="flex flex-wrap flex-col mx-0"
|
||||
@submit.prevent="createChannel()"
|
||||
>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.channelName.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.LABEL') }}
|
||||
<input
|
||||
v-model="channelName"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.channelName.$touch"
|
||||
/>
|
||||
<span v-if="v$.channelName.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.appId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.LABEL') }}
|
||||
<input
|
||||
v-model="appId"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.APP_ID.PLACEHOLDER')"
|
||||
@blur="v$.appId.$touch"
|
||||
/>
|
||||
<span v-if="v$.appId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.issuerId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.LABEL') }}
|
||||
<input
|
||||
v-model="issuerId"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.PLACEHOLDER')"
|
||||
@blur="v$.issuerId.$touch"
|
||||
/>
|
||||
<span v-if="v$.issuerId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.keyId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.LABEL') }}
|
||||
<input
|
||||
v-model="keyId"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.PLACEHOLDER')"
|
||||
@blur="v$.keyId.$touch"
|
||||
/>
|
||||
<span v-if="v$.keyId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.privateKey.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.LABEL') }}
|
||||
<textarea
|
||||
v-model="privateKey"
|
||||
rows="8"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.privateKey.$touch"
|
||||
/>
|
||||
<span v-if="v$.privateKey.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
type="submit"
|
||||
solid
|
||||
blue
|
||||
:label="$t('INBOX_MGMT.ADD.APP_STORE.SUBMIT_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -34,6 +34,7 @@ const i18nMap = {
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Tiktok': 'TIKTOK',
|
||||
'Channel::AppStore': 'APP_STORE',
|
||||
};
|
||||
|
||||
const twilioChannelName = () => {
|
||||
|
||||
@@ -156,7 +156,8 @@ export const getters = {
|
||||
},
|
||||
dialogFlowEnabledInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||
item =>
|
||||
![INBOX_TYPES.EMAIL, INBOX_TYPES.APP_STORE].includes(item.channel_type)
|
||||
);
|
||||
},
|
||||
getFacebookInboxByInstagramId: $state => instagramId => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
@@ -23,6 +24,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
@@ -119,6 +121,8 @@ export default {
|
||||
badgeKey = 'whatsapp';
|
||||
} else if (this.isATiktokChannel) {
|
||||
badgeKey = 'tiktok';
|
||||
} else if (this.isAnAppStoreChannel) {
|
||||
badgeKey = 'app_store';
|
||||
}
|
||||
return badgeKey || this.channelType;
|
||||
},
|
||||
@@ -134,6 +138,9 @@ export default {
|
||||
isATiktokChannel() {
|
||||
return this.channelType === INBOX_TYPES.TIKTOK;
|
||||
},
|
||||
isAnAppStoreChannel() {
|
||||
return this.channelType === INBOX_TYPES.APP_STORE;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inboxHasFeature(feature) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform
|
||||
Inbox.where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox|
|
||||
next if inbox.account.suspended?
|
||||
next unless inbox.channel.sync_due?
|
||||
|
||||
::Inboxes::FetchAppStoreReviewsJob.perform_later(inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform(channel)
|
||||
channel.fetch_reviews.each do |review_payload|
|
||||
::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
channel.update!(last_synced_at: Time.current)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ class SendReplyJob < ApplicationJob
|
||||
'Channel::Sms' => ::Sms::SendOnSmsService,
|
||||
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
|
||||
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
|
||||
'Channel::AppStore' => ::AppStore::SendOnAppStoreService,
|
||||
'Channel::Email' => ::Email::SendOnEmailService,
|
||||
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
|
||||
'Channel::Api' => ::Messages::SendEmailNotificationService
|
||||
|
||||
@@ -61,6 +61,7 @@ class Account < ApplicationRecord
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
has_many :agent_bots, dependent: :destroy_async
|
||||
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
|
||||
has_many :app_store_channels, dependent: :destroy_async, class_name: '::Channel::AppStore'
|
||||
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :automation_rules, dependent: :destroy_async
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
class Channel::AppStore < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
self.table_name = 'channel_app_store'
|
||||
|
||||
EDITABLE_ATTRS = [:app_id, :bundle_id, :app_name, :issuer_id, :key_id, :private_key, { provider_config: {} }].freeze
|
||||
|
||||
API_BASE_URL = 'https://api.appstoreconnect.apple.com'.freeze
|
||||
REVIEWS_PAGE_SIZE = 200
|
||||
SYNC_INTERVAL = 1.hour
|
||||
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :issuer_id
|
||||
encrypts :key_id
|
||||
encrypts :private_key
|
||||
end
|
||||
|
||||
validates :app_id, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :issuer_id, :key_id, :private_key, presence: true
|
||||
validate :validate_app_access, on: :create
|
||||
|
||||
before_validation :normalize_auth_fields
|
||||
|
||||
after_create_commit :enqueue_initial_review_fetch
|
||||
|
||||
def name
|
||||
'App Store'
|
||||
end
|
||||
|
||||
def sync_due?
|
||||
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
|
||||
end
|
||||
|
||||
def fetch_reviews
|
||||
app_store_client.fetch_reviews
|
||||
end
|
||||
|
||||
def reply_to_review(review_id, response_body, response_id: nil)
|
||||
response = if response_id.present?
|
||||
app_store_client.update_review_response(response_id, response_body)
|
||||
else
|
||||
app_store_client.create_review_response(review_id, response_body)
|
||||
end
|
||||
|
||||
response['id']
|
||||
end
|
||||
|
||||
def app_store_client
|
||||
@app_store_client ||= AppStoreConnect::Client.new(channel: self)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_auth_fields
|
||||
self.app_id = app_id.to_s.strip
|
||||
self.issuer_id = issuer_id.to_s.strip
|
||||
self.key_id = key_id.to_s.strip
|
||||
self.private_key = private_key.to_s.gsub('\n', "\n").gsub("\r\n", "\n").strip
|
||||
end
|
||||
|
||||
def validate_app_access
|
||||
app = app_store_client.fetch_app
|
||||
self.app_name = app.dig('attributes', 'name') if app_name.blank?
|
||||
self.bundle_id = app.dig('attributes', 'bundleId') if bundle_id.blank?
|
||||
rescue StandardError => e
|
||||
errors.add(:base, e.message)
|
||||
end
|
||||
|
||||
def enqueue_initial_review_fetch
|
||||
::Inboxes::FetchAppStoreReviewsJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
@@ -134,6 +134,10 @@ class Inbox < ApplicationRecord
|
||||
channel_type == 'Channel::Tiktok'
|
||||
end
|
||||
|
||||
def app_store?
|
||||
channel_type == 'Channel::AppStore'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
class AppStore::ReviewBuilder
|
||||
pattr_initialize [:review_payload!, :channel!]
|
||||
|
||||
def perform
|
||||
return if review_id.blank? || review_body.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact_inbox
|
||||
build_conversation
|
||||
upsert_review_message
|
||||
build_response_message if response_body.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox
|
||||
@inbox ||= channel.inbox
|
||||
end
|
||||
|
||||
def review
|
||||
@review ||= review_payload['review'] || {}
|
||||
end
|
||||
|
||||
def response
|
||||
@response ||= review_payload['response'] || {}
|
||||
end
|
||||
|
||||
def attributes
|
||||
@attributes ||= review['attributes'] || {}
|
||||
end
|
||||
|
||||
def response_attributes
|
||||
@response_attributes ||= response['attributes'] || {}
|
||||
end
|
||||
|
||||
def review_id
|
||||
review['id']
|
||||
end
|
||||
|
||||
def review_body
|
||||
attributes['body'].to_s.strip
|
||||
end
|
||||
|
||||
def review_title
|
||||
attributes['title'].to_s.strip
|
||||
end
|
||||
|
||||
def rating
|
||||
attributes['rating'].to_i
|
||||
end
|
||||
|
||||
def created_at
|
||||
Time.zone.parse(attributes['createdDate'].to_s)
|
||||
rescue StandardError
|
||||
Time.current
|
||||
end
|
||||
|
||||
def response_id
|
||||
response['id']
|
||||
end
|
||||
|
||||
def response_body
|
||||
response_attributes['responseBody'].to_s.strip
|
||||
end
|
||||
|
||||
def response_created_at
|
||||
Time.zone.parse(response_attributes['lastModifiedDate'].to_s)
|
||||
rescue StandardError
|
||||
Time.current
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: review_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: attributes['reviewerNickname'].presence || 'App Store User',
|
||||
additional_attributes: { source_id: "app_store:#{review_id}" }
|
||||
}
|
||||
).perform
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { source: 'app_store', app_id: channel.app_id }
|
||||
)
|
||||
end
|
||||
|
||||
def upsert_review_message
|
||||
message = @conversation.messages.find_by(source_id: review_id)
|
||||
if message
|
||||
message.update!(content: message_content, content_attributes: review_metadata)
|
||||
return
|
||||
end
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @conversation.contact,
|
||||
message_type: :incoming,
|
||||
source_id: review_id,
|
||||
content: message_content,
|
||||
content_attributes: review_metadata,
|
||||
created_at: created_at,
|
||||
updated_at: created_at
|
||||
)
|
||||
end
|
||||
|
||||
def build_response_message
|
||||
return if response_id.blank?
|
||||
return if @conversation.messages.exists?(source_id: response_id)
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :outgoing,
|
||||
source_id: response_id,
|
||||
content: response_body,
|
||||
status: :delivered,
|
||||
content_attributes: response_metadata,
|
||||
created_at: response_created_at,
|
||||
updated_at: response_created_at
|
||||
)
|
||||
end
|
||||
|
||||
def message_content
|
||||
[
|
||||
rating_line,
|
||||
review_title.presence,
|
||||
review_body,
|
||||
review_footer
|
||||
].compact_blank.join("\n\n")
|
||||
end
|
||||
|
||||
def rating_line
|
||||
stars = ('★' * rating) + ('☆' * (5 - rating))
|
||||
"#{stars} (#{rating}/5)"
|
||||
end
|
||||
|
||||
def review_footer
|
||||
parts = [attributes['territory'], attributes['reviewerNickname']].compact_blank
|
||||
return nil if parts.empty?
|
||||
|
||||
parts.join(' • ')
|
||||
end
|
||||
|
||||
def review_metadata
|
||||
{
|
||||
app_store: {
|
||||
rating: rating,
|
||||
title: review_title,
|
||||
territory: attributes['territory'],
|
||||
reviewer_nickname: attributes['reviewerNickname'],
|
||||
created_date: attributes['createdDate']
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def response_metadata
|
||||
{
|
||||
app_store: {
|
||||
response_id: response_id,
|
||||
response_state: response_attributes['state'],
|
||||
response_last_modified_date: response_attributes['lastModifiedDate']
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
class AppStore::SendOnAppStoreService < Base::SendOnChannelService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::AppStore
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
validate_message_support!
|
||||
source_id = channel.reply_to_review(review_id, reply_content, response_id: existing_response_id)
|
||||
message.update!(source_id: source_id) if source_id.present?
|
||||
Messages::StatusUpdateService.new(message, 'delivered').perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||
end
|
||||
|
||||
def validate_message_support!
|
||||
raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any?
|
||||
end
|
||||
|
||||
def review_id
|
||||
message.conversation.contact_inbox.source_id
|
||||
end
|
||||
|
||||
def reply_content
|
||||
message.outgoing_content.presence || message.content
|
||||
end
|
||||
|
||||
def existing_response_id
|
||||
message.conversation.messages
|
||||
.outgoing
|
||||
.where.not(id: message.id)
|
||||
.where.not(source_id: [nil, ''])
|
||||
.order(created_at: :desc)
|
||||
.pick(:source_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
class AppStoreConnect::Client
|
||||
class Error < StandardError; end
|
||||
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def fetch_app
|
||||
get("/v1/apps/#{channel.app_id}")['data']
|
||||
end
|
||||
|
||||
def fetch_reviews
|
||||
reviews = []
|
||||
next_url = nil
|
||||
|
||||
loop do
|
||||
payload = next_url ? get_url(next_url) : get(reviews_path, reviews_query)
|
||||
included = Array(payload['included'])
|
||||
reviews.concat(Array(payload['data']).map { |review| normalize_review(review, included) })
|
||||
|
||||
next_url = payload.dig('links', 'next')
|
||||
break if next_url.blank?
|
||||
end
|
||||
|
||||
reviews
|
||||
end
|
||||
|
||||
def create_review_response(review_id, response_body)
|
||||
post('/v1/customerReviewResponses', review_response_payload(review_id, response_body))['data']
|
||||
end
|
||||
|
||||
def update_review_response(response_id, response_body)
|
||||
patch("/v1/customerReviewResponses/#{response_id}", review_response_update_payload(response_id, response_body))['data']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reviews_path
|
||||
"/v1/apps/#{channel.app_id}/customerReviews"
|
||||
end
|
||||
|
||||
def reviews_query
|
||||
{
|
||||
include: 'response',
|
||||
limit: Channel::AppStore::REVIEWS_PAGE_SIZE,
|
||||
sort: '-createdDate'
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_review(review, included)
|
||||
response_id = review.dig('relationships', 'response', 'data', 'id')
|
||||
response = included.find { |item| item['type'] == 'customerReviewResponses' && item['id'] == response_id }
|
||||
|
||||
{
|
||||
'review' => review,
|
||||
'response' => response
|
||||
}
|
||||
end
|
||||
|
||||
def get(path, query = {})
|
||||
request(:get, "#{Channel::AppStore::API_BASE_URL}#{path}", query: query)
|
||||
end
|
||||
|
||||
def get_url(url)
|
||||
request(:get, url)
|
||||
end
|
||||
|
||||
def post(path, body)
|
||||
request(:post, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body)
|
||||
end
|
||||
|
||||
def patch(path, body)
|
||||
request(:patch, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body)
|
||||
end
|
||||
|
||||
def request(method, url, query: {}, body: nil)
|
||||
response = HTTParty.public_send(
|
||||
method,
|
||||
url,
|
||||
headers: headers,
|
||||
query: query,
|
||||
body: body&.to_json
|
||||
)
|
||||
|
||||
log_rate_limit(response)
|
||||
return response.parsed_response if response.success?
|
||||
|
||||
raise Error, error_message(response)
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
'Authorization' => "Bearer #{token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def token
|
||||
@token ||= AppStoreConnect::TokenService.new(channel: channel).token
|
||||
end
|
||||
|
||||
def review_response_payload(review_id, response_body)
|
||||
{
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: response_body.to_s
|
||||
},
|
||||
relationships: {
|
||||
review: {
|
||||
data: {
|
||||
type: 'customerReviews',
|
||||
id: review_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def review_response_update_payload(response_id, response_body)
|
||||
{
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
id: response_id,
|
||||
attributes: {
|
||||
responseBody: response_body.to_s
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def log_rate_limit(response)
|
||||
rate_limit = response.headers['x-rate-limit']
|
||||
Rails.logger.info("[APP_STORE_CONNECT] rate_limit=#{rate_limit}") if rate_limit.present?
|
||||
end
|
||||
|
||||
def error_message(response)
|
||||
parsed_response = response.parsed_response
|
||||
errors = parsed_response.is_a?(Hash) ? parsed_response['errors'] : []
|
||||
details = Array(errors).filter_map { |error| error['detail'] || error['title'] }.join(', ')
|
||||
details = response.body if details.blank?
|
||||
"App Store Connect API failed (#{response.code}): #{details}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class AppStoreConnect::TokenService
|
||||
TOKEN_TTL = 19.minutes
|
||||
EXPIRY_BUFFER = 1.minute
|
||||
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def token
|
||||
cached_token || generate_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cached_token
|
||||
Rails.cache.read(cache_key)
|
||||
end
|
||||
|
||||
def generate_token
|
||||
token = JWT.encode(payload, private_key, 'ES256', headers)
|
||||
Rails.cache.write(cache_key, token, expires_in: TOKEN_TTL - EXPIRY_BUFFER)
|
||||
token
|
||||
end
|
||||
|
||||
def payload
|
||||
now = Time.current.to_i
|
||||
{
|
||||
iss: channel.issuer_id,
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL.to_i,
|
||||
aud: 'appstoreconnect-v1'
|
||||
}
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
kid: channel.key_id,
|
||||
typ: 'JWT'
|
||||
}
|
||||
end
|
||||
|
||||
def private_key
|
||||
OpenSSL::PKey.read(channel.private_key.to_s.gsub('\\n', "\n"))
|
||||
end
|
||||
|
||||
def cache_key
|
||||
"app_store_connect_token:#{channel.id}:#{channel.updated_at.to_i}"
|
||||
end
|
||||
end
|
||||
@@ -63,6 +63,14 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
||||
## Tiktok Attributes
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok?
|
||||
|
||||
## App Store Attributes
|
||||
if resource.app_store?
|
||||
json.app_id resource.channel.try(:app_id)
|
||||
json.bundle_id resource.channel.try(:bundle_id)
|
||||
json.app_name resource.channel.try(:app_name)
|
||||
json.last_synced_at resource.channel.try(:last_synced_at)
|
||||
end
|
||||
|
||||
## Twilio Attributes
|
||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||
json.phone_number resource.channel.try(:phone_number)
|
||||
|
||||
@@ -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.
|
||||
@@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job:
|
||||
class: 'Inboxes::FetchImapEmailInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed every 15 minutes to fetch App Store reviews
|
||||
trigger_app_store_review_inboxes_job:
|
||||
cron: '*/15 * * * *'
|
||||
class: 'Inboxes::FetchAppStoreReviewInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed daily at 2230 UTC
|
||||
# which is our lowest traffic time
|
||||
remove_stale_contact_inboxes_job.rb:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class CreateChannelAppStore < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :channel_app_store do |t|
|
||||
t.bigint :account_id, null: false
|
||||
t.string :app_id, null: false
|
||||
t.string :bundle_id
|
||||
t.string :app_name
|
||||
t.string :issuer_id, null: false
|
||||
t.string :key_id, null: false
|
||||
t.text :private_key, null: false
|
||||
t.jsonb :provider_config, null: false, default: {}
|
||||
t.datetime :last_synced_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :channel_app_store, [:account_id, :app_id], unique: true
|
||||
end
|
||||
end
|
||||
+16
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_20_090000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -449,6 +449,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
|
||||
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_app_store", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "app_id", null: false
|
||||
t.string "bundle_id"
|
||||
t.string "app_name"
|
||||
t.string "issuer_id", null: false
|
||||
t.string "key_id", null: false
|
||||
t.text "private_key", null: false
|
||||
t.jsonb "provider_config", default: {}, null: false
|
||||
t.datetime "last_synced_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "app_id"], name: "index_channel_app_store_on_account_id_and_app_id", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_email", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.string "email", null: false
|
||||
|
||||
Reference in New Issue
Block a user