mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +00:00
Merge branch 'develop' into feature/stripe_v2
This commit is contained in:
1
.github/workflows/run_mfa_spec.yml
vendored
1
.github/workflows/run_mfa_spec.yml
vendored
@@ -70,6 +70,7 @@ jobs:
|
|||||||
spec/services/mfa/authentication_service_spec.rb \
|
spec/services/mfa/authentication_service_spec.rb \
|
||||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||||
|
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||||
--profile=10 \
|
--profile=10 \
|
||||||
--format documentation
|
--format documentation
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
},
|
},
|
||||||
"DATE_RANGE_OPTIONS": {
|
"DATE_RANGE_OPTIONS": {
|
||||||
"LAST_7_DAYS": "Last 7 days",
|
"LAST_7_DAYS": "Last 7 days",
|
||||||
|
"LAST_14_DAYS": "Last 14 days",
|
||||||
"LAST_30_DAYS": "Last 30 days",
|
"LAST_30_DAYS": "Last 30 days",
|
||||||
"LAST_3_MONTHS": "Last 3 months",
|
"LAST_3_MONTHS": "Last 3 months",
|
||||||
"LAST_6_MONTHS": "Last 6 months",
|
"LAST_6_MONTHS": "Last 6 months",
|
||||||
@@ -266,6 +267,8 @@
|
|||||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||||
|
"ALL_INBOXES": "All Inboxes",
|
||||||
|
"SEARCH_INBOX": "Search Inbox",
|
||||||
"METRICS": {
|
"METRICS": {
|
||||||
"CONVERSATIONS": {
|
"CONVERSATIONS": {
|
||||||
"NAME": "Conversations",
|
"NAME": "Conversations",
|
||||||
@@ -467,6 +470,13 @@
|
|||||||
"CONVERSATIONS": "{count} conversations",
|
"CONVERSATIONS": "{count} conversations",
|
||||||
"DOWNLOAD_REPORT": "Download report"
|
"DOWNLOAD_REPORT": "Download report"
|
||||||
},
|
},
|
||||||
|
"RESOLUTION_HEATMAP": {
|
||||||
|
"HEADER": "Resolutions",
|
||||||
|
"NO_CONVERSATIONS": "No conversations",
|
||||||
|
"CONVERSATION": "{count} conversation",
|
||||||
|
"CONVERSATIONS": "{count} conversations",
|
||||||
|
"DOWNLOAD_REPORT": "Download report"
|
||||||
|
},
|
||||||
"AGENT_CONVERSATIONS": {
|
"AGENT_CONVERSATIONS": {
|
||||||
"HEADER": "Conversations by agents",
|
"HEADER": "Conversations by agents",
|
||||||
"LOADING_MESSAGE": "Loading agent metrics...",
|
"LOADING_MESSAGE": "Loading agent metrics...",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import ReportHeader from './components/ReportHeader.vue';
|
import ReportHeader from './components/ReportHeader.vue';
|
||||||
import HeatmapContainer from './components/HeatmapContainer.vue';
|
import ConversationHeatmapContainer from './components/heatmaps/ConversationHeatmapContainer.vue';
|
||||||
|
import ResolutionHeatmapContainer from './components/heatmaps/ResolutionHeatmapContainer.vue';
|
||||||
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
|
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
|
||||||
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
|
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
|
||||||
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
|
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
|
||||||
@@ -10,7 +11,8 @@ import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vu
|
|||||||
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
||||||
<div class="flex flex-col gap-4 pb-6">
|
<div class="flex flex-col gap-4 pb-6">
|
||||||
<StatsLiveReportsContainer />
|
<StatsLiveReportsContainer />
|
||||||
<HeatmapContainer />
|
<ConversationHeatmapContainer />
|
||||||
|
<ResolutionHeatmapContainer />
|
||||||
<AgentLiveReportContainer />
|
<AgentLiveReportContainer />
|
||||||
<TeamLiveReportContainer />
|
<TeamLiveReportContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
import format from 'date-fns/format';
|
|
||||||
import getDay from 'date-fns/getDay';
|
|
||||||
|
|
||||||
import { getQuantileIntervals } from '@chatwoot/utils';
|
|
||||||
|
|
||||||
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
heatmapData: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
numberOfRows: {
|
|
||||||
type: Number,
|
|
||||||
default: 7,
|
|
||||||
},
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { t } = useI18n();
|
|
||||||
const processedData = computed(() => {
|
|
||||||
return groupHeatmapByDay(props.heatmapData);
|
|
||||||
});
|
|
||||||
|
|
||||||
const quantileRange = computed(() => {
|
|
||||||
const flattendedData = props.heatmapData.map(data => data.value);
|
|
||||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
|
||||||
});
|
|
||||||
|
|
||||||
function getCountTooltip(value) {
|
|
||||||
if (!value) {
|
|
||||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === 1) {
|
|
||||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
|
||||||
count: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
|
||||||
count: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
return format(new Date(dateString), 'MMM d, yyyy');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDayOfTheWeek(date) {
|
|
||||||
const dayIndex = getDay(date);
|
|
||||||
const days = [
|
|
||||||
t('DAYS_OF_WEEK.SUNDAY'),
|
|
||||||
t('DAYS_OF_WEEK.MONDAY'),
|
|
||||||
t('DAYS_OF_WEEK.TUESDAY'),
|
|
||||||
t('DAYS_OF_WEEK.WEDNESDAY'),
|
|
||||||
t('DAYS_OF_WEEK.THURSDAY'),
|
|
||||||
t('DAYS_OF_WEEK.FRIDAY'),
|
|
||||||
t('DAYS_OF_WEEK.SATURDAY'),
|
|
||||||
];
|
|
||||||
return days[dayIndex];
|
|
||||||
}
|
|
||||||
function getHeatmapLevelClass(value) {
|
|
||||||
if (!value) return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50';
|
|
||||||
|
|
||||||
let level = [...quantileRange.value, Infinity].findIndex(
|
|
||||||
range => value <= range && value > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (level > 6) level = 5;
|
|
||||||
|
|
||||||
if (level === 0) {
|
|
||||||
return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50';
|
|
||||||
}
|
|
||||||
|
|
||||||
const classes = [
|
|
||||||
'bg-n-blue-3 dark:outline-n-blue-4',
|
|
||||||
'bg-n-blue-5 dark:outline-n-blue-6',
|
|
||||||
'bg-n-blue-7 dark:outline-n-blue-8',
|
|
||||||
'bg-n-blue-8 dark:outline-n-blue-9',
|
|
||||||
'bg-n-blue-10 dark:outline-n-blue-8',
|
|
||||||
'bg-n-blue-11 dark:outline-n-blue-10',
|
|
||||||
];
|
|
||||||
|
|
||||||
return classes[level - 1];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
|
|
||||||
>
|
|
||||||
<template v-if="isLoading">
|
|
||||||
<div class="grid gap-[5px] flex-shrink-0">
|
|
||||||
<div
|
|
||||||
v-for="ii in numberOfRows"
|
|
||||||
:key="ii"
|
|
||||||
class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
|
||||||
<div
|
|
||||||
v-for="ii in numberOfRows"
|
|
||||||
:key="ii"
|
|
||||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="jj in 24"
|
|
||||||
:key="jj"
|
|
||||||
class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div />
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="ii in 24"
|
|
||||||
:key="ii"
|
|
||||||
class="flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{{ ii - 1 }} – {{ ii }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="grid gap-[5px] flex-shrink-0">
|
|
||||||
<div
|
|
||||||
v-for="dateKey in processedData.keys()"
|
|
||||||
:key="dateKey"
|
|
||||||
class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center"
|
|
||||||
>
|
|
||||||
{{ getDayOfTheWeek(new Date(dateKey)) }}
|
|
||||||
<time class="font-normal text-n-slate-11">
|
|
||||||
{{ formatDate(dateKey) }}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
|
||||||
<div
|
|
||||||
v-for="dateKey in processedData.keys()"
|
|
||||||
:key="dateKey"
|
|
||||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="data in processedData.get(dateKey)"
|
|
||||||
:key="data.timestamp"
|
|
||||||
v-tooltip.top="getCountTooltip(data.value)"
|
|
||||||
class="h-8 rounded-sm shadow-inner dark:outline dark:outline-1"
|
|
||||||
:class="getHeatmapLevelClass(data.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div />
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="ii in 24"
|
|
||||||
:key="ii"
|
|
||||||
class="flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{{ ii - 1 }} – {{ ii }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { onMounted, ref, computed } from 'vue';
|
|
||||||
import { useToggle } from '@vueuse/core';
|
|
||||||
import MetricCard from './overview/MetricCard.vue';
|
|
||||||
import ReportHeatmap from './Heatmap.vue';
|
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
|
||||||
import endOfDay from 'date-fns/endOfDay';
|
|
||||||
import getUnixTime from 'date-fns/getUnixTime';
|
|
||||||
import startOfDay from 'date-fns/startOfDay';
|
|
||||||
import subDays from 'date-fns/subDays';
|
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const uiFlags = useMapGetter('getOverviewUIFlags');
|
|
||||||
const accountConversationHeatmap = useMapGetter(
|
|
||||||
'getAccountConversationHeatmapData'
|
|
||||||
);
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{
|
|
||||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
|
||||||
value: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
|
||||||
value: 29,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const selectedDays = ref(6);
|
|
||||||
|
|
||||||
const selectedDayFilter = computed(() =>
|
|
||||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadHeatmapData = () => {
|
|
||||||
const to = endOfDay(new Date());
|
|
||||||
store.dispatch('downloadAccountConversationHeatmap', {
|
|
||||||
daysBefore: selectedDays.value,
|
|
||||||
to: getUnixTime(to),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const [showDropdown, toggleDropdown] = useToggle();
|
|
||||||
const fetchHeatmapData = () => {
|
|
||||||
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let to = endOfDay(new Date());
|
|
||||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
|
||||||
|
|
||||||
store.dispatch('fetchAccountConversationHeatmap', {
|
|
||||||
metric: 'conversations_count',
|
|
||||||
from: getUnixTime(from),
|
|
||||||
to: getUnixTime(to),
|
|
||||||
groupBy: 'hour',
|
|
||||||
businessHours: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAction = ({ value }) => {
|
|
||||||
toggleDropdown(false);
|
|
||||||
selectedDays.value = value;
|
|
||||||
fetchHeatmapData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchHeatmapData();
|
|
||||||
startRefetching();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-row flex-wrap max-w-full">
|
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
|
||||||
<template #control>
|
|
||||||
<div
|
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
|
||||||
class="relative flex items-center group"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
sm
|
|
||||||
slate
|
|
||||||
faded
|
|
||||||
:label="selectedDayFilter.label"
|
|
||||||
class="rounded-md group-hover:bg-n-alpha-2"
|
|
||||||
@click="toggleDropdown()"
|
|
||||||
/>
|
|
||||||
<DropdownMenu
|
|
||||||
v-if="showDropdown"
|
|
||||||
:menu-items="menuItems"
|
|
||||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
|
||||||
@action="handleAction($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
sm
|
|
||||||
slate
|
|
||||||
faded
|
|
||||||
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
|
||||||
class="rounded-md group-hover:bg-n-alpha-2"
|
|
||||||
@click="downloadHeatmapData"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<ReportHeatmap
|
|
||||||
:heatmap-data="accountConversationHeatmap"
|
|
||||||
:number-of-rows="selectedDays + 1"
|
|
||||||
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
|
||||||
/>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMemoize } from '@vueuse/core';
|
||||||
|
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
import getDay from 'date-fns/getDay';
|
||||||
|
|
||||||
|
import { getQuantileIntervals } from '@chatwoot/utils';
|
||||||
|
|
||||||
|
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useHeatmapTooltip } from './composables/useHeatmapTooltip';
|
||||||
|
import HeatmapTooltip from './HeatmapTooltip.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
heatmapData: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
numberOfRows: {
|
||||||
|
type: Number,
|
||||||
|
default: 7,
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
colorScheme: {
|
||||||
|
type: String,
|
||||||
|
default: 'blue',
|
||||||
|
validator: value => ['blue', 'green'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dataRows = computed(() => {
|
||||||
|
const groupedData = groupHeatmapByDay(props.heatmapData);
|
||||||
|
return Array.from(groupedData.keys()).map(dateKey => {
|
||||||
|
const rowData = groupedData.get(dateKey);
|
||||||
|
return {
|
||||||
|
dateKey,
|
||||||
|
data: rowData,
|
||||||
|
dataHash: rowData.map(d => d.value).join(','),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const quantileRange = computed(() => {
|
||||||
|
const flattendedData = props.heatmapData.map(data => data.value);
|
||||||
|
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
return format(new Date(dateString), 'MMM d, yyyy');
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
t('DAYS_OF_WEEK.SUNDAY'),
|
||||||
|
t('DAYS_OF_WEEK.MONDAY'),
|
||||||
|
t('DAYS_OF_WEEK.TUESDAY'),
|
||||||
|
t('DAYS_OF_WEEK.WEDNESDAY'),
|
||||||
|
t('DAYS_OF_WEEK.THURSDAY'),
|
||||||
|
t('DAYS_OF_WEEK.FRIDAY'),
|
||||||
|
t('DAYS_OF_WEEK.SATURDAY'),
|
||||||
|
];
|
||||||
|
|
||||||
|
function getDayOfTheWeek(date) {
|
||||||
|
const dayIndex = getDay(date);
|
||||||
|
|
||||||
|
return DAYS_OF_WEEK[dayIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_SCHEMES = {
|
||||||
|
blue: [
|
||||||
|
'bg-n-blue-3 border border-n-blue-4/30',
|
||||||
|
'bg-n-blue-5 border border-n-blue-6/30',
|
||||||
|
'bg-n-blue-7 border border-n-blue-8/30',
|
||||||
|
'bg-n-blue-8 border border-n-blue-9/30',
|
||||||
|
'bg-n-blue-10 border border-n-blue-8/30',
|
||||||
|
'bg-n-blue-11 border border-n-blue-10/30',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'bg-n-teal-3 border border-n-teal-4/30',
|
||||||
|
'bg-n-teal-5 border border-n-teal-6/30',
|
||||||
|
'bg-n-teal-7 border border-n-teal-8/30',
|
||||||
|
'bg-n-teal-8 border border-n-teal-9/30',
|
||||||
|
'bg-n-teal-10 border border-n-teal-8/30',
|
||||||
|
'bg-n-teal-11 border border-n-teal-10/30',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoized function to calculate CSS class for heatmap cell intensity levels
|
||||||
|
const getHeatmapLevelClass = useMemoize(
|
||||||
|
(value, quantileRangeArray, colorScheme) => {
|
||||||
|
if (!value)
|
||||||
|
return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30';
|
||||||
|
let level = [...quantileRangeArray, Infinity].findIndex(
|
||||||
|
range => value <= range && value > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (level > 6) level = 5;
|
||||||
|
|
||||||
|
if (level === 0) {
|
||||||
|
return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
return COLOR_SCHEMES[colorScheme][level - 1];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function getHeatmapClass(value) {
|
||||||
|
return getHeatmapLevelClass(value, quantileRange.value, props.colorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip composable
|
||||||
|
const tooltip = useHeatmapTooltip();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
|
||||||
|
>
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
|
<div
|
||||||
|
v-for="ii in numberOfRows"
|
||||||
|
:key="ii"
|
||||||
|
class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||||
|
<div
|
||||||
|
v-for="ii in numberOfRows"
|
||||||
|
:key="ii"
|
||||||
|
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="jj in 24"
|
||||||
|
:key="jj"
|
||||||
|
class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="ii in 24"
|
||||||
|
:key="ii"
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ ii - 1 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
|
<div
|
||||||
|
v-for="row in dataRows"
|
||||||
|
:key="row.dateKey"
|
||||||
|
v-memo="[row.dateKey]"
|
||||||
|
class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center"
|
||||||
|
>
|
||||||
|
{{ getDayOfTheWeek(new Date(row.dateKey)) }}
|
||||||
|
<time class="font-normal text-n-slate-11">
|
||||||
|
{{ formatDate(row.dateKey) }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid gap-[5px] w-full min-w-[700px]"
|
||||||
|
style="content-visibility: auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="row in dataRows"
|
||||||
|
:key="row.dateKey"
|
||||||
|
v-memo="[row.dataHash, colorScheme]"
|
||||||
|
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||||
|
style="content-visibility: auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="data in row.data"
|
||||||
|
:key="data.timestamp"
|
||||||
|
class="h-8 rounded-sm cursor-pointer"
|
||||||
|
:class="getHeatmapClass(data.value)"
|
||||||
|
@mouseenter="tooltip.show($event, data.value)"
|
||||||
|
@mouseleave="tooltip.hide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="ii in 24"
|
||||||
|
:key="ii"
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ ii - 1 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<HeatmapTooltip
|
||||||
|
:visible="tooltip.visible.value"
|
||||||
|
:x="tooltip.x.value"
|
||||||
|
:y="tooltip.y.value"
|
||||||
|
:value="tooltip.value.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import MetricCard from '../overview/MetricCard.vue';
|
||||||
|
import BaseHeatmap from './BaseHeatmap.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import subDays from 'date-fns/subDays';
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { downloadCsvFile } from 'dashboard/helper/downloadHelper';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
metric: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
downloadTitle: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
storeGetter: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
storeAction: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
downloadAction: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
uiFlagKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
colorScheme: {
|
||||||
|
type: String,
|
||||||
|
default: 'blue',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const heatmapData = useMapGetter(props.storeGetter);
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||||
|
value: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
|
||||||
|
value: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||||
|
value: 29,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedDays = ref(6);
|
||||||
|
const selectedInbox = ref(null);
|
||||||
|
|
||||||
|
const selectedDayFilter = computed(() =>
|
||||||
|
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const inboxMenuItems = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('INBOX_REPORTS.ALL_INBOXES'),
|
||||||
|
value: null,
|
||||||
|
action: 'select_inbox',
|
||||||
|
},
|
||||||
|
...inboxes.value.map(inbox => ({
|
||||||
|
label: inbox.name,
|
||||||
|
value: inbox.id,
|
||||||
|
action: 'select_inbox',
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedInboxFilter = computed(() => {
|
||||||
|
if (!selectedInbox.value) {
|
||||||
|
return { label: t('INBOX_REPORTS.ALL_INBOXES') };
|
||||||
|
}
|
||||||
|
return inboxMenuItems.value.find(
|
||||||
|
item => item.value === selectedInbox.value.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
|
||||||
|
|
||||||
|
const downloadHeatmapData = () => {
|
||||||
|
const to = endOfDay(new Date());
|
||||||
|
|
||||||
|
// If no inbox is selected and download action exists, use backend endpoint
|
||||||
|
if (!selectedInbox.value && props.downloadAction) {
|
||||||
|
store.dispatch(props.downloadAction, {
|
||||||
|
daysBefore: selectedDays.value,
|
||||||
|
to: getUnixTime(to),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate CSV from store data
|
||||||
|
if (!heatmapData.value || heatmapData.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CSV headers
|
||||||
|
const headers = ['Date', 'Hour', props.title];
|
||||||
|
const rows = [headers];
|
||||||
|
|
||||||
|
// Convert heatmap data to rows
|
||||||
|
heatmapData.value.forEach(item => {
|
||||||
|
const date = new Date(item.timestamp * 1000);
|
||||||
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||||||
|
const hour = date.getHours();
|
||||||
|
rows.push([dateStr, `${hour}:00 - ${hour + 1}:00`, item.value]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to CSV string
|
||||||
|
const csvContent = rows.map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const inboxName = selectedInbox.value
|
||||||
|
? `_${selectedInbox.value.name.replace(/[^a-z0-9]/gi, '_')}`
|
||||||
|
: '';
|
||||||
|
const fileName = `${props.downloadTitle}${inboxName}_${format(
|
||||||
|
new Date(),
|
||||||
|
'dd-MM-yyyy'
|
||||||
|
)}.csv`;
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
downloadCsvFile(fileName, csvContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
|
||||||
|
|
||||||
|
const fetchHeatmapData = () => {
|
||||||
|
if (isLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = endOfDay(new Date());
|
||||||
|
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
metric: props.metric,
|
||||||
|
from: getUnixTime(from),
|
||||||
|
to: getUnixTime(to),
|
||||||
|
groupBy: 'hour',
|
||||||
|
businessHours: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add inbox filtering if an inbox is selected
|
||||||
|
if (selectedInbox.value) {
|
||||||
|
params.type = 'inbox';
|
||||||
|
params.id = selectedInbox.value.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch(props.storeAction, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = ({ value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
selectedDays.value = value;
|
||||||
|
fetchHeatmapData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxAction = ({ value }) => {
|
||||||
|
toggleInboxDropdown(false);
|
||||||
|
selectedInbox.value = value
|
||||||
|
? inboxes.value.find(inbox => inbox.id === value)
|
||||||
|
: null;
|
||||||
|
fetchHeatmapData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('inboxes/get');
|
||||||
|
fetchHeatmapData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="title">
|
||||||
|
<template #control>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedDayFilter.label"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="menuItems"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleInboxDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedInboxFilter.label"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2 max-w-[200px]"
|
||||||
|
@click="toggleInboxDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showInboxDropdown"
|
||||||
|
:menu-items="inboxMenuItems"
|
||||||
|
show-search
|
||||||
|
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]"
|
||||||
|
@action="handleInboxAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="downloadHeatmapData"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<BaseHeatmap
|
||||||
|
:heatmap-data="heatmapData"
|
||||||
|
:number-of-rows="selectedDays + 1"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:color-scheme="colorScheme"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
import BaseHeatmapContainer from './BaseHeatmapContainer.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseHeatmapContainer
|
||||||
|
metric="conversations_count"
|
||||||
|
:title="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')"
|
||||||
|
download-title="conversation_heatmap"
|
||||||
|
store-getter="getAccountConversationHeatmapData"
|
||||||
|
store-action="fetchAccountConversationHeatmap"
|
||||||
|
download-action="downloadAccountConversationHeatmap"
|
||||||
|
ui-flag-key="isFetchingAccountConversationsHeatmap"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const tooltipText = computed(() => {
|
||||||
|
if (!props.value) {
|
||||||
|
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.value === 1) {
|
||||||
|
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
||||||
|
count: props.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
||||||
|
count: props.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed z-50 px-2 py-1 text-xs font-medium text-n-slate-6 bg-n-slate-12 rounded shadow-lg pointer-events-none transition-[opacity,transform] duration-75"
|
||||||
|
:class="{ 'opacity-100': visible, 'opacity-0': !visible }"
|
||||||
|
:style="{
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y - 15}px`,
|
||||||
|
transform: 'translateX(-50%) translateZ(0)',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ tooltipText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
import BaseHeatmapContainer from './BaseHeatmapContainer.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseHeatmapContainer
|
||||||
|
metric="resolutions_count"
|
||||||
|
:title="t('OVERVIEW_REPORTS.RESOLUTION_HEATMAP.HEADER')"
|
||||||
|
download-title="resolution_heatmap"
|
||||||
|
store-getter="getAccountResolutionHeatmapData"
|
||||||
|
store-action="fetchAccountResolutionHeatmap"
|
||||||
|
ui-flag-key="isFetchingAccountResolutionsHeatmap"
|
||||||
|
color-scheme="green"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export function useHeatmapTooltip() {
|
||||||
|
const visible = ref(false);
|
||||||
|
const x = ref(0);
|
||||||
|
const y = ref(0);
|
||||||
|
const value = ref(null);
|
||||||
|
|
||||||
|
let timeoutId = null;
|
||||||
|
|
||||||
|
const show = (event, cellValue) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Update position immediately for smooth movement
|
||||||
|
const rect = event.target.getBoundingClientRect();
|
||||||
|
x.value = rect.left + rect.width / 2;
|
||||||
|
y.value = rect.top;
|
||||||
|
|
||||||
|
// Only delay content update and visibility
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
value.value = cellValue;
|
||||||
|
visible.value = true;
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
visible.value = false;
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { visible, x, y, value, show, hide };
|
||||||
|
}
|
||||||
@@ -26,12 +26,12 @@ const fetchMetaData = async (commit, params) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
|
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
|
||||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000);
|
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
|
||||||
const superLongDebouncedFetchMetaData = debounce(
|
const superLongDebouncedFetchMetaData = debounce(
|
||||||
fetchMetaData,
|
fetchMetaData,
|
||||||
1500,
|
10000,
|
||||||
false,
|
false,
|
||||||
10000
|
20000
|
||||||
);
|
);
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ const state = {
|
|||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetchingAccountConversationMetric: false,
|
isFetchingAccountConversationMetric: false,
|
||||||
isFetchingAccountConversationsHeatmap: false,
|
isFetchingAccountConversationsHeatmap: false,
|
||||||
|
isFetchingAccountResolutionsHeatmap: false,
|
||||||
isFetchingAgentConversationMetric: false,
|
isFetchingAgentConversationMetric: false,
|
||||||
isFetchingTeamConversationMetric: false,
|
isFetchingTeamConversationMetric: false,
|
||||||
},
|
},
|
||||||
accountConversationMetric: {},
|
accountConversationMetric: {},
|
||||||
accountConversationHeatmap: [],
|
accountConversationHeatmap: [],
|
||||||
|
accountResolutionHeatmap: [],
|
||||||
agentConversationMetric: [],
|
agentConversationMetric: [],
|
||||||
teamConversationMetric: [],
|
teamConversationMetric: [],
|
||||||
},
|
},
|
||||||
@@ -89,6 +91,9 @@ const getters = {
|
|||||||
getAccountConversationHeatmapData(_state) {
|
getAccountConversationHeatmapData(_state) {
|
||||||
return _state.overview.accountConversationHeatmap;
|
return _state.overview.accountConversationHeatmap;
|
||||||
},
|
},
|
||||||
|
getAccountResolutionHeatmapData(_state) {
|
||||||
|
return _state.overview.accountResolutionHeatmap;
|
||||||
|
},
|
||||||
getAgentConversationMetric(_state) {
|
getAgentConversationMetric(_state) {
|
||||||
return _state.overview.agentConversationMetric;
|
return _state.overview.agentConversationMetric;
|
||||||
},
|
},
|
||||||
@@ -130,6 +135,16 @@ export const actions = {
|
|||||||
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
fetchAccountResolutionHeatmap({ commit }, reportObj) {
|
||||||
|
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true);
|
||||||
|
Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => {
|
||||||
|
let { data } = heatmapData;
|
||||||
|
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
||||||
|
|
||||||
|
commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data);
|
||||||
|
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false);
|
||||||
|
});
|
||||||
|
},
|
||||||
fetchAccountSummary({ commit }, reportObj) {
|
fetchAccountSummary({ commit }, reportObj) {
|
||||||
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
|
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
|
||||||
Report.getSummary(
|
Report.getSummary(
|
||||||
@@ -287,6 +302,9 @@ const mutations = {
|
|||||||
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
|
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
|
||||||
_state.overview.accountConversationHeatmap = heatmapData;
|
_state.overview.accountConversationHeatmap = heatmapData;
|
||||||
},
|
},
|
||||||
|
[types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) {
|
||||||
|
_state.overview.accountResolutionHeatmap = heatmapData;
|
||||||
|
},
|
||||||
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
|
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
|
||||||
_state.accountReport.isFetching[metric] = value;
|
_state.accountReport.isFetching[metric] = value;
|
||||||
},
|
},
|
||||||
@@ -299,6 +317,9 @@ const mutations = {
|
|||||||
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
|
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
|
||||||
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
|
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
|
||||||
},
|
},
|
||||||
|
[types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) {
|
||||||
|
_state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag;
|
||||||
|
},
|
||||||
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
||||||
_state.accountSummary = summaryData;
|
_state.accountSummary = summaryData;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ export default {
|
|||||||
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
||||||
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
|
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
|
||||||
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
|
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
|
||||||
|
SET_RESOLUTION_HEATMAP_DATA: 'SET_RESOLUTION_HEATMAP_DATA',
|
||||||
|
TOGGLE_RESOLUTION_HEATMAP_LOADING: 'TOGGLE_RESOLUTION_HEATMAP_LOADING',
|
||||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||||
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
||||||
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
class AgentBots::WebhookJob < WebhookJob
|
class AgentBots::WebhookJob < WebhookJob
|
||||||
queue_as :high
|
queue_as :high
|
||||||
|
|
||||||
|
def perform(url, payload, webhook_type = :agent_bot_webhook)
|
||||||
|
super(url, payload, webhook_type)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord
|
|||||||
|
|
||||||
AUTHORIZATION_ERROR_THRESHOLD = 10
|
AUTHORIZATION_ERROR_THRESHOLD = 10
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
if Chatwoot.encryption_configured?
|
||||||
|
encrypts :imap_password
|
||||||
|
encrypts :smtp_password
|
||||||
|
end
|
||||||
|
|
||||||
self.table_name = 'channel_email'
|
self.table_name = 'channel_email'
|
||||||
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
|
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
|
||||||
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
|
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord
|
|||||||
include Channelable
|
include Channelable
|
||||||
include Reauthorizable
|
include Reauthorizable
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
if Chatwoot.encryption_configured?
|
||||||
|
encrypts :page_access_token
|
||||||
|
encrypts :user_access_token
|
||||||
|
end
|
||||||
|
|
||||||
self.table_name = 'channel_facebook_pages'
|
self.table_name = 'channel_facebook_pages'
|
||||||
|
|
||||||
validates :page_id, uniqueness: { scope: :account_id }
|
validates :page_id, uniqueness: { scope: :account_id }
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord
|
|||||||
include Reauthorizable
|
include Reauthorizable
|
||||||
self.table_name = 'channel_instagram'
|
self.table_name = 'channel_instagram'
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
encrypts :access_token if Chatwoot.encryption_configured?
|
||||||
|
|
||||||
AUTHORIZATION_ERROR_THRESHOLD = 1
|
AUTHORIZATION_ERROR_THRESHOLD = 1
|
||||||
|
|
||||||
validates :access_token, presence: true
|
validates :access_token, presence: true
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
class Channel::Line < ApplicationRecord
|
class Channel::Line < ApplicationRecord
|
||||||
include Channelable
|
include Channelable
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
if Chatwoot.encryption_configured?
|
||||||
|
encrypts :line_channel_secret
|
||||||
|
encrypts :line_channel_token
|
||||||
|
end
|
||||||
|
|
||||||
self.table_name = 'channel_line'
|
self.table_name = 'channel_line'
|
||||||
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
|
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
class Channel::Telegram < ApplicationRecord
|
class Channel::Telegram < ApplicationRecord
|
||||||
include Channelable
|
include Channelable
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured?
|
||||||
|
|
||||||
self.table_name = 'channel_telegram'
|
self.table_name = 'channel_telegram'
|
||||||
EDITABLE_ATTRS = [:bot_token].freeze
|
EDITABLE_ATTRS = [:bot_token].freeze
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord
|
|||||||
|
|
||||||
self.table_name = 'channel_twilio_sms'
|
self.table_name = 'channel_twilio_sms'
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
encrypts :auth_token if Chatwoot.encryption_configured?
|
||||||
|
|
||||||
validates :account_sid, presence: true
|
validates :account_sid, presence: true
|
||||||
# The same parameter is used to store api_key_secret if api_key authentication is opted
|
# The same parameter is used to store api_key_secret if api_key authentication is opted
|
||||||
validates :auth_token, presence: true
|
validates :auth_token, presence: true
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
class Channel::TwitterProfile < ApplicationRecord
|
class Channel::TwitterProfile < ApplicationRecord
|
||||||
include Channelable
|
include Channelable
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
if Chatwoot.encryption_configured?
|
||||||
|
encrypts :twitter_access_token
|
||||||
|
encrypts :twitter_access_token_secret
|
||||||
|
end
|
||||||
|
|
||||||
self.table_name = 'channel_twitter_profiles'
|
self.table_name = 'channel_twitter_profiles'
|
||||||
|
|
||||||
validates :profile_id, uniqueness: { scope: :account_id }
|
validates :profile_id, uniqueness: { scope: :account_id }
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord
|
|||||||
before_validation :ensure_hook_type
|
before_validation :ensure_hook_type
|
||||||
after_create :trigger_setup_if_crm
|
after_create :trigger_setup_if_crm
|
||||||
|
|
||||||
|
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||||
|
encrypts :access_token, deterministic: true if Chatwoot.encryption_configured?
|
||||||
|
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
validates :app_id, presence: true
|
validates :app_id, presence: true
|
||||||
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
|
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ module Whatsapp::IncomingMessageServiceHelpers
|
|||||||
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def argentina_phone_number?(phone_number)
|
||||||
|
phone_number.match(/^54/)
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalised_argentina_mobil_number(phone_number)
|
||||||
|
# Remove 9 before country code
|
||||||
|
phone_number.sub(/^549/, '54')
|
||||||
|
end
|
||||||
|
|
||||||
def processed_waid(waid)
|
def processed_waid(waid)
|
||||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Handles Argentina phone number normalization
|
||||||
|
#
|
||||||
|
# Argentina phone numbers can appear with or without "9" after country code
|
||||||
|
# This normalizer removes the "9" when present to create consistent format: 54 + area + number
|
||||||
|
class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
|
||||||
|
def normalize(waid)
|
||||||
|
return waid unless handles_country?(waid)
|
||||||
|
|
||||||
|
# Remove "9" after country code if present (549 → 54)
|
||||||
|
waid.sub(/^549/, '54')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def country_code_pattern
|
||||||
|
/^54/
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Service to handle phone number normalization for WhatsApp messages
|
# Service to handle phone number normalization for WhatsApp messages
|
||||||
# Currently supports Brazil phone number format variations
|
# Currently supports Brazil and Argentina phone number format variations
|
||||||
# Designed to be extensible for additional countries in future PRs
|
# Designed to be extensible for additional countries in future PRs
|
||||||
#
|
#
|
||||||
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
||||||
@@ -34,6 +34,7 @@ class Whatsapp::PhoneNumberNormalizationService
|
|||||||
end
|
end
|
||||||
|
|
||||||
NORMALIZERS = [
|
NORMALIZERS = [
|
||||||
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer
|
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
|
||||||
|
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
|
||||||
].freeze
|
].freeze
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ module Chatwoot
|
|||||||
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
|
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
|
||||||
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
|
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
|
||||||
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
|
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
|
||||||
|
# TODO: Remove once encryption is mandatory and legacy plaintext is migrated.
|
||||||
config.active_record.encryption.support_unencrypted_data = true
|
config.active_record.encryption.support_unencrypted_data = true
|
||||||
|
# Extend deterministic queries so they match both encrypted and plaintext rows
|
||||||
|
config.active_record.encryption.extend_queries = true
|
||||||
|
# Store a per-row key reference to support future key rotation
|
||||||
config.active_record.encryption.store_key_references = true
|
config.active_record.encryption.store_key_references = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -94,6 +98,8 @@ module Chatwoot
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.encryption_configured?
|
def self.encryption_configured?
|
||||||
|
# TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out),
|
||||||
|
# remove this guard and assume encryption is always enabled.
|
||||||
# Check if proper encryption keys are configured
|
# Check if proper encryption keys are configured
|
||||||
# MFA/2FA features should only be enabled when proper keys are set
|
# MFA/2FA features should only be enabled when proper keys are set
|
||||||
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
|
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ en:
|
|||||||
captain:
|
captain:
|
||||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||||
open: 'Conversation was marked open by %{user_name}'
|
open: 'Conversation was marked open by %{user_name}'
|
||||||
|
agent_bot:
|
||||||
|
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||||
status:
|
status:
|
||||||
resolved: 'Conversation was marked resolved by %{user_name}'
|
resolved: 'Conversation was marked resolved by %{user_name}'
|
||||||
contact_resolved: 'Conversation was resolved by %{contact_name}'
|
contact_resolved: 'Conversation was resolved by %{contact_name}'
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
- purgable
|
- purgable
|
||||||
- housekeeping
|
- housekeeping
|
||||||
- async_database_migration
|
- async_database_migration
|
||||||
|
- bulk_reindex_low
|
||||||
- active_storage_analysis
|
- active_storage_analysis
|
||||||
- active_storage_purge
|
- active_storage_purge
|
||||||
- action_mailbox_incineration
|
- action_mailbox_incineration
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator
|
|||||||
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
|
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/MethodLength
|
||||||
def create_conversation(created_at:)
|
def create_conversation(created_at:)
|
||||||
conversation = nil
|
conversation = nil
|
||||||
|
should_resolve = false
|
||||||
|
resolution_time = nil
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
travel_to(created_at) do
|
travel_to(created_at) do
|
||||||
@@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator
|
|||||||
|
|
||||||
add_labels_to_conversation(conversation)
|
add_labels_to_conversation(conversation)
|
||||||
create_messages_for_conversation(conversation)
|
create_messages_for_conversation(conversation)
|
||||||
resolve_conversation_if_needed(conversation)
|
|
||||||
|
# Determine if should resolve but don't update yet
|
||||||
|
should_resolve = rand > 0.3
|
||||||
|
if should_resolve
|
||||||
|
resolution_delay = rand((30.minutes)..(24.hours))
|
||||||
|
resolution_time = created_at + resolution_delay
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
travel_back
|
travel_back
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Now resolve outside of time travel if needed
|
||||||
|
if should_resolve && resolution_time
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
conversation.update_column(:status, :resolved)
|
||||||
|
conversation.update_column(:updated_at, resolution_time)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
# Trigger the event with proper timestamp
|
||||||
|
travel_to(resolution_time) do
|
||||||
|
trigger_conversation_resolved_event(conversation)
|
||||||
|
end
|
||||||
|
travel_back
|
||||||
|
end
|
||||||
|
|
||||||
conversation
|
conversation
|
||||||
end
|
end
|
||||||
|
# rubocop:enable Metrics/MethodLength
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator
|
|||||||
message_creator.create_messages
|
message_creator.create_messages
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_conversation_if_needed(conversation)
|
|
||||||
return unless rand < 0.7
|
|
||||||
|
|
||||||
resolution_delay = rand((30.minutes)..(24.hours))
|
|
||||||
travel(resolution_delay)
|
|
||||||
conversation.update!(status: :resolved)
|
|
||||||
|
|
||||||
trigger_conversation_resolved_event(conversation)
|
|
||||||
end
|
|
||||||
|
|
||||||
def trigger_conversation_resolved_event(conversation)
|
def trigger_conversation_resolved_event(conversation)
|
||||||
event_data = { conversation: conversation }
|
event_data = { conversation: conversation }
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,33 @@ class Webhooks::Trigger
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_error(error)
|
def handle_error(error)
|
||||||
return unless should_handle_error?
|
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
||||||
return unless message
|
return unless message
|
||||||
|
|
||||||
update_message_status(error)
|
case @webhook_type
|
||||||
|
when :agent_bot_webhook
|
||||||
|
conversation = message.conversation
|
||||||
|
return unless conversation&.pending?
|
||||||
|
|
||||||
|
conversation.open!
|
||||||
|
create_agent_bot_error_activity(conversation)
|
||||||
|
when :api_inbox_webhook
|
||||||
|
update_message_status(error)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_handle_error?
|
def create_agent_bot_error_activity(conversation)
|
||||||
@webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
|
||||||
|
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
|
||||||
|
end
|
||||||
|
|
||||||
|
def activity_message_params(conversation, content)
|
||||||
|
{
|
||||||
|
account_id: conversation.account_id,
|
||||||
|
inbox_id: conversation.inbox_id,
|
||||||
|
message_type: :activity,
|
||||||
|
content: content
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_message_status(error)
|
def update_message_status(error)
|
||||||
|
|||||||
58
script/bulk_reindex_messages.rb
Normal file
58
script/bulk_reindex_messages.rb
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Bulk reindex all messages with throttling to prevent DB overload
|
||||||
|
# This creates jobs slowly to avoid overwhelming the database connection pool
|
||||||
|
# Usage: RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb
|
||||||
|
|
||||||
|
JOBS_PER_MINUTE = 50 # Adjust based on your DB capacity
|
||||||
|
BATCH_SIZE = 1000 # Messages per job
|
||||||
|
|
||||||
|
batch_count = 0
|
||||||
|
total_batches = (Message.count / BATCH_SIZE.to_f).ceil
|
||||||
|
start_time = Time.zone.now
|
||||||
|
|
||||||
|
index_name = Message.searchkick_index.name
|
||||||
|
|
||||||
|
puts '=' * 80
|
||||||
|
puts "Bulk Reindex Started at #{start_time}"
|
||||||
|
puts '=' * 80
|
||||||
|
puts "Total messages: #{Message.count}"
|
||||||
|
puts "Batch size: #{BATCH_SIZE}"
|
||||||
|
puts "Total batches: #{total_batches}"
|
||||||
|
puts "Index name: #{index_name}"
|
||||||
|
puts "Rate: #{JOBS_PER_MINUTE} jobs/minute (#{JOBS_PER_MINUTE * BATCH_SIZE} messages/minute)"
|
||||||
|
puts "Estimated time: #{(total_batches / JOBS_PER_MINUTE.to_f / 60).round(2)} hours"
|
||||||
|
puts '=' * 80
|
||||||
|
puts ''
|
||||||
|
|
||||||
|
sleep(15)
|
||||||
|
|
||||||
|
Message.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, index|
|
||||||
|
batch_count += 1
|
||||||
|
|
||||||
|
# Enqueue to low priority queue with proper format
|
||||||
|
Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
|
||||||
|
class_name: 'Message',
|
||||||
|
index_name: index_name,
|
||||||
|
batch_id: index,
|
||||||
|
record_ids: batch.map(&:id) # Keep as integers like Message.reindex does
|
||||||
|
)
|
||||||
|
|
||||||
|
# Throttle: wait after every N jobs
|
||||||
|
if (batch_count % JOBS_PER_MINUTE).zero?
|
||||||
|
elapsed = Time.zone.now - start_time
|
||||||
|
progress = (batch_count.to_f / total_batches * 100).round(2)
|
||||||
|
queue_size = Sidekiq::Queue.new('bulk_reindex_low').size
|
||||||
|
|
||||||
|
puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}] Progress: #{batch_count}/#{total_batches} (#{progress}%)"
|
||||||
|
puts " Queue size: #{queue_size}"
|
||||||
|
puts " Elapsed: #{(elapsed / 3600).round(2)} hours"
|
||||||
|
puts " ETA: #{((elapsed / batch_count * (total_batches - batch_count)) / 3600).round(2)} hours remaining"
|
||||||
|
puts ''
|
||||||
|
|
||||||
|
sleep(60)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
puts '=' * 80
|
||||||
|
puts "Done! Created #{batch_count} jobs"
|
||||||
|
puts "Total time: #{((Time.zone.now - start_time) / 3600).round(2)} hours"
|
||||||
|
puts '=' * 80
|
||||||
19
script/monitor_reindex.rb
Normal file
19
script/monitor_reindex.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Monitor bulk reindex progress
|
||||||
|
# RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
|
||||||
|
|
||||||
|
puts 'Monitoring bulk reindex progress (Ctrl+C to stop)...'
|
||||||
|
puts ''
|
||||||
|
|
||||||
|
loop do
|
||||||
|
bulk_queue = Sidekiq::Queue.new('bulk_reindex_low')
|
||||||
|
prod_queue = Sidekiq::Queue.new('async_database_migration')
|
||||||
|
retry_set = Sidekiq::RetrySet.new
|
||||||
|
|
||||||
|
puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}]"
|
||||||
|
puts " Bulk Reindex Queue: #{bulk_queue.size} jobs"
|
||||||
|
puts " Production Queue: #{prod_queue.size} jobs"
|
||||||
|
puts " Retry Queue: #{retry_set.size} jobs"
|
||||||
|
puts " #{('-' * 60)}"
|
||||||
|
|
||||||
|
sleep(30)
|
||||||
|
end
|
||||||
58
script/reindex_single_account.rb
Normal file
58
script/reindex_single_account.rb
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Reindex messages for a single account
|
||||||
|
# Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]
|
||||||
|
|
||||||
|
#account_id = ARGV[0]&.to_i
|
||||||
|
days_back = (ARGV[1] || 30).to_i
|
||||||
|
|
||||||
|
# if account_id.nil? || account_id.zero?
|
||||||
|
# puts "Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]"
|
||||||
|
# puts "Example: bundle exec rails runner script/reindex_single_account.rb 93293 30"
|
||||||
|
# exit 1
|
||||||
|
# end
|
||||||
|
|
||||||
|
# account = Account.find(account_id)
|
||||||
|
# puts "=" * 80
|
||||||
|
# puts "Reindexing messages for: #{account.name} (ID: #{account.id})"
|
||||||
|
# puts "=" * 80
|
||||||
|
|
||||||
|
# Enable feature if not already enabled
|
||||||
|
# unless account.feature_enabled?('advanced_search_indexing')
|
||||||
|
# puts "Enabling advanced_search_indexing feature..."
|
||||||
|
# account.enable_features(:advanced_search_indexing)
|
||||||
|
# account.save!
|
||||||
|
# end
|
||||||
|
|
||||||
|
# Get messages to index
|
||||||
|
# messages = Message.where(account_id: account.id)
|
||||||
|
# .where(message_type: [0, 1]) # incoming/outgoing only
|
||||||
|
# .where('created_at >= ?', days_back.days.ago)
|
||||||
|
|
||||||
|
messages = Message.where('created_at >= ?', days_back.days.ago)
|
||||||
|
|
||||||
|
puts "Found #{messages.count} messages to index (last #{days_back} days)"
|
||||||
|
puts ''
|
||||||
|
|
||||||
|
sleep(15)
|
||||||
|
|
||||||
|
# Create bulk reindex jobs
|
||||||
|
index_name = Message.searchkick_index.name
|
||||||
|
batch_count = 0
|
||||||
|
|
||||||
|
messages.find_in_batches(batch_size: 1000).with_index do |batch, index|
|
||||||
|
Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
|
||||||
|
class_name: 'Message',
|
||||||
|
index_name: index_name,
|
||||||
|
batch_id: index,
|
||||||
|
record_ids: batch.map(&:id)
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_count += 1
|
||||||
|
print '.'
|
||||||
|
sleep(0.5) # Small delay
|
||||||
|
end
|
||||||
|
|
||||||
|
puts ''
|
||||||
|
puts '=' * 80
|
||||||
|
puts "Done! Created #{batch_count} bulk reindex jobs"
|
||||||
|
puts 'Messages will be indexed shortly via the bulk_reindex_low queue'
|
||||||
|
puts '=' * 80
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
describe Webhooks::Trigger do
|
describe Webhooks::Trigger do
|
||||||
|
include ActiveJob::TestHelper
|
||||||
|
|
||||||
subject(:trigger) { described_class }
|
subject(:trigger) { described_class }
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
@@ -8,8 +10,18 @@ describe Webhooks::Trigger do
|
|||||||
let!(:conversation) { create(:conversation, inbox: inbox) }
|
let!(:conversation) { create(:conversation, inbox: inbox) }
|
||||||
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
|
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
|
||||||
|
|
||||||
let!(:webhook_type) { :api_inbox_webhook }
|
let(:webhook_type) { :api_inbox_webhook }
|
||||||
let!(:url) { 'https://test.com' }
|
let!(:url) { 'https://test.com' }
|
||||||
|
let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
ActiveJob::Base.queue_adapter = :test
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
clear_enqueued_jobs
|
||||||
|
clear_performed_jobs
|
||||||
|
end
|
||||||
|
|
||||||
describe '#execute' do
|
describe '#execute' do
|
||||||
it 'triggers webhook' do
|
it 'triggers webhook' do
|
||||||
@@ -54,6 +66,57 @@ describe Webhooks::Trigger do
|
|||||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||||
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
|
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when webhook type is agent bot' do
|
||||||
|
let(:webhook_type) { :agent_bot_webhook }
|
||||||
|
|
||||||
|
it 'reopens conversation and enqueues activity message if pending' do
|
||||||
|
conversation.update(status: :pending)
|
||||||
|
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
||||||
|
|
||||||
|
expect(RestClient::Request).to receive(:execute)
|
||||||
|
.with(
|
||||||
|
method: :post,
|
||||||
|
url: url,
|
||||||
|
payload: payload.to_json,
|
||||||
|
headers: { content_type: :json, accept: :json },
|
||||||
|
timeout: 5
|
||||||
|
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||||
|
|
||||||
|
expect do
|
||||||
|
perform_enqueued_jobs do
|
||||||
|
trigger.execute(url, payload, webhook_type)
|
||||||
|
end
|
||||||
|
end.not_to(change { message.reload.status })
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('open')
|
||||||
|
|
||||||
|
activity_message = conversation.reload.messages.order(:created_at).last
|
||||||
|
expect(activity_message.message_type).to eq('activity')
|
||||||
|
expect(activity_message.content).to eq(agent_bot_error_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not change message status or enqueue activity when conversation is not pending' do
|
||||||
|
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
||||||
|
|
||||||
|
expect(RestClient::Request).to receive(:execute)
|
||||||
|
.with(
|
||||||
|
method: :post,
|
||||||
|
url: url,
|
||||||
|
payload: payload.to_json,
|
||||||
|
headers: { content_type: :json, accept: :json },
|
||||||
|
timeout: 5
|
||||||
|
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||||
|
|
||||||
|
expect do
|
||||||
|
trigger.execute(url, payload, webhook_type)
|
||||||
|
end.not_to(change { message.reload.status })
|
||||||
|
|
||||||
|
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('open')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not update message status if webhook fails for other events' do
|
it 'does not update message status if webhook fails for other events' do
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ApplicationRecord do
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_email,
|
||||||
|
attribute: :smtp_password,
|
||||||
|
value: 'smtp-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_email,
|
||||||
|
attribute: :imap_password,
|
||||||
|
value: 'imap-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_twilio_sms,
|
||||||
|
attribute: :auth_token,
|
||||||
|
value: 'twilio-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :integrations_hook,
|
||||||
|
attribute: :access_token,
|
||||||
|
value: 'hook-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_facebook_page,
|
||||||
|
attribute: :page_access_token,
|
||||||
|
value: 'fb-page-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_facebook_page,
|
||||||
|
attribute: :user_access_token,
|
||||||
|
value: 'fb-user-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_instagram,
|
||||||
|
attribute: :access_token,
|
||||||
|
value: 'ig-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_line,
|
||||||
|
attribute: :line_channel_secret,
|
||||||
|
value: 'line-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_line,
|
||||||
|
attribute: :line_channel_token,
|
||||||
|
value: 'line-token-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_telegram,
|
||||||
|
attribute: :bot_token,
|
||||||
|
value: 'telegram-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_twitter_profile,
|
||||||
|
attribute: :twitter_access_token,
|
||||||
|
value: 'twitter-access-secret'
|
||||||
|
|
||||||
|
it_behaves_like 'encrypted external credential',
|
||||||
|
factory: :channel_twitter_profile,
|
||||||
|
attribute: :twitter_access_token_secret,
|
||||||
|
value: 'twitter-secret-secret'
|
||||||
|
|
||||||
|
context 'when backfilling legacy plaintext' do
|
||||||
|
before do
|
||||||
|
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reads existing plaintext and encrypts on update' do
|
||||||
|
account = create(:account)
|
||||||
|
channel = create(:channel_email, account: account, smtp_password: nil)
|
||||||
|
|
||||||
|
# Simulate legacy plaintext by updating the DB directly
|
||||||
|
sql = ActiveRecord::Base.send(
|
||||||
|
:sanitize_sql_array,
|
||||||
|
['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id]
|
||||||
|
)
|
||||||
|
ActiveRecord::Base.connection.execute(sql)
|
||||||
|
|
||||||
|
legacy_record = Channel::Email.find(channel.id)
|
||||||
|
expect(legacy_record.smtp_password).to eq('legacy-plain')
|
||||||
|
|
||||||
|
legacy_record.update!(smtp_password: 'encrypted-now')
|
||||||
|
|
||||||
|
stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password)
|
||||||
|
expect(stored_value).to be_present
|
||||||
|
expect(stored_value).not_to include('encrypted-now')
|
||||||
|
expect(legacy_record.smtp_password).to eq('encrypted-now')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when looking up telegram legacy records' do
|
||||||
|
before do
|
||||||
|
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'finds plaintext records via fallback lookup' do
|
||||||
|
channel = create(:channel_telegram, bot_token: 'legacy-token')
|
||||||
|
|
||||||
|
# Simulate legacy plaintext by updating the DB directly
|
||||||
|
sql = ActiveRecord::Base.send(
|
||||||
|
:sanitize_sql_array,
|
||||||
|
['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id]
|
||||||
|
)
|
||||||
|
ActiveRecord::Base.connection.execute(sql)
|
||||||
|
|
||||||
|
found = Channel::Telegram.find_by(bot_token: 'legacy-token')
|
||||||
|
expect(found).to eq(channel)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'When the incoming waid is an Argentine number with 9 after country code' do
|
||||||
|
let(:wa_id) { '5491123456789' }
|
||||||
|
|
||||||
|
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||||
|
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||||
|
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
||||||
|
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'appends to existing contact if contact inbox exists with normalized format' do
|
||||||
|
# Normalized format removes the 9 after country code
|
||||||
|
normalized_wa_id = '541123456789'
|
||||||
|
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
|
||||||
|
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
# no new conversation should be created
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
# message appended to the last conversation
|
||||||
|
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
|
||||||
|
# should use the normalized wa_id from existing contact
|
||||||
|
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'When incoming waid is an Argentine number without 9 after country code' do
|
||||||
|
let(:wa_id) { '541123456789' }
|
||||||
|
|
||||||
|
context 'when a contact inbox exists with the same format' do
|
||||||
|
it 'appends to existing contact' do
|
||||||
|
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
|
||||||
|
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
# no new conversation should be created
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||||
|
# message appended to the last conversation
|
||||||
|
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when a contact inbox does not exist' do
|
||||||
|
it 'creates contact inbox with the incoming waid' do
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||||
|
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||||
|
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
||||||
|
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'when message processing is in progress' do
|
describe 'when message processing is in progress' do
|
||||||
it 'ignores the current message creation request' do
|
it 'ignores the current message creation request' do
|
||||||
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
|
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'|
|
||||||
|
before do
|
||||||
|
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
|
||||||
|
if defined?(Facebook::Messenger::Subscriptions)
|
||||||
|
allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true)
|
||||||
|
allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "encrypts #{attribute} at rest" do
|
||||||
|
record = create(factory, attribute => value)
|
||||||
|
|
||||||
|
raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s
|
||||||
|
expect(raw_stored_value).to be_present
|
||||||
|
expect(raw_stored_value).not_to include(value)
|
||||||
|
expect(record.public_send(attribute)).to eq(value)
|
||||||
|
expect(record.encrypted_attribute?(attribute)).to be(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user