Compare commits

...
Author SHA1 Message Date
Shivam Mishra 22fd596d6b fix: don't override smtp 2025-04-22 22:12:07 +05:30
1140ca7a78 fix: stale report value shown if summary fetch breaks (#11270)
This pull request includes several changes to better show the report
metrics fetching status on the dashboard. This also prevents showing
stale data in case fetching summary fails due to timeout or other issue

The most important changes include adding a new fetching status state to
the store called `summaryFetchingStatus`, updating the
`useReportMetrics` composable, and modifying the `ChartStats` component
to handle different fetching statuses.

#### Loading
![CleanShot 2025-04-09 at 13 49
35@2x](https://github.com/user-attachments/assets/575c9905-0bf7-4a6e-8709-026896dd95f8)

#### Finished
![CleanShot 2025-04-09 at 13 49
43@2x](https://github.com/user-attachments/assets/ef7f8cb3-ca34-4627-a954-ba23f156d2ff)

#### Failed
<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/d521a785-9299-4e59-94dc-561a7a84377e"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-04-15 20:18:06 +05:30
Sivin VargheseandGitHub 79fc5bb555 fix: Display error message on empty response from Captain (#11302) 2025-04-15 13:20:06 +05:30
Sojan JoseandGitHub 78a40114ef feat: Use portal logo as favicon in helpcenter pages (#11289)
- Added favicon link to portal layout when logo is present
- Added tests to verify favicon behavior with and without logo
2025-04-14 19:59:56 -07:00
Sivin VargheseandGitHub a1f61f0e21 fix: Hide message status for failed and deleted messages (#11294)
# Pull Request Template

## Description

This PR fixes the issue where a clock with animation is shown inside the
message bubble for failed and deleted messages. The message status is
now hidden for such messages.

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Before
**Failed message bubble**
<img width="223" alt="image"
src="https://github.com/user-attachments/assets/bb4d7a34-4a1c-495a-9a3d-21d065bba020"
/>

**Deleted message bubble**
<img width="223" alt="image"
src="https://github.com/user-attachments/assets/ece8e2ff-c6d7-4fa7-b11c-04748bf9ea2d"
/>


### After
**Failed message bubble**
<img width="223" alt="image"
src="https://github.com/user-attachments/assets/6a6d81eb-52d9-48c3-bbc1-810b19770d61"
/>

**Deleted message bubble**
<img width="223" alt="image"
src="https://github.com/user-attachments/assets/828b553a-c88a-4a9e-9773-d75d76a9d0fd"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-04-14 15:56:16 +05:30
Vishnu NarayananandGitHub befdfb0ae6 fix: use stricter validation to restrict gmail signups (#11285)
- use stricter validation to restrict gmail signups
2025-04-12 10:52:49 +05:30
Muhsin KelothandGitHub e0097ab102 chore: Centralize outgoing message reply restrictions for all the channels (#11279) 2025-04-12 08:52:12 +05:30
Muhsin KelothGitHubSivin VargheseCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>Shivam Mishra
bdcb080e40 feat: Handle instagram test service (#11244)
This PR will handle the Instagram test events. We are using the last
created Instagram channel as the test channel since we don't have any
other channels for testing purposes at the time of Meta approval.



https://github.com/user-attachments/assets/98302b7a-d72c-4950-9660-861a5e08d55f

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-04-11 19:11:29 +05:30
PranavandGitHub 50344a282b fix: Return new Array instead of freezed object (#11283)
Users who have not changed the order of the sidebar items were not able
to change it as the object returned was created using Object.freeze

To reproduce: 

- Create a new user account, open a conversation, try changing the order
of the sidebar items.
2025-04-11 13:02:16 +05:30
Muhsin KelothandGitHub 65cd0717e6 fix: Handle Instagram echo events (#11275)
This PR fixes the issue with message creation when someone sends
messages from the Instagram app instead of the Chatwoot dashboard.
2025-04-10 12:58:38 +05:30
73dcf539ed feat: allow role based filtering on the frontend (#11246)
This pull request introduces frontend role filtering to allStatusChat
getter. The key changes include the addition of a new helper function to
get the user's role, updates to the conversation filtering logic to
incorporate role and permissions, and the addition of unit tests for the
new filtering logic.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-04-09 11:46:20 -07:00
Sivin VargheseandGitHub 35d9dc925f chore: Remove sorting by phone_number from contact list (#11271) 2025-04-09 18:41:18 +05:30
Sivin VargheseandGitHub 2d14f27d5b chore: Update date range picker with new theme colors (#11267) 2025-04-09 16:03:53 +05:30
Sivin VargheseandGitHub 70a65e2c34 fix: Prevent mentions menu from triggering on reply mode change (#11264)
# Pull Request Template

## Description

**Issue:** When typing "@" in reply mode and then switching to private
note mode, the user mentions menu remains visible and cannot be
dismissed.

**Cause:** The Prose Mirror suggestion plugin for "@" was active in both
reply modes (normal and private). When triggered in normal reply mode
and then switching to private note mode, the menu would remain open and
couldn't be dismissed.

**Solution**
```
createSuggestionPlugin({
  trigger: '@',
  showMenu: showUserMentions,
  searchTerm: mentionSearchKey,
  isAllowed: () => props.isPrivate, // Only allow @ mentions in private note mode
}),
```
1. By setting `isAllowed: () => props.isPrivate`, the @ mention trigger
will only activate when in private note mode
2. In normal reply mode, the plugin won't activate at all since
`isAllowed` returns false
3. This prevents the menu from being triggered in the wrong context and
fix the scenario where the menu gets stuck.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Loom video

**Before**

https://www.loom.com/share/5333f0c6498d4a9ea4d220b1de1b608c?sid=1425f24f-2c6f-4ff5-aab3-23c3203d2e05

**After**

https://www.loom.com/share/9f183c76d6a94b618a7c2aaed280b780?sid=19ef08b8-2b70-434b-ad5a-267410212e11

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-04-09 15:25:46 +05:30
53 changed files with 1654 additions and 279 deletions
@@ -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;
@@ -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"
/>
@@ -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]"
@@ -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',
+19
View File
@@ -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]
+17 -15
View File
@@ -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
-4
View File
@@ -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
-4
View File
@@ -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,
-4
View File
@@ -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?
-4
View File
@@ -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)
-4
View File
@@ -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
+1 -26
View File
@@ -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
+4
View File
@@ -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
-22
View File
@@ -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) }
+21 -110
View File
@@ -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
+15
View File
@@ -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>)',