From 609e27a4c45e7523de66293a9dca17ccc7e18dc1 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Thu, 21 May 2026 08:18:22 +0400 Subject: [PATCH 1/3] feat(inboxes): add app store reviews channel --- .../api/v1/accounts/inboxes_controller.rb | 5 +- app/helpers/api/v1/inboxes_helper.rb | 3 +- .../components-next/icon/provider.js | 1 + .../components-next/message/MessageMeta.vue | 10 +- .../components/widgets/ChannelItem.vue | 3 +- .../dashboard/composables/useInbox.js | 7 + app/javascript/dashboard/constants/editor.js | 5 + app/javascript/dashboard/helper/inbox.js | 6 + .../dashboard/i18n/locale/en/inboxMgmt.json | 38 ++ .../settings/inbox/ChannelFactory.vue | 2 + .../dashboard/settings/inbox/ChannelList.vue | 6 + .../dashboard/settings/inbox/Settings.vue | 13 + .../settings/inbox/channels/AppStore.vue | 176 +++++++ .../settings/inbox/components/ChannelName.vue | 1 + .../dashboard/store/modules/inboxes.js | 3 +- app/javascript/shared/mixins/inboxMixin.js | 7 + .../fetch_app_store_review_inboxes_job.rb | 12 + .../inboxes/fetch_app_store_reviews_job.rb | 15 + app/jobs/send_reply_job.rb | 1 + app/models/account.rb | 1 + app/models/channel/app_store.rb | 72 +++ app/models/inbox.rb | 4 + app/services/app_store/review_builder.rb | 173 +++++++ .../app_store/send_on_app_store_service.rb | 38 ++ app/services/app_store_connect/client.rb | 143 ++++++ .../app_store_connect/token_service.rb | 47 ++ app/views/api/v1/models/_inbox.json.jbuilder | 8 + app_store_inbox.md | 429 ++++++++++++++++++ config/schedule.yml | 6 + ...20260520090000_create_channel_app_store.rb | 19 + db/schema.rb | 17 +- 31 files changed, 1262 insertions(+), 9 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue create mode 100644 app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb create mode 100644 app/jobs/inboxes/fetch_app_store_reviews_job.rb create mode 100644 app/models/channel/app_store.rb create mode 100644 app/services/app_store/review_builder.rb create mode 100644 app/services/app_store/send_on_app_store_service.rb create mode 100644 app/services/app_store_connect/client.rb create mode 100644 app/services/app_store_connect/token_service.rb create mode 100644 app_store_inbox.md create mode 100644 db/migrate/20260520090000_create_channel_app_store.rb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 757af9b62..9b8f7b747 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def allowed_channel_types - %w[web_widget api email line telegram whatsapp sms] + %w[web_widget api email line telegram whatsapp sms app_store] end def update_inbox_working_hours @@ -179,7 +179,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController 'line' => Channel::Line, 'telegram' => Channel::Telegram, 'whatsapp' => Channel::Whatsapp, - 'sms' => Channel::Sms + 'sms' => Channel::Sms, + 'app_store' => Channel::AppStore }[permitted_params[:channel][:type]] end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8a10fa99c..3920ecfa4 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -111,7 +111,8 @@ module Api::V1::InboxesHelper 'line' => Current.account.line_channels, 'telegram' => Current.account.telegram_channels, 'whatsapp' => Current.account.whatsapp_channels, - 'sms' => Current.account.sms_channels + 'sms' => Current.account.sms_channels, + 'app_store' => Current.account.app_store_channels }[permitted_params[:channel][:type]] end diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index d7a9c93ad..ebb168f3d 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -15,6 +15,7 @@ export function useChannelIcon(inbox) { 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', 'Channel::Tiktok': 'i-woot-tiktok', + 'Channel::AppStore': 'i-ri-app-store-fill', }; const providerIconMap = { diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index f26339cbb..e0c1f071e 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -20,6 +20,7 @@ const { isAWhatsAppChannel, isAnEmailChannel, isAnInstagramChannel, + isAnAppStoreChannel, isATiktokChannel, } = useInbox(); @@ -62,7 +63,8 @@ const isSent = computed(() => { isASmsInbox.value || isATelegramChannel.value || isAnInstagramChannel.value || - isATiktokChannel.value + isATiktokChannel.value || + isAnAppStoreChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.SENT; } @@ -85,7 +87,8 @@ const isDelivered = computed(() => { isASmsInbox.value || isAFacebookInbox.value || isAnInstagramChannel.value || - isATiktokChannel.value + isATiktokChannel.value || + isAnAppStoreChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED; } @@ -110,7 +113,8 @@ const isRead = computed(() => { isATwilioChannel.value || isAFacebookInbox.value || isAnInstagramChannel.value || - isATiktokChannel.value + isATiktokChannel.value || + isAnAppStoreChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.READ; } diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 2cbad72c5..54c9c707d 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -66,6 +66,7 @@ const isActive = computed(() => { 'line', 'instagram', 'tiktok', + 'app_store', 'voice', ].includes(key); }); @@ -78,7 +79,7 @@ const isComingSoon = computed(() => { }); const isBeta = computed(() => { - return ['tiktok', 'voice'].includes(props.channel.key); + return ['tiktok', 'app_store', 'voice'].includes(props.channel.key); }); const onItemClick = () => { diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index 6e8d0a52a..bb6a01374 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -22,6 +22,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], [INBOX_FEATURES.REPLY_TO_OUTGOING]: [ @@ -30,6 +31,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], }; @@ -138,6 +140,10 @@ export const useInbox = (inboxId = null) => { return channelType.value === INBOX_TYPES.TIKTOK; }); + const isAnAppStoreChannel = computed(() => { + return channelType.value === INBOX_TYPES.APP_STORE; + }); + const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value)); const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); @@ -160,6 +166,7 @@ export const useInbox = (inboxId = null) => { isAnEmailChannel, isAnInstagramChannel, isATiktokChannel, + isAnAppStoreChannel, voiceCallEnabled, voiceCallProvider, }; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 378d303b4..a89d197a9 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -114,6 +114,11 @@ export const FORMATTING = { nodes: [], menu: [], }, + 'Channel::AppStore': { + marks: [], + nodes: [], + menu: [], + }, // Special contexts (not actual channels) 'Context::PrivateNote': { marks: ['strong', 'em', 'code', 'link', 'strike'], diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 4039a07d1..65d04b2ca 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -11,6 +11,7 @@ export const INBOX_TYPES = { SMS: 'Channel::Sms', INSTAGRAM: 'Channel::Instagram', TIKTOK: 'Channel::Tiktok', + APP_STORE: 'Channel::AppStore', }; // Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp) @@ -50,6 +51,7 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill', + [INBOX_TYPES.APP_STORE]: 'i-ri-app-store-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -65,6 +67,7 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.LINE]: 'i-woot-line', [INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram', [INBOX_TYPES.TIKTOK]: 'i-woot-tiktok', + [INBOX_TYPES.APP_STORE]: 'i-ri-app-store-line', }; const DEFAULT_ICON_LINE = 'i-ri-chat-1-line'; @@ -114,6 +117,9 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; + case INBOX_TYPES.APP_STORE: + return 'app_store'; + default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 2620f89f7..9b9df0fa2 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -382,6 +382,39 @@ "ERROR_MESSAGE": "We were not able to save the api channel" } }, + "APP_STORE": { + "TITLE": "App Store Reviews", + "DESC": "Manage and reply to your App Store reviews from Chatwoot.", + "CHANNEL_NAME": { + "LABEL": "Inbox Name", + "PLACEHOLDER": "Please enter an inbox name", + "ERROR": "This field is required" + }, + "APP_ID": { + "LABEL": "App Store Connect app ID", + "PLACEHOLDER": "Enter your App Store Connect app ID", + "ERROR": "This field is required" + }, + "ISSUER_ID": { + "LABEL": "Issuer ID", + "PLACEHOLDER": "Enter your App Store Connect issuer ID", + "ERROR": "This field is required" + }, + "KEY_ID": { + "LABEL": "Key ID", + "PLACEHOLDER": "Enter your App Store Connect key ID", + "ERROR": "This field is required" + }, + "PRIVATE_KEY": { + "LABEL": "Private key", + "PLACEHOLDER": "Paste the contents of your .p8 private key", + "ERROR": "This field is required" + }, + "SUBMIT_BUTTON": "Create App Store Channel", + "API": { + "ERROR_MESSAGE": "We were not able to save the App Store channel" + } + }, "EMAIL_CHANNEL": { "TITLE": "Email Channel", "DESC": "Integrate your email inbox.", @@ -493,6 +526,10 @@ "TITLE": "TikTok", "DESCRIPTION": "Connect your TikTok account" }, + "APP_STORE": { + "TITLE": "App Store Reviews", + "DESCRIPTION": "Manage your App Store app reviews" + }, "VOICE": { "TITLE": "Voice", "DESCRIPTION": "Integrate with Twilio Voice" @@ -1165,6 +1202,7 @@ "API": "API Channel", "INSTAGRAM": "Instagram", "TIKTOK": "TikTok", + "APP_STORE": "App Store Reviews", "VOICE": "Voice" } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index 7d1d58854..fec8090a1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -12,6 +12,7 @@ import Telegram from './channels/Telegram.vue'; import Instagram from './channels/Instagram.vue'; import Tiktok from './channels/Tiktok.vue'; import Voice from './channels/Voice.vue'; +import AppStore from './channels/AppStore.vue'; const channelViewList = { facebook: Facebook, @@ -25,6 +26,7 @@ const channelViewList = { telegram: Telegram, instagram: Instagram, tiktok: Tiktok, + app_store: AppStore, voice: Voice, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index e2ebd27cd..0a7f191ff 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -77,6 +77,12 @@ const channelList = computed(() => { description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'), icon: 'i-woot-instagram', }, + { + key: 'app_store', + title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.TITLE'), + description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.DESCRIPTION'), + icon: 'i-ri-app-store-line', + }, ]; if (hasTiktokConfigured.value) { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index a26eb0e18..2e5e44724 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -249,6 +249,13 @@ export default { ]; } + if (this.isAnAppStoreChannel) { + const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration']; + visibleToAllChannelTabs = visibleToAllChannelTabs.filter( + tab => !unsupportedKeys.includes(tab.key) + ); + } + return visibleToAllChannelTabs; }, currentInboxId() { @@ -282,6 +289,9 @@ export default { if (this.isAnEmailChannel) { return `${this.inbox.name} (${this.inbox.email})`; } + if (this.isAnAppStoreChannel && this.inbox.app_id) { + return `${this.inbox.name} (${this.inbox.app_id})`; + } return this.inbox.name; }, canLocktoSingleConversation() { @@ -293,6 +303,7 @@ export default { this.isAnInstagramChannel || this.isALineChannel || this.isATiktokChannel || + this.isAnAppStoreChannel || this.isATelegramChannel ); }, @@ -829,6 +840,7 @@ export default { @@ -1128,6 +1140,7 @@ export default { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue new file mode 100644 index 000000000..6eb5b6073 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue @@ -0,0 +1,176 @@ + + + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.ERROR') }} + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 711217f32..3625d7601 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -34,6 +34,7 @@ const i18nMap = { 'Channel::Api': 'API', 'Channel::Instagram': 'INSTAGRAM', 'Channel::Tiktok': 'TIKTOK', + 'Channel::AppStore': 'APP_STORE', }; const twilioChannelName = () => { diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index ce24ef653..2d6084a0c 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -156,7 +156,8 @@ export const getters = { }, dialogFlowEnabledInboxes($state) { return $state.records.filter( - item => item.channel_type !== INBOX_TYPES.EMAIL + item => + ![INBOX_TYPES.EMAIL, INBOX_TYPES.APP_STORE].includes(item.channel_type) ); }, getFacebookInboxByInstagramId: $state => instagramId => { diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 40579125b..ee32c782b 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -15,6 +15,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], [INBOX_FEATURES.REPLY_TO_OUTGOING]: [ @@ -23,6 +24,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], }; @@ -119,6 +121,8 @@ export default { badgeKey = 'whatsapp'; } else if (this.isATiktokChannel) { badgeKey = 'tiktok'; + } else if (this.isAnAppStoreChannel) { + badgeKey = 'app_store'; } return badgeKey || this.channelType; }, @@ -134,6 +138,9 @@ export default { isATiktokChannel() { return this.channelType === INBOX_TYPES.TIKTOK; }, + isAnAppStoreChannel() { + return this.channelType === INBOX_TYPES.APP_STORE; + }, }, methods: { inboxHasFeature(feature) { diff --git a/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb b/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb new file mode 100644 index 000000000..d056c63b8 --- /dev/null +++ b/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb @@ -0,0 +1,12 @@ +class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + Inbox.where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox| + next if inbox.account.suspended? + next unless inbox.channel.sync_due? + + ::Inboxes::FetchAppStoreReviewsJob.perform_later(inbox.channel) + end + end +end diff --git a/app/jobs/inboxes/fetch_app_store_reviews_job.rb b/app/jobs/inboxes/fetch_app_store_reviews_job.rb new file mode 100644 index 000000000..bd1fa79a7 --- /dev/null +++ b/app/jobs/inboxes/fetch_app_store_reviews_job.rb @@ -0,0 +1,15 @@ +class Inboxes::FetchAppStoreReviewsJob < ApplicationJob + queue_as :scheduled_jobs + + def perform(channel) + channel.fetch_reviews.each do |review_payload| + ::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: channel.account).capture_exception + end + + channel.update!(last_synced_at: Time.current) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: channel.account).capture_exception + end +end diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index e892c189a..8df046883 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -10,6 +10,7 @@ class SendReplyJob < ApplicationJob 'Channel::Sms' => ::Sms::SendOnSmsService, 'Channel::Instagram' => ::Instagram::SendOnInstagramService, 'Channel::Tiktok' => ::Tiktok::SendOnTiktokService, + 'Channel::AppStore' => ::AppStore::SendOnAppStoreService, 'Channel::Email' => ::Email::SendOnEmailService, 'Channel::WebWidget' => ::Messages::SendEmailNotificationService, 'Channel::Api' => ::Messages::SendEmailNotificationService diff --git a/app/models/account.rb b/app/models/account.rb index efaca8850..9b52b998c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -61,6 +61,7 @@ class Account < ApplicationRecord has_many :agent_bot_inboxes, dependent: :destroy_async has_many :agent_bots, dependent: :destroy_async has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api' + has_many :app_store_channels, dependent: :destroy_async, class_name: '::Channel::AppStore' has_many :articles, dependent: :destroy_async, class_name: '::Article' has_many :assignment_policies, dependent: :destroy_async has_many :automation_rules, dependent: :destroy_async diff --git a/app/models/channel/app_store.rb b/app/models/channel/app_store.rb new file mode 100644 index 000000000..d03da73b0 --- /dev/null +++ b/app/models/channel/app_store.rb @@ -0,0 +1,72 @@ +class Channel::AppStore < ApplicationRecord + include Channelable + + self.table_name = 'channel_app_store' + + EDITABLE_ATTRS = [:app_id, :bundle_id, :app_name, :issuer_id, :key_id, :private_key, { provider_config: {} }].freeze + + API_BASE_URL = 'https://api.appstoreconnect.apple.com'.freeze + REVIEWS_PAGE_SIZE = 200 + SYNC_INTERVAL = 1.hour + + if Chatwoot.encryption_configured? + encrypts :issuer_id + encrypts :key_id + encrypts :private_key + end + + validates :app_id, presence: true, uniqueness: { scope: :account_id } + validates :issuer_id, :key_id, :private_key, presence: true + validate :validate_app_access, on: :create + + before_validation :normalize_auth_fields + + after_create_commit :enqueue_initial_review_fetch + + def name + 'App Store' + end + + def sync_due? + last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago + end + + def fetch_reviews + app_store_client.fetch_reviews + end + + def reply_to_review(review_id, response_body, response_id: nil) + response = if response_id.present? + app_store_client.update_review_response(response_id, response_body) + else + app_store_client.create_review_response(review_id, response_body) + end + + response['id'] + end + + def app_store_client + @app_store_client ||= AppStoreConnect::Client.new(channel: self) + end + + private + + def normalize_auth_fields + self.app_id = app_id.to_s.strip + self.issuer_id = issuer_id.to_s.strip + self.key_id = key_id.to_s.strip + self.private_key = private_key.to_s.gsub('\n', "\n").gsub("\r\n", "\n").strip + end + + def validate_app_access + app = app_store_client.fetch_app + self.app_name = app.dig('attributes', 'name') if app_name.blank? + self.bundle_id = app.dig('attributes', 'bundleId') if bundle_id.blank? + rescue StandardError => e + errors.add(:base, e.message) + end + + def enqueue_initial_review_fetch + ::Inboxes::FetchAppStoreReviewsJob.perform_later(self) + end +end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 82b250560..97e01a8f1 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -134,6 +134,10 @@ class Inbox < ApplicationRecord channel_type == 'Channel::Tiktok' end + def app_store? + channel_type == 'Channel::AppStore' + end + def web_widget? channel_type == 'Channel::WebWidget' end diff --git a/app/services/app_store/review_builder.rb b/app/services/app_store/review_builder.rb new file mode 100644 index 000000000..a42c16c09 --- /dev/null +++ b/app/services/app_store/review_builder.rb @@ -0,0 +1,173 @@ +class AppStore::ReviewBuilder + pattr_initialize [:review_payload!, :channel!] + + def perform + return if review_id.blank? || review_body.blank? + + ActiveRecord::Base.transaction do + build_contact_inbox + build_conversation + upsert_review_message + build_response_message if response_body.present? + end + end + + private + + def inbox + @inbox ||= channel.inbox + end + + def review + @review ||= review_payload['review'] || {} + end + + def response + @response ||= review_payload['response'] || {} + end + + def attributes + @attributes ||= review['attributes'] || {} + end + + def response_attributes + @response_attributes ||= response['attributes'] || {} + end + + def review_id + review['id'] + end + + def review_body + attributes['body'].to_s.strip + end + + def review_title + attributes['title'].to_s.strip + end + + def rating + attributes['rating'].to_i + end + + def created_at + Time.zone.parse(attributes['createdDate'].to_s) + rescue StandardError + Time.current + end + + def response_id + response['id'] + end + + def response_body + response_attributes['responseBody'].to_s.strip + end + + def response_created_at + Time.zone.parse(response_attributes['lastModifiedDate'].to_s) + rescue StandardError + Time.current + end + + def build_contact_inbox + @contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: review_id, + inbox: inbox, + contact_attributes: { + name: attributes['reviewerNickname'].presence || 'App Store User', + additional_attributes: { source_id: "app_store:#{review_id}" } + } + ).perform + end + + def build_conversation + @conversation = @contact_inbox.conversations.last || ::Conversation.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + contact_id: @contact_inbox.contact_id, + contact_inbox_id: @contact_inbox.id, + additional_attributes: { source: 'app_store', app_id: channel.app_id } + ) + end + + def upsert_review_message + message = @conversation.messages.find_by(source_id: review_id) + if message + message.update!(content: message_content, content_attributes: review_metadata) + return + end + + @conversation.messages.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + sender: @conversation.contact, + message_type: :incoming, + source_id: review_id, + content: message_content, + content_attributes: review_metadata, + created_at: created_at, + updated_at: created_at + ) + end + + def build_response_message + return if response_id.blank? + return if @conversation.messages.exists?(source_id: response_id) + + @conversation.messages.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + message_type: :outgoing, + source_id: response_id, + content: response_body, + status: :delivered, + content_attributes: response_metadata, + created_at: response_created_at, + updated_at: response_created_at + ) + end + + def message_content + [ + rating_line, + review_title.presence, + review_body, + review_footer + ].compact_blank.join("\n\n") + end + + def rating_line + stars = ('★' * rating) + ('☆' * (5 - rating)) + "#{stars} (#{rating}/5)" + end + + def review_footer + parts = [attributes['territory'], attributes['reviewerNickname']].compact_blank + return nil if parts.empty? + + parts.join(' • ') + end + + def review_metadata + { + app_store: { + rating: rating, + title: review_title, + territory: attributes['territory'], + reviewer_nickname: attributes['reviewerNickname'], + created_date: attributes['createdDate'] + } + } + end + + def response_metadata + { + app_store: { + response_id: response_id, + response_state: response_attributes['state'], + response_last_modified_date: response_attributes['lastModifiedDate'] + } + } + end +end diff --git a/app/services/app_store/send_on_app_store_service.rb b/app/services/app_store/send_on_app_store_service.rb new file mode 100644 index 000000000..3949a2b08 --- /dev/null +++ b/app/services/app_store/send_on_app_store_service.rb @@ -0,0 +1,38 @@ +class AppStore::SendOnAppStoreService < Base::SendOnChannelService + private + + def channel_class + Channel::AppStore + end + + def perform_reply + validate_message_support! + source_id = channel.reply_to_review(review_id, reply_content, response_id: existing_response_id) + message.update!(source_id: source_id) if source_id.present? + Messages::StatusUpdateService.new(message, 'delivered').perform + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: message.account).capture_exception + Messages::StatusUpdateService.new(message, 'failed', e.message).perform + end + + def validate_message_support! + raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any? + end + + def review_id + message.conversation.contact_inbox.source_id + end + + def reply_content + message.outgoing_content.presence || message.content + end + + def existing_response_id + message.conversation.messages + .outgoing + .where.not(id: message.id) + .where.not(source_id: [nil, '']) + .order(created_at: :desc) + .pick(:source_id) + end +end diff --git a/app/services/app_store_connect/client.rb b/app/services/app_store_connect/client.rb new file mode 100644 index 000000000..77a41d83b --- /dev/null +++ b/app/services/app_store_connect/client.rb @@ -0,0 +1,143 @@ +class AppStoreConnect::Client + class Error < StandardError; end + + pattr_initialize [:channel!] + + def fetch_app + get("/v1/apps/#{channel.app_id}")['data'] + end + + def fetch_reviews + reviews = [] + next_url = nil + + loop do + payload = next_url ? get_url(next_url) : get(reviews_path, reviews_query) + included = Array(payload['included']) + reviews.concat(Array(payload['data']).map { |review| normalize_review(review, included) }) + + next_url = payload.dig('links', 'next') + break if next_url.blank? + end + + reviews + end + + def create_review_response(review_id, response_body) + post('/v1/customerReviewResponses', review_response_payload(review_id, response_body))['data'] + end + + def update_review_response(response_id, response_body) + patch("/v1/customerReviewResponses/#{response_id}", review_response_update_payload(response_id, response_body))['data'] + end + + private + + def reviews_path + "/v1/apps/#{channel.app_id}/customerReviews" + end + + def reviews_query + { + include: 'response', + limit: Channel::AppStore::REVIEWS_PAGE_SIZE, + sort: '-createdDate' + } + end + + def normalize_review(review, included) + response_id = review.dig('relationships', 'response', 'data', 'id') + response = included.find { |item| item['type'] == 'customerReviewResponses' && item['id'] == response_id } + + { + 'review' => review, + 'response' => response + } + end + + def get(path, query = {}) + request(:get, "#{Channel::AppStore::API_BASE_URL}#{path}", query: query) + end + + def get_url(url) + request(:get, url) + end + + def post(path, body) + request(:post, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body) + end + + def patch(path, body) + request(:patch, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body) + end + + def request(method, url, query: {}, body: nil) + response = HTTParty.public_send( + method, + url, + headers: headers, + query: query, + body: body&.to_json + ) + + log_rate_limit(response) + return response.parsed_response if response.success? + + raise Error, error_message(response) + end + + def headers + { + 'Authorization' => "Bearer #{token}", + 'Content-Type' => 'application/json' + } + end + + def token + @token ||= AppStoreConnect::TokenService.new(channel: channel).token + end + + def review_response_payload(review_id, response_body) + { + data: { + type: 'customerReviewResponses', + attributes: { + responseBody: response_body.to_s + }, + relationships: { + review: { + data: { + type: 'customerReviews', + id: review_id + } + } + } + } + } + end + + def review_response_update_payload(response_id, response_body) + { + data: { + type: 'customerReviewResponses', + id: response_id, + attributes: { + responseBody: response_body.to_s + } + } + } + end + + def log_rate_limit(response) + rate_limit = response.headers['x-rate-limit'] + Rails.logger.info("[APP_STORE_CONNECT] rate_limit=#{rate_limit}") if rate_limit.present? + end + + def error_message(response) + parsed_response = response.parsed_response + errors = parsed_response.is_a?(Hash) ? parsed_response['errors'] : [] + details = Array(errors).filter_map { |error| error['detail'] || error['title'] }.join(', ') + details = response.body if details.blank? + "App Store Connect API failed (#{response.code}): #{details}" + end +end diff --git a/app/services/app_store_connect/token_service.rb b/app/services/app_store_connect/token_service.rb new file mode 100644 index 000000000..abb66b75a --- /dev/null +++ b/app/services/app_store_connect/token_service.rb @@ -0,0 +1,47 @@ +class AppStoreConnect::TokenService + TOKEN_TTL = 19.minutes + EXPIRY_BUFFER = 1.minute + + pattr_initialize [:channel!] + + def token + cached_token || generate_token + end + + private + + def cached_token + Rails.cache.read(cache_key) + end + + def generate_token + token = JWT.encode(payload, private_key, 'ES256', headers) + Rails.cache.write(cache_key, token, expires_in: TOKEN_TTL - EXPIRY_BUFFER) + token + end + + def payload + now = Time.current.to_i + { + iss: channel.issuer_id, + iat: now, + exp: now + TOKEN_TTL.to_i, + aud: 'appstoreconnect-v1' + } + end + + def headers + { + kid: channel.key_id, + typ: 'JWT' + } + end + + def private_key + OpenSSL::PKey.read(channel.private_key.to_s.gsub('\\n', "\n")) + end + + def cache_key + "app_store_connect_token:#{channel.id}:#{channel.updated_at.to_i}" + end +end diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 0ae0745cd..40f833b5d 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -63,6 +63,14 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram? ## Tiktok Attributes json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok? +## App Store Attributes +if resource.app_store? + json.app_id resource.channel.try(:app_id) + json.bundle_id resource.channel.try(:bundle_id) + json.app_name resource.channel.try(:app_name) + json.last_synced_at resource.channel.try(:last_synced_at) +end + ## Twilio Attributes json.messaging_service_sid resource.channel.try(:messaging_service_sid) json.phone_number resource.channel.try(:phone_number) diff --git a/app_store_inbox.md b/app_store_inbox.md new file mode 100644 index 000000000..441a1f165 --- /dev/null +++ b/app_store_inbox.md @@ -0,0 +1,429 @@ +# App Store Reviews Inbox Plan + +## Goal + +Add Apple App Store reviews as a Chatwoot inbox, similar to the Google Play Reviews inbox, so agents can read App Store reviews in Chatwoot and post developer responses from the conversation reply box. + +## API Findings + +- Use the official App Store Connect API, not public RSS/iTunes review feeds. +- App Store Connect API uses API keys and ES256 JWT bearer tokens, not OAuth user sign-in. +- Required credentials: + - Issuer ID + - Key ID + - `.p8` private key +- JWTs should be short-lived. Apple generally rejects App Store Connect API tokens with expiration more than 20 minutes in the future. +- Reviews can be fetched from: + - `GET /v1/apps/{id}/customerReviews` + - `GET /v1/appStoreVersions/{id}/customerReviews` +- Review fields include: + - `rating` + - `title` + - `body` + - `reviewerNickname` + - `createdDate` + - `territory` + - `response` +- Developer responses can be included with `include=response`. +- Developer replies are created or updated through: + - `POST /v1/customerReviewResponses` +- A review can have at most one developer response. Posting another response updates/replaces the existing one. +- Apple says responses can take up to 24 hours to appear publicly. +- Required App Store Connect role for responding: Account Holder, Admin, or Customer Support. +- No Apple equivalent of Google Play's 7-day review fetch limit was found. Do not add a 7-day reply window unless real API testing proves one exists. + +References: + +- https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests +- https://developer.apple.com/documentation/appstoreconnectapi/customer-review-responses +- https://developer.apple.com/documentation/appstoreconnectapi/post-v1-customerreviewresponses +- https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app_store_version +- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/respond-to-reviews/ + +## Product Shape + +The App Store inbox should be a new channel type, separate from Google Play: + +- Channel name: App Store Reviews +- One inbox per App Store Connect app per Chatwoot account. +- Setup should be credential-form based, not OAuth redirect based. +- Agents should see each review as a conversation. +- Agents should be able to reply once; later replies update the App Store response. +- Existing developer responses from App Store Connect should be mirrored as outgoing messages. +- Review title, body, rating, territory, and reviewer nickname should be visible in the conversation. + +## Data Model + +Add `channel_app_store`. + +Suggested fields: + +- `account_id` +- `app_id` - App Store Connect app resource ID +- `bundle_id` +- `app_name` +- `provider_config` - non-secret metadata only, if needed +- `issuer_id` +- `key_id` +- `private_key` +- `last_synced_at` +- timestamps + +Indexes: + +- unique index on `[:account_id, :app_id]` + +Security: + +- Treat `.p8` private key as a sensitive credential. +- Prefer explicit encrypted columns for `issuer_id`, `key_id`, and `private_key`. +- Avoid storing the private key inside unencrypted JSONB. +- Follow existing `Chatwoot.encryption_configured?` patterns used by channel credentials. + +Model wiring: + +- Add `Channel::AppStore`. +- Add `Account#app_store_channels`. +- Add `Inbox#app_store?`. +- Add API serialization for `app_id`, `bundle_id`, `app_name`, and `last_synced_at`. +- Add `SendReplyJob` mapping to `AppStore::SendOnAppStoreService`. + +## Backend Services + +### `AppStoreConnect::TokenService` + +Responsibilities: + +- Build an ES256 JWT using the channel credentials. +- Use existing `jwt` gem. +- Parse private key with `OpenSSL::PKey`. +- Set JWT header: + - `alg: ES256` + - `kid: key_id` + - `typ: JWT` +- Set JWT payload: + - `iss: issuer_id` + - `iat` + - `exp` + - `aud: appstoreconnect-v1` +- Cache token per channel until close to expiry. + +### `AppStoreConnect::Client` + +Responsibilities: + +- Add bearer token auth header. +- Fetch reviews with pagination. +- Fetch included developer responses. +- Post developer responses. +- Raise clear errors for: + - 401 invalid credentials + - 403 missing permissions + - 404 app/review not found + - 409/422 invalid response payload + - 429 rate limited + - 5xx Apple errors + +Suggested methods: + +- `list_reviews(app_id, cursor: nil)` +- `reply_to_review(review_id, response_body)` +- `fetch_app(app_id)` or `validate_app_access(app_id)` + +## Import Pipeline + +Add jobs: + +- `Inboxes::FetchAppStoreReviewInboxesJob` +- `Inboxes::FetchAppStoreReviewsJob` + +Polling behavior: + +- Scheduled polling similar to Google Play. +- Skip suspended accounts. +- Use `last_synced_at` to avoid excessive polling. +- Page through review results using `links.next`. +- Consider sorting by `-createdDate`. + +Add `AppStore::ReviewBuilder`. + +Mapping: + +- Apple review ID maps to `ContactInbox#source_id`. +- One review maps to one conversation. +- Review edits should be handled idempotently. +- Incoming message source ID can be based on review ID plus `createdDate` or a stable edit/version field if Apple exposes one. +- Existing developer response maps to an outgoing message. +- Developer response message source ID should use Apple response ID if present. + +Message content: + +- Include star rating. +- Include title. +- Include body. +- Include a compact footer with territory and reviewer nickname when useful. + +Message timestamps: + +- Use Apple `createdDate` as `created_at` and `updated_at` for imported review messages. +- Do not use import time for review messages. + +Metadata: + +Store under `content_attributes[:app_store]`: + +- `rating` +- `title` +- `territory` +- `reviewer_nickname` +- `created_date` +- `response_state` +- `response_id` + +## Reply Pipeline + +Add `AppStore::SendOnAppStoreService`. + +Behavior: + +- Use `conversation.contact_inbox.source_id` as the Apple review ID. +- Call `POST /v1/customerReviewResponses`. +- On success: + - Update `message.source_id` with Apple response ID if returned. + - Mark message as sent/delivered through `Messages::StatusUpdateService`. +- On failure: + - Mark message as failed through `Messages::StatusUpdateService`. + - Store `external_error`. + +Constraints: + +- Disable attachments. +- Disable rich-text formatting. +- Confirm Apple's response length limit during implementation. Do not guess a hard cap unless verified. + +## Frontend + +Add App Store Reviews to inbox creation. + +Setup form fields: + +- Inbox name +- App Store Connect app ID +- Bundle ID, optional if app ID is enough +- Issuer ID +- Key ID +- Private key `.p8` + +Backend should validate credentials before creating the inbox by calling App Store Connect. + +Frontend wiring: + +- Add `INBOX_TYPES.APP_STORE`. +- Add `isAnAppStoreChannel` / equivalent composable and mixin helpers. +- Add channel icon. +- Add i18n strings in `en.json` only. +- Add channel to inbox list and channel factory. +- Add API client for creating/validating App Store channel. +- Add plain text editor config for `Channel::AppStore`. +- Add reply max length only after Apple limit is confirmed. + +Unsupported settings: + +- Hide bots. +- Hide business hours. +- Hide CSAT. +- Hide help center. +- Hide channel preferences that do not apply. +- Disable attachments. + +## Tests + +Backend specs: + +- `Channel::AppStore` validations and associations. +- JWT generation with generated EC key. +- Client review pagination. +- Client reply request body. +- Client error handling. +- Inbox creation credential validation. +- Review builder creates contact, conversation, incoming message. +- Review builder idempotency. +- Review builder mirrors existing developer response. +- Send service success and failure. +- Polling job skips suspended accounts. +- Polling job respects sync interval. + +Frontend specs: + +- Channel detection helper/composable. +- Inbox type icon/readable label. +- Setup form validation. +- Reply box behavior for unsupported attachments/formatting. + +## Resolved Open Questions + +### App-level reviews vs version-level reviews + +Use app-level reviews as the primary fetch path: + +- `GET /v1/apps/{id}/customerReviews` + +Reasoning: + +- Chatwoot inboxes should map to an app, not to a specific App Store version. +- Apple documents app-level customer reviews as the endpoint for getting reviews for a specific app. +- Version-level reviews are still useful for narrower workflows, but they would make inbox setup more complicated and could fragment one app's support queue across several inboxes. + +Implementation decision: + +- Store the App Store Connect app resource ID on `Channel::AppStore`. +- Fetch app-level reviews by default. +- Keep version-level support out of MVP. +- Add `platform` or `app_store_version_id` later only if real API testing shows app-level reviews mix platforms in a way agents cannot work with. + +Still needs real API validation: + +- Confirm whether app-level reviews include all platforms and all versions for a multi-platform app. +- Confirm whether app-level review payload includes enough context to identify platform/version. The documented review attributes include `rating`, `title`, `body`, `reviewerNickname`, `createdDate`, and `territory`, but not platform/version. + +### Response payload shape + +Use JSON:API format for `POST /v1/customerReviewResponses`. + +Request body: + +```json +{ + "data": { + "type": "customerReviewResponses", + "attributes": { + "responseBody": "Thanks for the feedback." + }, + "relationships": { + "review": { + "data": { + "type": "customerReviews", + "id": "CUSTOMER_REVIEW_ID" + } + } + } + } +} +``` + +Expected successful response: + +- HTTP `201 Created`. +- Response resource type: `customerReviewResponses`. +- Response fields can include: + - `responseBody` + - `lastModifiedDate` + - `state` + - `review` + +Implementation decision: + +- Use the returned customer review response ID as outgoing message `source_id`. +- Store `state` and `lastModifiedDate` in `content_attributes[:app_store]` when present. + +### Review edits and idempotency + +Apple's documented `CustomerReview.Attributes` include `createdDate`, but not an update timestamp for the customer review itself. + +Implementation decision: + +- Use the Apple review ID as the stable incoming message source ID. +- Create one incoming message per review. +- If a fetched review with the same ID has changed title/body/rating, update the existing message content and metadata instead of creating a new message. +- Do not append edit history in MVP because the API docs do not expose a review edit timestamp. + +Still needs real API validation: + +- Confirm how Apple represents a reviewer editing an existing review in API responses. +- Confirm whether edited reviews preserve the same review ID. + +### Response body length + +No official customer review response length limit was found in the App Store Connect API docs checked. + +Implementation decision: + +- Do not hardcode a special App Store response length limit in MVP. +- Use Chatwoot's general reply validation on the frontend. +- Let Apple return `409` or `422` for invalid response payloads and surface the error through `external_error`. + +Still needs real API validation: + +- Check whether App Store Connect applies a hidden maximum length for `responseBody`. + +### Rate limits and retries + +Apple documents rate limits through the `X-Rate-Limit` response header. + +Header shape: + +```text +user-hour-lim:3500;user-hour-rem:500; +``` + +Behavior: + +- Limits apply to requests using the same API key. +- The window is a rolling hour. +- Exceeding the limit returns HTTP `429` with `RATE_LIMIT_EXCEEDED`. + +Implementation decision: + +- Parse and log `X-Rate-Limit` headers in the client. +- On `429`, do not mark the inbox broken. +- Re-enqueue the fetch job later with backoff. +- Keep polling conservative, similar to Google Play, and page with `limit=200`. + +### Inbox scope for multiple platforms and versions + +Implementation decision: + +- MVP scope is one inbox per App Store Connect app resource ID per Chatwoot account. +- Do not create separate inboxes by platform or app version. +- Store `platform` only if we can reliably derive it during setup or fetch. + +Reasoning: + +- This matches the way agents think about supporting one app. +- It avoids forcing customers to know App Store version resource IDs. +- It keeps parity with Google Play's one-app-per-inbox model. + +Still needs real API validation: + +- Confirm if app-level reviews for multi-platform apps are agent-friendly without platform separation. + +## Remaining Risks + +- Credential storage must be handled carefully because `.p8` private keys are highly sensitive. +- Apple responses can remain pending for up to 24 hours, so Chatwoot send status and public App Store visibility are not the same. +- A real App Store Connect app and API key are required before finalizing response-length handling, review-edit behavior, and multi-platform behavior. + +## Suggested Implementation Order + +1. Add model, migration, associations, and inbox serialization. +2. Add JWT token service and low-level App Store Connect client. +3. Add backend channel creation/validation endpoint. +4. Add review fetch job and review builder. +5. Add send service and `SendReplyJob` mapping. +6. Add frontend inbox setup and channel helpers. +7. Add settings/reply-box restrictions. +8. Add specs. +9. Manually verify with a real App Store Connect app and API key. +10. Revisit shared abstractions with Google Play after both integrations work. + +## Potential Shared Abstraction Later + +After Google Play and App Store are both implemented, consider extracting shared store-review behavior: + +- Store review polling orchestration. +- Review-to-conversation builder conventions. +- Reply status handling. +- Unsupported inbox settings. +- Plain-text reply channel behavior. + +Do not extract this upfront. Let both implementations settle first. diff --git a/config/schedule.yml b/config/schedule.yml index f5e335ba3..6f3d4e0cf 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job: class: 'Inboxes::FetchImapEmailInboxesJob' queue: scheduled_jobs +# executed every 15 minutes to fetch App Store reviews +trigger_app_store_review_inboxes_job: + cron: '*/15 * * * *' + class: 'Inboxes::FetchAppStoreReviewInboxesJob' + queue: scheduled_jobs + # executed daily at 2230 UTC # which is our lowest traffic time remove_stale_contact_inboxes_job.rb: diff --git a/db/migrate/20260520090000_create_channel_app_store.rb b/db/migrate/20260520090000_create_channel_app_store.rb new file mode 100644 index 000000000..cff96863f --- /dev/null +++ b/db/migrate/20260520090000_create_channel_app_store.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 diff --git a/db/schema.rb b/db/schema.rb index 9d9fe3cbc..35afe0fb4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_20_090000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -449,6 +449,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true end + create_table "channel_app_store", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "app_id", null: false + t.string "bundle_id" + t.string "app_name" + t.string "issuer_id", null: false + t.string "key_id", null: false + t.text "private_key", null: false + t.jsonb "provider_config", default: {}, null: false + t.datetime "last_synced_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "app_id"], name: "index_channel_app_store_on_account_id_and_app_id", unique: true + end + create_table "channel_email", force: :cascade do |t| t.integer "account_id", null: false t.string "email", null: false From 572d6d2e404e7139c2000ee0a0865b71f2e5cbe7 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Thu, 21 May 2026 08:18:22 +0400 Subject: [PATCH 2/3] feat(inboxes): add app store reviews channel --- .../api/v1/accounts/inboxes_controller.rb | 5 +- app/helpers/api/v1/inboxes_helper.rb | 3 +- .../components-next/icon/provider.js | 1 + .../components-next/message/MessageMeta.vue | 10 +- .../components/widgets/ChannelItem.vue | 3 +- .../dashboard/composables/useInbox.js | 7 + app/javascript/dashboard/constants/editor.js | 5 + app/javascript/dashboard/helper/inbox.js | 6 + .../dashboard/i18n/locale/en/inboxMgmt.json | 38 ++++ .../settings/inbox/ChannelFactory.vue | 2 + .../dashboard/settings/inbox/ChannelList.vue | 6 + .../dashboard/settings/inbox/Settings.vue | 13 ++ .../settings/inbox/channels/AppStore.vue | 176 ++++++++++++++++++ .../settings/inbox/components/ChannelName.vue | 1 + .../dashboard/store/modules/inboxes.js | 3 +- app/javascript/shared/mixins/inboxMixin.js | 7 + .../fetch_app_store_review_inboxes_job.rb | 12 ++ .../inboxes/fetch_app_store_reviews_job.rb | 15 ++ app/jobs/send_reply_job.rb | 1 + app/models/account.rb | 1 + app/models/channel/app_store.rb | 72 +++++++ app/models/inbox.rb | 4 + app/services/app_store/review_builder.rb | 173 +++++++++++++++++ .../app_store/send_on_app_store_service.rb | 38 ++++ app/services/app_store_connect/client.rb | 143 ++++++++++++++ .../app_store_connect/token_service.rb | 47 +++++ app/views/api/v1/models/_inbox.json.jbuilder | 8 + config/schedule.yml | 6 + ...20260520090000_create_channel_app_store.rb | 19 ++ db/schema.rb | 17 +- 30 files changed, 833 insertions(+), 9 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue create mode 100644 app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb create mode 100644 app/jobs/inboxes/fetch_app_store_reviews_job.rb create mode 100644 app/models/channel/app_store.rb create mode 100644 app/services/app_store/review_builder.rb create mode 100644 app/services/app_store/send_on_app_store_service.rb create mode 100644 app/services/app_store_connect/client.rb create mode 100644 app/services/app_store_connect/token_service.rb create mode 100644 db/migrate/20260520090000_create_channel_app_store.rb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 757af9b62..9b8f7b747 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def allowed_channel_types - %w[web_widget api email line telegram whatsapp sms] + %w[web_widget api email line telegram whatsapp sms app_store] end def update_inbox_working_hours @@ -179,7 +179,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController 'line' => Channel::Line, 'telegram' => Channel::Telegram, 'whatsapp' => Channel::Whatsapp, - 'sms' => Channel::Sms + 'sms' => Channel::Sms, + 'app_store' => Channel::AppStore }[permitted_params[:channel][:type]] end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8a10fa99c..3920ecfa4 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -111,7 +111,8 @@ module Api::V1::InboxesHelper 'line' => Current.account.line_channels, 'telegram' => Current.account.telegram_channels, 'whatsapp' => Current.account.whatsapp_channels, - 'sms' => Current.account.sms_channels + 'sms' => Current.account.sms_channels, + 'app_store' => Current.account.app_store_channels }[permitted_params[:channel][:type]] end diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index d7a9c93ad..ebb168f3d 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -15,6 +15,7 @@ export function useChannelIcon(inbox) { 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', 'Channel::Tiktok': 'i-woot-tiktok', + 'Channel::AppStore': 'i-ri-app-store-fill', }; const providerIconMap = { diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index f26339cbb..e0c1f071e 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -20,6 +20,7 @@ const { isAWhatsAppChannel, isAnEmailChannel, isAnInstagramChannel, + isAnAppStoreChannel, isATiktokChannel, } = useInbox(); @@ -62,7 +63,8 @@ const isSent = computed(() => { isASmsInbox.value || isATelegramChannel.value || isAnInstagramChannel.value || - isATiktokChannel.value + isATiktokChannel.value || + isAnAppStoreChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.SENT; } @@ -85,7 +87,8 @@ const isDelivered = computed(() => { isASmsInbox.value || isAFacebookInbox.value || isAnInstagramChannel.value || - isATiktokChannel.value + isATiktokChannel.value || + isAnAppStoreChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED; } @@ -110,7 +113,8 @@ const isRead = computed(() => { isATwilioChannel.value || isAFacebookInbox.value || isAnInstagramChannel.value || - isATiktokChannel.value + isATiktokChannel.value || + isAnAppStoreChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.READ; } diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 2cbad72c5..54c9c707d 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -66,6 +66,7 @@ const isActive = computed(() => { 'line', 'instagram', 'tiktok', + 'app_store', 'voice', ].includes(key); }); @@ -78,7 +79,7 @@ const isComingSoon = computed(() => { }); const isBeta = computed(() => { - return ['tiktok', 'voice'].includes(props.channel.key); + return ['tiktok', 'app_store', 'voice'].includes(props.channel.key); }); const onItemClick = () => { diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index 6e8d0a52a..bb6a01374 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -22,6 +22,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], [INBOX_FEATURES.REPLY_TO_OUTGOING]: [ @@ -30,6 +31,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], }; @@ -138,6 +140,10 @@ export const useInbox = (inboxId = null) => { return channelType.value === INBOX_TYPES.TIKTOK; }); + const isAnAppStoreChannel = computed(() => { + return channelType.value === INBOX_TYPES.APP_STORE; + }); + const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value)); const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); @@ -160,6 +166,7 @@ export const useInbox = (inboxId = null) => { isAnEmailChannel, isAnInstagramChannel, isATiktokChannel, + isAnAppStoreChannel, voiceCallEnabled, voiceCallProvider, }; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 378d303b4..a89d197a9 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -114,6 +114,11 @@ export const FORMATTING = { nodes: [], menu: [], }, + 'Channel::AppStore': { + marks: [], + nodes: [], + menu: [], + }, // Special contexts (not actual channels) 'Context::PrivateNote': { marks: ['strong', 'em', 'code', 'link', 'strike'], diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 4039a07d1..65d04b2ca 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -11,6 +11,7 @@ export const INBOX_TYPES = { SMS: 'Channel::Sms', INSTAGRAM: 'Channel::Instagram', TIKTOK: 'Channel::Tiktok', + APP_STORE: 'Channel::AppStore', }; // Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp) @@ -50,6 +51,7 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill', + [INBOX_TYPES.APP_STORE]: 'i-ri-app-store-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -65,6 +67,7 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.LINE]: 'i-woot-line', [INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram', [INBOX_TYPES.TIKTOK]: 'i-woot-tiktok', + [INBOX_TYPES.APP_STORE]: 'i-ri-app-store-line', }; const DEFAULT_ICON_LINE = 'i-ri-chat-1-line'; @@ -114,6 +117,9 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; + case INBOX_TYPES.APP_STORE: + return 'app_store'; + default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 2620f89f7..9b9df0fa2 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -382,6 +382,39 @@ "ERROR_MESSAGE": "We were not able to save the api channel" } }, + "APP_STORE": { + "TITLE": "App Store Reviews", + "DESC": "Manage and reply to your App Store reviews from Chatwoot.", + "CHANNEL_NAME": { + "LABEL": "Inbox Name", + "PLACEHOLDER": "Please enter an inbox name", + "ERROR": "This field is required" + }, + "APP_ID": { + "LABEL": "App Store Connect app ID", + "PLACEHOLDER": "Enter your App Store Connect app ID", + "ERROR": "This field is required" + }, + "ISSUER_ID": { + "LABEL": "Issuer ID", + "PLACEHOLDER": "Enter your App Store Connect issuer ID", + "ERROR": "This field is required" + }, + "KEY_ID": { + "LABEL": "Key ID", + "PLACEHOLDER": "Enter your App Store Connect key ID", + "ERROR": "This field is required" + }, + "PRIVATE_KEY": { + "LABEL": "Private key", + "PLACEHOLDER": "Paste the contents of your .p8 private key", + "ERROR": "This field is required" + }, + "SUBMIT_BUTTON": "Create App Store Channel", + "API": { + "ERROR_MESSAGE": "We were not able to save the App Store channel" + } + }, "EMAIL_CHANNEL": { "TITLE": "Email Channel", "DESC": "Integrate your email inbox.", @@ -493,6 +526,10 @@ "TITLE": "TikTok", "DESCRIPTION": "Connect your TikTok account" }, + "APP_STORE": { + "TITLE": "App Store Reviews", + "DESCRIPTION": "Manage your App Store app reviews" + }, "VOICE": { "TITLE": "Voice", "DESCRIPTION": "Integrate with Twilio Voice" @@ -1165,6 +1202,7 @@ "API": "API Channel", "INSTAGRAM": "Instagram", "TIKTOK": "TikTok", + "APP_STORE": "App Store Reviews", "VOICE": "Voice" } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index 7d1d58854..fec8090a1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -12,6 +12,7 @@ import Telegram from './channels/Telegram.vue'; import Instagram from './channels/Instagram.vue'; import Tiktok from './channels/Tiktok.vue'; import Voice from './channels/Voice.vue'; +import AppStore from './channels/AppStore.vue'; const channelViewList = { facebook: Facebook, @@ -25,6 +26,7 @@ const channelViewList = { telegram: Telegram, instagram: Instagram, tiktok: Tiktok, + app_store: AppStore, voice: Voice, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index e2ebd27cd..0a7f191ff 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -77,6 +77,12 @@ const channelList = computed(() => { description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'), icon: 'i-woot-instagram', }, + { + key: 'app_store', + title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.TITLE'), + description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.DESCRIPTION'), + icon: 'i-ri-app-store-line', + }, ]; if (hasTiktokConfigured.value) { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index a26eb0e18..2e5e44724 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -249,6 +249,13 @@ export default { ]; } + if (this.isAnAppStoreChannel) { + const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration']; + visibleToAllChannelTabs = visibleToAllChannelTabs.filter( + tab => !unsupportedKeys.includes(tab.key) + ); + } + return visibleToAllChannelTabs; }, currentInboxId() { @@ -282,6 +289,9 @@ export default { if (this.isAnEmailChannel) { return `${this.inbox.name} (${this.inbox.email})`; } + if (this.isAnAppStoreChannel && this.inbox.app_id) { + return `${this.inbox.name} (${this.inbox.app_id})`; + } return this.inbox.name; }, canLocktoSingleConversation() { @@ -293,6 +303,7 @@ export default { this.isAnInstagramChannel || this.isALineChannel || this.isATiktokChannel || + this.isAnAppStoreChannel || this.isATelegramChannel ); }, @@ -829,6 +840,7 @@ export default { @@ -1128,6 +1140,7 @@ export default { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue new file mode 100644 index 000000000..6eb5b6073 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/AppStore.vue @@ -0,0 +1,176 @@ + + + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.ERROR') }} + + + + + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.LABEL') }} + + + {{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.ERROR') }} + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 711217f32..3625d7601 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -34,6 +34,7 @@ const i18nMap = { 'Channel::Api': 'API', 'Channel::Instagram': 'INSTAGRAM', 'Channel::Tiktok': 'TIKTOK', + 'Channel::AppStore': 'APP_STORE', }; const twilioChannelName = () => { diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index ce24ef653..2d6084a0c 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -156,7 +156,8 @@ export const getters = { }, dialogFlowEnabledInboxes($state) { return $state.records.filter( - item => item.channel_type !== INBOX_TYPES.EMAIL + item => + ![INBOX_TYPES.EMAIL, INBOX_TYPES.APP_STORE].includes(item.channel_type) ); }, getFacebookInboxByInstagramId: $state => instagramId => { diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 40579125b..ee32c782b 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -15,6 +15,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], [INBOX_FEATURES.REPLY_TO_OUTGOING]: [ @@ -23,6 +24,7 @@ export const INBOX_FEATURE_MAP = { INBOX_TYPES.WHATSAPP, INBOX_TYPES.TELEGRAM, INBOX_TYPES.TIKTOK, + INBOX_TYPES.APP_STORE, INBOX_TYPES.API, ], }; @@ -119,6 +121,8 @@ export default { badgeKey = 'whatsapp'; } else if (this.isATiktokChannel) { badgeKey = 'tiktok'; + } else if (this.isAnAppStoreChannel) { + badgeKey = 'app_store'; } return badgeKey || this.channelType; }, @@ -134,6 +138,9 @@ export default { isATiktokChannel() { return this.channelType === INBOX_TYPES.TIKTOK; }, + isAnAppStoreChannel() { + return this.channelType === INBOX_TYPES.APP_STORE; + }, }, methods: { inboxHasFeature(feature) { diff --git a/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb b/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb new file mode 100644 index 000000000..d056c63b8 --- /dev/null +++ b/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb @@ -0,0 +1,12 @@ +class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + Inbox.where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox| + next if inbox.account.suspended? + next unless inbox.channel.sync_due? + + ::Inboxes::FetchAppStoreReviewsJob.perform_later(inbox.channel) + end + end +end diff --git a/app/jobs/inboxes/fetch_app_store_reviews_job.rb b/app/jobs/inboxes/fetch_app_store_reviews_job.rb new file mode 100644 index 000000000..bd1fa79a7 --- /dev/null +++ b/app/jobs/inboxes/fetch_app_store_reviews_job.rb @@ -0,0 +1,15 @@ +class Inboxes::FetchAppStoreReviewsJob < ApplicationJob + queue_as :scheduled_jobs + + def perform(channel) + channel.fetch_reviews.each do |review_payload| + ::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: channel.account).capture_exception + end + + channel.update!(last_synced_at: Time.current) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: channel.account).capture_exception + end +end diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index e892c189a..8df046883 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -10,6 +10,7 @@ class SendReplyJob < ApplicationJob 'Channel::Sms' => ::Sms::SendOnSmsService, 'Channel::Instagram' => ::Instagram::SendOnInstagramService, 'Channel::Tiktok' => ::Tiktok::SendOnTiktokService, + 'Channel::AppStore' => ::AppStore::SendOnAppStoreService, 'Channel::Email' => ::Email::SendOnEmailService, 'Channel::WebWidget' => ::Messages::SendEmailNotificationService, 'Channel::Api' => ::Messages::SendEmailNotificationService diff --git a/app/models/account.rb b/app/models/account.rb index efaca8850..9b52b998c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -61,6 +61,7 @@ class Account < ApplicationRecord has_many :agent_bot_inboxes, dependent: :destroy_async has_many :agent_bots, dependent: :destroy_async has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api' + has_many :app_store_channels, dependent: :destroy_async, class_name: '::Channel::AppStore' has_many :articles, dependent: :destroy_async, class_name: '::Article' has_many :assignment_policies, dependent: :destroy_async has_many :automation_rules, dependent: :destroy_async diff --git a/app/models/channel/app_store.rb b/app/models/channel/app_store.rb new file mode 100644 index 000000000..d03da73b0 --- /dev/null +++ b/app/models/channel/app_store.rb @@ -0,0 +1,72 @@ +class Channel::AppStore < ApplicationRecord + include Channelable + + self.table_name = 'channel_app_store' + + EDITABLE_ATTRS = [:app_id, :bundle_id, :app_name, :issuer_id, :key_id, :private_key, { provider_config: {} }].freeze + + API_BASE_URL = 'https://api.appstoreconnect.apple.com'.freeze + REVIEWS_PAGE_SIZE = 200 + SYNC_INTERVAL = 1.hour + + if Chatwoot.encryption_configured? + encrypts :issuer_id + encrypts :key_id + encrypts :private_key + end + + validates :app_id, presence: true, uniqueness: { scope: :account_id } + validates :issuer_id, :key_id, :private_key, presence: true + validate :validate_app_access, on: :create + + before_validation :normalize_auth_fields + + after_create_commit :enqueue_initial_review_fetch + + def name + 'App Store' + end + + def sync_due? + last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago + end + + def fetch_reviews + app_store_client.fetch_reviews + end + + def reply_to_review(review_id, response_body, response_id: nil) + response = if response_id.present? + app_store_client.update_review_response(response_id, response_body) + else + app_store_client.create_review_response(review_id, response_body) + end + + response['id'] + end + + def app_store_client + @app_store_client ||= AppStoreConnect::Client.new(channel: self) + end + + private + + def normalize_auth_fields + self.app_id = app_id.to_s.strip + self.issuer_id = issuer_id.to_s.strip + self.key_id = key_id.to_s.strip + self.private_key = private_key.to_s.gsub('\n', "\n").gsub("\r\n", "\n").strip + end + + def validate_app_access + app = app_store_client.fetch_app + self.app_name = app.dig('attributes', 'name') if app_name.blank? + self.bundle_id = app.dig('attributes', 'bundleId') if bundle_id.blank? + rescue StandardError => e + errors.add(:base, e.message) + end + + def enqueue_initial_review_fetch + ::Inboxes::FetchAppStoreReviewsJob.perform_later(self) + end +end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 82b250560..97e01a8f1 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -134,6 +134,10 @@ class Inbox < ApplicationRecord channel_type == 'Channel::Tiktok' end + def app_store? + channel_type == 'Channel::AppStore' + end + def web_widget? channel_type == 'Channel::WebWidget' end diff --git a/app/services/app_store/review_builder.rb b/app/services/app_store/review_builder.rb new file mode 100644 index 000000000..a42c16c09 --- /dev/null +++ b/app/services/app_store/review_builder.rb @@ -0,0 +1,173 @@ +class AppStore::ReviewBuilder + pattr_initialize [:review_payload!, :channel!] + + def perform + return if review_id.blank? || review_body.blank? + + ActiveRecord::Base.transaction do + build_contact_inbox + build_conversation + upsert_review_message + build_response_message if response_body.present? + end + end + + private + + def inbox + @inbox ||= channel.inbox + end + + def review + @review ||= review_payload['review'] || {} + end + + def response + @response ||= review_payload['response'] || {} + end + + def attributes + @attributes ||= review['attributes'] || {} + end + + def response_attributes + @response_attributes ||= response['attributes'] || {} + end + + def review_id + review['id'] + end + + def review_body + attributes['body'].to_s.strip + end + + def review_title + attributes['title'].to_s.strip + end + + def rating + attributes['rating'].to_i + end + + def created_at + Time.zone.parse(attributes['createdDate'].to_s) + rescue StandardError + Time.current + end + + def response_id + response['id'] + end + + def response_body + response_attributes['responseBody'].to_s.strip + end + + def response_created_at + Time.zone.parse(response_attributes['lastModifiedDate'].to_s) + rescue StandardError + Time.current + end + + def build_contact_inbox + @contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: review_id, + inbox: inbox, + contact_attributes: { + name: attributes['reviewerNickname'].presence || 'App Store User', + additional_attributes: { source_id: "app_store:#{review_id}" } + } + ).perform + end + + def build_conversation + @conversation = @contact_inbox.conversations.last || ::Conversation.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + contact_id: @contact_inbox.contact_id, + contact_inbox_id: @contact_inbox.id, + additional_attributes: { source: 'app_store', app_id: channel.app_id } + ) + end + + def upsert_review_message + message = @conversation.messages.find_by(source_id: review_id) + if message + message.update!(content: message_content, content_attributes: review_metadata) + return + end + + @conversation.messages.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + sender: @conversation.contact, + message_type: :incoming, + source_id: review_id, + content: message_content, + content_attributes: review_metadata, + created_at: created_at, + updated_at: created_at + ) + end + + def build_response_message + return if response_id.blank? + return if @conversation.messages.exists?(source_id: response_id) + + @conversation.messages.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + message_type: :outgoing, + source_id: response_id, + content: response_body, + status: :delivered, + content_attributes: response_metadata, + created_at: response_created_at, + updated_at: response_created_at + ) + end + + def message_content + [ + rating_line, + review_title.presence, + review_body, + review_footer + ].compact_blank.join("\n\n") + end + + def rating_line + stars = ('★' * rating) + ('☆' * (5 - rating)) + "#{stars} (#{rating}/5)" + end + + def review_footer + parts = [attributes['territory'], attributes['reviewerNickname']].compact_blank + return nil if parts.empty? + + parts.join(' • ') + end + + def review_metadata + { + app_store: { + rating: rating, + title: review_title, + territory: attributes['territory'], + reviewer_nickname: attributes['reviewerNickname'], + created_date: attributes['createdDate'] + } + } + end + + def response_metadata + { + app_store: { + response_id: response_id, + response_state: response_attributes['state'], + response_last_modified_date: response_attributes['lastModifiedDate'] + } + } + end +end diff --git a/app/services/app_store/send_on_app_store_service.rb b/app/services/app_store/send_on_app_store_service.rb new file mode 100644 index 000000000..3949a2b08 --- /dev/null +++ b/app/services/app_store/send_on_app_store_service.rb @@ -0,0 +1,38 @@ +class AppStore::SendOnAppStoreService < Base::SendOnChannelService + private + + def channel_class + Channel::AppStore + end + + def perform_reply + validate_message_support! + source_id = channel.reply_to_review(review_id, reply_content, response_id: existing_response_id) + message.update!(source_id: source_id) if source_id.present? + Messages::StatusUpdateService.new(message, 'delivered').perform + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: message.account).capture_exception + Messages::StatusUpdateService.new(message, 'failed', e.message).perform + end + + def validate_message_support! + raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any? + end + + def review_id + message.conversation.contact_inbox.source_id + end + + def reply_content + message.outgoing_content.presence || message.content + end + + def existing_response_id + message.conversation.messages + .outgoing + .where.not(id: message.id) + .where.not(source_id: [nil, '']) + .order(created_at: :desc) + .pick(:source_id) + end +end diff --git a/app/services/app_store_connect/client.rb b/app/services/app_store_connect/client.rb new file mode 100644 index 000000000..77a41d83b --- /dev/null +++ b/app/services/app_store_connect/client.rb @@ -0,0 +1,143 @@ +class AppStoreConnect::Client + class Error < StandardError; end + + pattr_initialize [:channel!] + + def fetch_app + get("/v1/apps/#{channel.app_id}")['data'] + end + + def fetch_reviews + reviews = [] + next_url = nil + + loop do + payload = next_url ? get_url(next_url) : get(reviews_path, reviews_query) + included = Array(payload['included']) + reviews.concat(Array(payload['data']).map { |review| normalize_review(review, included) }) + + next_url = payload.dig('links', 'next') + break if next_url.blank? + end + + reviews + end + + def create_review_response(review_id, response_body) + post('/v1/customerReviewResponses', review_response_payload(review_id, response_body))['data'] + end + + def update_review_response(response_id, response_body) + patch("/v1/customerReviewResponses/#{response_id}", review_response_update_payload(response_id, response_body))['data'] + end + + private + + def reviews_path + "/v1/apps/#{channel.app_id}/customerReviews" + end + + def reviews_query + { + include: 'response', + limit: Channel::AppStore::REVIEWS_PAGE_SIZE, + sort: '-createdDate' + } + end + + def normalize_review(review, included) + response_id = review.dig('relationships', 'response', 'data', 'id') + response = included.find { |item| item['type'] == 'customerReviewResponses' && item['id'] == response_id } + + { + 'review' => review, + 'response' => response + } + end + + def get(path, query = {}) + request(:get, "#{Channel::AppStore::API_BASE_URL}#{path}", query: query) + end + + def get_url(url) + request(:get, url) + end + + def post(path, body) + request(:post, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body) + end + + def patch(path, body) + request(:patch, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body) + end + + def request(method, url, query: {}, body: nil) + response = HTTParty.public_send( + method, + url, + headers: headers, + query: query, + body: body&.to_json + ) + + log_rate_limit(response) + return response.parsed_response if response.success? + + raise Error, error_message(response) + end + + def headers + { + 'Authorization' => "Bearer #{token}", + 'Content-Type' => 'application/json' + } + end + + def token + @token ||= AppStoreConnect::TokenService.new(channel: channel).token + end + + def review_response_payload(review_id, response_body) + { + data: { + type: 'customerReviewResponses', + attributes: { + responseBody: response_body.to_s + }, + relationships: { + review: { + data: { + type: 'customerReviews', + id: review_id + } + } + } + } + } + end + + def review_response_update_payload(response_id, response_body) + { + data: { + type: 'customerReviewResponses', + id: response_id, + attributes: { + responseBody: response_body.to_s + } + } + } + end + + def log_rate_limit(response) + rate_limit = response.headers['x-rate-limit'] + Rails.logger.info("[APP_STORE_CONNECT] rate_limit=#{rate_limit}") if rate_limit.present? + end + + def error_message(response) + parsed_response = response.parsed_response + errors = parsed_response.is_a?(Hash) ? parsed_response['errors'] : [] + details = Array(errors).filter_map { |error| error['detail'] || error['title'] }.join(', ') + details = response.body if details.blank? + "App Store Connect API failed (#{response.code}): #{details}" + end +end diff --git a/app/services/app_store_connect/token_service.rb b/app/services/app_store_connect/token_service.rb new file mode 100644 index 000000000..abb66b75a --- /dev/null +++ b/app/services/app_store_connect/token_service.rb @@ -0,0 +1,47 @@ +class AppStoreConnect::TokenService + TOKEN_TTL = 19.minutes + EXPIRY_BUFFER = 1.minute + + pattr_initialize [:channel!] + + def token + cached_token || generate_token + end + + private + + def cached_token + Rails.cache.read(cache_key) + end + + def generate_token + token = JWT.encode(payload, private_key, 'ES256', headers) + Rails.cache.write(cache_key, token, expires_in: TOKEN_TTL - EXPIRY_BUFFER) + token + end + + def payload + now = Time.current.to_i + { + iss: channel.issuer_id, + iat: now, + exp: now + TOKEN_TTL.to_i, + aud: 'appstoreconnect-v1' + } + end + + def headers + { + kid: channel.key_id, + typ: 'JWT' + } + end + + def private_key + OpenSSL::PKey.read(channel.private_key.to_s.gsub('\\n', "\n")) + end + + def cache_key + "app_store_connect_token:#{channel.id}:#{channel.updated_at.to_i}" + end +end diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 0ae0745cd..40f833b5d 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -63,6 +63,14 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram? ## Tiktok Attributes json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok? +## App Store Attributes +if resource.app_store? + json.app_id resource.channel.try(:app_id) + json.bundle_id resource.channel.try(:bundle_id) + json.app_name resource.channel.try(:app_name) + json.last_synced_at resource.channel.try(:last_synced_at) +end + ## Twilio Attributes json.messaging_service_sid resource.channel.try(:messaging_service_sid) json.phone_number resource.channel.try(:phone_number) diff --git a/config/schedule.yml b/config/schedule.yml index f5e335ba3..6f3d4e0cf 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job: class: 'Inboxes::FetchImapEmailInboxesJob' queue: scheduled_jobs +# executed every 15 minutes to fetch App Store reviews +trigger_app_store_review_inboxes_job: + cron: '*/15 * * * *' + class: 'Inboxes::FetchAppStoreReviewInboxesJob' + queue: scheduled_jobs + # executed daily at 2230 UTC # which is our lowest traffic time remove_stale_contact_inboxes_job.rb: diff --git a/db/migrate/20260520090000_create_channel_app_store.rb b/db/migrate/20260520090000_create_channel_app_store.rb new file mode 100644 index 000000000..cff96863f --- /dev/null +++ b/db/migrate/20260520090000_create_channel_app_store.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 diff --git a/db/schema.rb b/db/schema.rb index 9d9fe3cbc..35afe0fb4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_20_090000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -449,6 +449,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true end + create_table "channel_app_store", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "app_id", null: false + t.string "bundle_id" + t.string "app_name" + t.string "issuer_id", null: false + t.string "key_id", null: false + t.text "private_key", null: false + t.jsonb "provider_config", default: {}, null: false + t.datetime "last_synced_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "app_id"], name: "index_channel_app_store_on_account_id_and_app_id", unique: true + end + create_table "channel_email", force: :cascade do |t| t.integer "account_id", null: false t.string "email", null: false From 361b28cb03811fd588bb422ab639dff659da9726 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Fri, 22 May 2026 11:56:21 +0400 Subject: [PATCH 3/3] chore: Use per-account feature flag for App Store channel --- .../api/v1/accounts/inboxes_controller.rb | 6 +----- app/controllers/dashboard_controller.rb | 1 - .../components/widgets/ChannelItem.vue | 6 +----- .../fetch_app_store_review_inboxes_job.rb | 9 +-------- app/jobs/inboxes/fetch_app_store_reviews_job.rb | 8 +------- .../app_store/send_on_app_store_service.rb | 2 +- config/features.yml | 6 +++--- config/installation_config.yml | 5 ----- ...ssage_reply_to_flag_for_channel_app_store.rb | 11 +++++++++++ db/schema.rb | 2 +- .../api/v1/accounts/inboxes_controller_spec.rb | 6 +----- .../fetch_app_store_review_inboxes_job_spec.rb | 6 +++--- .../inboxes/fetch_app_store_reviews_job_spec.rb | 5 ++--- spec/models/account_spec.rb | 17 +++++++++++++++++ .../app_store/send_on_app_store_service_spec.rb | 8 ++++---- 15 files changed, 47 insertions(+), 51 deletions(-) create mode 100644 db/migrate/20260522080000_repurpose_message_reply_to_flag_for_channel_app_store.rb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 5c56c0d92..7d93af25a 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -31,7 +31,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def create - return render_app_store_feature_disabled if app_store_channel_requested? && !app_store_reviews_enabled? + return render_app_store_feature_disabled if app_store_channel_requested? && !Current.account.feature_enabled?(:channel_app_store) ActiveRecord::Base.transaction do channel = create_channel @@ -110,10 +110,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render json: { message: 'App Store Reviews channel is not enabled for this account' }, status: :forbidden end - def app_store_reviews_enabled? - GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true' - end - def update_inbox_working_hours @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 3b0e981fc..b6df015f7 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -74,7 +74,6 @@ class DashboardController < ActionController::Base FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''), - ENABLE_APP_STORE_REVIEWS_CHANNEL: GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false'), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 07028378e..a8327a466 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -27,10 +27,6 @@ const hasTiktokConfigured = computed(() => { return window.chatwootConfig?.tiktokAppId; }); -const isAppStoreReviewsEnabled = computed(() => { - return window.chatwootConfig?.enableAppStoreReviewsChannel === 'true'; -}); - const isActive = computed(() => { const { key } = props.channel; if (Object.keys(props.enabledFeatures).length === 0) { @@ -57,7 +53,7 @@ const isActive = computed(() => { } if (key === 'app_store') { - return isAppStoreReviewsEnabled.value; + return props.enabledFeatures.channel_app_store; } if (key === 'voice') { diff --git a/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb b/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb index 2507e2956..fc7b079d3 100644 --- a/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_app_store_review_inboxes_job.rb @@ -2,19 +2,12 @@ class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob queue_as :scheduled_jobs def perform - return unless app_store_reviews_enabled? - Inbox.where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox| next if inbox.account.suspended? + next unless inbox.account.feature_enabled?(:channel_app_store) next unless inbox.channel.sync_due? ::Inboxes::FetchAppStoreReviewsJob.perform_later(inbox.channel) end end - - private - - def app_store_reviews_enabled? - GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true' - end end diff --git a/app/jobs/inboxes/fetch_app_store_reviews_job.rb b/app/jobs/inboxes/fetch_app_store_reviews_job.rb index dedd4e6aa..d90fc05d4 100644 --- a/app/jobs/inboxes/fetch_app_store_reviews_job.rb +++ b/app/jobs/inboxes/fetch_app_store_reviews_job.rb @@ -2,7 +2,7 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob queue_as :scheduled_jobs def perform(channel) - return unless app_store_reviews_enabled? + return unless channel.account.feature_enabled?(:channel_app_store) channel.fetch_reviews.each do |review_payload| ::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform @@ -14,10 +14,4 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob rescue StandardError => e ChatwootExceptionTracker.new(e, account: channel.account).capture_exception end - - private - - def app_store_reviews_enabled? - GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true' - end end diff --git a/app/services/app_store/send_on_app_store_service.rb b/app/services/app_store/send_on_app_store_service.rb index 3545f83e5..c3f6a7f6b 100644 --- a/app/services/app_store/send_on_app_store_service.rb +++ b/app/services/app_store/send_on_app_store_service.rb @@ -21,7 +21,7 @@ class AppStore::SendOnAppStoreService < Base::SendOnChannelService end def validate_feature_enabled! - return if GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true' + return if message.account.feature_enabled?(:channel_app_store) raise 'App Store Reviews channel is not enabled for this account.' end diff --git a/config/features.yml b/config/features.yml index 03105588b..231f402bd 100644 --- a/config/features.yml +++ b/config/features.yml @@ -108,10 +108,10 @@ display_name: Custom Tools enabled: false premium: true -- name: message_reply_to - display_name: Message Reply To +- name: channel_app_store + display_name: App Store Reviews Channel enabled: false - deprecated: true + chatwoot_internal: true - name: insert_article_in_reply display_name: Insert Article in Reply enabled: false diff --git a/config/installation_config.yml b/config/installation_config.yml index 6c5de1a06..e374d0948 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -277,11 +277,6 @@ display_title: 'Inactive WhatsApp Numbers' description: 'Comma-separated list of WhatsApp numbers that should be rejected with a 422 error' type: code -- name: ENABLE_APP_STORE_REVIEWS_CHANNEL - value: false - display_title: 'Enable App Store Reviews Channel' - description: 'Enable the App Store Reviews inbox channel' - type: boolean # ------- End of Chatwoot Internal Config for Cloud ----# # ------- Chatwoot Internal Config for Self Hosted ----# diff --git a/db/migrate/20260522080000_repurpose_message_reply_to_flag_for_channel_app_store.rb b/db/migrate/20260522080000_repurpose_message_reply_to_flag_for_channel_app_store.rb new file mode 100644 index 000000000..ca17582be --- /dev/null +++ b/db/migrate/20260522080000_repurpose_message_reply_to_flag_for_channel_app_store.rb @@ -0,0 +1,11 @@ +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 in its intended default-off state. + Account.feature_channel_app_store.find_each(batch_size: 100) do |account| + account.disable_features(:channel_app_store) + account.save!(validate: false) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 35afe0fb4..f3bae1192 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_05_20_090000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_22_080000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index ed8fea076..ed5871be9 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -435,9 +435,6 @@ RSpec.describe 'Inboxes API', type: :request do end it 'does not create an app store inbox when the feature is disabled' do - allow(GlobalConfigService).to receive(:load).and_call_original - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false') - post "/api/v1/accounts/#{account.id}/inboxes", headers: admin.create_new_auth_token, params: { name: 'App Store Reviews', @@ -452,8 +449,7 @@ RSpec.describe 'Inboxes API', type: :request do it 'creates an app store inbox when the feature is enabled' do app_store_client = instance_double(AppStoreConnect::Client) - allow(GlobalConfigService).to receive(:load).and_call_original - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true') + 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( { diff --git a/spec/jobs/inboxes/fetch_app_store_review_inboxes_job_spec.rb b/spec/jobs/inboxes/fetch_app_store_review_inboxes_job_spec.rb index 410c1d1d0..894d4ca2a 100644 --- a/spec/jobs/inboxes/fetch_app_store_review_inboxes_job_spec.rb +++ b/spec/jobs/inboxes/fetch_app_store_review_inboxes_job_spec.rb @@ -10,8 +10,8 @@ RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do let(:suspended_channel) { create(:channel_app_store, account: suspended_account, last_synced_at: 2.hours.ago) } before do - allow(GlobalConfigService).to receive(:load).and_call_original - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true') + account.enable_features!(:channel_app_store) + suspended_account.enable_features!(:channel_app_store) end it 'enqueues the job' do @@ -32,7 +32,7 @@ RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do end it 'skips channels when the feature is disabled for the account' do - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false') + account.disable_features!(:channel_app_store) due_channel expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later) diff --git a/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb b/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb index 00bafc46f..e67c0d403 100644 --- a/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb +++ b/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb @@ -8,8 +8,7 @@ RSpec.describe Inboxes::FetchAppStoreReviewsJob do let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) } before do - allow(GlobalConfigService).to receive(:load).and_call_original - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true') + channel.account.enable_features!(:channel_app_store) end it 'enqueues the job' do @@ -43,7 +42,7 @@ RSpec.describe Inboxes::FetchAppStoreReviewsJob do end it 'does not fetch reviews when the feature is disabled for the account' do - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false') + channel.account.disable_features!(:channel_app_store) expect(channel).not_to receive(:fetch_reviews) diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 38ca9694a..82da9f961 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -50,6 +50,23 @@ RSpec.describe Account do 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 let(:account) { create(:account) } let(:inbox) { create(:inbox, account: account) } diff --git a/spec/services/app_store/send_on_app_store_service_spec.rb b/spec/services/app_store/send_on_app_store_service_spec.rb index 821c4843f..854aa04e3 100644 --- a/spec/services/app_store/send_on_app_store_service_spec.rb +++ b/spec/services/app_store/send_on_app_store_service_spec.rb @@ -3,7 +3,8 @@ require 'rails_helper' RSpec.describe AppStore::SendOnAppStoreService do - let(:channel) { create(:channel_app_store) } + 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') } @@ -12,8 +13,7 @@ RSpec.describe AppStore::SendOnAppStoreService do let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) } before do - allow(GlobalConfigService).to receive(:load).and_call_original - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true') + 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 @@ -58,7 +58,7 @@ RSpec.describe AppStore::SendOnAppStoreService do end it 'marks the message as failed when the feature is disabled' do - allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false') + 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