Merge branch 'develop' into feature/stripe_v2

This commit is contained in:
Sivin Varghese
2025-10-14 19:23:56 +05:30
committed by GitHub
38 changed files with 1159 additions and 317 deletions

View File

@@ -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:

View File

@@ -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...",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -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 = {

View File

@@ -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;
}, },

View File

@@ -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',

View File

@@ -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

View File

@@ -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,

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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' }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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? &&

View File

@@ -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}'

View File

@@ -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

View File

@@ -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 }

View File

@@ -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)

View 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
View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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' }],

View File

@@ -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