Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a26e9714c3 | ||
|
|
7f793ccda8 | ||
|
|
08a520070b | ||
|
|
c5b58e2aed | ||
|
|
17a0d05853 | ||
|
|
13a1afc0d1 | ||
|
|
b93cb5b654 | ||
|
|
48a0fa1d67 | ||
|
|
ccd2944df9 | ||
|
|
594bfa0bdf | ||
|
|
5a4416612c | ||
|
|
5c204e6647 | ||
|
|
65ef019248 | ||
|
|
1c420cb7f7 | ||
|
|
2fadbf81bc | ||
|
|
14e9e9f63d | ||
|
|
01a8812de5 | ||
|
|
dfb604d98c | ||
|
|
4e9abd6f08 | ||
|
|
0cdcf30c39 | ||
|
|
a398de21f4 | ||
|
|
18a5915872 | ||
|
|
97af2c4564 | ||
|
|
7adb0dcbae | ||
|
|
9b346e4c29 | ||
|
|
8595a46642 | ||
|
|
74339ff981 | ||
|
|
962b6902e7 | ||
|
|
fd5de4398d | ||
|
|
df538a6a3b | ||
|
|
361b28cb03 | ||
|
|
15227a424a | ||
|
|
1e36126a00 | ||
|
|
eb9647d6c6 | ||
|
|
b3fa179b7f | ||
|
|
da0a122919 | ||
|
|
eb30c8dea4 | ||
|
|
2a624ac800 | ||
|
|
379daeef18 | ||
|
|
127a544a89 | ||
|
|
572d6d2e40 | ||
|
|
609e27a4c4 |
@@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def allowed_channel_types
|
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
|
end
|
||||||
|
|
||||||
def update_inbox_working_hours
|
def update_inbox_working_hours
|
||||||
@@ -179,7 +179,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
'line' => Channel::Line,
|
'line' => Channel::Line,
|
||||||
'telegram' => Channel::Telegram,
|
'telegram' => Channel::Telegram,
|
||||||
'whatsapp' => Channel::Whatsapp,
|
'whatsapp' => Channel::Whatsapp,
|
||||||
'sms' => Channel::Sms
|
'sms' => Channel::Sms,
|
||||||
|
'app_store' => Channel::AppStore
|
||||||
}[permitted_params[:channel][:type]]
|
}[permitted_params[:channel][:type]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ module Api::V1::InboxesHelper
|
|||||||
'line' => Current.account.line_channels,
|
'line' => Current.account.line_channels,
|
||||||
'telegram' => Current.account.telegram_channels,
|
'telegram' => Current.account.telegram_channels,
|
||||||
'whatsapp' => Current.account.whatsapp_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]]
|
}[permitted_params[:channel][:type]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ sms:
|
|||||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-message-line'
|
icon: 'icon-message-line'
|
||||||
|
app_store:
|
||||||
|
name: 'App Store Reviews'
|
||||||
|
description: 'Manage your App Store app reviews from Chatwoot.'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-apps-2-line'
|
||||||
messenger:
|
messenger:
|
||||||
name: 'Messenger'
|
name: 'Messenger'
|
||||||
description: 'Stay connected with your customers on Facebook & Instagram.'
|
description: 'Stay connected with your customers on Facebook & Instagram.'
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const channelTypeIconMap = {
|
|||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
'Channel::Tiktok': 'i-woot-tiktok',
|
'Channel::Tiktok': 'i-woot-tiktok',
|
||||||
|
'Channel::AppStore': 'i-ri-app-store-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const {
|
|||||||
isAWhatsAppChannel,
|
isAWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
|
isAnAppStoreChannel,
|
||||||
isATiktokChannel,
|
isATiktokChannel,
|
||||||
} = useInbox();
|
} = useInbox();
|
||||||
|
|
||||||
@@ -62,7 +63,8 @@ const isSent = computed(() => {
|
|||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isATelegramChannel.value ||
|
isATelegramChannel.value ||
|
||||||
isAnInstagramChannel.value ||
|
isAnInstagramChannel.value ||
|
||||||
isATiktokChannel.value
|
isATiktokChannel.value ||
|
||||||
|
isAnAppStoreChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||||
}
|
}
|
||||||
@@ -85,7 +87,8 @@ const isDelivered = computed(() => {
|
|||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isAnInstagramChannel.value ||
|
isAnInstagramChannel.value ||
|
||||||
isATiktokChannel.value
|
isATiktokChannel.value ||
|
||||||
|
isAnAppStoreChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||||
}
|
}
|
||||||
@@ -110,7 +113,8 @@ const isRead = computed(() => {
|
|||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isAnInstagramChannel.value ||
|
isAnInstagramChannel.value ||
|
||||||
isATiktokChannel.value
|
isATiktokChannel.value ||
|
||||||
|
isAnAppStoreChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ const isActive = computed(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'app_store') {
|
||||||
|
return props.enabledFeatures.channel_app_store;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'website',
|
'website',
|
||||||
'twilio',
|
'twilio',
|
||||||
@@ -86,7 +90,9 @@ const isComingSoon = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isBeta = computed(() => {
|
const isBeta = computed(() => {
|
||||||
return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key);
|
return ['tiktok', 'voice', 'whatsapp_call', 'app_store'].includes(
|
||||||
|
props.channel.key
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasVoiceBadge = computed(() => {
|
const hasVoiceBadge = computed(() => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -244,6 +245,29 @@ export default {
|
|||||||
|
|
||||||
return { incoming, outgoing };
|
return { incoming, outgoing };
|
||||||
},
|
},
|
||||||
|
hasSubmittedAppStoreResponse() {
|
||||||
|
const messages = this.currentChat.messages || [];
|
||||||
|
return messages.some(message => {
|
||||||
|
const appStoreAttributes =
|
||||||
|
message.content_attributes?.app_store ||
|
||||||
|
message.contentAttributes?.appStore ||
|
||||||
|
{};
|
||||||
|
const hasAppStoreResponse =
|
||||||
|
message.source_id || appStoreAttributes.response_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
message.message_type === MESSAGE_TYPE.OUTGOING && hasAppStoreResponse
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
appStoreReplyBannerMessage() {
|
||||||
|
return this.hasSubmittedAppStoreResponse
|
||||||
|
? this.$t('CONVERSATION.REPLYBOX.APP_STORE_RESPONSE_UPDATE')
|
||||||
|
: this.$t('CONVERSATION.REPLYBOX.APP_STORE_RESPONSE_DELAY');
|
||||||
|
},
|
||||||
|
showAppStoreReplyBanner() {
|
||||||
|
return this.isAnAppStoreChannel;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
@@ -468,6 +492,12 @@ export default {
|
|||||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||||
/>
|
/>
|
||||||
|
<Banner
|
||||||
|
v-if="showAppStoreReplyBanner"
|
||||||
|
color-scheme="warning"
|
||||||
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
|
:banner-message="appStoreReplyBannerMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<MessageList
|
<MessageList
|
||||||
ref="conversationPanelRef"
|
ref="conversationPanelRef"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
INBOX_TYPES.TIKTOK,
|
INBOX_TYPES.TIKTOK,
|
||||||
|
INBOX_TYPES.APP_STORE,
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||||
@@ -30,6 +31,7 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
INBOX_TYPES.TIKTOK,
|
INBOX_TYPES.TIKTOK,
|
||||||
|
INBOX_TYPES.APP_STORE,
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -138,6 +140,10 @@ export const useInbox = (inboxId = null) => {
|
|||||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAnAppStoreChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.APP_STORE;
|
||||||
|
});
|
||||||
|
|
||||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||||
|
|
||||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||||
@@ -160,6 +166,7 @@ export const useInbox = (inboxId = null) => {
|
|||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
isATiktokChannel,
|
isATiktokChannel,
|
||||||
|
isAnAppStoreChannel,
|
||||||
voiceCallEnabled,
|
voiceCallEnabled,
|
||||||
voiceCallProvider,
|
voiceCallProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ export const FORMATTING = {
|
|||||||
nodes: [],
|
nodes: [],
|
||||||
menu: [],
|
menu: [],
|
||||||
},
|
},
|
||||||
|
'Channel::AppStore': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
// Special contexts (not actual channels)
|
// Special contexts (not actual channels)
|
||||||
'Context::PrivateNote': {
|
'Context::PrivateNote': {
|
||||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export default {
|
|||||||
DOCS_URL: 'https://www.chatwoot.com/docs/product/',
|
DOCS_URL: 'https://www.chatwoot.com/docs/product/',
|
||||||
HELP_CENTER_DOCS_URL:
|
HELP_CENTER_DOCS_URL:
|
||||||
'https://www.chatwoot.com/docs/product/others/help-center',
|
'https://www.chatwoot.com/docs/product/others/help-center',
|
||||||
|
APP_STORE_REVIEWS_INBOX_DOCS_URL:
|
||||||
|
'https://www.chatwoot.com/hc/user-guide/articles/app-store-reviews-inbox',
|
||||||
TESTIMONIAL_URL:
|
TESTIMONIAL_URL:
|
||||||
'https://testimonials.cdn.chatwoot.com/testimonial-content.json',
|
'https://testimonials.cdn.chatwoot.com/testimonial-content.json',
|
||||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const INBOX_TYPES = {
|
|||||||
SMS: 'Channel::Sms',
|
SMS: 'Channel::Sms',
|
||||||
INSTAGRAM: 'Channel::Instagram',
|
INSTAGRAM: 'Channel::Instagram',
|
||||||
TIKTOK: 'Channel::Tiktok',
|
TIKTOK: 'Channel::Tiktok',
|
||||||
|
APP_STORE: 'Channel::AppStore',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||||
@@ -53,6 +54,7 @@ const INBOX_ICON_MAP_FILL = {
|
|||||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-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';
|
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||||
@@ -68,6 +70,7 @@ const INBOX_ICON_MAP_LINE = {
|
|||||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||||
|
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-line',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
||||||
@@ -117,6 +120,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
|||||||
case INBOX_TYPES.LINE:
|
case INBOX_TYPES.LINE:
|
||||||
return 'line';
|
return 'line';
|
||||||
|
|
||||||
|
case INBOX_TYPES.APP_STORE:
|
||||||
|
return 'app_store';
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'chat';
|
return 'chat';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,8 @@
|
|||||||
"START_AUDIO_RECORDING": "Start audio recording",
|
"START_AUDIO_RECORDING": "Start audio recording",
|
||||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||||
"COPILOT_THINKING": "Copilot is thinking",
|
"COPILOT_THINKING": "Copilot is thinking",
|
||||||
|
"APP_STORE_RESPONSE_DELAY": "Apple may take up to 24 hours to make submitted replies or updates public.",
|
||||||
|
"APP_STORE_RESPONSE_UPDATE": "This review already has a reply. Apple may take up to 24 hours to make submitted replies or updates public. Sending another reply updates the existing public App Store response.",
|
||||||
"EMAIL_HEAD": {
|
"EMAIL_HEAD": {
|
||||||
"TO": "TO",
|
"TO": "TO",
|
||||||
"ADD_BCC": "Add bcc",
|
"ADD_BCC": "Add bcc",
|
||||||
|
|||||||
@@ -384,6 +384,40 @@
|
|||||||
"ERROR_MESSAGE": "We were not able to save the api channel"
|
"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.",
|
||||||
|
"LEARN_MORE": "Learn how to create App Store Connect credentials",
|
||||||
|
"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": {
|
"EMAIL_CHANNEL": {
|
||||||
"TITLE": "Email Channel",
|
"TITLE": "Email Channel",
|
||||||
"DESC": "Integrate your email inbox.",
|
"DESC": "Integrate your email inbox.",
|
||||||
@@ -499,6 +533,10 @@
|
|||||||
"TITLE": "TikTok",
|
"TITLE": "TikTok",
|
||||||
"DESCRIPTION": "Connect your TikTok account"
|
"DESCRIPTION": "Connect your TikTok account"
|
||||||
},
|
},
|
||||||
|
"APP_STORE": {
|
||||||
|
"TITLE": "App Store Reviews",
|
||||||
|
"DESCRIPTION": "Manage your App Store app reviews"
|
||||||
|
},
|
||||||
"VOICE": {
|
"VOICE": {
|
||||||
"TITLE": "Voice",
|
"TITLE": "Voice",
|
||||||
"DESCRIPTION": "Integrate with Twilio Voice"
|
"DESCRIPTION": "Integrate with Twilio Voice"
|
||||||
@@ -1200,6 +1238,7 @@
|
|||||||
"API": "API Channel",
|
"API": "API Channel",
|
||||||
"INSTAGRAM": "Instagram",
|
"INSTAGRAM": "Instagram",
|
||||||
"TIKTOK": "TikTok",
|
"TIKTOK": "TikTok",
|
||||||
|
"APP_STORE": "App Store Reviews",
|
||||||
"VOICE": "Voice"
|
"VOICE": "Voice"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Telegram from './channels/Telegram.vue';
|
|||||||
import Instagram from './channels/Instagram.vue';
|
import Instagram from './channels/Instagram.vue';
|
||||||
import Tiktok from './channels/Tiktok.vue';
|
import Tiktok from './channels/Tiktok.vue';
|
||||||
import Voice from './channels/Voice.vue';
|
import Voice from './channels/Voice.vue';
|
||||||
|
import AppStore from './channels/AppStore.vue';
|
||||||
|
|
||||||
const channelViewList = {
|
const channelViewList = {
|
||||||
facebook: Facebook,
|
facebook: Facebook,
|
||||||
@@ -27,6 +28,7 @@ const channelViewList = {
|
|||||||
telegram: Telegram,
|
telegram: Telegram,
|
||||||
instagram: Instagram,
|
instagram: Instagram,
|
||||||
tiktok: Tiktok,
|
tiktok: Tiktok,
|
||||||
|
app_store: AppStore,
|
||||||
voice: Voice,
|
voice: Voice,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ const channelList = computed(() => {
|
|||||||
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
|
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
|
||||||
icon: 'i-woot-instagram',
|
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) {
|
if (hasTiktokConfigured.value) {
|
||||||
|
|||||||
@@ -267,6 +267,13 @@ export default {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isAnAppStoreChannel) {
|
||||||
|
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
|
||||||
|
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
|
||||||
|
tab => !unsupportedKeys.includes(tab.key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return visibleToAllChannelTabs;
|
return visibleToAllChannelTabs;
|
||||||
},
|
},
|
||||||
currentInboxId() {
|
currentInboxId() {
|
||||||
@@ -300,6 +307,9 @@ export default {
|
|||||||
if (this.isAnEmailChannel) {
|
if (this.isAnEmailChannel) {
|
||||||
return `${this.inbox.name} (${this.inbox.email})`;
|
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;
|
return this.inbox.name;
|
||||||
},
|
},
|
||||||
canLocktoSingleConversation() {
|
canLocktoSingleConversation() {
|
||||||
@@ -311,6 +321,7 @@ export default {
|
|||||||
this.isAnInstagramChannel ||
|
this.isAnInstagramChannel ||
|
||||||
this.isALineChannel ||
|
this.isALineChannel ||
|
||||||
this.isATiktokChannel ||
|
this.isATiktokChannel ||
|
||||||
|
this.isAnAppStoreChannel ||
|
||||||
this.isATelegramChannel
|
this.isATelegramChannel
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -847,6 +858,7 @@ export default {
|
|||||||
</SettingsFieldSection>
|
</SettingsFieldSection>
|
||||||
|
|
||||||
<SettingsFieldSection
|
<SettingsFieldSection
|
||||||
|
v-if="!isAnAppStoreChannel"
|
||||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||||
>
|
>
|
||||||
@@ -1146,6 +1158,7 @@ export default {
|
|||||||
</SettingsAccordion>
|
</SettingsAccordion>
|
||||||
|
|
||||||
<SettingsAccordion
|
<SettingsAccordion
|
||||||
|
v-if="!isAnAppStoreChannel"
|
||||||
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
|
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
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';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import globalConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const channelName = ref('');
|
||||||
|
const appId = ref('');
|
||||||
|
const issuerId = ref('');
|
||||||
|
const keyId = ref('');
|
||||||
|
const privateKey = ref('');
|
||||||
|
const appStoreReviewsInboxDocsUrl =
|
||||||
|
globalConstants.APP_STORE_REVIEWS_INBOX_DOCS_URL;
|
||||||
|
|
||||||
|
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
channelName: { required },
|
||||||
|
appId: { required },
|
||||||
|
issuerId: { required },
|
||||||
|
keyId: { required },
|
||||||
|
privateKey: { required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(rules, {
|
||||||
|
channelName,
|
||||||
|
appId,
|
||||||
|
issuerId,
|
||||||
|
keyId,
|
||||||
|
privateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createChannel = async () => {
|
||||||
|
v$.value.$touch();
|
||||||
|
if (v$.value.$invalid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appStoreChannel = await store.dispatch('inboxes/createChannel', {
|
||||||
|
name: channelName.value?.trim(),
|
||||||
|
channel: {
|
||||||
|
type: 'app_store',
|
||||||
|
app_id: appId.value.trim(),
|
||||||
|
issuer_id: issuerId.value.trim(),
|
||||||
|
key_id: keyId.value.trim(),
|
||||||
|
private_key: privateKey.value.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
name: 'settings_inboxes_add_agents',
|
||||||
|
params: {
|
||||||
|
page: 'new',
|
||||||
|
inbox_id: appStoreChannel.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(error.message || 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')"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
:href="appStoreReviewsInboxDocsUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-1 mb-5 text-sm font-medium text-n-brand"
|
||||||
|
>
|
||||||
|
{{ $t('INBOX_MGMT.ADD.APP_STORE.LEARN_MORE') }}
|
||||||
|
<Icon icon="i-lucide-external-link" class="size-4" />
|
||||||
|
</a>
|
||||||
|
<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::Api': 'API',
|
||||||
'Channel::Instagram': 'INSTAGRAM',
|
'Channel::Instagram': 'INSTAGRAM',
|
||||||
'Channel::Tiktok': 'TIKTOK',
|
'Channel::Tiktok': 'TIKTOK',
|
||||||
|
'Channel::AppStore': 'APP_STORE',
|
||||||
};
|
};
|
||||||
|
|
||||||
const twilioChannelName = () => {
|
const twilioChannelName = () => {
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ export const getters = {
|
|||||||
},
|
},
|
||||||
dialogFlowEnabledInboxes($state) {
|
dialogFlowEnabledInboxes($state) {
|
||||||
return $state.records.filter(
|
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 => {
|
getFacebookInboxByInstagramId: $state => instagramId => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
INBOX_TYPES.TIKTOK,
|
INBOX_TYPES.TIKTOK,
|
||||||
|
INBOX_TYPES.APP_STORE,
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||||
@@ -23,6 +24,7 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
INBOX_TYPES.TIKTOK,
|
INBOX_TYPES.TIKTOK,
|
||||||
|
INBOX_TYPES.APP_STORE,
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -119,6 +121,8 @@ export default {
|
|||||||
badgeKey = 'whatsapp';
|
badgeKey = 'whatsapp';
|
||||||
} else if (this.isATiktokChannel) {
|
} else if (this.isATiktokChannel) {
|
||||||
badgeKey = 'tiktok';
|
badgeKey = 'tiktok';
|
||||||
|
} else if (this.isAnAppStoreChannel) {
|
||||||
|
badgeKey = 'app_store';
|
||||||
}
|
}
|
||||||
return badgeKey || this.channelType;
|
return badgeKey || this.channelType;
|
||||||
},
|
},
|
||||||
@@ -134,6 +138,9 @@ export default {
|
|||||||
isATiktokChannel() {
|
isATiktokChannel() {
|
||||||
return this.channelType === INBOX_TYPES.TIKTOK;
|
return this.channelType === INBOX_TYPES.TIKTOK;
|
||||||
},
|
},
|
||||||
|
isAnAppStoreChannel() {
|
||||||
|
return this.channelType === INBOX_TYPES.APP_STORE;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
inboxHasFeature(feature) {
|
inboxHasFeature(feature) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob
|
||||||
|
queue_as :scheduled_jobs
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Inbox.includes(:account, :channel).where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox|
|
||||||
|
account = inbox.account
|
||||||
|
channel = inbox.channel
|
||||||
|
|
||||||
|
next if account.suspended?
|
||||||
|
next unless account.feature_enabled?(:channel_app_store)
|
||||||
|
next unless channel.sync_due?
|
||||||
|
|
||||||
|
::Inboxes::FetchAppStoreReviewsJob.perform_later(channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
|
||||||
|
queue_as :scheduled_jobs
|
||||||
|
|
||||||
|
def perform(channel)
|
||||||
|
return unless channel.account.feature_enabled?(:channel_app_store)
|
||||||
|
|
||||||
|
synced_until = sync_reviews(channel)
|
||||||
|
return if synced_until.blank?
|
||||||
|
|
||||||
|
channel.update!(last_synced_at: synced_until)
|
||||||
|
rescue StandardError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def sync_reviews(channel)
|
||||||
|
failed = false
|
||||||
|
synced_dates = []
|
||||||
|
|
||||||
|
channel.fetch_reviews.each do |review_payload|
|
||||||
|
::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform
|
||||||
|
synced_dates << payload_synced_at(review_payload)
|
||||||
|
rescue StandardError => e
|
||||||
|
failed = true
|
||||||
|
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||||
|
end
|
||||||
|
|
||||||
|
return if failed
|
||||||
|
|
||||||
|
[channel.last_synced_at, synced_dates.compact.max].compact.max
|
||||||
|
end
|
||||||
|
|
||||||
|
def payload_synced_at(review_payload)
|
||||||
|
[
|
||||||
|
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
|
||||||
|
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
|
||||||
|
].compact.max
|
||||||
|
end
|
||||||
|
|
||||||
|
def parsed_timestamp(value)
|
||||||
|
Time.zone.parse(value.to_s)
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,6 +10,7 @@ class SendReplyJob < ApplicationJob
|
|||||||
'Channel::Sms' => ::Sms::SendOnSmsService,
|
'Channel::Sms' => ::Sms::SendOnSmsService,
|
||||||
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
|
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
|
||||||
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
|
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
|
||||||
|
'Channel::AppStore' => ::AppStore::SendOnAppStoreService,
|
||||||
'Channel::Email' => ::Email::SendOnEmailService,
|
'Channel::Email' => ::Email::SendOnEmailService,
|
||||||
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
|
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
|
||||||
'Channel::Api' => ::Messages::SendEmailNotificationService
|
'Channel::Api' => ::Messages::SendEmailNotificationService
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class Account < ApplicationRecord
|
|||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
has_many :agent_bots, dependent: :destroy_async
|
has_many :agent_bots, dependent: :destroy_async
|
||||||
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
|
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 :articles, dependent: :destroy_async, class_name: '::Article'
|
||||||
has_many :assignment_policies, dependent: :destroy_async
|
has_many :assignment_policies, dependent: :destroy_async
|
||||||
has_many :automation_rules, dependent: :destroy_async
|
has_many :automation_rules, dependent: :destroy_async
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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(since: last_synced_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply_to_review(review_id, response_body)
|
||||||
|
response = app_store_client.create_or_update_review_response(review_id, response_body)
|
||||||
|
|
||||||
|
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'
|
channel_type == 'Channel::Tiktok'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def app_store?
|
||||||
|
channel_type == 'Channel::AppStore'
|
||||||
|
end
|
||||||
|
|
||||||
def web_widget?
|
def web_widget?
|
||||||
channel_type == 'Channel::WebWidget'
|
channel_type == 'Channel::WebWidget'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
class AppStore::ReviewBuilder
|
||||||
|
pattr_initialize [:review_payload!, :channel!]
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return if review_id.blank?
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
build_contact_inbox
|
||||||
|
@contact_inbox.with_lock do
|
||||||
|
build_conversation
|
||||||
|
@conversation.with_lock do
|
||||||
|
upsert_review_message
|
||||||
|
build_response_message if response_body.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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.clamp(0, 5)
|
||||||
|
end
|
||||||
|
|
||||||
|
def created_at
|
||||||
|
Time.zone.parse(attributes['createdDate'].to_s).presence || Time.current
|
||||||
|
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).presence || Time.current
|
||||||
|
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?
|
||||||
|
|
||||||
|
message = @conversation.messages.find_by(source_id: response_id)
|
||||||
|
if message
|
||||||
|
message.update!(content: response_body, content_attributes: response_metadata, updated_at: response_created_at)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@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
|
||||||
|
{
|
||||||
|
external_echo: true,
|
||||||
|
app_store: {
|
||||||
|
response_id: response_id,
|
||||||
|
response_state: response_attributes['state'],
|
||||||
|
response_last_modified_date: response_attributes['lastModifiedDate']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
class AppStore::SendOnAppStoreService < Base::SendOnChannelService
|
||||||
|
private
|
||||||
|
|
||||||
|
def channel_class
|
||||||
|
Channel::AppStore
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_reply
|
||||||
|
validate_feature_enabled!
|
||||||
|
validate_message_support!
|
||||||
|
source_id = channel.reply_to_review(review_id, reply_content)
|
||||||
|
sync_response_message(source_id)
|
||||||
|
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 'Only outgoing text messages are supported for App Store reviews.' unless message.outgoing? && message.text?
|
||||||
|
raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_feature_enabled!
|
||||||
|
return if message.account.feature_enabled?(:channel_app_store)
|
||||||
|
|
||||||
|
raise 'App Store Reviews channel is not enabled for this account.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def review_id
|
||||||
|
message.conversation.contact_inbox.source_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply_content
|
||||||
|
message.outgoing_content.presence || message.content
|
||||||
|
end
|
||||||
|
|
||||||
|
def existing_response_message(source_id)
|
||||||
|
return if source_id.blank?
|
||||||
|
|
||||||
|
message.conversation.messages.where.not(id: message.id).find_by(source_id: source_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_response_message(source_id)
|
||||||
|
response_message = existing_response_message(source_id)
|
||||||
|
|
||||||
|
if response_message.present?
|
||||||
|
update_existing_response_message(response_message, source_id)
|
||||||
|
message.destroy!
|
||||||
|
Messages::StatusUpdateService.new(response_message, 'delivered').perform
|
||||||
|
else
|
||||||
|
message.update!(source_id: source_id) if source_id.present?
|
||||||
|
Messages::StatusUpdateService.new(message, 'delivered').perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_existing_response_message(response_message, source_id)
|
||||||
|
content_attributes = (response_message.content_attributes || {}).deep_merge(
|
||||||
|
'external_echo' => true,
|
||||||
|
'app_store' => {
|
||||||
|
'response_id' => source_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response_message.update!(content: reply_content, content_attributes: content_attributes)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
class AppStoreConnect::Client
|
||||||
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
pattr_initialize [:channel!]
|
||||||
|
|
||||||
|
def fetch_app
|
||||||
|
get("/v1/apps/#{channel.app_id}")['data']
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_reviews(since: nil)
|
||||||
|
reviews = []
|
||||||
|
next_url = nil
|
||||||
|
|
||||||
|
loop do
|
||||||
|
payload = next_url ? get_url(next_url) : get(reviews_path, reviews_query)
|
||||||
|
included = Array(payload['included'])
|
||||||
|
review_payloads = Array(payload['data']).map { |review| normalize_review(review, included) }
|
||||||
|
fresh_payloads = fresh_review_payloads(review_payloads, since)
|
||||||
|
reviews.concat(fresh_payloads)
|
||||||
|
|
||||||
|
next_url = payload.dig('links', 'next')
|
||||||
|
break if next_url.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
reviews
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_review_response(review_id, response_body)
|
||||||
|
create_or_update_review_response(review_id, response_body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_or_update_review_response(review_id, response_body)
|
||||||
|
post('/v1/customerReviewResponses', review_response_payload(review_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 fresh_review_payloads(review_payloads, since)
|
||||||
|
return review_payloads if since.blank?
|
||||||
|
|
||||||
|
review_payloads.select { |review_payload| review_updated_after?(review_payload, since) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def review_updated_after?(review_payload, since)
|
||||||
|
review_updated_at = [
|
||||||
|
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
|
||||||
|
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
|
||||||
|
].compact.max
|
||||||
|
|
||||||
|
return true if review_updated_at.blank?
|
||||||
|
|
||||||
|
review_updated_at >= since
|
||||||
|
end
|
||||||
|
|
||||||
|
def parsed_timestamp(value)
|
||||||
|
Time.zone.parse(value.to_s)
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
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 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
|
||||||
|
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 log_rate_limit(response)
|
||||||
|
rate_limit = response.headers['x-rate-limit']
|
||||||
|
Rails.logger.debug { "[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,50 @@
|
|||||||
|
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
|
||||||
|
identifier = channel.id || channel.object_id
|
||||||
|
version = channel.updated_at&.to_i || 'new'
|
||||||
|
|
||||||
|
"app_store_connect_token:#{identifier}:#{version}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -21,8 +21,7 @@ class MessageTemplates::HookExecutionService
|
|||||||
|
|
||||||
def should_send_out_of_office_message?
|
def should_send_out_of_office_message?
|
||||||
return false if conversation.campaign.present?
|
return false if conversation.campaign.present?
|
||||||
# should not send if its a tweet message
|
return false if review_inbox_or_tweet_conversation?
|
||||||
return false if conversation.tweet?
|
|
||||||
# should not send for outbound messages
|
# should not send for outbound messages
|
||||||
return false unless message.incoming?
|
return false unless message.incoming?
|
||||||
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
||||||
@@ -38,12 +37,15 @@ class MessageTemplates::HookExecutionService
|
|||||||
|
|
||||||
def should_send_greeting?
|
def should_send_greeting?
|
||||||
return false if conversation.campaign.present?
|
return false if conversation.campaign.present?
|
||||||
# should not send if its a tweet message
|
return false if review_inbox_or_tweet_conversation?
|
||||||
return false if conversation.tweet?
|
|
||||||
|
|
||||||
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
|
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def review_inbox_or_tweet_conversation?
|
||||||
|
conversation.tweet? || inbox.app_store?
|
||||||
|
end
|
||||||
|
|
||||||
def email_collect_was_sent?
|
def email_collect_was_sent?
|
||||||
conversation.messages.where(content_type: 'input_email').present?
|
conversation.messages.where(content_type: 'input_email').present?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
|||||||
## Tiktok Attributes
|
## Tiktok Attributes
|
||||||
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok?
|
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
|
## Twilio Attributes
|
||||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||||
json.phone_number resource.channel.try(:phone_number)
|
json.phone_number resource.channel.try(:phone_number)
|
||||||
|
|||||||
+3
-4
@@ -108,10 +108,9 @@
|
|||||||
display_name: Custom Tools
|
display_name: Custom Tools
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
premium: true
|
||||||
- name: message_reply_to
|
- name: channel_app_store
|
||||||
display_name: Message Reply To
|
display_name: App Store Reviews Channel
|
||||||
enabled: false
|
enabled: true
|
||||||
deprecated: true
|
|
||||||
- name: insert_article_in_reply
|
- name: insert_article_in_reply
|
||||||
display_name: Insert Article in Reply
|
display_name: Insert Article in Reply
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job:
|
|||||||
class: 'Inboxes::FetchImapEmailInboxesJob'
|
class: 'Inboxes::FetchImapEmailInboxesJob'
|
||||||
queue: scheduled_jobs
|
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
|
# executed daily at 2230 UTC
|
||||||
# which is our lowest traffic time
|
# which is our lowest traffic time
|
||||||
remove_stale_contact_inboxes_job.rb:
|
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
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class RepurposeMessageReplyToFlagForChannelAppStore < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
# The message_reply_to flag (deprecated) has been renamed to channel_app_store.
|
||||||
|
# Disable it on any accounts that had message_reply_to enabled so the repurposed
|
||||||
|
# flag starts from plan/default configuration instead of inheriting stale state.
|
||||||
|
Account.feature_channel_app_store.find_each(batch_size: 100) do |account|
|
||||||
|
account.disable_features(:channel_app_store)
|
||||||
|
account.save!(validate: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove the stale message_reply_to entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
|
||||||
|
# ConfigLoader only adds new flags; it never removes renamed ones.
|
||||||
|
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||||
|
return if config&.value.blank?
|
||||||
|
|
||||||
|
config.value = config.value.reject { |feature| feature['name'] == 'message_reply_to' }
|
||||||
|
config.save!
|
||||||
|
GlobalConfig.clear_cache
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -450,6 +450,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
|
|||||||
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
|
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
|
||||||
end
|
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|
|
create_table "channel_email", force: :cascade do |t|
|
||||||
t.integer "account_id", null: false
|
t.integer "account_id", null: false
|
||||||
t.string "email", null: false
|
t.string "email", null: false
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
|||||||
channel_facebook
|
channel_facebook
|
||||||
channel_email
|
channel_email
|
||||||
channel_instagram
|
channel_instagram
|
||||||
|
channel_app_store
|
||||||
channel_tiktok
|
channel_tiktok
|
||||||
captain_integration
|
captain_integration
|
||||||
advanced_search_indexing
|
advanced_search_indexing
|
||||||
|
|||||||
@@ -463,6 +463,32 @@ RSpec.describe 'Inboxes API', type: :request do
|
|||||||
expect(response.body).to include('+123456789')
|
expect(response.body).to include('+123456789')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates an app store inbox when the feature is enabled' do
|
||||||
|
app_store_client = instance_double(AppStoreConnect::Client)
|
||||||
|
|
||||||
|
account.enable_features!(:channel_app_store)
|
||||||
|
allow(AppStoreConnect::Client).to receive(:new).and_return(app_store_client)
|
||||||
|
allow(app_store_client).to receive(:fetch_app).and_return(
|
||||||
|
{
|
||||||
|
'attributes' => {
|
||||||
|
'name' => 'Chatwoot',
|
||||||
|
'bundleId' => 'com.chatwoot.app'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { name: 'App Store Reviews',
|
||||||
|
channel: { type: 'app_store', app_id: '123456789', issuer_id: SecureRandom.uuid, key_id: 'KEY123',
|
||||||
|
private_key: OpenSSL::PKey::EC.generate('prime256v1').to_pem } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.body).to include('App Store Reviews')
|
||||||
|
expect(Channel::AppStore.last.app_name).to eq('Chatwoot')
|
||||||
|
end
|
||||||
|
|
||||||
it 'creates the webwidget inbox that allow messages after conversation is resolved' do
|
it 'creates the webwidget inbox that allow messages after conversation is resolved' do
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes",
|
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ describe Enterprise::Billing::HandleStripeEventService do
|
|||||||
|
|
||||||
# But other premium features should be disabled
|
# But other premium features should be disabled
|
||||||
expect(account).not_to be_feature_enabled('channel_instagram')
|
expect(account).not_to be_feature_enabled('channel_instagram')
|
||||||
|
expect(account).not_to be_feature_enabled('channel_app_store')
|
||||||
expect(account).not_to be_feature_enabled('help_center')
|
expect(account).not_to be_feature_enabled('help_center')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -333,6 +334,7 @@ describe Enterprise::Billing::HandleStripeEventService do
|
|||||||
account.reload
|
account.reload
|
||||||
# Spot check one startup feature
|
# Spot check one startup feature
|
||||||
expect(account).to be_feature_enabled('channel_instagram')
|
expect(account).to be_feature_enabled('channel_instagram')
|
||||||
|
expect(account).to be_feature_enabled('channel_app_store')
|
||||||
expect(account).not_to be_feature_enabled('sla')
|
expect(account).not_to be_feature_enabled('sla')
|
||||||
expect(account).not_to be_feature_enabled('custom_roles')
|
expect(account).not_to be_feature_enabled('custom_roles')
|
||||||
end
|
end
|
||||||
@@ -350,6 +352,7 @@ describe Enterprise::Billing::HandleStripeEventService do
|
|||||||
it 'disables all premium features' do
|
it 'disables all premium features' do
|
||||||
# Verify startup features were enabled
|
# Verify startup features were enabled
|
||||||
expect(account).to be_feature_enabled('channel_instagram')
|
expect(account).to be_feature_enabled('channel_instagram')
|
||||||
|
expect(account).to be_feature_enabled('channel_app_store')
|
||||||
|
|
||||||
# Downgrade to Hacker (default) plan
|
# Downgrade to Hacker (default) plan
|
||||||
allow(subscription).to receive(:[]).with('plan')
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
@@ -359,6 +362,7 @@ describe Enterprise::Billing::HandleStripeEventService do
|
|||||||
account.reload
|
account.reload
|
||||||
# Spot check that premium features are disabled
|
# Spot check that premium features are disabled
|
||||||
expect(account).not_to be_feature_enabled('channel_instagram')
|
expect(account).not_to be_feature_enabled('channel_instagram')
|
||||||
|
expect(account).not_to be_feature_enabled('channel_app_store')
|
||||||
expect(account).not_to be_feature_enabled('help_center')
|
expect(account).not_to be_feature_enabled('help_center')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :channel_app_store, class: 'Channel::AppStore' do
|
||||||
|
account
|
||||||
|
app_id { SecureRandom.random_number(1_000_000_000..9_999_999_999).to_s }
|
||||||
|
bundle_id { 'com.example.app' }
|
||||||
|
app_name { 'Example App' }
|
||||||
|
issuer_id { SecureRandom.uuid }
|
||||||
|
key_id { SecureRandom.alphanumeric(10).upcase }
|
||||||
|
private_key do
|
||||||
|
key = OpenSSL::PKey::EC.generate('prime256v1')
|
||||||
|
key.to_pem
|
||||||
|
end
|
||||||
|
|
||||||
|
to_create { |instance| instance.save!(validate: false) }
|
||||||
|
|
||||||
|
after(:create) do |channel|
|
||||||
|
create(:inbox, channel: channel, account: channel.account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:suspended_account) { create(:account, status: 'suspended') }
|
||||||
|
let(:due_channel) { create(:channel_app_store, account: account, last_synced_at: 2.hours.ago) }
|
||||||
|
let(:fresh_channel) { create(:channel_app_store, account: account, last_synced_at: 10.minutes.ago) }
|
||||||
|
let(:suspended_channel) { create(:channel_app_store, account: suspended_account, last_synced_at: 2.hours.ago) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
account.enable_features!(:channel_app_store)
|
||||||
|
suspended_account.enable_features!(:channel_app_store)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'enqueues the job' do
|
||||||
|
expect { described_class.perform_later }.to have_enqueued_job(described_class)
|
||||||
|
.on_queue('scheduled_jobs')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'enqueues fetch jobs only for due channels on active accounts' do
|
||||||
|
due_channel
|
||||||
|
fresh_channel
|
||||||
|
suspended_channel
|
||||||
|
|
||||||
|
expect(Inboxes::FetchAppStoreReviewsJob).to receive(:perform_later).with(due_channel).once
|
||||||
|
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(fresh_channel)
|
||||||
|
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(suspended_channel)
|
||||||
|
|
||||||
|
described_class.perform_now
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips channels when the feature is disabled for the account' do
|
||||||
|
account.disable_features!(:channel_app_store)
|
||||||
|
due_channel
|
||||||
|
|
||||||
|
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later)
|
||||||
|
|
||||||
|
described_class.perform_now
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Inboxes::FetchAppStoreReviewsJob do
|
||||||
|
let(:channel) { create(:channel_app_store, last_synced_at: nil) }
|
||||||
|
let(:review_payload) do
|
||||||
|
{
|
||||||
|
'review' => {
|
||||||
|
'id' => 'review-1',
|
||||||
|
'attributes' => { 'createdDate' => '2026-05-20T10:00:00-00:00' }
|
||||||
|
},
|
||||||
|
'response' => nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
channel.account.enable_features!(:channel_app_store)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'enqueues the job' do
|
||||||
|
expect { described_class.perform_later(channel) }.to have_enqueued_job(described_class)
|
||||||
|
.with(channel)
|
||||||
|
.on_queue('scheduled_jobs')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fetches reviews, builds messages, and updates the sync timestamp' do
|
||||||
|
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||||
|
allow(AppStore::ReviewBuilder).to receive(:new).with(review_payload: review_payload, channel: channel).and_return(review_builder)
|
||||||
|
|
||||||
|
described_class.perform_now(channel)
|
||||||
|
|
||||||
|
expect(review_builder).to have_received(:perform)
|
||||||
|
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T10:00:00-00:00'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the sync timestamp to the latest fetched review date' do
|
||||||
|
older_payload = review_payload.deep_dup
|
||||||
|
newer_payload = review_payload.deep_dup
|
||||||
|
older_payload['review']['id'] = 'review-1'
|
||||||
|
older_payload['review']['attributes']['createdDate'] = '2026-05-20T10:00:00-00:00'
|
||||||
|
newer_payload['review']['id'] = 'review-2'
|
||||||
|
newer_payload['review']['attributes']['createdDate'] = '2026-05-20T11:00:00-00:00'
|
||||||
|
|
||||||
|
allow(channel).to receive(:fetch_reviews).and_return([newer_payload, older_payload])
|
||||||
|
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||||
|
|
||||||
|
described_class.perform_now(channel)
|
||||||
|
|
||||||
|
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T11:00:00-00:00'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the sync timestamp to the response update date when it is newer than the review date' do
|
||||||
|
payload = review_payload.deep_dup
|
||||||
|
payload['response'] = {
|
||||||
|
'id' => 'response-1',
|
||||||
|
'attributes' => {
|
||||||
|
'lastModifiedDate' => '2026-05-20T12:00:00-00:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allow(channel).to receive(:fetch_reviews).and_return([payload])
|
||||||
|
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||||
|
|
||||||
|
described_class.perform_now(channel)
|
||||||
|
|
||||||
|
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T12:00:00-00:00'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not move the sync timestamp backwards when processing an older response update' do
|
||||||
|
channel.update!(last_synced_at: Time.zone.parse('2026-05-20T13:00:00-00:00'))
|
||||||
|
payload = review_payload.deep_dup
|
||||||
|
payload['response'] = {
|
||||||
|
'id' => 'response-1',
|
||||||
|
'attributes' => {
|
||||||
|
'lastModifiedDate' => '2026-05-20T12:00:00-00:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allow(channel).to receive(:fetch_reviews).and_return([payload])
|
||||||
|
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||||
|
|
||||||
|
described_class.perform_now(channel)
|
||||||
|
|
||||||
|
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T13:00:00-00:00'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'captures per-review errors and continues syncing' do
|
||||||
|
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
|
||||||
|
|
||||||
|
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||||
|
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||||
|
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
|
||||||
|
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||||
|
|
||||||
|
described_class.perform_now(channel)
|
||||||
|
|
||||||
|
expect(exception_tracker).to have_received(:capture_exception)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not update the sync timestamp when a review fails to build' do
|
||||||
|
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
|
||||||
|
|
||||||
|
channel.update!(last_synced_at: 2.hours.ago)
|
||||||
|
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||||
|
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||||
|
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
|
||||||
|
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||||
|
|
||||||
|
expect { described_class.perform_now(channel) }
|
||||||
|
.not_to change(channel.reload, :last_synced_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not fetch reviews when the feature is disabled for the account' do
|
||||||
|
channel.account.disable_features!(:channel_app_store)
|
||||||
|
|
||||||
|
expect(channel).not_to receive(:fetch_reviews)
|
||||||
|
|
||||||
|
described_class.perform_now(channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -122,5 +122,11 @@ RSpec.describe SendReplyJob do
|
|||||||
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
|
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
|
||||||
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
|
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'calls ::AppStore::SendOnAppStoreService when its app store message' do
|
||||||
|
app_store_channel = create(:channel_app_store)
|
||||||
|
message = create(:message, conversation: create(:conversation, inbox: app_store_channel.inbox))
|
||||||
|
expect_mapped_service_to_perform(message, 'AppStore::SendOnAppStoreService')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ RSpec.describe Account do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'app store channel feature flag' do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
|
||||||
|
it 'stores app store channel feature state in feature flags' do
|
||||||
|
expect(account.feature_enabled?(:channel_app_store)).to be false
|
||||||
|
|
||||||
|
account.enable_features!(:channel_app_store)
|
||||||
|
|
||||||
|
expect(account.reload.feature_enabled?(:channel_app_store)).to be true
|
||||||
|
expect(account.enabled_features).to include('channel_app_store' => true)
|
||||||
|
|
||||||
|
account.disable_features!(:channel_app_store)
|
||||||
|
|
||||||
|
expect(account.reload.feature_enabled?(:channel_app_store)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'conversation unread counts feature flag' do
|
describe 'conversation unread counts feature flag' do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:inbox) { create(:inbox, account: account) }
|
let(:inbox) { create(:inbox, account: account) }
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Channel::AppStore do
|
||||||
|
describe 'validations' do
|
||||||
|
it 'normalizes auth fields and stores app metadata from App Store Connect' do
|
||||||
|
app_store_client = instance_double(AppStoreConnect::Client)
|
||||||
|
channel = build(
|
||||||
|
:channel_app_store,
|
||||||
|
account: create(:account),
|
||||||
|
app_id: ' 123456789 ',
|
||||||
|
issuer_id: ' issuer-id ',
|
||||||
|
key_id: ' key-id ',
|
||||||
|
private_key: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\r\n",
|
||||||
|
app_name: nil,
|
||||||
|
bundle_id: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||||
|
allow(app_store_client).to receive(:fetch_app).and_return(
|
||||||
|
{
|
||||||
|
'attributes' => {
|
||||||
|
'name' => 'Chatwoot iOS',
|
||||||
|
'bundleId' => 'com.chatwoot.app'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(channel).to be_valid
|
||||||
|
expect(channel.app_id).to eq('123456789')
|
||||||
|
expect(channel.issuer_id).to eq('issuer-id')
|
||||||
|
expect(channel.key_id).to eq('key-id')
|
||||||
|
expect(channel.private_key).to eq("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----")
|
||||||
|
expect(channel.app_name).to eq('Chatwoot iOS')
|
||||||
|
expect(channel.bundle_id).to eq('com.chatwoot.app')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'adds an error when App Store Connect validation fails' do
|
||||||
|
app_store_client = instance_double(AppStoreConnect::Client)
|
||||||
|
channel = build(:channel_app_store)
|
||||||
|
|
||||||
|
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||||
|
allow(app_store_client).to receive(:fetch_app).and_raise(AppStoreConnect::Client::Error, 'invalid credentials')
|
||||||
|
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:base]).to include('invalid credentials')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#sync_due?' do
|
||||||
|
it 'returns true when the channel has never synced' do
|
||||||
|
channel = build(:channel_app_store, last_synced_at: nil)
|
||||||
|
|
||||||
|
expect(channel.sync_due?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when the last sync is within the sync interval' do
|
||||||
|
channel = build(:channel_app_store, last_synced_at: 30.minutes.ago)
|
||||||
|
|
||||||
|
expect(channel.sync_due?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AppStore::ReviewBuilder do
|
||||||
|
let(:channel) { create(:channel_app_store) }
|
||||||
|
let(:inbox) { channel.inbox }
|
||||||
|
let(:review_payload) do
|
||||||
|
{
|
||||||
|
'review' => {
|
||||||
|
'id' => 'review-1',
|
||||||
|
'attributes' => {
|
||||||
|
'rating' => 4,
|
||||||
|
'title' => 'Helpful app',
|
||||||
|
'body' => 'Works well for support.',
|
||||||
|
'territory' => 'US',
|
||||||
|
'reviewerNickname' => 'Reviewer',
|
||||||
|
'createdDate' => '2026-05-20T10:00:00-00:00'
|
||||||
|
},
|
||||||
|
'relationships' => {
|
||||||
|
'response' => {
|
||||||
|
'data' => {
|
||||||
|
'id' => 'response-1',
|
||||||
|
'type' => 'customerReviewResponses'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'response' => {
|
||||||
|
'id' => 'response-1',
|
||||||
|
'attributes' => {
|
||||||
|
'responseBody' => 'Thanks for the feedback.',
|
||||||
|
'state' => 'PUBLISHED',
|
||||||
|
'lastModifiedDate' => '2026-05-20T11:00:00-00:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
it 'creates a conversation with an incoming review and outgoing developer response' do
|
||||||
|
expect { described_class.new(review_payload: review_payload, channel: channel).perform }
|
||||||
|
.to change(inbox.conversations, :count).by(1)
|
||||||
|
.and change(Message.where(inbox_id: inbox.id), :count).by(2)
|
||||||
|
|
||||||
|
conversation = inbox.conversations.last
|
||||||
|
review_message = conversation.messages.incoming.find_by(source_id: 'review-1')
|
||||||
|
response_message = conversation.messages.outgoing.find_by(source_id: 'response-1')
|
||||||
|
|
||||||
|
expect(conversation.contact_inbox.source_id).to eq('review-1')
|
||||||
|
expect(review_message.content).to include('★★★★☆ (4/5)', 'Helpful app', 'Works well for support.', 'US • Reviewer')
|
||||||
|
expect(review_message.content_attributes['app_store']).to include(
|
||||||
|
'rating' => 4,
|
||||||
|
'title' => 'Helpful app',
|
||||||
|
'territory' => 'US',
|
||||||
|
'reviewer_nickname' => 'Reviewer'
|
||||||
|
)
|
||||||
|
expect(response_message.content).to eq('Thanks for the feedback.')
|
||||||
|
expect(response_message.status).to eq('delivered')
|
||||||
|
expect(response_message.content_attributes['external_echo']).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a conversation for a rating-only review' do
|
||||||
|
rating_only_payload = review_payload.deep_dup
|
||||||
|
rating_only_payload['review']['attributes']['title'] = ''
|
||||||
|
rating_only_payload['review']['attributes']['body'] = ''
|
||||||
|
rating_only_payload['response'] = nil
|
||||||
|
|
||||||
|
expect { described_class.new(review_payload: rating_only_payload, channel: channel).perform }
|
||||||
|
.to change(inbox.conversations, :count).by(1)
|
||||||
|
.and change(Message.where(inbox_id: inbox.id), :count).by(1)
|
||||||
|
|
||||||
|
review_message = inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1')
|
||||||
|
expect(review_message.content).to include('★★★★☆ (4/5)')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clamps the review rating before building the message content' do
|
||||||
|
invalid_rating_payload = review_payload.deep_dup
|
||||||
|
invalid_rating_payload['review']['attributes']['rating'] = 7
|
||||||
|
invalid_rating_payload['response'] = nil
|
||||||
|
|
||||||
|
described_class.new(review_payload: invalid_rating_payload, channel: channel).perform
|
||||||
|
|
||||||
|
review_message = inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1')
|
||||||
|
expect(review_message.content).to include('★★★★★ (5/5)')
|
||||||
|
expect(review_message.content_attributes['app_store']).to include('rating' => 5)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to current time when Apple timestamps are blank' do
|
||||||
|
blank_timestamp_payload = review_payload.deep_dup
|
||||||
|
blank_timestamp_payload['review']['attributes']['createdDate'] = ''
|
||||||
|
blank_timestamp_payload['response']['attributes']['lastModifiedDate'] = ''
|
||||||
|
|
||||||
|
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
|
||||||
|
described_class.new(review_payload: blank_timestamp_payload, channel: channel).perform
|
||||||
|
|
||||||
|
conversation = inbox.conversations.last
|
||||||
|
expect(conversation.messages.incoming.find_by(source_id: 'review-1').created_at.to_i).to eq(Time.current.to_i)
|
||||||
|
expect(conversation.messages.outgoing.find_by(source_id: 'response-1').created_at.to_i).to eq(Time.current.to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates an existing review message when Apple returns the same review again' do
|
||||||
|
described_class.new(review_payload: review_payload, channel: channel).perform
|
||||||
|
updated_payload = review_payload.deep_dup
|
||||||
|
updated_payload['review']['attributes']['body'] = 'Updated review body.'
|
||||||
|
|
||||||
|
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
|
||||||
|
.not_to change(Message.where(inbox_id: inbox.id), :count)
|
||||||
|
|
||||||
|
expect(inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1').content).to include('Updated review body.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates an existing developer response message when Apple returns an edited response' do
|
||||||
|
described_class.new(review_payload: review_payload, channel: channel).perform
|
||||||
|
updated_payload = review_payload.deep_dup
|
||||||
|
updated_payload['response']['attributes']['responseBody'] = 'Updated response.'
|
||||||
|
updated_payload['response']['attributes']['state'] = 'PENDING_PUBLISH'
|
||||||
|
updated_payload['response']['attributes']['lastModifiedDate'] = '2026-05-20T12:00:00-00:00'
|
||||||
|
|
||||||
|
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
|
||||||
|
.not_to change(Message.where(inbox_id: inbox.id), :count)
|
||||||
|
|
||||||
|
response_message = inbox.conversations.last.messages.outgoing.find_by(source_id: 'response-1')
|
||||||
|
expect(response_message.content).to eq('Updated response.')
|
||||||
|
expect(response_message.content_attributes['app_store']).to include(
|
||||||
|
'response_state' => 'PENDING_PUBLISH',
|
||||||
|
'response_last_modified_date' => '2026-05-20T12:00:00-00:00'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AppStore::SendOnAppStoreService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:channel) { create(:channel_app_store, account: account) }
|
||||||
|
let(:inbox) { channel.inbox }
|
||||||
|
let(:contact) { create(:contact, account: inbox.account) }
|
||||||
|
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'review-1') }
|
||||||
|
let(:conversation) { create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, account: inbox.account) }
|
||||||
|
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||||
|
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
account.enable_features!(:channel_app_store)
|
||||||
|
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
|
||||||
|
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
it 'creates an App Store response for a new reply' do
|
||||||
|
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
|
||||||
|
|
||||||
|
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
|
expect(channel).to have_received(:reply_to_review).with('review-1', 'Thanks')
|
||||||
|
expect(message.reload.source_id).to eq('response-1')
|
||||||
|
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the existing App Store response when the conversation already has one' do
|
||||||
|
existing_response = create(
|
||||||
|
:message,
|
||||||
|
message_type: :outgoing,
|
||||||
|
inbox: inbox,
|
||||||
|
conversation: conversation,
|
||||||
|
account: inbox.account,
|
||||||
|
content: 'Old reply',
|
||||||
|
source_id: 'response-1',
|
||||||
|
content_attributes: {
|
||||||
|
external_echo: true,
|
||||||
|
app_store: {
|
||||||
|
response_id: 'response-1',
|
||||||
|
response_state: 'PUBLISHED'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Updated reply')
|
||||||
|
|
||||||
|
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||||
|
|
||||||
|
expect { described_class.new(message: message).perform }
|
||||||
|
.to change { conversation.messages.reload.count }.by(-1)
|
||||||
|
|
||||||
|
expect(channel).to have_received(:reply_to_review).with('review-1', 'Updated reply')
|
||||||
|
expect { message.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
expect(existing_response.reload.content).to eq('Updated reply')
|
||||||
|
expect(existing_response.content_attributes['external_echo']).to be true
|
||||||
|
expect(existing_response.content_attributes['app_store']).to include(
|
||||||
|
'response_id' => 'response-1',
|
||||||
|
'response_state' => 'PUBLISHED'
|
||||||
|
)
|
||||||
|
expect(Messages::StatusUpdateService).to have_received(:new).with(existing_response, 'delivered')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks the message as failed when attachments are present' do
|
||||||
|
message = create(:message, :with_attachment, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account)
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
|
expect(Messages::StatusUpdateService).to have_received(:new).with(
|
||||||
|
message,
|
||||||
|
'failed',
|
||||||
|
'Sending attachments is not supported for App Store reviews.'
|
||||||
|
)
|
||||||
|
expect(exception_tracker).to have_received(:capture_exception)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks the message as failed when the message is not plain outgoing text' do
|
||||||
|
message = create(
|
||||||
|
:message,
|
||||||
|
message_type: :outgoing,
|
||||||
|
content_type: :input_csat,
|
||||||
|
inbox: inbox,
|
||||||
|
conversation: conversation,
|
||||||
|
account: inbox.account,
|
||||||
|
content: 'Please rate this conversation'
|
||||||
|
)
|
||||||
|
|
||||||
|
allow(channel).to receive(:reply_to_review)
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
|
expect(channel).not_to have_received(:reply_to_review)
|
||||||
|
expect(Messages::StatusUpdateService).to have_received(:new).with(
|
||||||
|
message,
|
||||||
|
'failed',
|
||||||
|
'Only outgoing text messages are supported for App Store reviews.'
|
||||||
|
)
|
||||||
|
expect(exception_tracker).to have_received(:capture_exception)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks the message as failed when the feature is disabled' do
|
||||||
|
account.disable_features!(:channel_app_store)
|
||||||
|
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
|
expect(Messages::StatusUpdateService).to have_received(:new).with(
|
||||||
|
message,
|
||||||
|
'failed',
|
||||||
|
'App Store Reviews channel is not enabled for this account.'
|
||||||
|
)
|
||||||
|
expect(exception_tracker).to have_received(:capture_exception)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AppStoreConnect::Client do
|
||||||
|
let(:channel) { create(:channel_app_store, app_id: '123456789') }
|
||||||
|
let(:token_service) { instance_double(AppStoreConnect::TokenService, token: 'jwt-token') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(token_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#fetch_app' do
|
||||||
|
it 'fetches the configured app' do
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||||
|
.with(headers: { 'Authorization' => 'Bearer jwt-token' })
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
id: '123456789',
|
||||||
|
attributes: {
|
||||||
|
name: 'Chatwoot',
|
||||||
|
bundleId: 'com.chatwoot.app'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(described_class.new(channel: channel).fetch_app['id']).to eq('123456789')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#fetch_reviews' do
|
||||||
|
it 'fetches reviews and attaches the included developer response' do
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
|
||||||
|
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'review-1',
|
||||||
|
type: 'customerReviews',
|
||||||
|
attributes: {
|
||||||
|
createdDate: '2026-05-20T10:00:00-00:00'
|
||||||
|
},
|
||||||
|
relationships: {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
id: 'response-1',
|
||||||
|
type: 'customerReviewResponses'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
included: [
|
||||||
|
{
|
||||||
|
id: 'response-1',
|
||||||
|
type: 'customerReviewResponses',
|
||||||
|
attributes: {
|
||||||
|
responseBody: 'Thanks for the review'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
review_payload = described_class.new(channel: channel).fetch_reviews.first
|
||||||
|
|
||||||
|
expect(review_payload['review']['id']).to eq('review-1')
|
||||||
|
expect(review_payload['response']['id']).to eq('response-1')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps fetching older pages so updated developer responses are not skipped' do
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
|
||||||
|
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
|
||||||
|
.to_return(app_store_response(mixed_freshness_reviews_page))
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
|
||||||
|
.to_return(app_store_response(older_reviews_page_with_updated_response))
|
||||||
|
|
||||||
|
review_payloads = described_class.new(channel: channel).fetch_reviews(since: Time.zone.parse('2026-05-20T00:00:00-00:00'))
|
||||||
|
|
||||||
|
expect(review_payloads.pluck('review').pluck('id')).to eq(%w[review-1 review-3])
|
||||||
|
expect(WebMock).to have_requested(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes older reviews when the developer response was updated after the sync cursor' do
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
|
||||||
|
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'review-1',
|
||||||
|
type: 'customerReviews',
|
||||||
|
attributes: {
|
||||||
|
createdDate: '2026-05-19T10:00:00-00:00'
|
||||||
|
},
|
||||||
|
relationships: {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
id: 'response-1',
|
||||||
|
type: 'customerReviewResponses'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
included: [
|
||||||
|
{
|
||||||
|
id: 'response-1',
|
||||||
|
type: 'customerReviewResponses',
|
||||||
|
attributes: {
|
||||||
|
responseBody: 'Updated response',
|
||||||
|
lastModifiedDate: '2026-05-20T11:00:00-00:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
review_payloads = described_class.new(channel: channel).fetch_reviews(since: Time.zone.parse('2026-05-20T10:00:00-00:00'))
|
||||||
|
|
||||||
|
expect(review_payloads.pluck('review').pluck('id')).to eq(['review-1'])
|
||||||
|
expect(review_payloads.first['response']['attributes']['responseBody']).to eq('Updated response')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fetches a fresh cached token for each request' do
|
||||||
|
first_token_service = instance_double(AppStoreConnect::TokenService, token: 'first-token')
|
||||||
|
second_token_service = instance_double(AppStoreConnect::TokenService, token: 'second-token')
|
||||||
|
|
||||||
|
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(first_token_service, second_token_service)
|
||||||
|
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
|
||||||
|
.with(
|
||||||
|
headers: { 'Authorization' => 'Bearer first-token' },
|
||||||
|
query: { include: 'response', limit: '200', sort: '-createdDate' }
|
||||||
|
)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: [],
|
||||||
|
links: {
|
||||||
|
next: 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2'
|
||||||
|
}
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
|
||||||
|
.with(headers: { 'Authorization' => 'Bearer second-token' })
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: { data: [] }.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.new(channel: channel).fetch_reviews
|
||||||
|
|
||||||
|
expect(AppStoreConnect::TokenService).to have_received(:new).twice
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_response(body)
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
body: body.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def mixed_freshness_reviews_page
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'review-1',
|
||||||
|
type: 'customerReviews',
|
||||||
|
attributes: { createdDate: '2026-05-20T10:00:00-00:00' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review-2',
|
||||||
|
type: 'customerReviews',
|
||||||
|
attributes: { createdDate: '2026-05-19T10:00:00-00:00' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
links: {
|
||||||
|
next: 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def older_reviews_page_with_updated_response
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'review-3',
|
||||||
|
type: 'customerReviews',
|
||||||
|
attributes: { createdDate: '2026-05-18T10:00:00-00:00' },
|
||||||
|
relationships: response_relationship('response-3')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
included: [updated_response_payload]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def response_relationship(response_id)
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
data: { id: response_id, type: 'customerReviewResponses' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def updated_response_payload
|
||||||
|
{
|
||||||
|
id: 'response-3',
|
||||||
|
type: 'customerReviewResponses',
|
||||||
|
attributes: {
|
||||||
|
responseBody: 'Updated response',
|
||||||
|
lastModifiedDate: '2026-05-20T11:00:00-00:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#create_review_response' do
|
||||||
|
it 'creates a response for a review' do
|
||||||
|
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
|
||||||
|
.with(
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
type: 'customerReviewResponses',
|
||||||
|
attributes: {
|
||||||
|
responseBody: 'Thanks'
|
||||||
|
},
|
||||||
|
relationships: {
|
||||||
|
review: {
|
||||||
|
data: {
|
||||||
|
type: 'customerReviews',
|
||||||
|
id: 'review-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.to_json
|
||||||
|
)
|
||||||
|
.to_return(
|
||||||
|
status: 201,
|
||||||
|
body: { data: { id: 'response-1' } }.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
response = described_class.new(channel: channel).create_review_response('review-1', 'Thanks')
|
||||||
|
|
||||||
|
expect(response['id']).to eq('response-1')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#create_or_update_review_response' do
|
||||||
|
it 'creates or updates a response for a review' do
|
||||||
|
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
|
||||||
|
.with(
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
type: 'customerReviewResponses',
|
||||||
|
attributes: {
|
||||||
|
responseBody: 'Updated response'
|
||||||
|
},
|
||||||
|
relationships: {
|
||||||
|
review: {
|
||||||
|
data: {
|
||||||
|
type: 'customerReviews',
|
||||||
|
id: 'review-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.to_json
|
||||||
|
)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: { data: { id: 'response-1' } }.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
response = described_class.new(channel: channel).create_or_update_review_response('review-1', 'Updated response')
|
||||||
|
|
||||||
|
expect(response['id']).to eq('response-1')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises a useful error when Apple returns an error response' do
|
||||||
|
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||||
|
.to_return(
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
detail: 'Provide a properly configured and signed bearer token.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.new(channel: channel).fetch_app }
|
||||||
|
.to raise_error(AppStoreConnect::Client::Error, /properly configured and signed bearer token/)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AppStoreConnect::TokenService do
|
||||||
|
let(:private_key) { OpenSSL::PKey::EC.generate('prime256v1').to_pem }
|
||||||
|
let(:channel) do
|
||||||
|
instance_double(
|
||||||
|
Channel::AppStore,
|
||||||
|
id: 1,
|
||||||
|
updated_at: Time.zone.at(1_700_000_000),
|
||||||
|
issuer_id: 'issuer-id',
|
||||||
|
key_id: 'key-id',
|
||||||
|
private_key: private_key
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#token' do
|
||||||
|
it 'generates an App Store Connect JWT with the expected claims and headers' do
|
||||||
|
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
|
||||||
|
token = described_class.new(channel: channel).token
|
||||||
|
payload, header = JWT.decode(token, nil, false)
|
||||||
|
|
||||||
|
expect(payload).to include(
|
||||||
|
'iss' => 'issuer-id',
|
||||||
|
'iat' => Time.current.to_i,
|
||||||
|
'exp' => 19.minutes.from_now.to_i,
|
||||||
|
'aud' => 'appstoreconnect-v1'
|
||||||
|
)
|
||||||
|
expect(header).to include('kid' => 'key-id', 'typ' => 'JWT', 'alg' => 'ES256')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns cached tokens for the channel' do
|
||||||
|
allow(Rails.cache).to receive(:read).with('app_store_connect_token:1:1700000000').and_return('cached-token')
|
||||||
|
|
||||||
|
expect(described_class.new(channel: channel).token).to eq('cached-token')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates a token before the channel is persisted' do
|
||||||
|
new_channel = instance_double(
|
||||||
|
Channel::AppStore,
|
||||||
|
id: nil,
|
||||||
|
updated_at: nil,
|
||||||
|
issuer_id: 'issuer-id',
|
||||||
|
key_id: 'key-id',
|
||||||
|
private_key: private_key
|
||||||
|
)
|
||||||
|
|
||||||
|
token = described_class.new(channel: new_channel).token
|
||||||
|
payload, = JWT.decode(token, nil, false)
|
||||||
|
|
||||||
|
expect(payload['iss']).to eq('issuer-id')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -270,4 +270,28 @@ describe MessageTemplates::HookExecutionService do
|
|||||||
expect(out_of_office_service).not_to receive(:perform)
|
expect(out_of_office_service).not_to receive(:perform)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when the inbox is an App Store Reviews channel' do
|
||||||
|
let(:channel) { create(:channel_app_store) }
|
||||||
|
let(:inbox) { channel.inbox }
|
||||||
|
let(:conversation) { create(:conversation, inbox: inbox, account: channel.account) }
|
||||||
|
|
||||||
|
it 'does not fire the greeting template even when greeting_enabled is true' do
|
||||||
|
inbox.update!(greeting_enabled: true, greeting_message: 'Thanks for reviewing!')
|
||||||
|
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||||
|
|
||||||
|
create(:message, conversation: conversation, account: conversation.account)
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not fire the out-of-office template even when configured' do
|
||||||
|
inbox.update!(working_hours_enabled: true, out_of_office_message: 'Back tomorrow')
|
||||||
|
allow(MessageTemplates::Template::OutOfOffice).to receive(:new)
|
||||||
|
|
||||||
|
create(:message, conversation: conversation, account: conversation.account)
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user