fix(app-store): address review feedback

This commit is contained in:
Muhsin
2026-06-03 12:33:55 +04:00
parent 97af2c4564
commit 18a5915872
11 changed files with 189 additions and 95 deletions
@@ -40,6 +40,8 @@ export default {
DOCS_URL: 'https://www.chatwoot.com/docs/product/',
HELP_CENTER_DOCS_URL:
'https://www.chatwoot.com/docs/product/others/help-center',
APP_STORE_REVIEWS_INBOX_DOCS_URL:
'https://www.chatwoot.com/hc/user-guide/articles/app-store-reviews-inbox',
TESTIMONIAL_URL:
'https://testimonials.cdn.chatwoot.com/testimonial-content.json',
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
@@ -387,6 +387,7 @@
"APP_STORE": {
"TITLE": "App Store Reviews",
"DESC": "Manage and reply to your App Store reviews from Chatwoot.",
"LEARN_MORE": "Learn how to create App Store Connect credentials",
"CHANNEL_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -1,75 +1,71 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import globalConstants from 'dashboard/constants/globals';
export default {
components: {
PageHeader,
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
channelName: '',
appId: '',
issuerId: '',
keyId: '',
privateKey: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
appId: { required },
issuerId: { required },
keyId: { required },
privateKey: { required },
},
methods: {
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) return;
const store = useStore();
const { t } = useI18n();
try {
const appStoreChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName?.trim(),
channel: {
type: 'app_store',
app_id: this.appId.trim(),
issuer_id: this.issuerId.trim(),
key_id: this.keyId.trim(),
private_key: this.privateKey.trim(),
},
}
);
const channelName = ref('');
const appId = ref('');
const issuerId = ref('');
const keyId = ref('');
const privateKey = ref('');
const appStoreReviewsInboxDocsUrl =
globalConstants.APP_STORE_REVIEWS_INBOX_DOCS_URL;
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: appStoreChannel.id,
},
});
} catch (error) {
useAlert(
error.message || this.$t('INBOX_MGMT.ADD.APP_STORE.API.ERROR_MESSAGE')
);
}
},
},
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
const rules = {
channelName: { required },
appId: { required },
issuerId: { required },
keyId: { required },
privateKey: { required },
};
const v$ = useVuelidate(rules, {
channelName,
appId,
issuerId,
keyId,
privateKey,
});
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
const appStoreChannel = await store.dispatch('inboxes/createChannel', {
name: channelName.value?.trim(),
channel: {
type: 'app_store',
app_id: appId.value.trim(),
issuer_id: issuerId.value.trim(),
key_id: keyId.value.trim(),
private_key: privateKey.value.trim(),
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: appStoreChannel.id,
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.APP_STORE.API.ERROR_MESSAGE'));
}
};
</script>
@@ -79,6 +75,15 @@ export default {
:header-title="$t('INBOX_MGMT.ADD.APP_STORE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.APP_STORE.DESC')"
/>
<a
:href="appStoreReviewsInboxDocsUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mb-5 text-sm font-medium text-n-brand"
>
{{ $t('INBOX_MGMT.ADD.APP_STORE.LEARN_MORE') }}
<Icon icon="i-lucide-external-link" class="size-4" />
</a>
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="createChannel()"
+1 -1
View File
@@ -37,7 +37,7 @@ class Channel::AppStore < ApplicationRecord
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)
app_store_client.update_review_response(review_id, response_body)
else
app_store_client.create_review_response(review_id, response_body)
end
+10 -7
View File
@@ -2,14 +2,16 @@ class AppStore::ReviewBuilder
pattr_initialize [:review_payload!, :channel!]
def perform
return if review_id.blank? || review_body.blank?
return if review_id.blank?
ActiveRecord::Base.transaction do
build_contact_inbox
build_conversation
@conversation.with_lock do
upsert_review_message
build_response_message if response_body.present?
@contact_inbox.with_lock do
build_conversation
@conversation.with_lock do
upsert_review_message
build_response_message if response_body.present?
end
end
end
end
@@ -53,7 +55,7 @@ class AppStore::ReviewBuilder
end
def created_at
Time.zone.parse(attributes['createdDate'].to_s)
Time.zone.parse(attributes['createdDate'].to_s).presence || Time.current
rescue StandardError
Time.current
end
@@ -67,7 +69,7 @@ class AppStore::ReviewBuilder
end
def response_created_at
Time.zone.parse(response_attributes['lastModifiedDate'].to_s)
Time.zone.parse(response_attributes['lastModifiedDate'].to_s).presence || Time.current
rescue StandardError
Time.current
end
@@ -170,6 +172,7 @@ class AppStore::ReviewBuilder
def response_metadata
{
external_echo: true,
app_store: {
response_id: response_id,
response_state: response_attributes['state'],
+4 -20
View File
@@ -27,8 +27,8 @@ class AppStoreConnect::Client
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']
def update_review_response(review_id, response_body)
post('/v1/customerReviewResponses', review_response_payload(review_id, response_body))['data']
end
private
@@ -67,10 +67,6 @@ class AppStoreConnect::Client
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,
@@ -94,7 +90,7 @@ class AppStoreConnect::Client
end
def token
@token ||= AppStoreConnect::TokenService.new(channel: channel).token
AppStoreConnect::TokenService.new(channel: channel).token
end
def review_response_payload(review_id, response_body)
@@ -116,21 +112,9 @@ class AppStoreConnect::Client
}
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?
Rails.logger.debug { "[APP_STORE_CONNECT] rate_limit=#{rate_limit}" } if rate_limit.present?
end
def error_message(response)
@@ -42,6 +42,9 @@ class AppStoreConnect::TokenService
end
def cache_key
"app_store_connect_token:#{channel.id}:#{channel.updated_at.to_i}"
identifier = channel.id || channel.object_id
version = channel.updated_at&.to_i || 'new'
"app_store_connect_token:#{identifier}:#{version}"
end
end
@@ -7,5 +7,14 @@ class RepurposeMessageReplyToFlagForChannelAppStore < ActiveRecord::Migration[7.
account.disable_features(:channel_app_store)
account.save!(validate: false)
end
# Remove the stale message_reply_to entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
# ConfigLoader only adds new flags; it never removes renamed ones.
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |feature| feature['name'] == 'message_reply_to' }
config.save!
GlobalConfig.clear_cache
end
end
@@ -57,6 +57,35 @@ RSpec.describe AppStore::ReviewBuilder do
)
expect(response_message.content).to eq('Thanks for the feedback.')
expect(response_message.status).to eq('delivered')
expect(response_message.content_attributes['external_echo']).to be true
end
it 'creates a conversation for a rating-only review' do
rating_only_payload = review_payload.deep_dup
rating_only_payload['review']['attributes']['title'] = ''
rating_only_payload['review']['attributes']['body'] = ''
rating_only_payload['response'] = nil
expect { described_class.new(review_payload: rating_only_payload, channel: channel).perform }
.to change(inbox.conversations, :count).by(1)
.and change(Message.where(inbox_id: inbox.id), :count).by(1)
review_message = inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1')
expect(review_message.content).to include('★★★★☆ (4/5)')
end
it 'falls back to current time when Apple timestamps are blank' do
blank_timestamp_payload = review_payload.deep_dup
blank_timestamp_payload['review']['attributes']['createdDate'] = ''
blank_timestamp_payload['response']['attributes']['lastModifiedDate'] = ''
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
described_class.new(review_payload: blank_timestamp_payload, channel: channel).perform
conversation = inbox.conversations.last
expect(conversation.messages.incoming.find_by(source_id: 'review-1').created_at.to_i).to eq(Time.current.to_i)
expect(conversation.messages.outgoing.find_by(source_id: 'response-1').created_at.to_i).to eq(Time.current.to_i)
end
end
it 'updates an existing review message when Apple returns the same review again' do
+45 -3
View File
@@ -71,6 +71,41 @@ RSpec.describe AppStoreConnect::Client do
expect(review_payload['review']['id']).to eq('review-1')
expect(review_payload['response']['id']).to eq('response-1')
end
it 'fetches a fresh cached token for each request' do
first_token_service = instance_double(AppStoreConnect::TokenService, token: 'first-token')
second_token_service = instance_double(AppStoreConnect::TokenService, token: 'second-token')
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(first_token_service, second_token_service)
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
.with(
headers: { 'Authorization' => 'Bearer first-token' },
query: { include: 'response', limit: '200', sort: '-createdDate' }
)
.to_return(
status: 200,
body: {
data: [],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2'
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
.with(headers: { 'Authorization' => 'Bearer second-token' })
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
described_class.new(channel: channel).fetch_reviews
expect(AppStoreConnect::TokenService).to have_received(:new).twice
end
end
describe '#create_review_response' do
@@ -108,14 +143,21 @@ RSpec.describe AppStoreConnect::Client do
describe '#update_review_response' do
it 'updates an existing response' do
stub_request(:patch, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses/response-1')
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
.with(
body: {
data: {
type: 'customerReviewResponses',
id: 'response-1',
attributes: {
responseBody: 'Updated response'
},
relationships: {
review: {
data: {
type: 'customerReviews',
id: 'review-1'
}
}
}
}
}.to_json
@@ -126,7 +168,7 @@ RSpec.describe AppStoreConnect::Client do
headers: { 'Content-Type' => 'application/json' }
)
response = described_class.new(channel: channel).update_review_response('response-1', 'Updated response')
response = described_class.new(channel: channel).update_review_response('review-1', 'Updated response')
expect(response['id']).to eq('response-1')
end
@@ -36,5 +36,21 @@ RSpec.describe AppStoreConnect::TokenService do
expect(described_class.new(channel: channel).token).to eq('cached-token')
end
it 'generates a token before the channel is persisted' do
new_channel = instance_double(
Channel::AppStore,
id: nil,
updated_at: nil,
issuer_id: 'issuer-id',
key_id: 'key-id',
private_key: private_key
)
token = described_class.new(channel: new_channel).token
payload, = JWT.decode(token, nil, false)
expect(payload['iss']).to eq('issuer-id')
end
end
end