Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22fd596d6b | ||
|
|
1140ca7a78 | ||
|
|
79fc5bb555 | ||
|
|
78a40114ef | ||
|
|
a1f61f0e21 | ||
|
|
befdfb0ae6 | ||
|
|
e0097ab102 | ||
|
|
bdcb080e40 | ||
|
|
50344a282b | ||
|
|
65cd0717e6 | ||
|
|
73dcf539ed | ||
|
|
35d9dc925f | ||
|
|
2d14f27d5b | ||
|
|
70a65e2c34 |
@@ -94,11 +94,10 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
def build_message
|
||||
# Duplicate webhook events may be sent for the same message
|
||||
# when a user is connected to the Instagram account through both Messenger and Instagram login.
|
||||
# There is chance for echo events to be sent for the same message.
|
||||
# Therefore, we need to check if the message already exists before creating it.
|
||||
return if message_already_exists?
|
||||
|
||||
return if @outgoing_echo
|
||||
|
||||
return if message_content.blank? && all_unsupported_files?
|
||||
|
||||
@message = conversation.messages.create!(message_params)
|
||||
|
||||
@@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
|
||||
def validate_business_account?
|
||||
# return true if the user is a business account, false if it is a gmail account
|
||||
auth_hash['info']['email'].exclude?('@gmail.com')
|
||||
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
|
||||
end
|
||||
|
||||
def create_account_for_user
|
||||
|
||||
@@ -29,6 +29,19 @@
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
--blue-1: 251 253 255;
|
||||
--blue-2: 245 249 255;
|
||||
--blue-3: 233 243 255;
|
||||
--blue-4: 218 236 255;
|
||||
--blue-5: 201 226 255;
|
||||
--blue-6: 181 213 255;
|
||||
--blue-7: 155 195 252;
|
||||
--blue-8: 117 171 247;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 16 115 233;
|
||||
--blue-11: 8 109 224;
|
||||
--blue-12: 11 50 101;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
@@ -131,6 +144,19 @@
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
--blue-1: 10 17 28;
|
||||
--blue-2: 15 24 38;
|
||||
--blue-3: 15 39 72;
|
||||
--blue-4: 10 49 99;
|
||||
--blue-5: 18 61 117;
|
||||
--blue-6: 29 84 134;
|
||||
--blue-7: 40 89 156;
|
||||
--blue-8: 48 106 186;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 21 116 231;
|
||||
--blue-11: 126 182 255;
|
||||
--blue-12: 205 227 255;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
||||
<Button
|
||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||
variant="link"
|
||||
type="reset"
|
||||
class="h-10 hover:!no-underline hover:text-n-brand"
|
||||
@click="closeDialog"
|
||||
/>
|
||||
|
||||
-4
@@ -31,10 +31,6 @@ const sortMenus = [
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
|
||||
value: 'email',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
|
||||
value: 'phone_number',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
|
||||
value: 'company_name',
|
||||
|
||||
@@ -33,6 +33,8 @@ const insertIntoRichEditor = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
||||
|
||||
const useCopilotResponse = () => {
|
||||
if (insertIntoRichEditor.value) {
|
||||
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
||||
@@ -53,9 +55,17 @@ const useCopilotResponse = () => {
|
||||
/>
|
||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||
<div v-dompurify-html="messageContent" class="prose-sm break-words" />
|
||||
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
v-dompurify-html="messageContent"
|
||||
class="prose-sm break-words"
|
||||
/>
|
||||
<div class="flex flex-row mt-1">
|
||||
<Button
|
||||
v-if="!hasEmptyMessageContent"
|
||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||
faded
|
||||
sm
|
||||
|
||||
@@ -22,8 +22,14 @@ const {
|
||||
isAInstagramChannel,
|
||||
} = useInbox();
|
||||
|
||||
const { status, isPrivate, createdAt, sourceId, messageType } =
|
||||
useMessageContext();
|
||||
const {
|
||||
status,
|
||||
isPrivate,
|
||||
createdAt,
|
||||
sourceId,
|
||||
messageType,
|
||||
contentAttributes,
|
||||
} = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
@@ -31,6 +37,11 @@ const readableTime = computed(() =>
|
||||
|
||||
const showStatusIndicator = computed(() => {
|
||||
if (isPrivate.value) return false;
|
||||
// Don't show status for failed messages, we already show error message
|
||||
if (status.value === MESSAGE_STATUS.FAILED) return false;
|
||||
// Don't show status for deleted messages
|
||||
if (contentAttributes.value?.deleted) return false;
|
||||
|
||||
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
|
||||
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
|
||||
|
||||
|
||||
@@ -218,14 +218,14 @@ const emitDateRange = () => {
|
||||
/>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
>
|
||||
<CalendarDateRange
|
||||
:selected-range="selectedRange"
|
||||
@set-range="setDateRange"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
|
||||
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
|
||||
>
|
||||
<div class="flex justify-around h-fit">
|
||||
<!-- Calendars for Start and End Dates -->
|
||||
@@ -251,12 +251,12 @@ const emitDateRange = () => {
|
||||
@validate="updateManualInput($event, calendar)"
|
||||
@error="handleManualInputError($event)"
|
||||
/>
|
||||
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
|
||||
<div class="py-5 border-b border-n-strong">
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
|
||||
:class="
|
||||
calendar === START_CALENDAR &&
|
||||
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
|
||||
'ltr:border-r rtl:border-l border-n-strong'
|
||||
"
|
||||
>
|
||||
<CalendarYear
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
calendarType: {
|
||||
type: String,
|
||||
@@ -38,42 +40,38 @@ const onClickSetView = (type, mode) => {
|
||||
|
||||
<template>
|
||||
<div class="flex items-start justify-between w-full h-9">
|
||||
<button
|
||||
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
|
||||
<NextButton
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickPrev(calendarType)"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-left"
|
||||
size="14"
|
||||
class="text-slate-900 dark:text-slate-50"
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="firstButtonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
|
||||
@click="onClickSetView(calendarType, viewMode)"
|
||||
>
|
||||
{{ firstButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="buttonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
|
||||
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12"
|
||||
:class="{ 'hover:text-n-brand': viewMode }"
|
||||
@click="onClickSetView(calendarType, YEAR)"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
|
||||
<NextButton
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickNext(calendarType)"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-right"
|
||||
size="14"
|
||||
class="text-slate-900 dark:text-slate-50"
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,7 +65,7 @@ const validateDate = () => {
|
||||
<input
|
||||
v-model="localDateValue"
|
||||
type="text"
|
||||
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
|
||||
class="!text-sm !mb-0 disabled:!outline-n-strong"
|
||||
:placeholder="dateFormat"
|
||||
:disabled="isDisabled"
|
||||
@keypress.enter="validateDate"
|
||||
|
||||
@@ -18,7 +18,7 @@ const setDateRange = range => {
|
||||
<template>
|
||||
<div class="w-[200px] flex flex-col items-start">
|
||||
<h4
|
||||
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
|
||||
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
|
||||
>
|
||||
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
||||
</h4>
|
||||
@@ -26,11 +26,11 @@ const setDateRange = range => {
|
||||
<button
|
||||
v-for="range in dateRanges"
|
||||
:key="range.label"
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
|
||||
: 'text-slate-600 dark:text-slate-200'
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['clear', 'change']);
|
||||
|
||||
const onClickClear = () => {
|
||||
@@ -11,18 +13,19 @@ const onClickApply = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
|
||||
<button
|
||||
class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
|
||||
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center">
|
||||
<NextButton
|
||||
slate
|
||||
ghost
|
||||
sm
|
||||
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
|
||||
@click="onClickClear"
|
||||
>
|
||||
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
ghost
|
||||
:label="$t('DATE_PICKER.APPLY_BUTTON')"
|
||||
@click="onClickApply"
|
||||
>
|
||||
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,10 +71,12 @@ const selectMonth = index => {
|
||||
<button
|
||||
v-for="(month, index) in months"
|
||||
:key="index"
|
||||
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
|
||||
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2"
|
||||
:class="{
|
||||
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
|
||||
'bg-n-brand text-white hover:bg-n-blue-10':
|
||||
index === activeMonthIndex,
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
|
||||
index !== activeMonthIndex,
|
||||
}"
|
||||
@click="selectMonth(index)"
|
||||
>
|
||||
|
||||
@@ -107,17 +107,16 @@ const isNextDayInRange = day => {
|
||||
};
|
||||
|
||||
const dayClasses = day => ({
|
||||
'text-slate-500 dark:text-slate-400 pointer-events-none':
|
||||
!isInCurrentMonth(day),
|
||||
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
|
||||
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
|
||||
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
|
||||
isInCurrentMonth(day),
|
||||
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
|
||||
'bg-n-brand text-white':
|
||||
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
|
||||
'bg-woot-50 dark:bg-woot-800':
|
||||
'bg-n-blue-4 dark:bg-n-blue-5':
|
||||
(isInRange(day) || isHoveringInRange(day)) &&
|
||||
!isSelectedStartOrEndDate(day) &&
|
||||
isInCurrentMonth(day),
|
||||
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
|
||||
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
|
||||
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
|
||||
});
|
||||
</script>
|
||||
@@ -164,7 +163,7 @@ const dayClasses = day => ({
|
||||
!isLastDayOfMonth(day) &&
|
||||
isInCurrentMonth(day)
|
||||
"
|
||||
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
|
||||
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-n-blue-4 dark:bg-n-blue-5 -z-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,10 +72,10 @@ const selectYear = year => {
|
||||
<button
|
||||
v-for="year in years"
|
||||
:key="year"
|
||||
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
|
||||
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2"
|
||||
:class="{
|
||||
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
|
||||
year === activeYear,
|
||||
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
|
||||
}"
|
||||
@click="selectYear(year)"
|
||||
>
|
||||
|
||||
@@ -48,7 +48,7 @@ const openDatePicker = () => {
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<fluent-icon
|
||||
|
||||
@@ -220,6 +220,7 @@ const plugins = computed(() => {
|
||||
trigger: '@',
|
||||
showMenu: showUserMentions,
|
||||
searchTerm: mentionSearchKey,
|
||||
isAllowed: () => props.isPrivate,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '/',
|
||||
|
||||
@@ -218,8 +218,14 @@ export default {
|
||||
if (additionalAttributes) {
|
||||
const {
|
||||
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
||||
agent_reply_time_window: agentReplyTimeWindow,
|
||||
} = additionalAttributes;
|
||||
return agentReplyTimeWindowMessage;
|
||||
return (
|
||||
agentReplyTimeWindowMessage ||
|
||||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
|
||||
hours: agentReplyTimeWindow,
|
||||
})
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -69,10 +69,6 @@ const onAgentSelect = index => {
|
||||
v-if="items.length"
|
||||
ref="tagAgentsRef"
|
||||
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
||||
:class="{
|
||||
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
|
||||
items.length <= 4,
|
||||
}"
|
||||
>
|
||||
<li
|
||||
v-for="(agent, index) in items"
|
||||
|
||||
@@ -7,8 +7,12 @@ import { formatTime } from '@chatwoot/utils';
|
||||
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
|
||||
* @returns {Object} An object containing utility functions for report metrics.
|
||||
*/
|
||||
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
|
||||
export function useReportMetrics(
|
||||
accountSummaryKey = 'getAccountSummary',
|
||||
summarFetchingKey = 'getAccountSummaryFetchingStatus'
|
||||
) {
|
||||
const accountSummary = useMapGetter(accountSummaryKey);
|
||||
const fetchingStatus = useMapGetter(summarFetchingKey);
|
||||
|
||||
/**
|
||||
* Calculates the trend percentage for a given metric.
|
||||
@@ -53,5 +57,6 @@ export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
|
||||
calculateTrend,
|
||||
isAverageMetricType,
|
||||
displayMetric,
|
||||
fetchingStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const useConversationSidebarItemsOrder = uiSettings => {
|
||||
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
|
||||
// If the sidebar order is not set, use the default order.
|
||||
if (!itemsOrder) {
|
||||
return DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;
|
||||
return [...DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER];
|
||||
}
|
||||
// Create a copy of itemsOrder to avoid mutating the original store object.
|
||||
const itemsOrderCopy = [...itemsOrder];
|
||||
|
||||
@@ -16,6 +16,15 @@ export const getUserPermissions = (user, accountId) => {
|
||||
return currentAccount.permissions || [];
|
||||
};
|
||||
|
||||
export const getUserRole = (user, accountId) => {
|
||||
const currentAccount = getCurrentAccount(user, accountId) || {};
|
||||
if (currentAccount.custom_role_id) {
|
||||
return 'custom_role';
|
||||
}
|
||||
|
||||
return currentAccount.role || 'agent';
|
||||
};
|
||||
|
||||
const isPermissionsPresentInRoute = route =>
|
||||
route.meta && route.meta.permissions;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||
"CANNOT_REPLY": "You cannot reply due to",
|
||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
|
||||
@@ -326,6 +326,7 @@
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
"COPILOT": {
|
||||
"SEND_MESSAGE": "Send message...",
|
||||
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
||||
"LOADER": "Captain is thinking",
|
||||
"YOU": "You",
|
||||
"USE": "Use this",
|
||||
|
||||
@@ -98,6 +98,7 @@ export default {
|
||||
<BotMetrics :filters="requestPayload" />
|
||||
<ReportContainer
|
||||
account-summary-key="getBotSummary"
|
||||
summary-fetching-key="getBotSummaryFetchingStatus"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,10 @@ export default {
|
||||
type: String,
|
||||
default: 'getAccountSummary',
|
||||
},
|
||||
summaryFetchingKey: {
|
||||
type: String,
|
||||
default: 'getAccountSummaryFetchingStatus',
|
||||
},
|
||||
reportKeys: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
@@ -148,7 +152,11 @@ export default {
|
||||
:key="metric.KEY"
|
||||
class="p-4 mb-3 rounded-md"
|
||||
>
|
||||
<ChartStats :metric="metric" :account-summary-key="accountSummaryKey" />
|
||||
<ChartStats
|
||||
:metric="metric"
|
||||
:account-summary-key="accountSummaryKey"
|
||||
:summary-fetching-key="summaryFetchingKey"
|
||||
/>
|
||||
<div class="mt-4 h-72">
|
||||
<woot-loading-state
|
||||
v-if="accountReport.isFetching[metric.KEY]"
|
||||
|
||||
+28
-5
@@ -1,5 +1,8 @@
|
||||
<script setup>
|
||||
import { useReportMetrics } from 'dashboard/composables/useReportMetrics';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { STATUS } from 'dashboard/store/constants';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
metric: {
|
||||
@@ -10,11 +13,16 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'getAccountSummary',
|
||||
},
|
||||
summaryFetchingKey: {
|
||||
type: String,
|
||||
default: 'getAccountSummaryFetchingStatus',
|
||||
},
|
||||
});
|
||||
|
||||
const { calculateTrend, displayMetric, isAverageMetricType } = useReportMetrics(
|
||||
props.accountSummaryKey
|
||||
);
|
||||
const { t } = useI18n();
|
||||
|
||||
const { calculateTrend, displayMetric, isAverageMetricType, fetchingStatus } =
|
||||
useReportMetrics(props.accountSummaryKey, props.summaryFetchingKey);
|
||||
|
||||
const trendColor = (value, key) => {
|
||||
if (isAverageMetricType(key)) {
|
||||
@@ -34,10 +42,25 @@ const trendColor = (value, key) => {
|
||||
{{ metric.NAME }}
|
||||
</span>
|
||||
<div class="flex items-end text-n-slate-12">
|
||||
<div class="text-xl font-medium">
|
||||
<div v-if="fetchingStatus === STATUS.FETCHING">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="fetchingStatus === STATUS.FAILED"
|
||||
class="text-n-ruby-10 text-sm"
|
||||
>
|
||||
{{ t('REPORT.SUMMARY_FETCHING_FAILED') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="fetchingStatus === STATUS.FINISHED"
|
||||
class="text-xl font-medium"
|
||||
>
|
||||
{{ displayMetric(metric.KEY) }}
|
||||
</div>
|
||||
<div v-if="metric.trend" class="text-xs ml-4 flex items-center mb-0.5">
|
||||
<div
|
||||
v-if="metric.trend && fetchingStatus === STATUS.FINISHED"
|
||||
class="text-xs ml-4 flex items-center mb-0.5"
|
||||
>
|
||||
<div
|
||||
v-if="metric.trend < 0"
|
||||
class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const STATUS = {
|
||||
FAILED: 'failed',
|
||||
FETCHING: 'fetching',
|
||||
FINISHED: 'finished',
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { applyPageFilters, sortComparator } from './helpers';
|
||||
import { applyPageFilters, applyRoleFilter, sortComparator } from './helpers';
|
||||
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||
import { matchesFilters } from './helpers/filterHelpers';
|
||||
import {
|
||||
getUserPermissions,
|
||||
getUserRole,
|
||||
} from '../../../helper/permissionsHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export const getSelectedChatConversation = ({
|
||||
@@ -77,10 +81,24 @@ const getters = {
|
||||
return isUnAssigned && shouldFilter;
|
||||
});
|
||||
},
|
||||
getAllStatusChats: _state => activeFilters => {
|
||||
getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => {
|
||||
const currentUser = rootGetters.getCurrentUser;
|
||||
const currentUserId = rootGetters.getCurrentUser.id;
|
||||
const currentAccountId = rootGetters.getCurrentAccountId;
|
||||
|
||||
const permissions = getUserPermissions(currentUser, currentAccountId);
|
||||
const userRole = getUserRole(currentUser, currentAccountId);
|
||||
|
||||
return _state.allConversations.filter(conversation => {
|
||||
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
||||
return shouldFilter;
|
||||
const allowedForRole = applyRoleFilter(
|
||||
conversation,
|
||||
userRole,
|
||||
permissions,
|
||||
currentUserId
|
||||
);
|
||||
|
||||
return shouldFilter && allowedForRole;
|
||||
});
|
||||
},
|
||||
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
|
||||
|
||||
@@ -62,6 +62,51 @@ export const applyPageFilters = (conversation, filters) => {
|
||||
return shouldFilter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters conversations based on user role and permissions
|
||||
*
|
||||
* @param {Object} conversation - The conversation object to check permissions for
|
||||
* @param {string} role - The user's role (administrator, agent, etc.)
|
||||
* @param {Array<string>} permissions - List of permission strings the user has
|
||||
* @param {number|string} currentUserId - The ID of the current user
|
||||
* @returns {boolean} - Whether the user has permissions to access this conversation
|
||||
*/
|
||||
export const applyRoleFilter = (
|
||||
conversation,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
) => {
|
||||
// the role === "agent" check is typically not correct on it's own
|
||||
// the backend handles this by checking the custom_role_id at the user model
|
||||
// here however, the `getUserRole` returns "custom_role" if the id is present,
|
||||
// so we can check the role === "agent" directly
|
||||
if (['administrator', 'agent'].includes(role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for full conversation management permission
|
||||
if (permissions.includes('conversation_manage')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const conversationAssignee = conversation.meta.assignee;
|
||||
const isUnassigned = !conversationAssignee;
|
||||
const isAssignedToUser = conversationAssignee?.id === currentUserId;
|
||||
|
||||
// Check unassigned management permission
|
||||
if (permissions.includes('conversation_unassigned_manage')) {
|
||||
return isUnassigned || isAssignedToUser;
|
||||
}
|
||||
|
||||
// Check participating conversation management permission
|
||||
if (permissions.includes('conversation_participating_manage')) {
|
||||
return isAssignedToUser;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = {
|
||||
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
|
||||
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyRoleFilter } from '../helpers';
|
||||
|
||||
describe('Conversation Helpers', () => {
|
||||
describe('#applyRoleFilter', () => {
|
||||
// Test data for conversations
|
||||
const conversationWithAssignee = {
|
||||
meta: {
|
||||
assignee: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const conversationWithDifferentAssignee = {
|
||||
meta: {
|
||||
assignee: {
|
||||
id: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const conversationWithoutAssignee = {
|
||||
meta: {
|
||||
assignee: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Test for administrator role
|
||||
it('always returns true for administrator role regardless of permissions', () => {
|
||||
const role = 'administrator';
|
||||
const permissions = [];
|
||||
const currentUserId = 1;
|
||||
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithDifferentAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithoutAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Test for agent role
|
||||
it('always returns true for agent role regardless of permissions', () => {
|
||||
const role = 'agent';
|
||||
const permissions = [];
|
||||
const currentUserId = 1;
|
||||
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithDifferentAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithoutAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Test for custom role with 'conversation_manage' permission
|
||||
it('returns true for any user with conversation_manage permission', () => {
|
||||
const role = 'custom_role';
|
||||
const permissions = ['conversation_manage'];
|
||||
const currentUserId = 1;
|
||||
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithDifferentAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithoutAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Test for custom role with 'conversation_unassigned_manage' permission
|
||||
describe('with conversation_unassigned_manage permission', () => {
|
||||
const role = 'custom_role';
|
||||
const permissions = ['conversation_unassigned_manage'];
|
||||
const currentUserId = 1;
|
||||
|
||||
it('returns true for conversations assigned to the user', () => {
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for unassigned conversations', () => {
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithoutAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for conversations assigned to other users', () => {
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithDifferentAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test for custom role with 'conversation_participating_manage' permission
|
||||
describe('with conversation_participating_manage permission', () => {
|
||||
const role = 'custom_role';
|
||||
const permissions = ['conversation_participating_manage'];
|
||||
const currentUserId = 1;
|
||||
|
||||
it('returns true for conversations assigned to the user', () => {
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unassigned conversations', () => {
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithoutAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for conversations assigned to other users', () => {
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithDifferentAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test for user with no relevant permissions
|
||||
it('returns false for custom role without any relevant permissions', () => {
|
||||
const role = 'custom_role';
|
||||
const permissions = ['some_other_permission'];
|
||||
const currentUserId = 1;
|
||||
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithDifferentAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithoutAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Test edge cases for meta.assignee
|
||||
describe('handles edge cases with meta.assignee', () => {
|
||||
const role = 'custom_role';
|
||||
const permissions = ['conversation_unassigned_manage'];
|
||||
const currentUserId = 1;
|
||||
|
||||
it('treats undefined assignee as unassigned', () => {
|
||||
const conversationWithUndefinedAssignee = {
|
||||
meta: {
|
||||
assignee: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithUndefinedAssignee,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty meta object', () => {
|
||||
const conversationWithEmptyMeta = {
|
||||
meta: {},
|
||||
};
|
||||
|
||||
expect(
|
||||
applyRoleFilter(
|
||||
conversationWithEmptyMeta,
|
||||
role,
|
||||
permissions,
|
||||
currentUserId
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint no-console: 0 */
|
||||
import * as types from '../mutation-types';
|
||||
import { STATUS } from '../constants';
|
||||
import Report from '../../api/reports';
|
||||
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
@@ -9,6 +10,8 @@ import liveReports from '../../api/liveReports';
|
||||
|
||||
const state = {
|
||||
fetchingStatus: false,
|
||||
accountSummaryFetchingStatus: STATUS.FINISHED,
|
||||
botSummaryFetchingStatus: STATUS.FINISHED,
|
||||
accountReport: {
|
||||
isFetching: {
|
||||
conversations_count: false,
|
||||
@@ -74,6 +77,12 @@ const getters = {
|
||||
getBotSummary(_state) {
|
||||
return _state.botSummary;
|
||||
},
|
||||
getAccountSummaryFetchingStatus(_state) {
|
||||
return _state.accountSummaryFetchingStatus;
|
||||
},
|
||||
getBotSummaryFetchingStatus(_state) {
|
||||
return _state.botSummaryFetchingStatus;
|
||||
},
|
||||
getAccountConversationMetric(_state) {
|
||||
return _state.overview.accountConversationMetric;
|
||||
},
|
||||
@@ -122,6 +131,7 @@ export const actions = {
|
||||
});
|
||||
},
|
||||
fetchAccountSummary({ commit }, reportObj) {
|
||||
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
|
||||
Report.getSummary(
|
||||
reportObj.from,
|
||||
reportObj.to,
|
||||
@@ -132,12 +142,14 @@ export const actions = {
|
||||
)
|
||||
.then(accountSummary => {
|
||||
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
|
||||
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED);
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED);
|
||||
});
|
||||
},
|
||||
fetchBotSummary({ commit }, reportObj) {
|
||||
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING);
|
||||
Report.getBotSummary({
|
||||
from: reportObj.from,
|
||||
to: reportObj.to,
|
||||
@@ -146,9 +158,10 @@ export const actions = {
|
||||
})
|
||||
.then(botSummary => {
|
||||
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
|
||||
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED);
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED);
|
||||
});
|
||||
},
|
||||
fetchAccountConversationMetric({ commit }, params = {}) {
|
||||
@@ -277,6 +290,12 @@ const mutations = {
|
||||
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
|
||||
_state.accountReport.isFetching[metric] = value;
|
||||
},
|
||||
[types.default.SET_BOT_SUMMARY_STATUS](_state, status) {
|
||||
_state.botSummaryFetchingStatus = status;
|
||||
},
|
||||
[types.default.SET_ACCOUNT_SUMMARY_STATUS](_state, status) {
|
||||
_state.accountSummaryFetchingStatus = status;
|
||||
},
|
||||
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
|
||||
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
|
||||
},
|
||||
|
||||
@@ -1,14 +1,122 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../reports';
|
||||
import * as types from '../../../mutation-types';
|
||||
import { STATUS } from '../../../constants';
|
||||
import * as DownloadHelper from 'dashboard/helper/downloadHelper';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
|
||||
global.open = vi.fn();
|
||||
global.axios = axios;
|
||||
global.URL.createObjectURL = vi.fn();
|
||||
|
||||
vi.mock('axios');
|
||||
vi.spyOn(DownloadHelper, 'downloadCsvFile');
|
||||
|
||||
describe('#actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('#fetchAccountSummary', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
const commit = vi.fn();
|
||||
const reportObj = {
|
||||
from: 1630504922510,
|
||||
to: 1630504922510,
|
||||
type: 'account',
|
||||
id: 1,
|
||||
groupBy: 'day',
|
||||
businessHours: true,
|
||||
};
|
||||
const summaryData = {
|
||||
conversations_count: 10,
|
||||
incoming_messages_count: 20,
|
||||
outgoing_messages_count: 15,
|
||||
avg_first_response_time: 30,
|
||||
avg_resolution_time: 60,
|
||||
resolutions_count: 5,
|
||||
bot_resolutions_count: 2,
|
||||
bot_handoffs_count: 1,
|
||||
reply_time: 25,
|
||||
};
|
||||
axios.get.mockResolvedValue({ data: summaryData });
|
||||
|
||||
actions.fetchAccountSummary({ commit }, reportObj);
|
||||
await flushPromises();
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||
[types.default.SET_ACCOUNT_SUMMARY, summaryData],
|
||||
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends correct actions if API fails', async () => {
|
||||
const commit = vi.fn();
|
||||
const reportObj = {
|
||||
from: 1630504922510,
|
||||
to: 1630504922510,
|
||||
};
|
||||
axios.get.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
actions.fetchAccountSummary({ commit }, reportObj);
|
||||
await flushPromises();
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchBotSummary', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
const commit = vi.fn();
|
||||
const reportObj = {
|
||||
from: 1630504922510,
|
||||
to: 1630504922510,
|
||||
groupBy: 'day',
|
||||
businessHours: true,
|
||||
};
|
||||
const summaryData = {
|
||||
bot_resolutions_count: 10,
|
||||
bot_handoffs_count: 5,
|
||||
previous: {
|
||||
bot_resolutions_count: 8,
|
||||
bot_handoffs_count: 4,
|
||||
},
|
||||
};
|
||||
axios.get.mockResolvedValue({ data: summaryData });
|
||||
|
||||
actions.fetchBotSummary({ commit }, reportObj);
|
||||
await flushPromises();
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||
[types.default.SET_BOT_SUMMARY, summaryData],
|
||||
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends correct actions if API fails', async () => {
|
||||
const commit = vi.fn();
|
||||
const reportObj = {
|
||||
from: 1630504922510,
|
||||
to: 1630504922510,
|
||||
};
|
||||
const error = new Error('API error');
|
||||
axios.get.mockRejectedValueOnce(error);
|
||||
|
||||
actions.fetchBotSummary({ commit }, reportObj);
|
||||
await flushPromises();
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#downloadAgentReports', () => {
|
||||
it('open CSV download prompt if API is success', async () => {
|
||||
const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
|
||||
@@ -20,7 +128,9 @@ describe('#actions', () => {
|
||||
to: 1630504922510,
|
||||
fileName: 'agent-report-01-09-2021.csv',
|
||||
};
|
||||
await actions.downloadAgentReports(1, param);
|
||||
actions.downloadAgentReports(1, param);
|
||||
await flushPromises();
|
||||
|
||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||
param.fileName,
|
||||
data
|
||||
@@ -39,7 +149,9 @@ describe('#actions', () => {
|
||||
type: 'label',
|
||||
fileName: 'label-report-01-09-2021.csv',
|
||||
};
|
||||
await actions.downloadLabelReports(1, param);
|
||||
actions.downloadLabelReports(1, param);
|
||||
await flushPromises();
|
||||
|
||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||
param.fileName,
|
||||
data
|
||||
@@ -59,7 +171,9 @@ describe('#actions', () => {
|
||||
to: 1635013800,
|
||||
fileName: 'inbox-report-24-10-2021.csv',
|
||||
};
|
||||
await actions.downloadInboxReports(1, param);
|
||||
actions.downloadInboxReports(1, param);
|
||||
await flushPromises();
|
||||
|
||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||
param.fileName,
|
||||
data
|
||||
@@ -78,7 +192,9 @@ describe('#actions', () => {
|
||||
to: 1635013800,
|
||||
fileName: 'inbox-report-24-10-2021.csv',
|
||||
};
|
||||
await actions.downloadInboxReports(1, param);
|
||||
actions.downloadInboxReports(1, param);
|
||||
await flushPromises();
|
||||
|
||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||
param.fileName,
|
||||
data
|
||||
|
||||
@@ -189,6 +189,8 @@ export default {
|
||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
||||
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
||||
SET_BOT_SUMMARY_STATUS: 'SET_BOT_SUMMARY_STATUS',
|
||||
SET_ACCOUNT_SUMMARY_STATUS: 'SET_ACCOUNT_SUMMARY_STATUS',
|
||||
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
|
||||
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
|
||||
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
|
||||
|
||||
@@ -24,6 +24,11 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
private
|
||||
|
||||
def process_single_entry(entry)
|
||||
if test_event?(entry)
|
||||
process_test_event(entry)
|
||||
return
|
||||
end
|
||||
|
||||
process_messages(entry)
|
||||
end
|
||||
|
||||
@@ -46,6 +51,20 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
messaging[:message].present? && messaging[:message][:is_echo].present?
|
||||
end
|
||||
|
||||
def test_event?(entry)
|
||||
entry[:changes].present?
|
||||
end
|
||||
|
||||
def process_test_event(entry)
|
||||
messaging = extract_messaging_from_test_event(entry)
|
||||
|
||||
Instagram::TestEventService.new(messaging).perform if messaging.present?
|
||||
end
|
||||
|
||||
def extract_messaging_from_test_event(entry)
|
||||
entry[:changes].first&.dig(:value) if entry[:changes].present?
|
||||
end
|
||||
|
||||
def instagram_id(messaging)
|
||||
if agent_message_via_echo?(messaging)
|
||||
messaging[:sender][:id]
|
||||
|
||||
@@ -3,6 +3,7 @@ module ConversationReplyMailerHelper
|
||||
@options = {
|
||||
to: to_emails,
|
||||
from: email_from,
|
||||
sender: @channel.smtp_login,
|
||||
reply_to: email_reply_to,
|
||||
subject: mail_subject,
|
||||
message_id: custom_message_id,
|
||||
@@ -42,22 +43,9 @@ module ConversationReplyMailerHelper
|
||||
@options[:delivery_method_options] = smtp_settings
|
||||
end
|
||||
|
||||
def base_smtp_settings(domain)
|
||||
{
|
||||
address: domain,
|
||||
port: 587,
|
||||
user_name: @channel.imap_login,
|
||||
password: @channel.provider_config['access_token'],
|
||||
domain: domain,
|
||||
tls: false,
|
||||
enable_starttls_auto: true,
|
||||
openssl_verify_mode: 'none',
|
||||
authentication: 'xoauth2'
|
||||
}
|
||||
end
|
||||
|
||||
def set_delivery_method
|
||||
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
||||
return unless @inbox.email? && @channel.smtp_enabled
|
||||
return if @channel.imap_enabled && (@inbox.channel.microsoft? || @inbox.channel.google?)
|
||||
|
||||
smtp_settings = {
|
||||
address: @channel.smtp_address,
|
||||
@@ -75,6 +63,20 @@ module ConversationReplyMailerHelper
|
||||
@options[:delivery_method_options] = smtp_settings
|
||||
end
|
||||
|
||||
def base_smtp_settings(domain)
|
||||
{
|
||||
address: domain,
|
||||
port: 587,
|
||||
user_name: @channel.imap_login,
|
||||
password: @channel.provider_config['access_token'],
|
||||
domain: domain,
|
||||
tls: false,
|
||||
enable_starttls_auto: true,
|
||||
openssl_verify_mode: 'none',
|
||||
authentication: 'xoauth2'
|
||||
}
|
||||
end
|
||||
|
||||
def email_smtp_enabled
|
||||
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
||||
end
|
||||
|
||||
@@ -33,10 +33,6 @@ class Channel::Api < ApplicationRecord
|
||||
'API'
|
||||
end
|
||||
|
||||
def messaging_window_enabled?
|
||||
additional_attributes.present? && additional_attributes['agent_reply_time_window'].present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_valid_agent_reply_time_window
|
||||
|
||||
@@ -32,10 +32,6 @@ class Channel::FacebookPage < ApplicationRecord
|
||||
'Facebook'
|
||||
end
|
||||
|
||||
def messaging_window_enabled?
|
||||
false
|
||||
end
|
||||
|
||||
def create_contact_inbox(instagram_id, name)
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new({
|
||||
source_id: instagram_id,
|
||||
|
||||
@@ -41,10 +41,6 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
|
||||
end
|
||||
|
||||
def messaging_window_enabled?
|
||||
medium == 'whatsapp'
|
||||
end
|
||||
|
||||
def send_message(to:, body:, media_url: nil)
|
||||
params = send_message_from.merge(to: to, body: body)
|
||||
params[:media_url] = media_url if media_url.present?
|
||||
|
||||
@@ -46,10 +46,6 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def messaging_window_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def mark_message_templates_updated
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
update_column(:message_templates_last_updated, Time.zone.now)
|
||||
|
||||
@@ -7,10 +7,6 @@ module Channelable
|
||||
after_update :create_audit_log_entry
|
||||
end
|
||||
|
||||
def messaging_window_enabled?
|
||||
false
|
||||
end
|
||||
|
||||
def create_audit_log_entry; end
|
||||
end
|
||||
|
||||
|
||||
@@ -115,14 +115,7 @@ class Conversation < ApplicationRecord
|
||||
delegate :auto_resolve_duration, to: :account
|
||||
|
||||
def can_reply?
|
||||
channel = inbox&.channel
|
||||
|
||||
return can_reply_on_instagram? if additional_attributes['type'] == 'instagram_direct_message'
|
||||
|
||||
return true unless channel&.messaging_window_enabled?
|
||||
|
||||
messaging_window = inbox.api? ? channel.additional_attributes['agent_reply_time_window'].to_i : 24
|
||||
last_message_in_messaging_window?(messaging_window)
|
||||
Conversations::MessageWindowService.new(self).can_reply?
|
||||
end
|
||||
|
||||
def language
|
||||
@@ -137,24 +130,6 @@ class Conversation < ApplicationRecord
|
||||
messages&.incoming&.last
|
||||
end
|
||||
|
||||
def last_message_in_messaging_window?(time)
|
||||
return false if last_incoming_message.nil?
|
||||
|
||||
Time.current < last_incoming_message.created_at + time.hours
|
||||
end
|
||||
|
||||
def can_reply_on_instagram?
|
||||
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
||||
|
||||
return false if last_incoming_message.nil?
|
||||
|
||||
if global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
|
||||
Time.current < last_incoming_message.created_at + 7.days
|
||||
else
|
||||
last_message_in_messaging_window?(24)
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_status
|
||||
# FIXME: implement state machine with aasm
|
||||
self.status = open? ? :resolved : :open
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
class Conversations::MessageWindowService
|
||||
MESSAGING_WINDOW_24_HOURS = 24.hours
|
||||
MESSAGING_WINDOW_7_DAYS = 7.days
|
||||
|
||||
def initialize(conversation)
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def can_reply?
|
||||
return true if messaging_window.blank?
|
||||
|
||||
last_message_in_messaging_window?(messaging_window)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def messaging_window
|
||||
case @conversation.inbox.channel_type
|
||||
when 'Channel::Api'
|
||||
api_messaging_window
|
||||
when 'Channel::FacebookPage'
|
||||
messenger_messaging_window
|
||||
when 'Channel::Instagram'
|
||||
instagram_messaging_window
|
||||
when 'Channel::Whatsapp'
|
||||
MESSAGING_WINDOW_24_HOURS
|
||||
when 'Channel::TwilioSms'
|
||||
twilio_messaging_window
|
||||
end
|
||||
end
|
||||
|
||||
def last_message_in_messaging_window?(time)
|
||||
return false if last_incoming_message.nil?
|
||||
|
||||
Time.current < last_incoming_message.created_at + time
|
||||
end
|
||||
|
||||
def api_messaging_window
|
||||
return if @conversation.inbox.channel.additional_attributes['agent_reply_time_window'].blank?
|
||||
|
||||
@conversation.inbox.channel.additional_attributes['agent_reply_time_window'].to_i.hours
|
||||
end
|
||||
|
||||
# Check medium of the inbox to determine the messaging window
|
||||
def twilio_messaging_window
|
||||
@conversation.inbox.channel.medium == 'whatsapp' ? MESSAGING_WINDOW_24_HOURS : nil
|
||||
end
|
||||
|
||||
def messenger_messaging_window
|
||||
meta_messaging_window('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
||||
end
|
||||
|
||||
def instagram_messaging_window
|
||||
meta_messaging_window('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
|
||||
end
|
||||
|
||||
def meta_messaging_window(config_key)
|
||||
GlobalConfigService.load(config_key, nil) ? MESSAGING_WINDOW_7_DAYS : MESSAGING_WINDOW_24_HOURS
|
||||
end
|
||||
|
||||
def last_incoming_message
|
||||
@last_incoming_message ||= @conversation.messages&.incoming&.last
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
class Instagram::TestEventService
|
||||
def initialize(messaging)
|
||||
@messaging = messaging
|
||||
end
|
||||
|
||||
def perform
|
||||
Rails.logger.info("Processing Instagram test webhook event, #{@messaging}")
|
||||
|
||||
return false unless test_webhook_event?
|
||||
|
||||
create_test_text
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def test_webhook_event?
|
||||
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
|
||||
end
|
||||
|
||||
def create_test_text
|
||||
# As of now, we are using the last created instagram channel as the test channel,
|
||||
# since we don't have any other channel for testing purpose at the time of meta approval
|
||||
channel = Channel::Instagram.last
|
||||
|
||||
@inbox = ::Inbox.find_by(channel: channel)
|
||||
return unless @inbox
|
||||
|
||||
@contact = create_test_contact
|
||||
|
||||
@conversation ||= create_test_conversation(conversation_params)
|
||||
|
||||
@message = @conversation.messages.create!(test_message_params)
|
||||
end
|
||||
|
||||
def create_test_contact
|
||||
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
|
||||
unless @contact_inbox
|
||||
@contact_inbox ||= @inbox.channel.create_contact_inbox(
|
||||
'sender_username', 'sender_username'
|
||||
)
|
||||
end
|
||||
|
||||
@contact_inbox.contact
|
||||
end
|
||||
|
||||
def create_test_conversation(conversation_params)
|
||||
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
|
||||
end
|
||||
|
||||
def test_message_params
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: 'incoming',
|
||||
source_id: @messaging[:message][:mid],
|
||||
content: @messaging[:message][:text],
|
||||
sender: @contact
|
||||
}
|
||||
end
|
||||
|
||||
def build_conversation(conversation_params)
|
||||
Conversation.create!(
|
||||
conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: @contact.id,
|
||||
additional_attributes: {
|
||||
type: 'instagram_direct_message'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -39,6 +39,10 @@ By default, it renders:
|
||||
<% else %>
|
||||
<title><%= @portal.page_title%></title>
|
||||
<% end %>
|
||||
|
||||
<% if @portal.logo.present? %>
|
||||
<link rel="icon" href="<%= url_for(@portal.logo) %>">
|
||||
<% end %>
|
||||
|
||||
<% unless @theme_from_params.blank? %>
|
||||
<%# this adds the theme from params, ensuring that there a localstorage value set %>
|
||||
|
||||
@@ -56,6 +56,23 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'blocks personal accounts signup with different Gmail case variations' do
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
# Test different case variations of Gmail
|
||||
['personal@Gmail.com', 'personal@GMAIL.com', 'personal@Gmail.COM'].each do |email|
|
||||
set_omniauth_config(email)
|
||||
get '/omniauth/google_oauth2/callback'
|
||||
|
||||
# expect a 302 redirect to auth/google_oauth2/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
|
||||
follow_redirect!
|
||||
|
||||
# expect a 302 redirect to app/login with error disallowing personal accounts
|
||||
expect(response).to redirect_to(%r{/app/login\?error=business-account-only$})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# This test does not affect line coverage, but it is important to ensure that the logic
|
||||
# does not allow any signup if the ENV explicitly disables it
|
||||
it 'blocks signup if ENV disabled' do
|
||||
|
||||
@@ -30,6 +30,32 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
|
||||
expect(json_response['error']).to eql "Domain: www.example.com is not registered with us. \
|
||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||
end
|
||||
|
||||
context 'when portal has a logo' do
|
||||
it 'includes the logo as favicon' do
|
||||
# Attach a test image to the portal
|
||||
file = Rails.root.join('spec/assets/sample.png').open
|
||||
portal.logo.attach(io: file, filename: 'sample.png', content_type: 'image/png')
|
||||
file.close
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('<link rel="icon" href=')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal has no logo' do
|
||||
it 'does not include a favicon link' do
|
||||
# Ensure logo is not attached
|
||||
portal.logo.purge if portal.logo.attached?
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).not_to include('<link rel="icon" href=')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/{portal_slug}/sitemap' do
|
||||
|
||||
@@ -361,4 +361,34 @@ FactoryBot.define do
|
||||
end
|
||||
initialize_with { attributes }
|
||||
end
|
||||
|
||||
factory :instagram_test_event, class: Hash do
|
||||
entry do
|
||||
[
|
||||
{
|
||||
'id': '0',
|
||||
'time': '2021-09-08T06:34:04+0000',
|
||||
'changes': [
|
||||
{
|
||||
'field': 'messages',
|
||||
'value': {
|
||||
'sender': {
|
||||
'id': '12334'
|
||||
},
|
||||
'recipient': {
|
||||
'id': '23245'
|
||||
},
|
||||
'timestamp': '1527459824',
|
||||
'message': {
|
||||
'mid': 'random_mid',
|
||||
'text': 'random_text'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
initialize_with { attributes }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,28 +3,6 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::TwilioSms do
|
||||
describe '#has_24_hour_messaging_window?' do
|
||||
context 'with medium whatsapp' do
|
||||
let!(:whatsapp_channel) { create(:channel_twilio_sms, medium: :whatsapp) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(whatsapp_channel.messaging_window_enabled?).to be true
|
||||
expect(whatsapp_channel.name).to eq 'Whatsapp'
|
||||
expect(whatsapp_channel.medium).to eq 'whatsapp'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with medium sms' do
|
||||
let!(:sms_channel) { create(:channel_twilio_sms, medium: :sms) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(sms_channel.messaging_window_enabled?).to be false
|
||||
expect(sms_channel.name).to eq 'Twilio SMS'
|
||||
expect(sms_channel.medium).to eq 'sms'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#validations' do
|
||||
context 'with phone number blank' do
|
||||
let!(:sms_channel) { create(:channel_twilio_sms, medium: :sms, phone_number: nil) }
|
||||
|
||||
@@ -585,116 +585,6 @@ RSpec.describe Conversation do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_reply?' do
|
||||
describe 'on channels without 24 hour restriction' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(conversation.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'return true for facebook channels' do
|
||||
stub_request(:post, /graph.facebook.com/)
|
||||
facebook_channel = create(:channel_facebook_page)
|
||||
facebook_inbox = create(:inbox, channel: facebook_channel, account: facebook_channel.account)
|
||||
fb_conversation = create(:conversation, inbox: facebook_inbox, account: facebook_channel.account)
|
||||
|
||||
expect(fb_conversation.can_reply?).to be true
|
||||
expect(facebook_channel.messaging_window_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on channels with 24 hour restriction' do
|
||||
before do
|
||||
stub_request(:post, /graph.facebook.com/)
|
||||
end
|
||||
|
||||
let!(:facebook_channel) { create(:channel_facebook_page) }
|
||||
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: facebook_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: facebook_inbox, account: facebook_channel.account) }
|
||||
|
||||
context 'when instagram channel' do
|
||||
it 'return true with HUMAN_AGENT if it is outside of 24 hour window' do
|
||||
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: true)
|
||||
|
||||
conversation.update(additional_attributes: { type: 'instagram_direct_message' })
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 48.hours.ago
|
||||
)
|
||||
|
||||
expect(conversation.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'return false without HUMAN_AGENT if it is outside of 24 hour window' do
|
||||
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
|
||||
|
||||
conversation.update(additional_attributes: { type: 'instagram_direct_message' })
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 48.hours.ago
|
||||
)
|
||||
|
||||
expect(conversation.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on API channels' do
|
||||
let!(:api_channel) { create(:channel_api, additional_attributes: {}) }
|
||||
let!(:api_channel_with_limit) { create(:channel_api, additional_attributes: { agent_reply_time_window: '12' }) }
|
||||
|
||||
context 'when agent_reply_time_window is not configured' do
|
||||
it 'return true irrespective of the last message time' do
|
||||
conversation = create(:conversation, inbox: api_channel.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel.inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
|
||||
expect(api_channel.additional_attributes['agent_reply_time_window']).to be_nil
|
||||
expect(conversation.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent_reply_time_window is configured' do
|
||||
it 'return false if it is outside of agent_reply_time_window' do
|
||||
conversation = create(:conversation, inbox: api_channel_with_limit.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel_with_limit.inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
|
||||
expect(api_channel_with_limit.additional_attributes['agent_reply_time_window']).to eq '12'
|
||||
expect(conversation.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if it is inside of agent_reply_time_window' do
|
||||
conversation = create(:conversation, inbox: api_channel_with_limit.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel_with_limit.inbox,
|
||||
conversation: conversation
|
||||
)
|
||||
expect(conversation.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#delete conversation' do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
@@ -918,4 +808,25 @@ RSpec.describe Conversation do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_reply?' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message_window_service) { instance_double(Conversations::MessageWindowService) }
|
||||
|
||||
before do
|
||||
allow(Conversations::MessageWindowService).to receive(:new).with(conversation).and_return(message_window_service)
|
||||
end
|
||||
|
||||
it 'delegates to MessageWindowService' do
|
||||
allow(message_window_service).to receive(:can_reply?).and_return(true)
|
||||
expect(conversation.can_reply?).to be true
|
||||
expect(message_window_service).to have_received(:can_reply?)
|
||||
end
|
||||
|
||||
it 'returns false when MessageWindowService returns false' do
|
||||
allow(message_window_service).to receive(:can_reply?).and_return(false)
|
||||
expect(conversation.can_reply?).to be false
|
||||
expect(message_window_service).to have_received(:can_reply?)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversations::MessageWindowService do
|
||||
describe 'on API channels' do
|
||||
let!(:api_channel) { create(:channel_api, additional_attributes: {}) }
|
||||
let!(:api_channel_with_limit) { create(:channel_api, additional_attributes: { agent_reply_time_window: '12' }) }
|
||||
|
||||
context 'when agent_reply_time_window is not configured' do
|
||||
it 'return true irrespective of the last message time' do
|
||||
conversation = create(:conversation, inbox: api_channel.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel.inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
|
||||
expect(api_channel.additional_attributes['agent_reply_time_window']).to be_nil
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent_reply_time_window is configured' do
|
||||
it 'return false if it is outside of agent_reply_time_window' do
|
||||
conversation = create(:conversation, inbox: api_channel_with_limit.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel_with_limit.inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
|
||||
expect(api_channel_with_limit.additional_attributes['agent_reply_time_window']).to eq '12'
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if it is inside of agent_reply_time_window' do
|
||||
conversation = create(:conversation, inbox: api_channel_with_limit.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel_with_limit.inbox,
|
||||
conversation: conversation
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Facebook channels' do
|
||||
before do
|
||||
stub_request(:post, /graph.facebook.com/)
|
||||
end
|
||||
|
||||
let!(:facebook_channel) { create(:channel_facebook_page) }
|
||||
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: facebook_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: facebook_inbox, account: facebook_channel.account) }
|
||||
|
||||
context 'when the HUMAN_AGENT is enabled' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 7 days)' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 5.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (8 days ago )' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 8.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the HUMAN_AGENT is disabled' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window ( 8 days ago )' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 4.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (24 hours limit)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Instagram channels' do
|
||||
let!(:instagram_channel) { create(:channel_instagram) }
|
||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: instagram_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: instagram_inbox, account: instagram_channel.account) }
|
||||
|
||||
context 'when the HUMAN_AGENT is enabled' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 7 days)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (8 days ago)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 8.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and exactly at the edge of 24-hour window with HUMAN_AGENT disabled' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 24.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the HUMAN_AGENT is disabled' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (8 days ago)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 9.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (24 hours limit)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on WhatsApp Cloud channels' do
|
||||
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
|
||||
let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: whatsapp_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: whatsapp_inbox, account: whatsapp_channel.account) }
|
||||
|
||||
it 'return false if the last message is outgoing' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (24 hours limit)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 25.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Web widget channels' do
|
||||
let!(:widget_channel) { create(:channel_widget) }
|
||||
let!(:widget_inbox) { create(:inbox, channel: widget_channel, account: widget_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: widget_inbox, account: widget_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: widget_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on SMS channels' do
|
||||
let!(:sms_channel) { create(:channel_sms) }
|
||||
let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: sms_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: sms_inbox, account: sms_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: sms_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Telegram channels' do
|
||||
let!(:telegram_channel) { create(:channel_telegram) }
|
||||
let!(:telegram_inbox) { create(:inbox, channel: telegram_channel, account: telegram_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: telegram_inbox, account: telegram_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: telegram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Email channels' do
|
||||
let!(:email_channel) { create(:channel_email) }
|
||||
let!(:email_inbox) { create(:inbox, channel: email_channel, account: email_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: email_inbox, account: email_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: email_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Line channels' do
|
||||
let!(:line_channel) { create(:channel_line) }
|
||||
let!(:line_inbox) { create(:inbox, channel: line_channel, account: line_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: line_inbox, account: line_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: line_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Twilio SMS channels' do
|
||||
let!(:twilio_sms_channel) { create(:channel_twilio_sms) }
|
||||
let!(:twilio_sms_inbox) { create(:inbox, channel: twilio_sms_channel, account: twilio_sms_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: twilio_sms_inbox, account: twilio_sms_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: twilio_sms_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on WhatsApp Twilio channels' do
|
||||
let!(:whatsapp_channel) { create(:channel_twilio_sms, medium: :whatsapp) }
|
||||
let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: whatsapp_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: whatsapp_inbox, account: whatsapp_channel.account) }
|
||||
|
||||
it 'return false if the last message is outgoing' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (24 hours limit)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 25.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Instagram::TestEventService do
|
||||
let(:account) { create(:account) }
|
||||
let(:instagram_channel) { create(:channel_instagram, account: account) }
|
||||
let(:inbox) { create(:inbox, channel: instagram_channel, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when validating test webhook event' do
|
||||
let(:test_messaging) do
|
||||
{
|
||||
'sender': {
|
||||
'id': '12334'
|
||||
},
|
||||
'recipient': {
|
||||
'id': '23245'
|
||||
},
|
||||
'timestamp': '1527459824',
|
||||
'message': {
|
||||
'mid': 'random_mid',
|
||||
'text': 'random_text'
|
||||
}
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'creates test message for valid test webhook event' do
|
||||
# Ensure inbox exists before test
|
||||
inbox
|
||||
|
||||
service = described_class.new(test_messaging)
|
||||
|
||||
expect { service.perform }.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('random_text')
|
||||
expect(message.source_id).to eq('random_mid')
|
||||
expect(message.message_type).to eq('incoming')
|
||||
end
|
||||
|
||||
it 'creates a contact with sender_username' do
|
||||
# Ensure inbox exists before test
|
||||
inbox
|
||||
|
||||
service = described_class.new(test_messaging)
|
||||
service.perform
|
||||
|
||||
contact = Contact.last
|
||||
expect(contact.name).to eq('sender_username')
|
||||
end
|
||||
|
||||
it 'returns false for non-test webhook events' do
|
||||
invalid_messaging = test_messaging.deep_dup
|
||||
invalid_messaging[:sender][:id] = 'different_id'
|
||||
|
||||
service = described_class.new(invalid_messaging)
|
||||
|
||||
expect(service.perform).to be(false)
|
||||
end
|
||||
|
||||
it 'returns nil when no Instagram channel exists' do
|
||||
# Delete all inboxes and channels
|
||||
Inbox.destroy_all
|
||||
Channel::Instagram.destroy_all
|
||||
|
||||
service = described_class.new(test_messaging)
|
||||
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -315,6 +315,21 @@ export const colors = {
|
||||
12: 'rgb(var(--iris-12) / <alpha-value>)',
|
||||
},
|
||||
|
||||
blue: {
|
||||
1: 'rgb(var(--blue-1) / <alpha-value>)',
|
||||
2: 'rgb(var(--blue-2) / <alpha-value>)',
|
||||
3: 'rgb(var(--blue-3) / <alpha-value>)',
|
||||
4: 'rgb(var(--blue-4) / <alpha-value>)',
|
||||
5: 'rgb(var(--blue-5) / <alpha-value>)',
|
||||
6: 'rgb(var(--blue-6) / <alpha-value>)',
|
||||
7: 'rgb(var(--blue-7) / <alpha-value>)',
|
||||
8: 'rgb(var(--blue-8) / <alpha-value>)',
|
||||
9: 'rgb(var(--blue-9) / <alpha-value>)',
|
||||
10: 'rgb(var(--blue-10) / <alpha-value>)',
|
||||
11: 'rgb(var(--blue-11) / <alpha-value>)',
|
||||
12: 'rgb(var(--blue-12) / <alpha-value>)',
|
||||
},
|
||||
|
||||
ruby: {
|
||||
1: 'rgb(var(--ruby-1) / <alpha-value>)',
|
||||
2: 'rgb(var(--ruby-2) / <alpha-value>)',
|
||||
|
||||
Reference in New Issue
Block a user