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
140 changed files with 2078 additions and 2397 deletions
+1 -1
View File
@@ -1 +1 @@
4.15.0
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
+1 -1
View File
@@ -142,7 +142,7 @@ class ConversationFinder
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'participating'
@conversations = @conversations.where(id: current_user.participating_conversations.where(account_id: current_account.id).select(:id))
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.unattended
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;
}
@@ -22,10 +22,6 @@ const props = defineProps({
type: [Number, String],
default: 0,
},
badgeTooltip: {
type: String,
default: '',
},
});
const reauthorizationRequired = computed(() => {
@@ -38,7 +34,7 @@ const reauthorizationRequired = computed(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
<SidebarUnreadBadge :count="badgeCount" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -199,18 +199,6 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const mentionsUnreadCount = useMapGetter(
'conversationUnreadCounts/getMentionsUnreadCount'
);
const participatingUnreadCount = useMapGetter(
'conversationUnreadCounts/getParticipatingUnreadCount'
);
const unattendedUnreadCount = useMapGetter(
'conversationUnreadCounts/getUnattendedUnreadCount'
);
const getFolderUnreadCount = useMapGetter(
'conversationUnreadCounts/getFolderUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -292,19 +280,6 @@ const sortedLabels = computed(() =>
})
);
const unreadBadgeTooltips = computed(() => ({
ALL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.ALL'),
INBOX: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.INBOX'),
LABEL: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.LABEL'),
TEAM: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.TEAM'),
FOLDER: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.FOLDER'),
MENTIONS: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.MENTIONS'),
PARTICIPATING: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.PARTICIPATING'),
UNATTENDED: t('SIDEBAR.UNREAD_COUNT_TOOLTIP.UNATTENDED'),
}));
const unreadBadgeTooltip = type => unreadBadgeTooltips.value[type];
const closeMobileSidebar = () => {
if (!props.isMobileSidebarOpen) return;
emit('closeMobileSidebar');
@@ -360,15 +335,12 @@ const menuItems = computed(() => {
label: t('SIDEBAR.ALL_CONVERSATIONS'),
icon: 'i-lucide-inbox',
badgeCount: allUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('ALL'),
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
badgeCount: mentionsUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('MENTIONS'),
icon: 'i-lucide-at-sign',
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
@@ -376,8 +348,6 @@ const menuItems = computed(() => {
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
badgeCount: participatingUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('PARTICIPATING'),
icon: 'i-lucide-user-round-check',
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
@@ -385,8 +355,6 @@ const menuItems = computed(() => {
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
badgeCount: unattendedUnreadCount.value,
badgeTooltip: unreadBadgeTooltip('UNATTENDED'),
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('conversation_unattended'),
@@ -402,8 +370,6 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
badgeCount: getFolderUnreadCount.value(view.id),
badgeTooltip: unreadBadgeTooltip('FOLDER'),
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -419,7 +385,6 @@ const menuItems = computed(() => {
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
badgeTooltip: unreadBadgeTooltip('TEAM'),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -435,7 +400,6 @@ const menuItems = computed(() => {
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
badgeTooltip: unreadBadgeTooltip('INBOX'),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -444,7 +408,6 @@ const menuItems = computed(() => {
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
badgeTooltip: leafProps.badgeTooltip,
}),
})),
},
@@ -460,7 +423,6 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
badgeTooltip: unreadBadgeTooltip('LABEL'),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
@@ -197,10 +197,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge
:count="subChild.badgeCount"
:tooltip="subChild.badgeTooltip"
/>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -223,10 +220,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge
:count="child.badgeCount"
:tooltip="child.badgeTooltip"
/>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -12,7 +12,6 @@ const props = defineProps({
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
badgeTooltip: { type: String, default: '' },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
});
@@ -53,14 +52,14 @@ const TREE_CONNECTOR =
<component
:is="component"
v-if="shouldRenderComponent"
v-bind="{ label, icon, active, badgeCount, badgeTooltip }"
v-bind="{ label, icon, active, badgeCount }"
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" :tooltip="badgeTooltip" />
<SidebarUnreadBadge :count="badgeCount" />
</template>
</component>
</Policy>
@@ -3,7 +3,6 @@ import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
tooltip: { type: String, default: '' },
});
const normalizedCount = computed(() => {
@@ -19,7 +18,6 @@ const displayCount = computed(() =>
<template>
<span
v-if="normalizedCount > 0"
v-tooltip.top="tooltip || null"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
>
@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
@@ -29,17 +28,6 @@ describe('ChannelLeaf', () => {
expect(badge.text()).toBe('3');
});
it('passes tooltip copy to unread badge', () => {
const wrapper = mountChannelLeaf({
badgeCount: 3,
badgeTooltip: 'Total unread conversations in this inbox',
});
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
'Total unread conversations in this inbox'
);
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
@@ -1,7 +1,6 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
import SidebarUnreadBadge from '../SidebarUnreadBadge.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
@@ -45,17 +44,6 @@ describe('SidebarGroupLeaf', () => {
expect(badge.text()).toBe('7');
});
it('passes tooltip copy to unread badge', () => {
const wrapper = mountLeaf({
badgeCount: 7,
badgeTooltip: 'Total unread conversations in this label',
});
expect(wrapper.findComponent(SidebarUnreadBadge).props('tooltip')).toBe(
'Total unread conversations in this label'
);
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
@@ -75,17 +63,14 @@ describe('SidebarGroupLeaf', () => {
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
badgeTooltip: 'Total unread conversations in this inbox',
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
`${leafProps.badgeCount}:${leafProps.badgeTooltip}`
leafProps.badgeCount
),
});
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe(
'4:Total unread conversations in this inbox'
);
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
});
});
@@ -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"
}
}
@@ -316,16 +316,6 @@
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",
"UNATTENDED_CONVERSATIONS": "Unattended",
"UNREAD_COUNT_TOOLTIP": {
"ALL": "Total unread conversations",
"INBOX": "Total unread conversations in this inbox",
"LABEL": "Total unread conversations in this label",
"TEAM": "Total unread conversations in this team",
"FOLDER": "Total unread conversations in this folder",
"MENTIONS": "Unread conversations where you were mentioned",
"PARTICIPATING": "Unread conversations you're participating in",
"UNATTENDED": "Unread unattended conversations"
},
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
@@ -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 } =
@@ -6,10 +6,6 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
const normalizeCount = count => {
@@ -41,18 +37,6 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
getMentionsUnreadCount($state) {
return $state.mentionsCount;
},
getParticipatingUnreadCount($state) {
return $state.participatingCount;
},
getUnattendedUnreadCount($state) {
return $state.unattendedCount;
},
getFolderUnreadCount: $state => folderId => {
return $state.folders[String(folderId)] || 0;
},
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -62,9 +46,6 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
getFolderUnreadCounts($state) {
return $state.folders;
},
};
export const actions = {
@@ -87,10 +68,6 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
$state.mentionsCount = normalizeCount(payload.mentions_count);
$state.participatingCount = normalizeCount(payload.participating_count);
$state.unattendedCount = normalizeCount(payload.unattended_count);
$state.folders = normalizeCounts(payload.folders);
},
};
@@ -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', () => {
@@ -57,32 +57,10 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
folders: { 8: 9 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
expect(getters.getFolderUnreadCounts(state)).toEqual({ 8: 9 });
});
it('returns mentions, participating, unattended, and folder unread counts', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 3,
participatingCount: 4,
unattendedCount: 5,
folders: { 7: 8 },
};
expect(getters.getMentionsUnreadCount(state)).toBe(3);
expect(getters.getParticipatingUnreadCount(state)).toBe(4);
expect(getters.getUnattendedUnreadCount(state)).toBe(5);
expect(getters.getFolderUnreadCount(state)(7)).toBe(8);
expect(getters.getFolderUnreadCount(state)('7')).toBe(8);
expect(getters.getFolderUnreadCount(state)(8)).toBe(0);
});
});
@@ -21,13 +21,6 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
mentions_count: '8',
participating_count: 9,
unattended_count: '10',
folders: {
11: '12',
12: 0,
},
});
expect(state).toEqual({
@@ -35,10 +28,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 11: 12 },
});
});
@@ -48,10 +37,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 10: 11 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -61,10 +46,6 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
});
});
@@ -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
+2 -2
View File
@@ -91,10 +91,10 @@ class ActionCableListener < BaseListener
end
def conversation_unread_count_changed(event)
account, inbox_members, include_admins = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
tokens = include_admins ? user_tokens(account, inbox_members) : inbox_members.pluck(:pubsub_token)
tokens = user_tokens(account, inbox_members)
broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
end
+2 -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
@@ -181,7 +182,7 @@ class Account < ApplicationRecord
end
def clear_unread_conversation_counts_cache
::Conversations::UnreadCounts::Store.clear_all_account!(id)
::Conversations::UnreadCounts::Store.clear_account!(id)
end
trigger.after(:insert).for_each(:row) do
-9
View File
@@ -39,7 +39,6 @@ class AccountUser < ApplicationRecord
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
validates :user_id, uniqueness: { scope: :account_id }
@@ -80,14 +79,6 @@ class AccountUser < ApplicationRecord
def update_presence_in_redis
OnlineStatusTracker.set_status(account.id, user.id, availability)
end
def unread_filter_access_changed?
destroyed? || previous_changes.key?('role') || previous_changes.key?('custom_role_id')
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
AccountUser.prepend_mod_with('AccountUser')
+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
View File
@@ -30,6 +30,7 @@ module CacheKeys
update_cache_key_for_account(id, model.name.underscore)
end
::Conversations::UnreadCounts::Store.clear_account!(id)
dispatch_cache_update_event
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
-5
View File
@@ -28,7 +28,6 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -39,8 +38,4 @@ class ConversationParticipant < ApplicationRecord
def ensure_inbox_access
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
-9
View File
@@ -22,19 +22,10 @@ class CustomFilter < ApplicationRecord
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
after_commit :notify_unread_filter_counts_changed, on: [:create, :update, :destroy]
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
private
def notify_unread_filter_counts_changed
return unless conversation?
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
end
+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
-12
View File
@@ -23,9 +23,7 @@ class InboxMember < ApplicationRecord
belongs_to :inbox
after_create :add_agent_to_round_robin
before_destroy :cache_unread_filter_notification_context
after_destroy :remove_agent_from_round_robin
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
private
@@ -36,16 +34,6 @@ class InboxMember < ApplicationRecord
def remove_agent_from_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
end
def cache_unread_filter_notification_context
@unread_filter_account = inbox&.account
@unread_filter_user = user
end
def notify_unread_filter_counts_changed
account = @unread_filter_account || inbox&.account
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: @unread_filter_user || user).perform
end
end
InboxMember.include_mod_with('Audit::InboxMember')
-5
View File
@@ -32,7 +32,6 @@ class Mention < ApplicationRecord
belongs_to :user
after_commit :notify_mentioned_user
after_commit :notify_unread_filter_counts_changed, on: [:create, :destroy]
scope :latest, -> { order(mentioned_at: :desc) }
@@ -56,8 +55,4 @@ class Mention < ApplicationRecord
def notify_mentioned_user
Rails.configuration.dispatcher.dispatch(CONVERSATION_MENTIONED, Time.zone.now, user: user, conversation: conversation)
end
def notify_unread_filter_counts_changed
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
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
+2 -8
View File
@@ -7,7 +7,8 @@ class Conversations::FilterService < FilterService
end
def perform
@conversations = filtered_relation
validate_query_operator
@conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
@@ -22,13 +23,6 @@ class Conversations::FilterService < FilterService
}
end
def filtered_relation
validate_query_operator
return base_relation if @params[:payload].blank?
query_builder(@filters['conversations'])
end
def base_relation
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
@@ -6,23 +6,13 @@ class Conversations::UnreadCounts::BroadcastScope
end
def perform
return user_scope if user.present?
return [conversation.account, conversation.inbox.members, true] if conversation.present?
return [conversation.account, conversation.inbox.members] if conversation.present?
deleted_conversation_scope
end
private
def user
event.data[:user]
end
def user_scope
account = event.data[:account] || user.account
[account, [user], false]
end
def conversation
event.data[:conversation]
end
@@ -34,7 +24,7 @@ class Conversations::UnreadCounts::BroadcastScope
account = Account.find_by(id: conversation_data[:account_id])
return if account.blank?
[account, inbox_members_for(account, conversation_data[:inbox_id]), true]
[account, inbox_members_for(account, conversation_data[:inbox_id])]
end
def inbox_members_for(account, inbox_id)
@@ -1,15 +0,0 @@
module Conversations::UnreadCounts::BuildLockKeys
private
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def filters_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK, account_id: account.id, user_id: user.id)
end
end
@@ -1,14 +1,5 @@
class Conversations::UnreadCounts::Builder
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
RELATIVE_DATE_FILTER_OPERATOR = 'days_before'.freeze
BATCH_SIZE = 1000
FILTER_ERRORS = [
ActiveRecord::StatementInvalid,
CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue
].freeze
attr_reader :account
@@ -33,37 +24,10 @@ class Conversations::UnreadCounts::Builder
build_assignment!
end
def build_filters_for!(user)
store.clear_user_filters!(account.id, user.id)
version_snapshot = store.filter_version_snapshot(account.id, user.id)
custom_filters = conversation_custom_filters(user).to_a
store.add_filter_memberships(
account_id: account.id,
user_id: user.id,
filters: {
mentions: mentioned_unread_conversation_ids(user),
participating: participating_unread_conversation_ids(user),
unattended: unattended_unread_conversation_ids(user)
},
folders: folder_unread_conversation_ids(custom_filters, user)
)
mark_filters_ready_if_current(user, custom_filters, version_snapshot)
end
private
def mark_filters_ready_if_current(user, custom_filters, version_snapshot)
store.mark_filters_ready_if_current!(
account.id,
user.id,
version_snapshot: version_snapshot,
expires_in: filters_ready_ttl(custom_filters)
)
end
def write_memberships(assignment:)
unread_conversations(open_only: true).in_batches(of: BATCH_SIZE) do |relation|
unread_conversations.in_batches(of: BATCH_SIZE) do |relation|
columns = %i[id inbox_id assignee_id cached_label_list team_id]
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
{
@@ -79,99 +43,14 @@ class Conversations::UnreadCounts::Builder
end
end
def mentioned_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.joins(:mentions)
.where(mentions: { account_id: account.id, user_id: user.id })
.pluck(:id)
end
def participating_unread_conversation_ids(user)
participating_visible_unread_conversations(user, open_only: true)
.where(id: user.participating_conversations.where(account_id: account.id).select(:id))
.pluck(:id)
end
def unattended_unread_conversation_ids(user)
visible_unread_conversations(user, open_only: true)
.unattended
.pluck(:id)
end
def folder_unread_conversation_ids(custom_filters, user)
custom_filters.each_with_object({}) do |custom_filter, result|
result[custom_filter.id] = unread_ids_for_filter(custom_filter, user)
rescue *FILTER_ERRORS
next
end
end
def conversation_custom_filters(user)
account.custom_filters.where(user: user, filter_type: :conversation)
end
def filters_ready_ttl(custom_filters)
return Conversations::UnreadCounts::READY_TTL unless relative_date_filter?(custom_filters)
seconds_until_next_day
end
def relative_date_filter?(custom_filters)
custom_filters.any? do |custom_filter|
Array(custom_filter.query.with_indifferent_access[:payload]).any? do |condition|
condition[:filter_operator] == RELATIVE_DATE_FILTER_OPERATOR
end
end
end
def seconds_until_next_day
[(Time.zone.tomorrow.beginning_of_day - Time.current).ceil, 1].max
end
def unread_ids_for_filter(custom_filter, user)
filter_relation = ::Conversations::FilterService.new(custom_filter.query.with_indifferent_access, user, account).filtered_relation
filter_relation
.where(id: unread_conversations(open_only: false).select(:id))
.reorder(nil)
.distinct
.pluck(:id)
end
def unread_conversations(open_only:)
conversations = account.conversations
conversations = conversations.open if open_only
conversations.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def visible_unread_conversations(user, open_only:)
::Conversations::PermissionFilterService.new(unread_conversations(open_only: open_only), user, account).perform
end
def participating_visible_unread_conversations(user, open_only:)
return inbox_visible_unread_conversations(user, open_only: open_only) if custom_role_participating_permission?(user)
visible_unread_conversations(user, open_only: open_only)
end
def inbox_visible_unread_conversations(user, open_only:)
conversations = unread_conversations(open_only: open_only)
return conversations if account_user_for(user)&.administrator?
conversations.where(inbox: user.inboxes.where(account_id: account.id))
end
def custom_role_participating_permission?(user)
account_user = account_user_for(user)
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?(PARTICIPATING_PERMISSION)
end
def account_user_for(user)
account.account_users.find_by(user_id: user.id)
def unread_conversations
account.conversations
.open
.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def unread_since_last_seen_condition
@@ -1,7 +1,4 @@
class Conversations::UnreadCounts::Counter
include ::Conversations::UnreadCounts::BuildLockKeys
include ::Conversations::UnreadCounts::FilterCounter
MANAGE_ALL_PERMISSION = 'conversation_manage'.freeze
UNASSIGNED_PERMISSION = 'conversation_unassigned_manage'.freeze
PARTICIPATING_PERMISSION = 'conversation_participating_manage'.freeze
@@ -21,38 +18,31 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
ensure_filters_cache!
inbox_counts = unread_inbox_counts
filter_counts = unread_filter_counts
{
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts,
mentions_count: filter_counts[:mentions_count],
participating_count: filter_counts[:participating_count],
unattended_count: filter_counts[:unattended_count],
folders: filter_counts[:folders]
teams: unread_team_counts
}
end
private
def ensure_base_cache!
ensure_cache_ready!(ready: -> { store.base_ready?(account.id) }, lock_key: base_build_lock_key) { builder.build_base! }
ensure_cache_ready!(
ready: -> { store.base_ready?(account.id) },
lock_key: base_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_base! }
end
def ensure_assignment_cache!
ensure_cache_ready!(ready: -> { store.assignment_ready?(account.id) }, lock_key: assignment_build_lock_key) { builder.build_assignment! }
end
def ensure_filters_cache!
ensure_cache_ready!(
ready: -> { store.filters_ready?(account.id, user.id) },
lock_key: filters_build_lock_key
) { builder.build_filters_for!(user) }
ready: -> { store.assignment_ready?(account.id) },
lock_key: assignment_build_lock_key
) { ::Conversations::UnreadCounts::Builder.new(account).build_assignment! }
end
def ensure_cache_ready!(ready:, lock_key:)
@@ -61,10 +51,9 @@ class Conversations::UnreadCounts::Counter
loop do
return if ready.call
lock_acquired = lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
return if ready.call
return if lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) { yield unless ready.call }
wait_for_cache_ready(ready) unless lock_acquired
wait_for_cache_ready(ready)
end
end
@@ -73,6 +62,14 @@ class Conversations::UnreadCounts::Counter
sleep BUILD_WAIT_INTERVAL until ready.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
end
def base_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_BUILD_LOCK, account_id: account.id)
end
def assignment_build_lock_key
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK, account_id: account.id)
end
def unread_inbox_counts
counts_for_grouped_keys(visible_inbox_ids.index_with { |inbox_id| inbox_keys_for_mode(inbox_id) })
end
@@ -197,14 +194,10 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
{ all_count: 0, inboxes: {}, labels: {}, teams: {}, mentions_count: 0, participating_count: 0, unattended_count: 0, folders: {} }
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
end
def store
::Conversations::UnreadCounts::Store
end
def builder
@builder ||= ::Conversations::UnreadCounts::Builder.new(account)
end
end
@@ -1,40 +0,0 @@
module Conversations::UnreadCounts::FilterCounter
private
def unread_filter_counts
keys = user_filter_keys
counts_by_key = store.counts_for_keys(keys.values + folder_keys.values)
{
mentions_count: counts_by_key[keys[:mentions]].to_i,
participating_count: counts_by_key[keys[:participating]].to_i,
unattended_count: counts_by_key[keys[:unattended]].to_i,
folders: folder_counts(counts_by_key)
}
end
def user_filter_keys
{
mentions: store.user_mentions_key(account.id, user.id),
participating: store.user_participating_key(account.id, user.id),
unattended: store.user_unattended_key(account.id, user.id)
}
end
def conversation_folder_ids
@conversation_folder_ids ||= account.custom_filters.where(user: user, filter_type: :conversation).pluck(:id)
end
def folder_keys
@folder_keys ||= conversation_folder_ids.index_with do |custom_filter_id|
store.user_folder_key(account.id, user.id, custom_filter_id)
end
end
def folder_counts(counts_by_key)
folder_keys.each_with_object({}) do |(custom_filter_id, key), result|
count = counts_by_key[key].to_i
result[custom_filter_id.to_s] = count if count.positive?
end
end
end
@@ -3,13 +3,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
if message.incoming?
refresh(message.conversation)
else
notify_filter_counts_changed(message.conversation)
end
refresh(message.conversation)
end
def conversation_status_changed(event)
@@ -18,21 +15,10 @@ class Conversations::UnreadCounts::Listener < BaseListener
end
def conversation_updated(event)
return unless label_changed?(event.data[:changed_attributes])
conversation, = extract_conversation_and_account(event)
return unless conversation.account.feature_enabled?('conversation_unread_counts')
if label_changed?(event.data[:changed_attributes])
refresh(conversation, event.data[:changed_attributes])
elsif folder_filter_changed?(event.data[:changed_attributes])
notify_filter_counts_changed(conversation)
end
end
def conversation_contact_changed(event)
conversation, = extract_conversation_and_account(event)
return unless conversation.account.feature_enabled?('conversation_unread_counts')
notify_filter_counts_changed(conversation)
refresh(conversation, event.data[:changed_attributes])
end
def assignee_changed(event)
@@ -51,10 +37,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
filters_cleared = store.clear_filter_caches!(account.id)
memberships_removed = remove_deleted_conversation(account, conversation_data)
return unless memberships_removed || filters_cleared
return unless remove_deleted_conversation(account, conversation_data)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
end
@@ -107,16 +90,6 @@ class Conversations::UnreadCounts::Listener < BaseListener
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
end
def folder_filter_changed?(changed_attributes)
changed_attributes.present?
end
def notify_filter_counts_changed(conversation)
return unless store.clear_filter_caches!(conversation.account_id)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
end
def store
::Conversations::UnreadCounts::Store
end
@@ -11,9 +11,7 @@ class Conversations::UnreadCounts::Notifier
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
filters_cleared = ::Conversations::UnreadCounts::Store.clear_filter_caches!(conversation.account_id)
memberships_refreshed = ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
return false unless memberships_refreshed || filters_cleared
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
true
@@ -1,6 +1,5 @@
class Conversations::UnreadCounts::Store
extend ::Conversations::UnreadCounts::StoreKeys
extend ::Conversations::UnreadCounts::UserFilterStore
class << self
def base_ready?(account_id)
@@ -20,10 +19,6 @@ class Conversations::UnreadCounts::Store
end
def clear_account!(account_id)
account_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
end
def clear_all_account!(account_id)
delete_matching("#{account_prefix(account_id)}::*")
end
@@ -183,11 +178,9 @@ class Conversations::UnreadCounts::Store
end
def delete_matching(pattern)
deleted = 0
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
deleted += 1 if Redis::Alfred.delete(key)
Redis::Alfred.delete(key)
end
deleted.positive?
end
def assignment_key_patterns(account_id)
@@ -202,16 +195,5 @@ class Conversations::UnreadCounts::Store
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
]
end
def account_key_patterns(account_id)
prefix = account_prefix(account_id)
[
base_ready_key(account_id),
assignment_ready_key(account_id),
"#{prefix}::INBOX::*",
"#{prefix}::LABEL::*::INBOX::*",
"#{prefix}::TEAM::*::INBOX::*"
]
end
end
end
@@ -40,20 +40,4 @@ module Conversations::UnreadCounts::StoreKeys
def team_inbox_assignee_key(account_id, team_id, inbox_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE, account_id: account_id, team_id: team_id, inbox_id: inbox_id, user_id: user_id)
end
def user_mentions_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_MENTIONS, account_id: account_id, user_id: user_id)
end
def user_participating_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_PARTICIPATING, account_id: account_id, user_id: user_id)
end
def user_unattended_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_UNATTENDED, account_id: account_id, user_id: user_id)
end
def user_folder_key(account_id, user_id, custom_filter_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FOLDER, account_id: account_id, user_id: user_id, custom_filter_id: custom_filter_id)
end
end
@@ -1,19 +0,0 @@
class Conversations::UnreadCounts::UserFilterNotifier
include Events::Types
attr_reader :account, :user
def initialize(account:, user:)
@account = account
@user = user
end
def perform
return false if account.blank? || user.blank?
return false unless account.feature_enabled?('conversation_unread_counts')
::Conversations::UnreadCounts::Store.clear_user_filters!(account.id, user.id)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, account: account, user: user)
true
end
end
@@ -1,100 +0,0 @@
module Conversations::UnreadCounts::UserFilterStore
USER_FILTER_KEY_SUFFIXES = [
'READY::FILTERS',
'MENTIONS',
'PARTICIPATING',
'UNATTENDED',
'FOLDER::*'
].freeze
def filters_ready?(account_id, user_id)
Redis::Alfred.exists?(filters_ready_key(account_id, user_id))
end
def mark_filters_ready!(account_id, user_id, expires_in: Conversations::UnreadCounts::READY_TTL)
Redis::Alfred.set(filters_ready_key(account_id, user_id), Time.current.to_i, ex: expires_in)
end
def mark_filters_ready_if_current!(account_id, user_id, version_snapshot:, expires_in: Conversations::UnreadCounts::READY_TTL)
return false unless filter_version_snapshot(account_id, user_id) == version_snapshot
mark_filters_ready!(account_id, user_id, expires_in: expires_in)
end
def filter_version_snapshot(account_id, user_id)
{
account: filter_version(account_filter_version_key(account_id)),
user: filter_version(user_filter_version_key(account_id, user_id))
}
end
def clear_filter_caches!(account_id)
bump_filter_version(account_filter_version_key(account_id))
delete_user_filter_patterns("#{account_prefix(account_id)}::USER::*")
end
def clear_user_filters!(account_id, user_id)
bump_filter_version(user_filter_version_key(account_id, user_id))
delete_user_filter_patterns(user_filter_prefix(account_id, user_id))
end
def add_filter_memberships(account_id:, user_id:, filters:, folders:)
memberships = {
user_mentions_key(account_id, user_id) => filters[:mentions],
user_participating_key(account_id, user_id) => filters[:participating],
user_unattended_key(account_id, user_id) => filters[:unattended]
}
folders.each do |custom_filter_id, conversation_ids|
memberships[user_folder_key(account_id, user_id, custom_filter_id)] = conversation_ids
end
write_membership_sets(memberships)
end
private
def filters_ready_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_READY, account_id: account_id, user_id: user_id)
end
def account_filter_version_key(account_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_FILTERS_VERSION, account_id: account_id)
end
def user_filter_version_key(account_id, user_id)
format(Redis::Alfred::UNREAD_CONVERSATIONS_USER_FILTERS_VERSION, account_id: account_id, user_id: user_id)
end
def user_filter_prefix(account_id, user_id)
"#{account_prefix(account_id)}::USER::#{user_id}"
end
def filter_version(key)
Redis::Alfred.get(key).to_i
end
def bump_filter_version(key)
Redis::Alfred.incr(key).tap { Redis::Alfred.expire(key, Conversations::UnreadCounts::SET_TTL) }
end
def delete_user_filter_patterns(prefix)
deleted = false
USER_FILTER_KEY_SUFFIXES.each do |suffix|
deleted = delete_matching("#{prefix}::#{suffix}") || deleted
end
deleted
end
def write_membership_sets(memberships)
memberships = memberships.transform_values { |conversation_ids| Array(conversation_ids).compact_blank }
memberships = memberships.select { |_key, conversation_ids| conversation_ids.present? }
return if memberships.blank?
Redis::Alfred.pipelined do |pipeline|
memberships.each do |key, conversation_ids|
conversation_ids.each { |conversation_id| pipeline.sadd(key, conversation_id) }
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
end
end
end
end
+1 -11
View File
@@ -11,7 +11,7 @@ class FilterService
}.with_indifferent_access
def initialize(params, user)
@params = normalize_params(params)
@params = params
@user = user
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@@ -140,16 +140,6 @@ class FilterService
private
def normalize_params(params)
return params unless params.respond_to?(:with_indifferent_access)
normalized_params = params.with_indifferent_access
normalized_params[:payload] = Array(normalized_params[:payload]).map do |condition|
condition.respond_to?(:with_indifferent_access) ? condition.with_indifferent_access : condition
end
normalized_params
end
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
@@ -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.0'
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
@@ -1,31 +1,5 @@
module Enterprise::ConversationFinder
def filter_by_conversation_type
return super unless params[:conversation_type] == 'participating' && custom_role_participating_permission?
@conversations = participating_visible_conversations.where(
id: current_user.participating_conversations.where(account_id: current_account.id).select(:id)
)
end
def conversations_base_query
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
end
private
def participating_visible_conversations
conversations = current_account.conversations
conversations = conversations.where(inbox_id: @inbox_ids) if params[:inbox_id]
return conversations if account_user&.administrator?
conversations.where(inbox: current_user.inboxes.where(account_id: current_account.id))
end
def custom_role_participating_permission?
account_user&.agent? && account_user.custom_role_id.present? && account_user.permissions.include?('conversation_participating_manage')
end
def account_user
@account_user ||= current_account.account_users.find_by(user_id: current_user.id)
end
end
-23
View File
@@ -28,9 +28,6 @@ class CustomRole < ApplicationRecord
belongs_to :account
has_many :account_users, dependent: :nullify
before_destroy :cache_users_for_unread_filter_notification, prepend: true
after_commit :notify_unread_filter_counts_changed, on: [:update, :destroy], if: :unread_filter_access_changed?
PERMISSIONS = %w[
conversation_manage
conversation_unassigned_manage
@@ -42,24 +39,4 @@ class CustomRole < ApplicationRecord
validates :name, presence: true
validates :permissions, inclusion: { in: PERMISSIONS }
private
def unread_filter_access_changed?
destroyed? || previous_changes.key?('permissions')
end
def cache_users_for_unread_filter_notification
@users_for_unread_filter_notification = account_users.includes(:user).map(&:user)
end
def users_for_unread_filter_notification
@users_for_unread_filter_notification || account_users.includes(:user).map(&:user)
end
def notify_unread_filter_counts_changed
users_for_unread_filter_notification.each do |user|
::Conversations::UnreadCounts::UserFilterNotifier.new(account: account, user: user).perform
end
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
-15
View File
@@ -14,15 +14,8 @@ module Redis::RedisKeys
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d'.freeze
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::ASSIGNMENT'.freeze
UNREAD_CONVERSATIONS_USER_FILTERS_READY =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::READY::FILTERS'.freeze
UNREAD_CONVERSATIONS_FILTERS_VERSION = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::VERSION::FILTERS'.freeze
UNREAD_CONVERSATIONS_USER_FILTERS_VERSION =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::VERSION::FILTERS'.freeze
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::BASE'.freeze
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::ASSIGNMENT'.freeze
UNREAD_CONVERSATIONS_USER_FILTERS_BUILD_LOCK =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::BUILD_LOCK::FILTERS'.freeze
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d'.freeze
UNREAD_CONVERSATIONS_LABEL_INBOX =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d'.freeze
@@ -40,14 +33,6 @@ module Redis::RedisKeys
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
UNREAD_CONVERSATIONS_USER_MENTIONS =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::MENTIONS'.freeze
UNREAD_CONVERSATIONS_USER_PARTICIPATING =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::PARTICIPATING'.freeze
UNREAD_CONVERSATIONS_USER_UNATTENDED =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::UNATTENDED'.freeze
UNREAD_CONVERSATIONS_USER_FOLDER =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::USER::%<user_id>d::FOLDER::%<custom_filter_id>d'.freeze
## User Keys
# SSO Auth Tokens
-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.0",
"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

Some files were not shown because too many files have changed in this diff Show More