fix(app-store): address review feedback
This commit is contained in:
@@ -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()"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user