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)
|
||||
|
||||
@@ -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