Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub a26e9714c3 Merge branch 'develop' into feat/app-store-reviews 2026-06-16 13:53:23 +04:00
Muhsin KelothandGitHub 7f793ccda8 Merge branch 'develop' into feat/app-store-reviews 2026-06-11 17:14:25 +04:00
Muhsin KelothandGitHub 08a520070b Merge branch 'develop' into feat/app-store-reviews 2026-06-09 14:52:02 +04:00
Muhsin KelothandGitHub c5b58e2aed Merge branch 'develop' into feat/app-store-reviews 2026-06-08 16:38:17 +04:00
Muhsin 17a0d05853 fix(app-store): handle reused flag and response pagination 2026-06-04 08:29:48 +04:00
Muhsin KelothandGitHub 13a1afc0d1 Merge branch 'develop' into feat/app-store-reviews 2026-06-04 08:26:39 +04:00
Muhsin b93cb5b654 fix(app-store): sync updated review responses 2026-06-03 20:23:26 +04:00
Muhsin 48a0fa1d67 fix(app-store): reject unsupported review responses 2026-06-03 20:10:45 +04:00
Muhsin ccd2944df9 Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews
# Conflicts:
#	db/migrate/20260522080000_repurpose_message_reply_to_flag_for_channel_app_store.rb
2026-06-03 20:05:55 +04:00
Muhsin 594bfa0bdf fix(app-store): enable channel feature by default 2026-06-03 20:03:52 +04:00
Muhsin 5a4416612c fix(app-store): enable channel feature by default 2026-06-03 18:41:49 +04:00
Muhsin 5c204e6647 Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews 2026-06-03 15:46:36 +04:00
Muhsin 65ef019248 fix(app-store): reuse existing response message 2026-06-03 15:45:55 +04:00
Muhsin KelothandGitHub 1c420cb7f7 Merge branch 'develop' into feat/app-store-reviews 2026-06-03 15:37:43 +04:00
Muhsin 2fadbf81bc fix(app-store): simplify inbox creation controller 2026-06-03 15:16:06 +04:00
Muhsin KelothandGitHub 14e9e9f63d Merge branch 'develop' into feat/app-store-reviews 2026-06-03 15:07:31 +04:00
Muhsin 01a8812de5 fix(app-store): address review sync comments 2026-06-03 14:34:58 +04:00
Muhsin KelothandGitHub dfb604d98c Merge branch 'develop' into feat/app-store-reviews 2026-06-03 13:42:29 +04:00
Muhsin 4e9abd6f08 fix(app-store): keep sync cursor on review failures 2026-06-03 13:40:14 +04:00
Muhsin 0cdcf30c39 fix(app-store): address follow-up review comments 2026-06-03 13:24:30 +04:00
Muhsin KelothandGitHub a398de21f4 Merge branch 'develop' into feat/app-store-reviews 2026-06-03 12:52:23 +04:00
Muhsin 18a5915872 fix(app-store): address review feedback 2026-06-03 12:33:55 +04:00
Muhsin KelothandGitHub 97af2c4564 Merge branch 'develop' into feat/app-store-reviews 2026-06-03 12:06:23 +04:00
Muhsin 7adb0dcbae chore: update message bubble 2026-06-03 11:41:37 +04:00
Muhsin 9b346e4c29 chore: lock conversation during review message upsert 2026-06-03 10:24:28 +04:00
Muhsin 8595a46642 chore: disable template messages 2026-06-02 22:01:23 +04:00
Muhsin 74339ff981 Update ChannelItem.vue 2026-06-02 21:26:15 +04:00
Muhsin KelothandGitHub 962b6902e7 Merge branch 'develop' into feat/app-store-reviews 2026-06-02 21:13:24 +04:00
Muhsin KelothandGitHub fd5de4398d Merge branch 'develop' into feat/app-store-reviews 2026-05-22 12:29:06 +04:00
Muhsin KelothandGitHub df538a6a3b Merge branch 'develop' into feat/app-store-reviews 2026-05-22 12:09:40 +04:00
Muhsin 361b28cb03 chore: Use per-account feature flag for App Store channel 2026-05-22 11:56:21 +04:00
Muhsin KelothandGitHub 15227a424a Merge branch 'develop' into feat/app-store-reviews 2026-05-22 10:55:59 +04:00
Muhsin 1e36126a00 Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews 2026-05-22 10:51:25 +04:00
Muhsin eb9647d6c6 Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews 2026-05-22 10:35:44 +04:00
Muhsin KelothandGitHub b3fa179b7f Merge branch 'develop' into feat/app-store-reviews 2026-05-22 10:26:46 +04:00
Muhsin da0a122919 Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews 2026-05-22 10:26:06 +04:00
Muhsin KelothandMuhsin eb30c8dea4 Merge branch 'develop' into feat/app-store-reviews 2026-05-22 10:23:33 +04:00
Muhsin KelothandGitHub 2a624ac800 Merge branch 'develop' into feat/app-store-reviews 2026-05-22 09:03:48 +04:00
Muhsin 379daeef18 Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews 2026-05-21 08:29:53 +04:00
Muhsin KelothandGitHub 127a544a89 Merge branch 'develop' into feat/app-store-reviews 2026-05-21 08:23:54 +04:00
Muhsin 572d6d2e40 feat(inboxes): add app store reviews channel 2026-05-21 08:21:35 +04:00
Muhsin 609e27a4c4 feat(inboxes): add app store reviews channel 2026-05-21 08:18:22 +04:00
88 changed files with 1983 additions and 818 deletions
+1 -1
View File
@@ -1 +1 @@
4.15.1
4.14.2
@@ -214,5 +214,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -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
+2 -1
View File
@@ -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
+5
View File
@@ -71,6 +71,11 @@ sms:
description: 'Manage your SMS customer interactions from Chatwoot.'
enabled: true
icon: 'icon-message-line'
app_store:
name: 'App Store Reviews'
description: 'Manage your App Store app reviews from Chatwoot.'
enabled: true
icon: 'icon-apps-2-line'
messenger:
name: 'Messenger'
description: 'Stay connected with your customers on Facebook & Instagram.'
@@ -145,12 +145,6 @@
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
// Voice call widget - light mode
--call-widget: 33, 34, 38, 0.95;
--call-widget-border: 255, 255, 255, 0.1;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
.dark {
@@ -297,12 +291,6 @@
--border-blue: 39, 129, 246, 0.5;
--border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
// Voice call widget - dark mode
--call-widget: 50, 53, 61, 1;
--call-widget-border: 255, 255, 255, 0.07;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
}
// NEXT COLORS END
@@ -26,13 +26,6 @@ const resetForm = () => {
form.description = '';
};
const open = (company = {}) => {
form.name = company.name || '';
form.domain = company.domain || '';
form.description = company.description || '';
dialogRef.value?.open();
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
@@ -52,7 +45,7 @@ const onSuccess = () => {
closeDialog();
};
defineExpose({ dialogRef, onSuccess, open });
defineExpose({ dialogRef, onSuccess });
</script>
<template>
@@ -1,153 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import CompanyAPI from 'dashboard/api/companies';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
// Name of the linked company, so the label shows before the list is loaded.
selectedName: {
type: String,
default: '',
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const CREATE_PREFIX = 'create:';
const options = ref([]);
const searchQuery = ref('');
const createDialogRef = ref(null);
const isCreatingCompany = ref(false);
const toOption = company => ({ label: company.name, value: company.id });
const createOption = computed(() => {
const name = searchQuery.value.trim();
if (!name) return null;
const exists = options.value.some(
option => option.label.toLowerCase() === name.toLowerCase()
);
if (exists) return null;
return {
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
value: `${CREATE_PREFIX}${name}`,
};
});
const comboboxOptions = computed(() => {
const list = [...options.value];
// Keep the linked company visible even when it is not in the loaded results.
if (
props.modelValue &&
props.selectedName &&
!list.some(option => option.value === Number(props.modelValue))
) {
list.unshift({
label: props.selectedName,
value: Number(props.modelValue),
});
}
if (createOption.value) list.push(createOption.value);
return list;
});
const fetchCompanies = async query => {
try {
const {
data: { payload },
} = query
? await CompanyAPI.search(query)
: await CompanyAPI.get({ page: 1 });
options.value = (payload || []).map(toOption);
} catch {
options.value = [];
}
};
// Fetch lazily, only when the dropdown opens, instead of on mount.
const handleOpen = () => {
searchQuery.value = '';
fetchCompanies('');
};
const handleSearch = useDebounceFn(query => {
searchQuery.value = query?.trim() || '';
fetchCompanies(searchQuery.value);
}, 300);
// Open the create dialog (prefilled with the typed name) so the user can add
// domain/description before saving, instead of creating with just a name.
const createCompany = async company => {
isCreatingCompany.value = true;
try {
const {
data: { payload },
} = await CompanyAPI.create({ company });
createDialogRef.value?.onSuccess();
emit('select', { id: payload.id, name: payload.name });
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
} finally {
isCreatingCompany.value = false;
}
};
const handleSelect = value => {
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
// Drop the transient "Add …" option so the button label doesn't stick to
// it if the dialog is dismissed without creating.
searchQuery.value = '';
return;
}
const id = value ? Number(value) : '';
const selected = comboboxOptions.value.find(option => option.value === id);
emit('select', { id, name: selected?.label || '' });
};
</script>
<template>
<ComboBox
:model-value="modelValue"
:options="comboboxOptions"
:display-label="selectedName"
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
use-api-results
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@open="handleOpen"
@search="handleSearch"
@update:model-value="handleSelect"
/>
<CompanyCreateDialog
ref="createDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</template>
@@ -15,7 +15,6 @@ const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
companyId: { type: [Number, String], default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
@@ -42,7 +41,6 @@ const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
companyId: props.companyId,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
@@ -5,11 +5,8 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
@@ -31,7 +28,6 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { currentAccount, isCloudFeatureEnabled } = useAccount();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
@@ -58,7 +54,6 @@ const defaultState = {
id: 0,
name: '',
email: '',
companyId: '',
firstName: '',
lastName: '',
phoneNumber: '',
@@ -90,23 +85,6 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const hasCompaniesFeature = computed(
() =>
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
);
const showCompanySelector = computed(
() =>
hasCompaniesFeature.value &&
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
);
const emitContactUpdate = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
};
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
@@ -118,7 +96,6 @@ const prepareStateBasedOnProps = () => {
name = '',
email: emailAddress,
phoneNumber,
companyId = '',
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
@@ -138,7 +115,6 @@ const prepareStateBasedOnProps = () => {
Object.assign(state, {
id,
name,
companyId: companyId || '',
firstName,
lastName,
email: emailAddress,
@@ -224,7 +200,11 @@ const getFormBinding = key => {
}
}
await emitContactUpdate();
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
@@ -241,12 +221,6 @@ const handleCountrySelection = value => {
emit('update', state);
};
const handleCompanySelection = async ({ id, name }) => {
state.companyId = id || '';
state.additionalAttributes.companyName = name || '';
await emitContactUpdate();
};
const resetValidation = () => {
v$.value.$reset();
};
@@ -299,13 +273,6 @@ defineExpose({
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<CompanySelector
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
:model-value="state.companyId"
:selected-name="state.additionalAttributes.companyName"
:is-details-view="isDetailsView"
@select="handleCompanySelection"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
@@ -92,7 +92,6 @@ const handleAvatarHover = (id, isHovered) => {
:id="contact.id"
:name="contact.name"
:email="contact.email"
:company-id="contact.companyId"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
@@ -75,11 +75,11 @@ const channelIcon = computed(() => {
<template>
<div
class="flex flex-col gap-1 pt-4 bg-n-call-widget rounded-2xl shadow-xl outline outline-1 outline-n-call-widget-border backdrop-blur-md"
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
>
<!-- Top section: status badge + location/inbox + duration -->
<div class="flex flex-col gap-3 pb-3 border-b border-n-call-widget-border">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2 px-4">
<!-- Ongoing: status badge on left -->
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
@@ -100,10 +100,10 @@ const channelIcon = computed(() => {
<Icon
v-else-if="!isOngoing"
:icon="channelIcon"
class="size-3.5 text-n-call-widget-sub-text shrink-0"
class="size-3.5 text-n-slate-10 shrink-0"
/>
<span
class="text-xs font-medium text-n-call-widget-sub-text tracking-tight truncate"
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
>
{{ callInfo.location }}
</span>
@@ -112,7 +112,7 @@ const channelIcon = computed(() => {
<!-- Ongoing: duration on right -->
<p
v-if="isOngoing"
class="font-display text-base font-medium text-n-call-widget-sub-text shrink-0 mb-0 tabular-nums tracking-tight"
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
>
{{ duration }}
</p>
@@ -132,7 +132,7 @@ const channelIcon = computed(() => {
slate
ghost
xs
class="!rounded-full -my-1 -me-1 !text-n-call-widget-sub-text"
class="!rounded-full -my-1"
@click="$emit('dismiss')"
/>
</div>
@@ -149,13 +149,13 @@ const channelIcon = computed(() => {
</div>
<div class="flex-1 min-w-0">
<p
class="font-display text-sm font-medium text-n-call-widget-text truncate mb-0.5 tracking-tight leading-tight"
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
>
{{ callInfo.contactName }}
</p>
<p
v-if="callInfo.phoneNumber"
class="text-sm text-n-call-widget-sub-text truncate mb-0 tracking-tight leading-tight"
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
>
{{ callInfo.phoneNumber }}
</p>
@@ -217,23 +217,23 @@ const channelIcon = computed(() => {
>
<template #icon>
<span
class="flex items-center gap-1 text-n-call-widget-sub-text group-hover:text-n-call-widget-text"
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
>
<Icon
icon="i-ph-chat-circle-text-bold"
class="size-3.5 text-n-call-widget-sub-text shrink-0"
class="size-3.5 text-n-slate-11 shrink-0"
/>
<span class="text-sm tracking-tight tabular-nums">
#{{ call.conversationId }}
</span>
<Icon
icon="i-ph-caret-right-bold"
class="size-3 text-n-call-widget-sub-text shrink-0"
class="size-3 text-n-slate-11 shrink-0"
/>
</span>
</template>
<span
class="text-sm text-n-call-widget-sub-text tracking-tight group-hover:text-n-call-widget-text"
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
>
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
</span>
@@ -14,9 +14,6 @@ const props = defineProps({
value.every(option => 'value' in option && 'label' in option),
},
placeholder: { type: String, default: '' },
// Fallback label shown when the selected value is not in `options` yet
// (e.g. API-backed lists that load lazily on open).
displayLabel: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
@@ -26,7 +23,7 @@ const props = defineProps({
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search', 'open']);
const emit = defineEmits(['update:modelValue', 'search']);
const { t } = useI18n();
@@ -55,7 +52,7 @@ const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
return selected?.label ?? selectPlaceholder.value;
});
const selectOption = option => {
@@ -75,7 +72,6 @@ const toggleDropdown = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
emit('open');
nextTick(() => dropdownRef.value?.focus());
}
};
@@ -87,7 +87,7 @@ defineExpose({
}"
role="option"
:aria-selected="isSelected(option)"
@click.stop="emit('select', option)"
@click="emit('select', option)"
>
<span
:class="{
@@ -14,6 +14,7 @@ const channelTypeIconMap = {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::AppStore': 'i-ri-app-store-fill',
};
const providerIconMap = {
@@ -20,6 +20,7 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAnAppStoreChannel,
isATiktokChannel,
} = useInbox();
@@ -62,7 +63,8 @@ const isSent = computed(() => {
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isATiktokChannel.value ||
isAnAppStoreChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -85,7 +87,8 @@ const isDelivered = computed(() => {
isASmsInbox.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isATiktokChannel.value ||
isAnAppStoreChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
@@ -110,7 +113,8 @@ const isRead = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isATiktokChannel.value ||
isAnAppStoreChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -64,6 +64,10 @@ const isActive = computed(() => {
);
}
if (key === 'app_store') {
return props.enabledFeatures.channel_app_store;
}
return [
'website',
'twilio',
@@ -86,7 +90,9 @@ const isComingSoon = computed(() => {
});
const isBeta = computed(() => {
return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key);
return ['tiktok', 'voice', 'whatsapp_call', 'app_store'].includes(
props.channel.key
);
});
const hasVoiceBadge = computed(() => {
@@ -36,6 +36,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { MESSAGE_TYPE } from 'shared/constants/messages';
export default {
components: {
@@ -244,6 +245,29 @@ export default {
return { incoming, outgoing };
},
hasSubmittedAppStoreResponse() {
const messages = this.currentChat.messages || [];
return messages.some(message => {
const appStoreAttributes =
message.content_attributes?.app_store ||
message.contentAttributes?.appStore ||
{};
const hasAppStoreResponse =
message.source_id || appStoreAttributes.response_id;
return (
message.message_type === MESSAGE_TYPE.OUTGOING && hasAppStoreResponse
);
});
},
appStoreReplyBannerMessage() {
return this.hasSubmittedAppStoreResponse
? this.$t('CONVERSATION.REPLYBOX.APP_STORE_RESPONSE_UPDATE')
: this.$t('CONVERSATION.REPLYBOX.APP_STORE_RESPONSE_DELAY');
},
showAppStoreReplyBanner() {
return this.isAnAppStoreChannel;
},
},
watch: {
@@ -468,6 +492,12 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<Banner
v-if="showAppStoreReplyBanner"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="appStoreReplyBannerMessage"
/>
</div>
<MessageList
ref="conversationPanelRef"
@@ -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,
};
@@ -116,6 +116,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Channel::AppStore': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
@@ -41,6 +41,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:
+6
View File
@@ -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)
@@ -53,6 +54,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';
@@ -68,6 +70,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';
@@ -117,6 +120,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.APP_STORE:
return 'app_store';
default:
return 'chat';
}
@@ -25,10 +25,6 @@
"ACTIONS": {
"CREATE": "Add company"
},
"SELECTOR": {
"PLACEHOLDER": "Select company",
"CREATE_OPTION": "Add \"{name}\""
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
@@ -237,6 +237,8 @@
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot is thinking",
"APP_STORE_RESPONSE_DELAY": "Apple may take up to 24 hours to make submitted replies or updates public.",
"APP_STORE_RESPONSE_UPDATE": "This review already has a reply. Apple may take up to 24 hours to make submitted replies or updates public. Sending another reply updates the existing public App Store response.",
"EMAIL_HEAD": {
"TO": "TO",
"ADD_BCC": "Add bcc",
@@ -384,6 +384,40 @@
"ERROR_MESSAGE": "We were not able to save the api channel"
}
},
"APP_STORE": {
"TITLE": "App Store Reviews",
"DESC": "Manage and reply to your App Store reviews from Chatwoot.",
"LEARN_MORE": "Learn how to create App Store Connect credentials",
"CHANNEL_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "This field is required"
},
"APP_ID": {
"LABEL": "App Store Connect app ID",
"PLACEHOLDER": "Enter your App Store Connect app ID",
"ERROR": "This field is required"
},
"ISSUER_ID": {
"LABEL": "Issuer ID",
"PLACEHOLDER": "Enter your App Store Connect issuer ID",
"ERROR": "This field is required"
},
"KEY_ID": {
"LABEL": "Key ID",
"PLACEHOLDER": "Enter your App Store Connect key ID",
"ERROR": "This field is required"
},
"PRIVATE_KEY": {
"LABEL": "Private key",
"PLACEHOLDER": "Paste the contents of your .p8 private key",
"ERROR": "This field is required"
},
"SUBMIT_BUTTON": "Create App Store Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the App Store channel"
}
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate your email inbox.",
@@ -499,6 +533,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"
@@ -1200,6 +1238,7 @@
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"APP_STORE": "App Store Reviews",
"VOICE": "Voice"
}
}
@@ -126,7 +126,7 @@ const showCompany = companyId => {
};
const openCreateCompanyDialog = () => {
createCompanyDialogRef.value?.open();
createCompanyDialogRef.value?.dialogRef.open();
};
const createCompany = async company => {
@@ -13,6 +13,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,
@@ -27,6 +28,7 @@ const channelViewList = {
telegram: Telegram,
instagram: Instagram,
tiktok: Tiktok,
app_store: AppStore,
voice: Voice,
};
@@ -77,6 +77,12 @@ const channelList = computed(() => {
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'app_store',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.DESCRIPTION'),
icon: 'i-ri-app-store-line',
},
];
if (hasTiktokConfigured.value) {
@@ -267,6 +267,13 @@ export default {
];
}
if (this.isAnAppStoreChannel) {
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
tab => !unsupportedKeys.includes(tab.key)
);
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -300,6 +307,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() {
@@ -311,6 +321,7 @@ export default {
this.isAnInstagramChannel ||
this.isALineChannel ||
this.isATiktokChannel ||
this.isAnAppStoreChannel ||
this.isATelegramChannel
);
},
@@ -847,6 +858,7 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAnAppStoreChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1146,6 +1158,7 @@ export default {
</SettingsAccordion>
<SettingsAccordion
v-if="!isAnAppStoreChannel"
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
class="mt-6"
>
@@ -0,0 +1,181 @@
<script setup>
import { computed, ref } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import globalConstants from 'dashboard/constants/globals';
const store = useStore();
const { t } = useI18n();
const channelName = ref('');
const appId = ref('');
const issuerId = ref('');
const keyId = ref('');
const privateKey = ref('');
const appStoreReviewsInboxDocsUrl =
globalConstants.APP_STORE_REVIEWS_INBOX_DOCS_URL;
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
const rules = {
channelName: { required },
appId: { required },
issuerId: { required },
keyId: { required },
privateKey: { required },
};
const v$ = useVuelidate(rules, {
channelName,
appId,
issuerId,
keyId,
privateKey,
});
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
const appStoreChannel = await store.dispatch('inboxes/createChannel', {
name: channelName.value?.trim(),
channel: {
type: 'app_store',
app_id: appId.value.trim(),
issuer_id: issuerId.value.trim(),
key_id: keyId.value.trim(),
private_key: privateKey.value.trim(),
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: appStoreChannel.id,
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.APP_STORE.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.APP_STORE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.APP_STORE.DESC')"
/>
<a
:href="appStoreReviewsInboxDocsUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mb-5 text-sm font-medium text-n-brand"
>
{{ $t('INBOX_MGMT.ADD.APP_STORE.LEARN_MORE') }}
<Icon icon="i-lucide-external-link" class="size-4" />
</a>
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="createChannel()"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.LABEL') }}
<input
v-model="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.PLACEHOLDER')
"
@blur="v$.channelName.$touch"
/>
<span v-if="v$.channelName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.appId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.LABEL') }}
<input
v-model="appId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.APP_ID.PLACEHOLDER')"
@blur="v$.appId.$touch"
/>
<span v-if="v$.appId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.issuerId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.LABEL') }}
<input
v-model="issuerId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.PLACEHOLDER')"
@blur="v$.issuerId.$touch"
/>
<span v-if="v$.issuerId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.keyId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.LABEL') }}
<input
v-model="keyId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.PLACEHOLDER')"
@blur="v$.keyId.$touch"
/>
<span v-if="v$.keyId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.privateKey.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.LABEL') }}
<textarea
v-model="privateKey"
rows="8"
:placeholder="
$t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.PLACEHOLDER')
"
@blur="v$.privateKey.$touch"
/>
<span v-if="v$.privateKey.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.ERROR') }}
</span>
</label>
</div>
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.APP_STORE.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
@@ -34,6 +34,7 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::AppStore': 'APP_STORE',
};
const twilioChannelName = () => {
@@ -13,12 +13,8 @@ const buildContactFormData = contactParams => {
const formData = new FormData();
const { additional_attributes = {}, ...contactProperties } = contactParams;
Object.keys(contactProperties).forEach(key => {
const value = contactProperties[key];
const shouldAppendBlankCompanyId =
key === 'company_id' && value !== undefined;
if (value || shouldAppendBlankCompanyId) {
formData.append(key, value ?? '');
if (contactProperties[key]) {
formData.append(key, contactProperties[key]);
}
});
const { social_profiles, ...additionalAttributesProperties } =
@@ -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 => {
@@ -145,33 +145,6 @@ describe('#actions', () => {
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
]);
});
it('preserves blank company_id when updating with form data', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
await actions.update(
{ commit },
{
id: contactList[0].id,
isFormData: true,
name: contactList[0].name,
companyId: '',
avatar: 'avatar-file',
additionalAttributes: {
companyName: '',
socialProfiles: {},
},
}
);
const lastPatchCall =
axios.patch.mock.calls[axios.patch.mock.calls.length - 1];
const formData = lastPatchCall[1];
expect(formData).toBeInstanceOf(FormData);
expect(formData.has('company_id')).toBe(true);
expect(formData.get('company_id')).toBe('');
});
});
describe('#create', () => {
@@ -15,6 +15,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -23,6 +24,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
};
@@ -119,6 +121,8 @@ export default {
badgeKey = 'whatsapp';
} else if (this.isATiktokChannel) {
badgeKey = 'tiktok';
} else if (this.isAnAppStoreChannel) {
badgeKey = 'app_store';
}
return badgeKey || this.channelType;
},
@@ -134,6 +138,9 @@ export default {
isATiktokChannel() {
return this.channelType === INBOX_TYPES.TIKTOK;
},
isAnAppStoreChannel() {
return this.channelType === INBOX_TYPES.APP_STORE;
},
},
methods: {
inboxHasFeature(feature) {
@@ -0,0 +1,16 @@
class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Inbox.includes(:account, :channel).where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox|
account = inbox.account
channel = inbox.channel
next if account.suspended?
next unless account.feature_enabled?(:channel_app_store)
next unless channel.sync_due?
::Inboxes::FetchAppStoreReviewsJob.perform_later(channel)
end
end
end
@@ -0,0 +1,46 @@
class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
queue_as :scheduled_jobs
def perform(channel)
return unless channel.account.feature_enabled?(:channel_app_store)
synced_until = sync_reviews(channel)
return if synced_until.blank?
channel.update!(last_synced_at: synced_until)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
private
def sync_reviews(channel)
failed = false
synced_dates = []
channel.fetch_reviews.each do |review_payload|
::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform
synced_dates << payload_synced_at(review_payload)
rescue StandardError => e
failed = true
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
return if failed
[channel.last_synced_at, synced_dates.compact.max].compact.max
end
def payload_synced_at(review_payload)
[
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
].compact.max
end
def parsed_timestamp(value)
Time.zone.parse(value.to_s)
rescue StandardError
nil
end
end
+1
View File
@@ -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
+1
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
class Channel::AppStore < ApplicationRecord
include Channelable
self.table_name = 'channel_app_store'
EDITABLE_ATTRS = [:app_id, :bundle_id, :app_name, :issuer_id, :key_id, :private_key, { provider_config: {} }].freeze
API_BASE_URL = 'https://api.appstoreconnect.apple.com'.freeze
REVIEWS_PAGE_SIZE = 200
SYNC_INTERVAL = 1.hour
if Chatwoot.encryption_configured?
encrypts :issuer_id
encrypts :key_id
encrypts :private_key
end
validates :app_id, presence: true, uniqueness: { scope: :account_id }
validates :issuer_id, :key_id, :private_key, presence: true
validate :validate_app_access, on: :create
before_validation :normalize_auth_fields
after_create_commit :enqueue_initial_review_fetch
def name
'App Store'
end
def sync_due?
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
end
def fetch_reviews
app_store_client.fetch_reviews(since: last_synced_at)
end
def reply_to_review(review_id, response_body)
response = app_store_client.create_or_update_review_response(review_id, response_body)
response['id']
end
def app_store_client
@app_store_client ||= AppStoreConnect::Client.new(channel: self)
end
private
def normalize_auth_fields
self.app_id = app_id.to_s.strip
self.issuer_id = issuer_id.to_s.strip
self.key_id = key_id.to_s.strip
self.private_key = private_key.to_s.gsub('\n', "\n").gsub("\r\n", "\n").strip
end
def validate_app_access
app = app_store_client.fetch_app
self.app_name = app.dig('attributes', 'name') if app_name.blank?
self.bundle_id = app.dig('attributes', 'bundleId') if bundle_id.blank?
rescue StandardError => e
errors.add(:base, e.message)
end
def enqueue_initial_review_fetch
::Inboxes::FetchAppStoreReviewsJob.perform_later(self)
end
end
+1 -3
View File
@@ -148,7 +148,7 @@ class Contact < ApplicationRecord
end
def push_event_data
data = {
{
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
email: email,
@@ -160,8 +160,6 @@ class Contact < ApplicationRecord
blocked: blocked,
type: 'contact'
}
data[:company_id] = company_id if account.feature_enabled?('companies')
data
end
def webhook_data
+4
View File
@@ -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
+183
View File
@@ -0,0 +1,183 @@
class AppStore::ReviewBuilder
pattr_initialize [:review_payload!, :channel!]
def perform
return if review_id.blank?
ActiveRecord::Base.transaction do
build_contact_inbox
@contact_inbox.with_lock do
build_conversation
@conversation.with_lock do
upsert_review_message
build_response_message if response_body.present?
end
end
end
end
private
def inbox
@inbox ||= channel.inbox
end
def review
@review ||= review_payload['review'] || {}
end
def response
@response ||= review_payload['response'] || {}
end
def attributes
@attributes ||= review['attributes'] || {}
end
def response_attributes
@response_attributes ||= response['attributes'] || {}
end
def review_id
review['id']
end
def review_body
attributes['body'].to_s.strip
end
def review_title
attributes['title'].to_s.strip
end
def rating
attributes['rating'].to_i.clamp(0, 5)
end
def created_at
Time.zone.parse(attributes['createdDate'].to_s).presence || Time.current
rescue StandardError
Time.current
end
def response_id
response['id']
end
def response_body
response_attributes['responseBody'].to_s.strip
end
def response_created_at
Time.zone.parse(response_attributes['lastModifiedDate'].to_s).presence || Time.current
rescue StandardError
Time.current
end
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: review_id,
inbox: inbox,
contact_attributes: {
name: attributes['reviewerNickname'].presence || 'App Store User',
additional_attributes: { source_id: "app_store:#{review_id}" }
}
).perform
end
def build_conversation
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: { source: 'app_store', app_id: channel.app_id }
)
end
def upsert_review_message
message = @conversation.messages.find_by(source_id: review_id)
if message
message.update!(content: message_content, content_attributes: review_metadata)
return
end
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
sender: @conversation.contact,
message_type: :incoming,
source_id: review_id,
content: message_content,
content_attributes: review_metadata,
created_at: created_at,
updated_at: created_at
)
end
def build_response_message
return if response_id.blank?
message = @conversation.messages.find_by(source_id: response_id)
if message
message.update!(content: response_body, content_attributes: response_metadata, updated_at: response_created_at)
return
end
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
message_type: :outgoing,
source_id: response_id,
content: response_body,
status: :delivered,
content_attributes: response_metadata,
created_at: response_created_at,
updated_at: response_created_at
)
end
def message_content
[
rating_line,
review_title.presence,
review_body,
review_footer
].compact_blank.join("\n\n")
end
def rating_line
stars = ('★' * rating) + ('☆' * (5 - rating))
"#{stars} (#{rating}/5)"
end
def review_footer
parts = [attributes['territory'], attributes['reviewerNickname']].compact_blank
return nil if parts.empty?
parts.join(' • ')
end
def review_metadata
{
app_store: {
rating: rating,
title: review_title,
territory: attributes['territory'],
reviewer_nickname: attributes['reviewerNickname'],
created_date: attributes['createdDate']
}
}
end
def response_metadata
{
external_echo: true,
app_store: {
response_id: response_id,
response_state: response_attributes['state'],
response_last_modified_date: response_attributes['lastModifiedDate']
}
}
end
end
@@ -0,0 +1,66 @@
class AppStore::SendOnAppStoreService < Base::SendOnChannelService
private
def channel_class
Channel::AppStore
end
def perform_reply
validate_feature_enabled!
validate_message_support!
source_id = channel.reply_to_review(review_id, reply_content)
sync_response_message(source_id)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
def validate_message_support!
raise 'Only outgoing text messages are supported for App Store reviews.' unless message.outgoing? && message.text?
raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any?
end
def validate_feature_enabled!
return if message.account.feature_enabled?(:channel_app_store)
raise 'App Store Reviews channel is not enabled for this account.'
end
def review_id
message.conversation.contact_inbox.source_id
end
def reply_content
message.outgoing_content.presence || message.content
end
def existing_response_message(source_id)
return if source_id.blank?
message.conversation.messages.where.not(id: message.id).find_by(source_id: source_id)
end
def sync_response_message(source_id)
response_message = existing_response_message(source_id)
if response_message.present?
update_existing_response_message(response_message, source_id)
message.destroy!
Messages::StatusUpdateService.new(response_message, 'delivered').perform
else
message.update!(source_id: source_id) if source_id.present?
Messages::StatusUpdateService.new(message, 'delivered').perform
end
end
def update_existing_response_message(response_message, source_id)
content_attributes = (response_message.content_attributes || {}).deep_merge(
'external_echo' => true,
'app_store' => {
'response_id' => source_id
}
)
response_message.update!(content: reply_content, content_attributes: content_attributes)
end
end
+152
View File
@@ -0,0 +1,152 @@
class AppStoreConnect::Client
class Error < StandardError; end
pattr_initialize [:channel!]
def fetch_app
get("/v1/apps/#{channel.app_id}")['data']
end
def fetch_reviews(since: nil)
reviews = []
next_url = nil
loop do
payload = next_url ? get_url(next_url) : get(reviews_path, reviews_query)
included = Array(payload['included'])
review_payloads = Array(payload['data']).map { |review| normalize_review(review, included) }
fresh_payloads = fresh_review_payloads(review_payloads, since)
reviews.concat(fresh_payloads)
next_url = payload.dig('links', 'next')
break if next_url.blank?
end
reviews
end
def create_review_response(review_id, response_body)
create_or_update_review_response(review_id, response_body)
end
def create_or_update_review_response(review_id, response_body)
post('/v1/customerReviewResponses', review_response_payload(review_id, response_body))['data']
end
private
def reviews_path
"/v1/apps/#{channel.app_id}/customerReviews"
end
def reviews_query
{
include: 'response',
limit: Channel::AppStore::REVIEWS_PAGE_SIZE,
sort: '-createdDate'
}
end
def normalize_review(review, included)
response_id = review.dig('relationships', 'response', 'data', 'id')
response = included.find { |item| item['type'] == 'customerReviewResponses' && item['id'] == response_id }
{
'review' => review,
'response' => response
}
end
def fresh_review_payloads(review_payloads, since)
return review_payloads if since.blank?
review_payloads.select { |review_payload| review_updated_after?(review_payload, since) }
end
def review_updated_after?(review_payload, since)
review_updated_at = [
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
].compact.max
return true if review_updated_at.blank?
review_updated_at >= since
end
def parsed_timestamp(value)
Time.zone.parse(value.to_s)
rescue StandardError
nil
end
def get(path, query = {})
request(:get, "#{Channel::AppStore::API_BASE_URL}#{path}", query: query)
end
def get_url(url)
request(:get, url)
end
def post(path, body)
request(:post, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body)
end
def request(method, url, query: {}, body: nil)
response = HTTParty.public_send(
method,
url,
headers: headers,
query: query,
body: body&.to_json
)
log_rate_limit(response)
return response.parsed_response if response.success?
raise Error, error_message(response)
end
def headers
{
'Authorization' => "Bearer #{token}",
'Content-Type' => 'application/json'
}
end
def token
AppStoreConnect::TokenService.new(channel: channel).token
end
def review_response_payload(review_id, response_body)
{
data: {
type: 'customerReviewResponses',
attributes: {
responseBody: response_body.to_s
},
relationships: {
review: {
data: {
type: 'customerReviews',
id: review_id
}
}
}
}
}
end
def log_rate_limit(response)
rate_limit = response.headers['x-rate-limit']
Rails.logger.debug { "[APP_STORE_CONNECT] rate_limit=#{rate_limit}" } if rate_limit.present?
end
def error_message(response)
parsed_response = response.parsed_response
errors = parsed_response.is_a?(Hash) ? parsed_response['errors'] : []
details = Array(errors).filter_map { |error| error['detail'] || error['title'] }.join(', ')
details = response.body if details.blank?
"App Store Connect API failed (#{response.code}): #{details}"
end
end
@@ -0,0 +1,50 @@
class AppStoreConnect::TokenService
TOKEN_TTL = 19.minutes
EXPIRY_BUFFER = 1.minute
pattr_initialize [:channel!]
def token
cached_token || generate_token
end
private
def cached_token
Rails.cache.read(cache_key)
end
def generate_token
token = JWT.encode(payload, private_key, 'ES256', headers)
Rails.cache.write(cache_key, token, expires_in: TOKEN_TTL - EXPIRY_BUFFER)
token
end
def payload
now = Time.current.to_i
{
iss: channel.issuer_id,
iat: now,
exp: now + TOKEN_TTL.to_i,
aud: 'appstoreconnect-v1'
}
end
def headers
{
kid: channel.key_id,
typ: 'JWT'
}
end
def private_key
OpenSSL::PKey.read(channel.private_key.to_s.gsub('\\n', "\n"))
end
def cache_key
identifier = channel.id || channel.object_id
version = channel.updated_at&.to_i || 'new'
"app_store_connect_token:#{identifier}:#{version}"
end
end
@@ -21,8 +21,7 @@ class MessageTemplates::HookExecutionService
def should_send_out_of_office_message?
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
return false if review_inbox_or_tweet_conversation?
# should not send for outbound messages
return false unless message.incoming?
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
@@ -38,12 +37,15 @@ class MessageTemplates::HookExecutionService
def should_send_greeting?
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
return false if review_inbox_or_tweet_conversation?
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
end
def review_inbox_or_tweet_conversation?
conversation.tweet? || inbox.app_store?
end
def email_collect_was_sent?
conversation.messages.where(content_type: 'input_email').present?
end
+1 -25
View File
@@ -1,11 +1,4 @@
class UserSessionTrackingService
# CFNetwork UAs cannot distinguish iPhone from iPad; both get labelled iPhone here.
LEGACY_MOBILE_UAS = [
{ match: %r{\Aokhttp/}, platform: 'Android', device: 'Android' },
{ match: %r{\AChatwoot/.*CFNetwork.*Darwin}, platform: 'iPhone', device: 'iPhone' }
].freeze
private_constant :LEGACY_MOBILE_UAS
def initialize(user:, request:, client_id:)
@user = user
@request = request
@@ -33,7 +26,7 @@ class UserSessionTrackingService
def session_attributes
browser = Browser.new(@request.user_agent)
attrs = {
{
ip_address: @request.remote_ip,
user_agent: @request.user_agent,
browser_name: browser.name,
@@ -42,22 +35,5 @@ class UserSessionTrackingService
platform_name: browser.platform.name,
platform_version: browser.platform.version
}
patch_for_legacy_mobile(attrs)
end
def patch_for_legacy_mobile(attrs)
return attrs unless attrs[:browser_name] == 'Unknown Browser'
hit = LEGACY_MOBILE_UAS.find { |m| @request.user_agent.to_s.match?(m[:match]) }
return attrs unless hit
attrs.merge(
browser_name: 'Chatwoot Mobile',
browser_version: nil,
platform_name: hit[:platform],
platform_version: nil,
device_name: hit[:device]
)
end
end
@@ -6,7 +6,6 @@ json.name resource.name
json.phone_number resource.phone_number
json.blocked resource.blocked
json.identifier resource.identifier
json.company_id resource.company_id if Current.account&.feature_enabled?('companies')
json.thumbnail resource.avatar_url
json.custom_attributes resource.custom_attributes
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
@@ -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)
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.15.1'
version: '4.14.2'
development:
<<: *shared
+4 -4
View File
@@ -108,10 +108,9 @@
display_name: Custom Tools
enabled: false
premium: true
- name: message_reply_to
display_name: Message Reply To
enabled: false
deprecated: true
- name: channel_app_store
display_name: App Store Reviews Channel
enabled: true
- name: insert_article_in_reply
display_name: Insert Article in Reply
enabled: false
@@ -200,6 +199,7 @@
display_name: Advanced Search
enabled: false
premium: true
chatwoot_internal: true
- name: saml
display_name: SAML
enabled: false
+6
View File
@@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job:
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
# executed every 15 minutes to fetch App Store reviews
trigger_app_store_review_inboxes_job:
cron: '*/15 * * * *'
class: 'Inboxes::FetchAppStoreReviewInboxesJob'
queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_contact_inboxes_job.rb:
@@ -0,0 +1,19 @@
class CreateChannelAppStore < ActiveRecord::Migration[7.1]
def change
create_table :channel_app_store do |t|
t.bigint :account_id, null: false
t.string :app_id, null: false
t.string :bundle_id
t.string :app_name
t.string :issuer_id, null: false
t.string :key_id, null: false
t.text :private_key, null: false
t.jsonb :provider_config, null: false, default: {}
t.datetime :last_synced_at
t.timestamps
end
add_index :channel_app_store, [:account_id, :app_id], unique: true
end
end
@@ -0,0 +1,20 @@
class RepurposeMessageReplyToFlagForChannelAppStore < ActiveRecord::Migration[7.1]
def up
# The message_reply_to flag (deprecated) has been renamed to channel_app_store.
# Disable it on any accounts that had message_reply_to enabled so the repurposed
# flag starts from plan/default configuration instead of inheriting stale state.
Account.feature_channel_app_store.find_each(batch_size: 100) do |account|
account.disable_features(:channel_app_store)
account.save!(validate: false)
end
# Remove the stale message_reply_to entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
# ConfigLoader only adds new flags; it never removes renamed ones.
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |feature| feature['name'] == 'message_reply_to' }
config.save!
GlobalConfig.clear_cache
end
end
+15
View File
@@ -450,6 +450,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
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
@@ -1,17 +0,0 @@
module Enterprise::Api::V1::Accounts::ContactsController
private
def permitted_params
params_with_company_id = super
return params_with_company_id unless Current.account.feature_enabled?('companies')
return params_with_company_id unless params.key?(:company_id)
params_with_company_id.merge(company_id: permitted_company_id)
end
def permitted_company_id
return nil if params[:company_id].blank?
Current.account.companies.find(params[:company_id]).id
end
end
@@ -75,14 +75,6 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
end
def plan_email_limit
base_limit = plan_base_email_limit
return nil if base_limit.nil?
return base_limit if free_plan?
base_limit * [agent_limits.to_i, 1].max
end
def plan_base_email_limit
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
return nil if config.blank? || plan_name.blank?
@@ -92,11 +84,6 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
nil
end
def free_plan?
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
default_plan.present? && plan_name&.downcase == default_plan['name']&.downcase
end
def default_captain_limits
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
@@ -6,7 +6,6 @@ module Enterprise::Concerns::Contact
after_commit :associate_company_from_email,
on: [:create, :update],
if: :should_associate_company?
before_save :sync_company_name_from_company, if: :will_save_change_to_company_id?
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
end
@@ -34,14 +33,4 @@ module Enterprise::Concerns::Contact
def record_company_activity
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
end
def sync_company_name_from_company
self.additional_attributes ||= {}
if company_id.present?
additional_attributes['company_name'] = company&.name
else
additional_attributes.delete('company_name')
end
end
end
@@ -6,7 +6,7 @@ class Contacts::CompanyAssociationService
if company
# rubocop:disable Rails/SkipsModelValidations
# Using update_column and increment_counter to avoid triggering callbacks while maintaining counter cache
contact.update_columns(company_id: company.id, additional_attributes: contact_attributes_with_company_name(contact, company))
contact.update_column(:company_id, company.id)
Company.increment_counter(:contacts_count, company.id)
# rubocop:enable Rails/SkipsModelValidations
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
@@ -45,8 +45,4 @@ class Contacts::CompanyAssociationService
def derive_company_name(contact, domain)
contact.additional_attributes&.dig('company_name') || domain.split('.').first.tr('-_', ' ').titleize
end
def contact_attributes_with_company_name(contact, company)
(contact.additional_attributes || {}).merge('company_name' => company.name)
end
end
@@ -11,6 +11,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_facebook
channel_email
channel_instagram
channel_app_store
channel_tiktok
captain_integration
advanced_search_indexing
-52
View File
@@ -1,52 +0,0 @@
require 'yaml'
require 'erb'
class ActiveStorage::Migrator
def self.migrate(from_service_name, to_service_name, should_update_service_name: true)
configs = load_storage_config
# Check if services are configured correctly
if configs[from_service_name.to_s].nil? || configs[to_service_name.to_s].nil?
raise "Error: The services '#{from_service_name}' or '#{to_service_name}' are not configured correctly."
end
from_service = configure_service(from_service_name, configs)
configure_blob_service(from_service)
migrate_blobs(from_service_name, to_service_name, should_update_service_name: should_update_service_name)
end
def self.load_storage_config
yaml_with_env = ERB.new(File.read('config/storage.yml')).result
YAML.load(yaml_with_env)
end
def self.configure_blob_service(service)
ActiveStorage::Blob.service = service
end
def self.configure_service(service_name, configs = load_storage_config)
service_config = configs[service_name.to_s]
ActiveStorage::Service.configure(service_name, { service_name.to_sym => service_config })
end
def self.migrate_blobs(from_service_name, to_service_name, should_update_service_name: true)
to_service = configure_service(to_service_name)
blobs = ActiveStorage::Blob.where(service_name: from_service_name.to_s)
Rails.logger.debug { "#{blobs.count} Blobs to migrate from #{from_service_name} to #{to_service_name}" }
blobs.find_each do |blob|
Rails.logger.debug { '.' }
blob.open do |io|
checksum = blob.checksum
to_service.upload(blob.key, io, checksum: checksum)
end
blob.update!(service_name: to_service_name.to_s) if should_update_service_name
rescue ActiveStorage::FileNotFoundError => e
Rails.logger.warn { "Skipping missing blob #{blob.id} (#{blob.key}): #{e.message}" }
end
Rails.logger.debug { 'Successful migration' }
end
end
-13
View File
@@ -1,13 +0,0 @@
namespace :storage do
desc 'Migrate blobs from one storage service to another'
# Example: FROM=local TO=amazon UPDATE_BLOB_SERVICE_NAME=true bundle exec rake storage:migrate
task migrate: :environment do
from_service = ENV.fetch('FROM', nil)
to_service = ENV.fetch('TO', nil)
should_update_service_name = ActiveModel::Type::Boolean.new.cast(ENV.fetch('UPDATE_BLOB_SERVICE_NAME', true))
raise 'Missing FROM or TO argument. Usage: FROM=service_name TO=service_name rake storage:migrate' if from_service.nil? || to_service.nil?
ActiveStorage::Migrator.migrate(from_service.to_sym, to_service.to_sym, should_update_service_name: should_update_service_name)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.15.1",
"version": "4.14.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -112,15 +112,15 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'returns all contacts with company name asc order with null values at last' do
contact_3
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first(2).pluck('id')).to contain_exactly(contact.id, contact_1.id)
expect(response_body['payload'].last(2).pluck('id')).to contain_exactly(contact_3.id, contact_4.id)
expect(response_body['payload'].first['email']).to eq(contact_1.email)
expect(response_body['payload'].first['id']).to eq(contact_1.id)
expect(response_body['payload'].last['email']).to eq(contact_4.email)
end
it 'returns all contacts with country name desc order with null values at last' do
@@ -463,6 +463,32 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response.body).to include('+123456789')
end
it 'creates an app store inbox when the feature is enabled' do
app_store_client = instance_double(AppStoreConnect::Client)
account.enable_features!(:channel_app_store)
allow(AppStoreConnect::Client).to receive(:new).and_return(app_store_client)
allow(app_store_client).to receive(:fetch_app).and_return(
{
'attributes' => {
'name' => 'Chatwoot',
'bundleId' => 'com.chatwoot.app'
}
}
)
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'App Store Reviews',
channel: { type: 'app_store', app_id: '123456789', issuer_id: SecureRandom.uuid, key_id: 'KEY123',
private_key: OpenSSL::PKey::EC.generate('prime256v1').to_pem } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('App Store Reviews')
expect(Channel::AppStore.last.app_name).to eq('Chatwoot')
end
it 'creates the webwidget inbox that allow messages after conversation is resolved' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
@@ -3,10 +3,7 @@ require 'rails_helper'
RSpec.describe 'Company contacts API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) do
create(:company, name: 'Acme', domain: 'acme.com', description: 'Primary account', account: account,
custom_attributes: { 'industry' => 'Manufacturing' })
end
let(:company) { create(:company, name: 'Acme', account: account) }
before { account.enable_features!(:companies) }
@@ -22,16 +19,8 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].pluck('id')).to eq([linked_contact.id])
contact_payload = response_body['payload'].first
expect(contact_payload['company_id']).to eq(company.id)
expect(contact_payload['linked_to_current_company']).to be true
expect(contact_payload['company']).to include(
'id' => company.id,
'name' => 'Acme',
'domain' => 'acme.com',
'description' => 'Primary account',
'custom_attributes' => { 'industry' => 'Manufacturing' }
)
expect(response_body['payload'].first['company_id']).to eq(company.id)
expect(response_body['payload'].first['linked_to_current_company']).to be true
expect(response_body['meta']['total_count']).to eq(1)
end
end
@@ -49,14 +38,9 @@ RSpec.describe 'Company contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
response_payload = response.parsed_body['payload']
contact_ids = response_payload.pluck('id')
contact_ids = response.parsed_body['payload'].pluck('id')
expect(contact_ids).to contain_exactly(available_contact.id, assigned_contact.id)
expect(contact_ids).not_to include(linked_contact.id)
expect(response_payload.find { |contact| contact['id'] == assigned_contact.id }['company']).to include(
'id' => other_company.id,
'name' => 'Other Company'
)
end
end
@@ -72,7 +56,7 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:success)
expect(contact.reload.company_id).to eq(company.id)
expect(contact.additional_attributes).to eq('city' => 'Berlin', 'company_name' => 'Acme')
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
@@ -90,7 +74,7 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:ok)
expect(contact.reload.company_id).to be_nil
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(contact.additional_attributes).to eq('company_name' => 'Acme', 'city' => 'Berlin')
end
end
end
@@ -1,24 +0,0 @@
require 'rails_helper'
RSpec.describe 'Enterprise Contacts API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
before { account.enable_features!(:companies) }
describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do
it 'updates company association' do
company = create(:company, account: account, name: 'Acme')
contact = create(:contact, account: account)
patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}",
headers: admin.create_new_auth_token,
params: { company_id: company.id },
as: :json
expect(response).to have_http_status(:success)
expect(contact.reload.company).to eq(company)
expect(contact.additional_attributes['company_name']).to eq('Acme')
end
end
end
@@ -67,20 +67,4 @@ RSpec.describe Contact, type: :model do
end
end
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:company) { create(:company, account: account) }
let(:contact) { create(:contact, account: account, company: company) }
it 'includes company_id when companies feature is enabled' do
account.enable_features!(:companies)
expect(contact.push_event_data[:company_id]).to eq(company.id)
end
it 'does not include company_id when companies feature is disabled' do
expect(contact.push_event_data).not_to have_key(:company_id)
end
end
end
@@ -25,7 +25,6 @@ RSpec.describe Contacts::CompanyAssociationService, type: :service do
expect(contact.company).to be_present
expect(contact.company.domain).to eq('acme.com')
expect(contact.company.name).to eq('Acme')
expect(contact.additional_attributes['company_name']).to eq('Acme')
end
it 'reuses existing company with same domain' do
@@ -272,6 +272,7 @@ describe Enterprise::Billing::HandleStripeEventService do
# But other premium features should be disabled
expect(account).not_to be_feature_enabled('channel_instagram')
expect(account).not_to be_feature_enabled('channel_app_store')
expect(account).not_to be_feature_enabled('help_center')
end
end
@@ -333,6 +334,7 @@ describe Enterprise::Billing::HandleStripeEventService do
account.reload
# Spot check one startup feature
expect(account).to be_feature_enabled('channel_instagram')
expect(account).to be_feature_enabled('channel_app_store')
expect(account).not_to be_feature_enabled('sla')
expect(account).not_to be_feature_enabled('custom_roles')
end
@@ -350,6 +352,7 @@ describe Enterprise::Billing::HandleStripeEventService do
it 'disables all premium features' do
# Verify startup features were enabled
expect(account).to be_feature_enabled('channel_instagram')
expect(account).to be_feature_enabled('channel_app_store')
# Downgrade to Hacker (default) plan
allow(subscription).to receive(:[]).with('plan')
@@ -359,6 +362,7 @@ describe Enterprise::Billing::HandleStripeEventService do
account.reload
# Spot check that premium features are disabled
expect(account).not_to be_feature_enabled('channel_instagram')
expect(account).not_to be_feature_enabled('channel_app_store')
expect(account).not_to be_feature_enabled('help_center')
end
end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_app_store, class: 'Channel::AppStore' do
account
app_id { SecureRandom.random_number(1_000_000_000..9_999_999_999).to_s }
bundle_id { 'com.example.app' }
app_name { 'Example App' }
issuer_id { SecureRandom.uuid }
key_id { SecureRandom.alphanumeric(10).upcase }
private_key do
key = OpenSSL::PKey::EC.generate('prime256v1')
key.to_pem
end
to_create { |instance| instance.save!(validate: false) }
after(:create) do |channel|
create(:inbox, channel: channel, account: channel.account)
end
end
end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
let(:due_channel) { create(:channel_app_store, account: account, last_synced_at: 2.hours.ago) }
let(:fresh_channel) { create(:channel_app_store, account: account, last_synced_at: 10.minutes.ago) }
let(:suspended_channel) { create(:channel_app_store, account: suspended_account, last_synced_at: 2.hours.ago) }
before do
account.enable_features!(:channel_app_store)
suspended_account.enable_features!(:channel_app_store)
end
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
it 'enqueues fetch jobs only for due channels on active accounts' do
due_channel
fresh_channel
suspended_channel
expect(Inboxes::FetchAppStoreReviewsJob).to receive(:perform_later).with(due_channel).once
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(fresh_channel)
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(suspended_channel)
described_class.perform_now
end
it 'skips channels when the feature is disabled for the account' do
account.disable_features!(:channel_app_store)
due_channel
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later)
described_class.perform_now
end
end
@@ -0,0 +1,122 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Inboxes::FetchAppStoreReviewsJob do
let(:channel) { create(:channel_app_store, last_synced_at: nil) }
let(:review_payload) do
{
'review' => {
'id' => 'review-1',
'attributes' => { 'createdDate' => '2026-05-20T10:00:00-00:00' }
},
'response' => nil
}
end
let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) }
before do
channel.account.enable_features!(:channel_app_store)
end
it 'enqueues the job' do
expect { described_class.perform_later(channel) }.to have_enqueued_job(described_class)
.with(channel)
.on_queue('scheduled_jobs')
end
it 'fetches reviews, builds messages, and updates the sync timestamp' do
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
allow(AppStore::ReviewBuilder).to receive(:new).with(review_payload: review_payload, channel: channel).and_return(review_builder)
described_class.perform_now(channel)
expect(review_builder).to have_received(:perform)
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T10:00:00-00:00'))
end
it 'updates the sync timestamp to the latest fetched review date' do
older_payload = review_payload.deep_dup
newer_payload = review_payload.deep_dup
older_payload['review']['id'] = 'review-1'
older_payload['review']['attributes']['createdDate'] = '2026-05-20T10:00:00-00:00'
newer_payload['review']['id'] = 'review-2'
newer_payload['review']['attributes']['createdDate'] = '2026-05-20T11:00:00-00:00'
allow(channel).to receive(:fetch_reviews).and_return([newer_payload, older_payload])
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
described_class.perform_now(channel)
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T11:00:00-00:00'))
end
it 'updates the sync timestamp to the response update date when it is newer than the review date' do
payload = review_payload.deep_dup
payload['response'] = {
'id' => 'response-1',
'attributes' => {
'lastModifiedDate' => '2026-05-20T12:00:00-00:00'
}
}
allow(channel).to receive(:fetch_reviews).and_return([payload])
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
described_class.perform_now(channel)
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T12:00:00-00:00'))
end
it 'does not move the sync timestamp backwards when processing an older response update' do
channel.update!(last_synced_at: Time.zone.parse('2026-05-20T13:00:00-00:00'))
payload = review_payload.deep_dup
payload['response'] = {
'id' => 'response-1',
'attributes' => {
'lastModifiedDate' => '2026-05-20T12:00:00-00:00'
}
}
allow(channel).to receive(:fetch_reviews).and_return([payload])
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
described_class.perform_now(channel)
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T13:00:00-00:00'))
end
it 'captures per-review errors and continues syncing' do
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
described_class.perform_now(channel)
expect(exception_tracker).to have_received(:capture_exception)
end
it 'does not update the sync timestamp when a review fails to build' do
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
channel.update!(last_synced_at: 2.hours.ago)
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
expect { described_class.perform_now(channel) }
.not_to change(channel.reload, :last_synced_at)
end
it 'does not fetch reviews when the feature is disabled for the account' do
channel.account.disable_features!(:channel_app_store)
expect(channel).not_to receive(:fetch_reviews)
described_class.perform_now(channel)
end
end
+6
View File
@@ -122,5 +122,11 @@ RSpec.describe SendReplyJob do
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
end
it 'calls ::AppStore::SendOnAppStoreService when its app store message' do
app_store_channel = create(:channel_app_store)
message = create(:message, conversation: create(:conversation, inbox: app_store_channel.inbox))
expect_mapped_service_to_perform(message, 'AppStore::SendOnAppStoreService')
end
end
end
+1 -3
View File
@@ -74,10 +74,8 @@ RSpec.describe SlackUnfurlJob do
end
context 'when another account URL is shared' do
let(:another_account) { create(:account) }
before do
link_shared[:event][:links][0][:url] = "https://qa.chatwoot.com/app/accounts/#{another_account.id}/conversations/123"
link_shared[:event][:links][0][:url] = 'https://qa.chatwoot.com/app/accounts/123/conversations/123'
end
it 'does not unfurl' do
-175
View File
@@ -1,175 +0,0 @@
require 'rails_helper'
require 'fileutils'
require 'stringio'
require 'tmpdir'
RSpec.describe ActiveStorage::Migrator do
around do |example|
original_services = ActiveStorage::Blob.services
original_service = ActiveStorage::Blob.service
example.run
ensure
ActiveStorage::Blob.services = original_services
ActiveStorage::Blob.service = original_service
end
describe '.migrate' do
let(:from_service_stub) { instance_double(ActiveStorage::Service, name: 'local') }
let(:to_service_stub) { instance_double(ActiveStorage::Service, name: 'amazon') }
before do
allow(ActiveStorage::Service).to receive(:configure).with('local', any_args).and_return(from_service_stub)
allow(ActiveStorage::Service).to receive(:configure).with('amazon', any_args).and_return(to_service_stub)
end
context 'when services are configured correctly' do
it 'migrates blobs from one service to another' do
expect(ActiveStorage::Service).to receive(:configure).with('local', any_args)
expect(described_class).to receive(:migrate_blobs).with('local', 'amazon', should_update_service_name: true)
expect { described_class.migrate('local', 'amazon') }.not_to raise_error
end
it 'passes the service-name update flag to blob migration' do
expect(described_class).to receive(:migrate_blobs).with('local', 'amazon', should_update_service_name: false)
described_class.migrate('local', 'amazon', should_update_service_name: false)
end
it 'does not override the application logger' do
allow(described_class).to receive(:migrate_blobs)
expect(Rails).not_to receive(:logger=)
described_class.migrate('local', 'amazon')
end
end
context 'when services are not configured correctly' do
it 'prints an error message' do
allow(ActiveStorage::Service).to receive(:configure).and_return(nil)
expect do
described_class.migrate('random', 'random')
end.to raise_error(RuntimeError, "Error: The services 'random' or 'random' are not configured correctly.")
end
end
end
describe '.migrate_blobs' do
let(:from_root) { Dir.mktmpdir('active-storage-local') }
let(:to_root) { Dir.mktmpdir('active-storage-amazon') }
let(:other_root) { Dir.mktmpdir('active-storage-other') }
let(:from_service) { build_disk_service('local', from_root) }
let(:to_service) { build_disk_service('amazon', to_root) }
let(:other_service) { build_disk_service('other', other_root) }
before do
allow(described_class).to receive(:configure_service).with(:amazon).and_return(to_service)
end
around do |example|
original_services = ActiveStorage::Blob.services
original_service = ActiveStorage::Blob.service
ActiveStorage::Blob.services = {
'local' => from_service,
'amazon' => to_service,
'other' => other_service
}
ActiveStorage::Blob.service = from_service
example.run
ensure
ActiveStorage::Blob.services = original_services
ActiveStorage::Blob.service = original_service
FileUtils.rm_rf([from_root, to_root, other_root])
end
it 'uploads non-image blobs and updates their service name' do
blob = create_blob(service_name: 'local', filename: 'invoice.pdf', content_type: 'application/pdf', content: 'pdf-content')
described_class.migrate_blobs(:local, :amazon, should_update_service_name: true)
expect(blob).not_to be_image
expect(blob.reload.service_name).to eq('amazon')
expect(to_service.download(blob.key)).to eq('pdf-content')
end
it 'only migrates blobs from the source service' do
local_blob = create_blob(service_name: 'local', filename: 'audio.mp3', content_type: 'audio/mpeg', content: 'audio-content')
other_blob = create_blob(service_name: 'other', filename: 'other.pdf', content_type: 'application/pdf', content: 'other-content')
described_class.migrate_blobs(:local, :amazon, should_update_service_name: true)
expect(local_blob.reload.service_name).to eq('amazon')
expect(to_service.download(local_blob.key)).to eq('audio-content')
expect(other_blob.reload.service_name).to eq('other')
expect(to_service.exist?(other_blob.key)).to be(false)
expect(other_service.download(other_blob.key)).to eq('other-content')
end
it 'skips missing source files and continues migration' do
missing_blob = create_blob(service_name: 'local', filename: 'missing.pdf', content_type: 'application/pdf', content: 'missing-content')
existing_blob = create_blob(service_name: 'local', filename: 'existing.pdf', content_type: 'application/pdf', content: 'existing-content')
from_service.delete(missing_blob.key)
expect do
described_class.migrate_blobs(:local, :amazon, should_update_service_name: true)
end.not_to raise_error
expect(missing_blob.reload.service_name).to eq('local')
expect(to_service.exist?(missing_blob.key)).to be(false)
expect(existing_blob.reload.service_name).to eq('amazon')
expect(to_service.download(existing_blob.key)).to eq('existing-content')
end
it 'does not update the blob service when upload fails' do
blob = create_blob(service_name: 'local', filename: 'report.pdf', content_type: 'application/pdf', content: 'report-content')
allow(to_service).to receive(:upload).and_raise(ActiveStorage::IntegrityError)
expect do
described_class.migrate_blobs(:local, :amazon, should_update_service_name: true)
end.to raise_error(ActiveStorage::IntegrityError)
expect(blob.reload.service_name).to eq('local')
expect(to_service.exist?(blob.key)).to be(false)
end
it 'skips the blob service update when the flag is disabled' do
blob = create_blob(service_name: 'local', filename: 'manual.pdf', content_type: 'application/pdf', content: 'manual-content')
described_class.migrate_blobs(:local, :amazon, should_update_service_name: false)
expect(blob.reload.service_name).to eq('local')
expect(to_service.download(blob.key)).to eq('manual-content')
end
it 'does not rewrite blobs that were already migrated' do
blob = create_blob(service_name: 'local', filename: 'archive.pdf', content_type: 'application/pdf', content: 'archive-content')
allow(to_service).to receive(:upload).and_call_original
2.times { described_class.migrate_blobs(:local, :amazon, should_update_service_name: true) }
expect(to_service).to have_received(:upload).with(blob.key, an_instance_of(Tempfile), checksum: blob.checksum).once
expect(blob.reload.service_name).to eq('amazon')
expect(ActiveStorage::Blob.where(id: blob.id, service_name: 'local')).to be_empty
end
def build_disk_service(service_name, root)
ActiveStorage::Service.configure(service_name, { service_name => { service: 'Disk', root: root } })
end
def create_blob(service_name:, filename:, content_type:, content:)
ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(content),
filename: filename,
content_type: content_type,
service_name: service_name,
identify: false
)
end
end
end
@@ -1,64 +0,0 @@
require 'rake'
require 'rails_helper'
RSpec.describe Rake::Task do
describe 'storage_migrations' do
describe 'rake task' do
subject(:task) { described_class['storage:migrate'] }
before do
task.reenable
end
context 'when FROM argument is missing' do
it 'raises an error' do
with_modified_env FROM: nil, TO: 'amazon' do
expect do
task.invoke
end.to raise_error(RuntimeError,
'Missing FROM or TO argument. Usage: FROM=service_name TO=service_name rake storage:migrate')
end
end
end
context 'when TO argument is missing' do
it 'raises an error' do
with_modified_env FROM: 'service_name', TO: nil do
expect do
task.invoke
end.to raise_error(RuntimeError,
'Missing FROM or TO argument. Usage: FROM=service_name TO=service_name rake storage:migrate')
end
end
end
context 'when required arguments are present' do
it 'updates blob service names by default' do
expect(ActiveStorage::Migrator).to receive(:migrate).with(:local, :amazon, should_update_service_name: true)
with_modified_env FROM: 'local', TO: 'amazon', UPDATE_BLOB_SERVICE_NAME: nil do
task.invoke
end
end
it 'skips updating blob service names when disabled' do
expect(ActiveStorage::Migrator).to receive(:migrate).with(:local, :amazon, should_update_service_name: false)
with_modified_env FROM: 'local', TO: 'amazon', UPDATE_BLOB_SERVICE_NAME: 'false' do
task.invoke
end
end
it 'can be invoked again after the task is re-enabled' do
expect(ActiveStorage::Migrator).to receive(:migrate).twice.with(:local, :amazon, should_update_service_name: true)
with_modified_env FROM: 'local', TO: 'amazon', UPDATE_BLOB_SERVICE_NAME: nil do
task.invoke
task.reenable
task.invoke
end
end
end
end
end
end
+17
View File
@@ -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) }
+64
View File
@@ -0,0 +1,64 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::AppStore do
describe 'validations' do
it 'normalizes auth fields and stores app metadata from App Store Connect' do
app_store_client = instance_double(AppStoreConnect::Client)
channel = build(
:channel_app_store,
account: create(:account),
app_id: ' 123456789 ',
issuer_id: ' issuer-id ',
key_id: ' key-id ',
private_key: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\r\n",
app_name: nil,
bundle_id: nil
)
allow(channel).to receive(:app_store_client).and_return(app_store_client)
allow(app_store_client).to receive(:fetch_app).and_return(
{
'attributes' => {
'name' => 'Chatwoot iOS',
'bundleId' => 'com.chatwoot.app'
}
}
)
expect(channel).to be_valid
expect(channel.app_id).to eq('123456789')
expect(channel.issuer_id).to eq('issuer-id')
expect(channel.key_id).to eq('key-id')
expect(channel.private_key).to eq("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----")
expect(channel.app_name).to eq('Chatwoot iOS')
expect(channel.bundle_id).to eq('com.chatwoot.app')
end
it 'adds an error when App Store Connect validation fails' do
app_store_client = instance_double(AppStoreConnect::Client)
channel = build(:channel_app_store)
allow(channel).to receive(:app_store_client).and_return(app_store_client)
allow(app_store_client).to receive(:fetch_app).and_raise(AppStoreConnect::Client::Error, 'invalid credentials')
expect(channel).not_to be_valid
expect(channel.errors[:base]).to include('invalid credentials')
end
end
describe '#sync_due?' do
it 'returns true when the channel has never synced' do
channel = build(:channel_app_store, last_synced_at: nil)
expect(channel.sync_due?).to be true
end
it 'returns false when the last sync is within the sync interval' do
channel = build(:channel_app_store, last_synced_at: 30.minutes.ago)
expect(channel.sync_due?).to be false
end
end
end
-4
View File
@@ -7,10 +7,6 @@ require 'rspec/rails'
require 'pundit/rspec'
require 'sidekiq/testing'
# Load Rake tasks
require 'rake'
Rails.application.load_tasks
# test-prof helpers for tests optimization
require 'test_prof/recipes/rspec/before_all'
require 'test_prof/recipes/rspec/let_it_be'
@@ -0,0 +1,132 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStore::ReviewBuilder do
let(:channel) { create(:channel_app_store) }
let(:inbox) { channel.inbox }
let(:review_payload) do
{
'review' => {
'id' => 'review-1',
'attributes' => {
'rating' => 4,
'title' => 'Helpful app',
'body' => 'Works well for support.',
'territory' => 'US',
'reviewerNickname' => 'Reviewer',
'createdDate' => '2026-05-20T10:00:00-00:00'
},
'relationships' => {
'response' => {
'data' => {
'id' => 'response-1',
'type' => 'customerReviewResponses'
}
}
}
},
'response' => {
'id' => 'response-1',
'attributes' => {
'responseBody' => 'Thanks for the feedback.',
'state' => 'PUBLISHED',
'lastModifiedDate' => '2026-05-20T11:00:00-00:00'
}
}
}
end
describe '#perform' do
it 'creates a conversation with an incoming review and outgoing developer response' do
expect { described_class.new(review_payload: review_payload, channel: channel).perform }
.to change(inbox.conversations, :count).by(1)
.and change(Message.where(inbox_id: inbox.id), :count).by(2)
conversation = inbox.conversations.last
review_message = conversation.messages.incoming.find_by(source_id: 'review-1')
response_message = conversation.messages.outgoing.find_by(source_id: 'response-1')
expect(conversation.contact_inbox.source_id).to eq('review-1')
expect(review_message.content).to include('★★★★☆ (4/5)', 'Helpful app', 'Works well for support.', 'US • Reviewer')
expect(review_message.content_attributes['app_store']).to include(
'rating' => 4,
'title' => 'Helpful app',
'territory' => 'US',
'reviewer_nickname' => 'Reviewer'
)
expect(response_message.content).to eq('Thanks for the feedback.')
expect(response_message.status).to eq('delivered')
expect(response_message.content_attributes['external_echo']).to be true
end
it 'creates a conversation for a rating-only review' do
rating_only_payload = review_payload.deep_dup
rating_only_payload['review']['attributes']['title'] = ''
rating_only_payload['review']['attributes']['body'] = ''
rating_only_payload['response'] = nil
expect { described_class.new(review_payload: rating_only_payload, channel: channel).perform }
.to change(inbox.conversations, :count).by(1)
.and change(Message.where(inbox_id: inbox.id), :count).by(1)
review_message = inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1')
expect(review_message.content).to include('★★★★☆ (4/5)')
end
it 'clamps the review rating before building the message content' do
invalid_rating_payload = review_payload.deep_dup
invalid_rating_payload['review']['attributes']['rating'] = 7
invalid_rating_payload['response'] = nil
described_class.new(review_payload: invalid_rating_payload, channel: channel).perform
review_message = inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1')
expect(review_message.content).to include('★★★★★ (5/5)')
expect(review_message.content_attributes['app_store']).to include('rating' => 5)
end
it 'falls back to current time when Apple timestamps are blank' do
blank_timestamp_payload = review_payload.deep_dup
blank_timestamp_payload['review']['attributes']['createdDate'] = ''
blank_timestamp_payload['response']['attributes']['lastModifiedDate'] = ''
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
described_class.new(review_payload: blank_timestamp_payload, channel: channel).perform
conversation = inbox.conversations.last
expect(conversation.messages.incoming.find_by(source_id: 'review-1').created_at.to_i).to eq(Time.current.to_i)
expect(conversation.messages.outgoing.find_by(source_id: 'response-1').created_at.to_i).to eq(Time.current.to_i)
end
end
it 'updates an existing review message when Apple returns the same review again' do
described_class.new(review_payload: review_payload, channel: channel).perform
updated_payload = review_payload.deep_dup
updated_payload['review']['attributes']['body'] = 'Updated review body.'
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
.not_to change(Message.where(inbox_id: inbox.id), :count)
expect(inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1').content).to include('Updated review body.')
end
it 'updates an existing developer response message when Apple returns an edited response' do
described_class.new(review_payload: review_payload, channel: channel).perform
updated_payload = review_payload.deep_dup
updated_payload['response']['attributes']['responseBody'] = 'Updated response.'
updated_payload['response']['attributes']['state'] = 'PENDING_PUBLISH'
updated_payload['response']['attributes']['lastModifiedDate'] = '2026-05-20T12:00:00-00:00'
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
.not_to change(Message.where(inbox_id: inbox.id), :count)
response_message = inbox.conversations.last.messages.outgoing.find_by(source_id: 'response-1')
expect(response_message.content).to eq('Updated response.')
expect(response_message.content_attributes['app_store']).to include(
'response_state' => 'PENDING_PUBLISH',
'response_last_modified_date' => '2026-05-20T12:00:00-00:00'
)
end
end
end
@@ -0,0 +1,120 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStore::SendOnAppStoreService do
let(:account) { create(:account) }
let(:channel) { create(:channel_app_store, account: account) }
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: inbox.account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'review-1') }
let(:conversation) { create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, account: inbox.account) }
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
before do
account.enable_features!(:channel_app_store)
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
end
describe '#perform' do
it 'creates an App Store response for a new reply' do
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
allow(channel).to receive(:reply_to_review).and_return('response-1')
described_class.new(message: message).perform
expect(channel).to have_received(:reply_to_review).with('review-1', 'Thanks')
expect(message.reload.source_id).to eq('response-1')
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
end
it 'updates the existing App Store response when the conversation already has one' do
existing_response = create(
:message,
message_type: :outgoing,
inbox: inbox,
conversation: conversation,
account: inbox.account,
content: 'Old reply',
source_id: 'response-1',
content_attributes: {
external_echo: true,
app_store: {
response_id: 'response-1',
response_state: 'PUBLISHED'
}
}
)
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Updated reply')
allow(channel).to receive(:reply_to_review).and_return('response-1')
expect { described_class.new(message: message).perform }
.to change { conversation.messages.reload.count }.by(-1)
expect(channel).to have_received(:reply_to_review).with('review-1', 'Updated reply')
expect { message.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(existing_response.reload.content).to eq('Updated reply')
expect(existing_response.content_attributes['external_echo']).to be true
expect(existing_response.content_attributes['app_store']).to include(
'response_id' => 'response-1',
'response_state' => 'PUBLISHED'
)
expect(Messages::StatusUpdateService).to have_received(:new).with(existing_response, 'delivered')
end
it 'marks the message as failed when attachments are present' do
message = create(:message, :with_attachment, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account)
described_class.new(message: message).perform
expect(Messages::StatusUpdateService).to have_received(:new).with(
message,
'failed',
'Sending attachments is not supported for App Store reviews.'
)
expect(exception_tracker).to have_received(:capture_exception)
end
it 'marks the message as failed when the message is not plain outgoing text' do
message = create(
:message,
message_type: :outgoing,
content_type: :input_csat,
inbox: inbox,
conversation: conversation,
account: inbox.account,
content: 'Please rate this conversation'
)
allow(channel).to receive(:reply_to_review)
described_class.new(message: message).perform
expect(channel).not_to have_received(:reply_to_review)
expect(Messages::StatusUpdateService).to have_received(:new).with(
message,
'failed',
'Only outgoing text messages are supported for App Store reviews.'
)
expect(exception_tracker).to have_received(:capture_exception)
end
it 'marks the message as failed when the feature is disabled' do
account.disable_features!(:channel_app_store)
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
described_class.new(message: message).perform
expect(Messages::StatusUpdateService).to have_received(:new).with(
message,
'failed',
'App Store Reviews channel is not enabled for this account.'
)
expect(exception_tracker).to have_received(:capture_exception)
end
end
end
@@ -0,0 +1,314 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStoreConnect::Client do
let(:channel) { create(:channel_app_store, app_id: '123456789') }
let(:token_service) { instance_double(AppStoreConnect::TokenService, token: 'jwt-token') }
before do
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(token_service)
end
describe '#fetch_app' do
it 'fetches the configured app' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
.with(headers: { 'Authorization' => 'Bearer jwt-token' })
.to_return(
status: 200,
body: {
data: {
id: '123456789',
attributes: {
name: 'Chatwoot',
bundleId: 'com.chatwoot.app'
}
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(described_class.new(channel: channel).fetch_app['id']).to eq('123456789')
end
end
describe '#fetch_reviews' do
it 'fetches reviews and attaches the included developer response' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
.to_return(
status: 200,
body: {
data: [
{
id: 'review-1',
type: 'customerReviews',
attributes: {
createdDate: '2026-05-20T10:00:00-00:00'
},
relationships: {
response: {
data: {
id: 'response-1',
type: 'customerReviewResponses'
}
}
}
}
],
included: [
{
id: 'response-1',
type: 'customerReviewResponses',
attributes: {
responseBody: 'Thanks for the review'
}
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
review_payload = described_class.new(channel: channel).fetch_reviews.first
expect(review_payload['review']['id']).to eq('review-1')
expect(review_payload['response']['id']).to eq('response-1')
end
it 'keeps fetching older pages so updated developer responses are not skipped' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
.to_return(app_store_response(mixed_freshness_reviews_page))
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
.to_return(app_store_response(older_reviews_page_with_updated_response))
review_payloads = described_class.new(channel: channel).fetch_reviews(since: Time.zone.parse('2026-05-20T00:00:00-00:00'))
expect(review_payloads.pluck('review').pluck('id')).to eq(%w[review-1 review-3])
expect(WebMock).to have_requested(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
end
it 'includes older reviews when the developer response was updated after the sync cursor' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
.to_return(
status: 200,
body: {
data: [
{
id: 'review-1',
type: 'customerReviews',
attributes: {
createdDate: '2026-05-19T10:00:00-00:00'
},
relationships: {
response: {
data: {
id: 'response-1',
type: 'customerReviewResponses'
}
}
}
}
],
included: [
{
id: 'response-1',
type: 'customerReviewResponses',
attributes: {
responseBody: 'Updated response',
lastModifiedDate: '2026-05-20T11:00:00-00:00'
}
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
review_payloads = described_class.new(channel: channel).fetch_reviews(since: Time.zone.parse('2026-05-20T10:00:00-00:00'))
expect(review_payloads.pluck('review').pluck('id')).to eq(['review-1'])
expect(review_payloads.first['response']['attributes']['responseBody']).to eq('Updated response')
end
it 'fetches a fresh cached token for each request' do
first_token_service = instance_double(AppStoreConnect::TokenService, token: 'first-token')
second_token_service = instance_double(AppStoreConnect::TokenService, token: 'second-token')
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(first_token_service, second_token_service)
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
.with(
headers: { 'Authorization' => 'Bearer first-token' },
query: { include: 'response', limit: '200', sort: '-createdDate' }
)
.to_return(
status: 200,
body: {
data: [],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2'
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
.with(headers: { 'Authorization' => 'Bearer second-token' })
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
described_class.new(channel: channel).fetch_reviews
expect(AppStoreConnect::TokenService).to have_received(:new).twice
end
def app_store_response(body)
{
status: 200,
body: body.to_json,
headers: { 'Content-Type' => 'application/json' }
}
end
def mixed_freshness_reviews_page
{
data: [
{
id: 'review-1',
type: 'customerReviews',
attributes: { createdDate: '2026-05-20T10:00:00-00:00' }
},
{
id: 'review-2',
type: 'customerReviews',
attributes: { createdDate: '2026-05-19T10:00:00-00:00' }
}
],
links: {
next: 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2'
}
}
end
def older_reviews_page_with_updated_response
{
data: [
{
id: 'review-3',
type: 'customerReviews',
attributes: { createdDate: '2026-05-18T10:00:00-00:00' },
relationships: response_relationship('response-3')
}
],
included: [updated_response_payload]
}
end
def response_relationship(response_id)
{
response: {
data: { id: response_id, type: 'customerReviewResponses' }
}
}
end
def updated_response_payload
{
id: 'response-3',
type: 'customerReviewResponses',
attributes: {
responseBody: 'Updated response',
lastModifiedDate: '2026-05-20T11:00:00-00:00'
}
}
end
end
describe '#create_review_response' do
it 'creates a response for a review' do
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
.with(
body: {
data: {
type: 'customerReviewResponses',
attributes: {
responseBody: 'Thanks'
},
relationships: {
review: {
data: {
type: 'customerReviews',
id: 'review-1'
}
}
}
}
}.to_json
)
.to_return(
status: 201,
body: { data: { id: 'response-1' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
response = described_class.new(channel: channel).create_review_response('review-1', 'Thanks')
expect(response['id']).to eq('response-1')
end
end
describe '#create_or_update_review_response' do
it 'creates or updates a response for a review' do
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
.with(
body: {
data: {
type: 'customerReviewResponses',
attributes: {
responseBody: 'Updated response'
},
relationships: {
review: {
data: {
type: 'customerReviews',
id: 'review-1'
}
}
}
}
}.to_json
)
.to_return(
status: 200,
body: { data: { id: 'response-1' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
response = described_class.new(channel: channel).create_or_update_review_response('review-1', 'Updated response')
expect(response['id']).to eq('response-1')
end
end
it 'raises a useful error when Apple returns an error response' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
.to_return(
status: 401,
body: {
errors: [
{
detail: 'Provide a properly configured and signed bearer token.'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect { described_class.new(channel: channel).fetch_app }
.to raise_error(AppStoreConnect::Client::Error, /properly configured and signed bearer token/)
end
end
@@ -0,0 +1,56 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStoreConnect::TokenService do
let(:private_key) { OpenSSL::PKey::EC.generate('prime256v1').to_pem }
let(:channel) do
instance_double(
Channel::AppStore,
id: 1,
updated_at: Time.zone.at(1_700_000_000),
issuer_id: 'issuer-id',
key_id: 'key-id',
private_key: private_key
)
end
describe '#token' do
it 'generates an App Store Connect JWT with the expected claims and headers' do
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
token = described_class.new(channel: channel).token
payload, header = JWT.decode(token, nil, false)
expect(payload).to include(
'iss' => 'issuer-id',
'iat' => Time.current.to_i,
'exp' => 19.minutes.from_now.to_i,
'aud' => 'appstoreconnect-v1'
)
expect(header).to include('kid' => 'key-id', 'typ' => 'JWT', 'alg' => 'ES256')
end
end
it 'returns cached tokens for the channel' do
allow(Rails.cache).to receive(:read).with('app_store_connect_token:1:1700000000').and_return('cached-token')
expect(described_class.new(channel: channel).token).to eq('cached-token')
end
it 'generates a token before the channel is persisted' do
new_channel = instance_double(
Channel::AppStore,
id: nil,
updated_at: nil,
issuer_id: 'issuer-id',
key_id: 'key-id',
private_key: private_key
)
token = described_class.new(channel: new_channel).token
payload, = JWT.decode(token, nil, false)
expect(payload['iss']).to eq('issuer-id')
end
end
end
@@ -270,4 +270,28 @@ describe MessageTemplates::HookExecutionService do
expect(out_of_office_service).not_to receive(:perform)
end
end
context 'when the inbox is an App Store Reviews channel' do
let(:channel) { create(:channel_app_store) }
let(:inbox) { channel.inbox }
let(:conversation) { create(:conversation, inbox: inbox, account: channel.account) }
it 'does not fire the greeting template even when greeting_enabled is true' do
inbox.update!(greeting_enabled: true, greeting_message: 'Thanks for reviewing!')
allow(MessageTemplates::Template::Greeting).to receive(:new)
create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
end
it 'does not fire the out-of-office template even when configured' do
inbox.update!(working_hours_enabled: true, out_of_office_message: 'Back tomorrow')
allow(MessageTemplates::Template::OutOfOffice).to receive(:new)
create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
end
@@ -47,75 +47,6 @@ RSpec.describe UserSessionTrackingService do
expect(existing.reload.ip_address).to eq('8.8.8.8')
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
end
context 'with a Chatwoot Mobile legacy User-Agent' do
let(:request) do
instance_double(
ActionDispatch::Request,
user_agent: ua,
remote_ip: '8.8.8.8'
)
end
context 'when the UA is okhttp (Android Chatwoot Mobile)' do
let(:ua) { 'okhttp/4.9.2' }
it 'labels the session as Chatwoot Mobile on Android', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Chatwoot Mobile')
expect(session.browser_version).to be_nil
expect(session.platform_name).to eq('Android')
expect(session.platform_version).to be_nil
expect(session.device_name).to eq('Android')
expect(session.user_agent).to eq(ua)
end
end
context 'when the UA is CFNetwork (iOS Chatwoot Mobile)' do
let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' }
it 'labels the session as Chatwoot Mobile on iPhone', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Chatwoot Mobile')
expect(session.browser_version).to be_nil
expect(session.platform_name).to eq('iPhone')
expect(session.platform_version).to be_nil
expect(session.device_name).to eq('iPhone')
expect(session.user_agent).to eq(ua)
end
end
context 'when the UA is a real browser (Firefox on Linux)' do
let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' }
it 'does not override the Browser-derived metadata', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Firefox')
expect(session.platform_name).to eq('Generic Linux')
expect(session.device_name).not_to eq('Android')
expect(session.device_name).not_to eq('iPhone')
end
end
context 'when the UA is unknown but does not match any mobile pattern' do
let(:ua) { 'curl/8.4.0' }
it 'leaves the Unknown labels untouched', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.browser_name).to eq('Unknown Browser')
expect(session.platform_name).to eq('Unknown')
expect(session.device_name).to eq('Unknown')
end
end
end
end
describe '#update_activity!' do
-5
View File
@@ -258,11 +258,6 @@ export const colors = {
black2: 'rgba(var(--black-alpha-2))',
white: 'rgba(var(--white-alpha))',
},
// Voice call widget
'call-widget': 'rgba(var(--call-widget))',
'call-widget-border': 'rgba(var(--call-widget-border))',
'call-widget-text': 'rgba(var(--call-widget-text))',
'call-widget-sub-text': 'rgba(var(--call-widget-sub-text))',
// Border colors
weak: 'rgb(var(--border-weak) / <alpha-value>)',
container: 'rgba(var(--border-container))',