Compare commits

..
Author SHA1 Message Date
Shivam MishraandGitHub 6928108b7a Merge branch 'develop' into fix/hc-editor 2025-10-13 12:30:50 +05:30
Muhsin KelothandGitHub e7e3ed3916 Merge branch 'develop' into fix/hc-editor 2025-09-22 16:38:21 +05:30
Shivam MishraandGitHub b4d589fe24 Merge branch 'develop' into fix/hc-editor 2025-08-14 17:15:05 +05:30
Muhsin KelothandGitHub 370f890f1f Merge branch 'develop' into fix/hc-editor 2025-08-14 14:33:53 +05:30
Muhsin KelothandGitHub a77ec8f5b5 Merge branch 'develop' into fix/hc-editor 2025-08-14 13:56:49 +05:30
Shivam MishraandGitHub 450736c4fe Merge branch 'develop' into fix/hc-editor 2025-02-10 20:02:49 +05:30
Shivam MishraandGitHub cb075f794d Merge branch 'develop' into fix/hc-editor 2025-01-31 13:53:53 +05:30
Shivam Mishra 4cc977b9ee fix: focus 2025-01-30 14:22:12 +05:30
Shivam Mishra 6222a8d103 feat: remove cursor manipulation
not needed anymore
2025-01-30 14:20:45 +05:30
Shivam MishraandGitHub 27d9142edf Merge branch 'develop' into fix/hc-editor 2025-01-30 14:20:31 +05:30
Shivam MishraandGitHub cb9e5806df Merge branch 'develop' into fix/hc-editor 2025-01-29 16:39:33 +05:30
Shivam Mishra af9762f04e feat: it's okay to just start with a title 2025-01-29 16:37:29 +05:30
Shivam Mishra ce56e3ed93 feat: update content one last time on blur 2025-01-29 16:37:16 +05:30
Shivam Mishra c50d00b48e feat: update title only on blur 2025-01-29 16:37:04 +05:30
Shivam Mishra 2655a8c6b4 feat: remove async saving 2025-01-29 15:59:59 +05:30
Shivam Mishra 346d7a0375 fix: model reload only once 2025-01-29 15:58:50 +05:30
Shivam Mishra e0449451f9 fix: always save 2025-01-29 15:00:25 +05:30
67 changed files with 547 additions and 1835 deletions
-1
View File
@@ -70,7 +70,6 @@ jobs:
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \
--format documentation
env:
+1 -20
View File
@@ -178,13 +178,7 @@ class Messages::MessageBuilder
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(normalized_content)
end
email_attributes[:html_content] = build_html_content(normalized_content)
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes
end
@@ -219,17 +213,4 @@ class Messages::MessageBuilder
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
def build_custom_html_content
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
html_content[:full] = @params[:email_html_content]
html_content[:reply] = @params[:email_html_content]
html_content
end
end
+16 -22
View File
@@ -10,28 +10,10 @@ class V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
def load_reporting_events_data
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
index_key = group_by_key.to_s.split('.').last
results = reporting_events
.select(
"#{group_by_key} as #{index_key}",
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
)
.group(group_by_key)
.index_by { |record| record.public_send(index_key) }
@resolved_count = results.transform_values(&:resolved_count)
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
@avg_reply_time = results.transform_values(&:avg_reply_time)
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def reporting_events
@@ -42,6 +24,14 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
# Override this method
end
@@ -50,6 +40,10 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
@@ -13,7 +13,10 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref, watch, onMounted } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -27,7 +27,6 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
@@ -35,52 +34,32 @@ const emit = defineEmits([
]);
const { t } = useI18n();
const articleTitle = ref('');
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
const saveArticle = value => {
emit('saveArticle', value);
};
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
const saveContent = debounce(content => saveArticle({ content }), 200, false);
const saveTitle = () => {
if (articleTitle.value) saveArticle({ title: articleTitle.value });
};
const articleTitle = computed({
get: () => props.article.title,
set: value => {
handleSave({ title: value });
watch(
() => props.article,
() => {
articleTitle.value = props.article.title;
},
{ immediate: true, deep: true }
);
onMounted(() => {
articleTitle.value = props.article.title;
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
handleSave({ content });
},
set: saveContent,
});
const onClickGoBack = () => {
@@ -122,10 +101,11 @@ const previewArticle = () => {
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
@blur="saveTitle"
/>
<ArticleEditorControls
:article="article"
@save-article="saveAndSync"
@save-article="saveArticle"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -138,6 +118,7 @@ const previewArticle = () => {
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
@blur="saveContent"
/>
</template>
</HelpCenterLayout>
@@ -1,5 +1,4 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
@@ -7,7 +6,6 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -22,7 +20,6 @@ const onClick = () => {
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
class="mb-8"
:hide-actions="!isOnChatwootCloud"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
@@ -1,5 +1,4 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -7,7 +6,6 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -21,7 +19,6 @@ const onClick = () => {
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
learn-more-url="https://chwt.app/captain-document"
:hide-actions="!isOnChatwootCloud"
class="mb-8"
/>
<EmptyStateLayout
@@ -1,5 +1,4 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
@@ -7,7 +6,6 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -21,7 +19,6 @@ const onClick = () => {
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
:hide-actions="!isOnChatwootCloud"
class="mb-8"
/>
<EmptyStateLayout
@@ -10,7 +10,6 @@ defineProps({
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
});
const imageError = ref(false);
@@ -66,7 +65,7 @@ const openLink = link => {
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8">
<p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
<div v-if="!hideActions" class="flex gap-3">
<div class="flex gap-3">
<slot name="actions">
<Button
v-if="videoUrl"
@@ -13,7 +13,6 @@ defineProps({
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
});
const imageError = ref(false);
@@ -93,7 +92,7 @@ const openLink = link => {
{{ note }}
</p>
<div v-if="!hideActions" class="flex gap-3 justify-between w-full">
<div class="flex gap-3 justify-between w-full">
<slot name="actions">
<Button
v-if="videoUrl"
@@ -32,7 +32,7 @@ const props = defineProps({
allowSignature: { type: Boolean, default: false }, // allowSignature is a kill switch, ensuring no signature methods are triggered except when this flag is true
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'blur']);
const textareaRef = ref(null);
const isFocused = ref(false);
@@ -106,6 +106,7 @@ const handleBlur = () => {
if (!props.disabled) {
isFocused.value = false;
}
emit('blur');
};
// Watch for changes in modelValue to adjust height
@@ -67,10 +67,11 @@ export default {
};
},
watch: {
modelValue(newValue = '') {
if (newValue !== this.contentFromEditor()) {
modelValue: {
handler() {
this.reloadState();
}
},
once: true,
},
editorId() {
this.reloadState();
@@ -176,6 +177,7 @@ export default {
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
editorView.updateState(state);
this.focusEditorInputField();
},
@@ -229,6 +231,7 @@ export default {
this.$emit('keydown');
},
onBlur() {
this.$emit('update:modelValue', this.contentFromEditor());
this.$emit('blur');
},
onFocus() {
@@ -51,7 +51,6 @@
},
"DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
@@ -267,8 +266,6 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -470,13 +467,6 @@
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"RESOLUTION_HEATMAP": {
"HEADER": "Resolutions",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "{count} conversation",
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents",
"LOADING_MESSAGE": "Loading agent metrics...",
@@ -2,8 +2,6 @@
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
@@ -13,8 +11,7 @@ import CreateAssistantDialog from 'dashboard/components-next/captain/pageCompone
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const { isOnChatwootCloud } = useAccount();
import { useRouter } from 'vue-router';
const router = useRouter();
@@ -93,7 +90,6 @@ onMounted(() => store.dispatch('captainAssistants/get'));
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
@@ -2,7 +2,6 @@
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -17,7 +16,6 @@ import LimitBanner from 'dashboard/components-next/captain/pageComponents/docume
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
const documents = useMapGetter('captainDocuments/getRecords');
const assistants = useMapGetter('captainAssistants/getRecords');
@@ -123,7 +121,6 @@ onMounted(() => {
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/document-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-popover-dark.svg"
learn-more-url="https://chwt.app/captain-document"
@@ -7,7 +7,6 @@ import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
@@ -26,7 +25,6 @@ import LimitBanner from 'dashboard/components-next/captain/pageComponents/respon
const router = useRouter();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const responseMeta = useMapGetter('captainResponses/getMeta');
@@ -287,7 +285,6 @@ onMounted(() => {
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
@@ -40,11 +40,10 @@ const articleLink = computed(() => {
);
});
const saveArticle = async ({ ...values }, isAsync = false) => {
const actionToDispatch = isAsync ? 'articles/updateAsync' : 'articles/update';
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch(actionToDispatch, {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
...values,
@@ -62,10 +61,6 @@ const saveArticle = async ({ ...values }, isAsync = false) => {
}
};
const saveArticleAsync = async ({ ...values }) => {
saveArticle({ ...values }, true);
};
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
@@ -112,7 +107,6 @@ onMounted(fetchArticleDetails);
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="saveArticle"
@save-article-async="saveArticleAsync"
@preview-article="previewArticle"
@go-back="goBackToArticles"
/>
@@ -1,7 +1,6 @@
<script setup>
import ReportHeader from './components/ReportHeader.vue';
import ConversationHeatmapContainer from './components/heatmaps/ConversationHeatmapContainer.vue';
import ResolutionHeatmapContainer from './components/heatmaps/ResolutionHeatmapContainer.vue';
import HeatmapContainer from './components/HeatmapContainer.vue';
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
@@ -11,8 +10,7 @@ import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vu
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
<div class="flex flex-col gap-4 pb-6">
<StatsLiveReportsContainer />
<ConversationHeatmapContainer />
<ResolutionHeatmapContainer />
<HeatmapContainer />
<AgentLiveReportContainer />
<TeamLiveReportContainer />
</div>
@@ -0,0 +1,175 @@
<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>
@@ -0,0 +1,119 @@
<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>
@@ -1,214 +0,0 @@
<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>
@@ -1,265 +0,0 @@
<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>
@@ -1,18 +0,0 @@
<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>
@@ -1,57 +0,0 @@
<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>
@@ -1,18 +0,0 @@
<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>
@@ -1,34 +0,0 @@
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 longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
10000,
1500,
false,
20000
10000
);
export const actions = {
@@ -69,25 +69,6 @@ export const actions = {
}
},
updateAsync: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { isUpdating: true },
articleId,
});
try {
await articlesAPI.updateArticle({ portalSlug, articleId, articleObj });
return articleId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { isUpdating: false },
articleId,
});
}
},
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: {
@@ -57,13 +57,11 @@ const state = {
uiFlags: {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAccountResolutionsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
accountResolutionHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
@@ -91,9 +89,6 @@ const getters = {
getAccountConversationHeatmapData(_state) {
return _state.overview.accountConversationHeatmap;
},
getAccountResolutionHeatmapData(_state) {
return _state.overview.accountResolutionHeatmap;
},
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
@@ -135,16 +130,6 @@ export const actions = {
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) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
@@ -302,9 +287,6 @@ const mutations = {
[types.default.SET_HEATMAP_DATA](_state, 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 }) {
_state.accountReport.isFetching[metric] = value;
},
@@ -317,9 +299,6 @@ const mutations = {
[types.default.TOGGLE_HEATMAP_LOADING](_state, 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) {
_state.accountSummary = summaryData;
},
@@ -187,8 +187,6 @@ export default {
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
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_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
-4
View File
@@ -1,7 +1,3 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
def perform(url, payload, webhook_type = :agent_bot_webhook)
super(url, payload, webhook_type)
end
end
+17 -19
View File
@@ -1,29 +1,27 @@
class SendReplyJob < ApplicationJob
queue_as :high
CHANNEL_SERVICES = {
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
'Channel::Email' => ::Email::SendOnEmailService,
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
'Channel::Api' => ::Messages::SendEmailNotificationService
}.freeze
def perform(message_id)
message = Message.find(message_id)
channel_name = message.conversation.inbox.channel.class.to_s
conversation = message.conversation
channel_name = conversation.inbox.channel.class.to_s
return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage'
services = {
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService
}
service_class = CHANNEL_SERVICES[channel_name]
return unless service_class
service_class.new(message: message).perform
case channel_name
when 'Channel::FacebookPage'
send_on_facebook_page(message)
else
services[channel_name].new(message: message).perform if services[channel_name].present?
end
end
private
+2 -1
View File
@@ -39,7 +39,8 @@ class ConversationReplyMailer < ApplicationMailer
init_conversation_attributes(message.conversation)
@message = message
prepare_mail(true)
reply_mail_object = prepare_mail(true)
message.update(source_id: reply_mail_object.message_id)
end
def conversation_transcript(conversation, to_email)
-1
View File
@@ -58,7 +58,6 @@ class Article < ApplicationRecord
validates :account_id, presence: true
validates :author_id, presence: true
validates :title, presence: true
validates :content, presence: true
# ensuring that the position is always set correctly
before_create :add_position_to_article
-6
View File
@@ -40,12 +40,6 @@ class Channel::Email < ApplicationRecord
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'
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,
-6
View File
@@ -21,12 +21,6 @@ class Channel::FacebookPage < ApplicationRecord
include Channelable
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'
validates :page_id, uniqueness: { scope: :account_id }
-3
View File
@@ -19,9 +19,6 @@ class Channel::Instagram < ApplicationRecord
include Reauthorizable
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
validates :access_token, presence: true
-6
View File
@@ -18,12 +18,6 @@
class Channel::Line < ApplicationRecord
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'
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
-3
View File
@@ -17,9 +17,6 @@
class Channel::Telegram < ApplicationRecord
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'
EDITABLE_ATTRS = [:bot_token].freeze
-3
View File
@@ -28,9 +28,6 @@ class Channel::TwilioSms < ApplicationRecord
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
# The same parameter is used to store api_key_secret if api_key authentication is opted
validates :auth_token, presence: true
-6
View File
@@ -19,12 +19,6 @@
class Channel::TwitterProfile < ApplicationRecord
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'
validates :profile_id, uniqueness: { scope: :account_id }
-3
View File
@@ -21,9 +21,6 @@ class Integrations::Hook < ApplicationRecord
before_validation :ensure_hook_type
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 :app_id, presence: true
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
+43
View File
@@ -300,6 +300,7 @@ class Message < ApplicationRecord
def execute_after_create_commit_callbacks
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
reopen_conversation
notify_via_mail
set_conversation_activity
dispatch_create_events
send_reply
@@ -385,6 +386,48 @@ class Message < ApplicationRecord
::MessageTemplates::HookExecutionService.new(message: self).perform
end
def email_notifiable_webwidget?
inbox.web_widget? && inbox.channel.continuity_via_email
end
def email_notifiable_api_channel?
inbox.api? && inbox.account.feature_enabled?('email_continuity_on_api_channel')
end
def email_notifiable_channel?
email_notifiable_webwidget? || %w[Email].include?(inbox.inbox_type) || email_notifiable_api_channel?
end
def can_notify_via_mail?
return unless email_notifiable_message?
return unless email_notifiable_channel?
return if conversation.contact.email.blank?
true
end
def notify_via_mail
return unless can_notify_via_mail?
trigger_notify_via_mail
end
def trigger_notify_via_mail
return EmailReplyWorker.perform_in(1.second, id) if inbox.inbox_type == 'Email'
# will set a redis key for the conversation so that we don't need to send email for every new message
# last few messages coupled together is sent every 2 minutes rather than one email for each message
# if redis key exists there is an unprocessed job that will take care of delivering the email
return if Redis::Alfred.get(conversation_mail_key).present?
Redis::Alfred.setex(conversation_mail_key, id)
ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, id)
end
def conversation_mail_key
format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
end
def validate_attachments_limit(_attachment)
errors.add(:attachments, message: 'exceeded maximum allowed') if attachments.size >= NUMBER_OF_PERMITTED_ATTACHMENTS
end
@@ -1,19 +0,0 @@
class Email::SendOnEmailService < Base::SendOnChannelService
private
def channel_class
Channel::Email
end
def perform_reply
return unless message.email_notifiable_message?
reply_mail = ConversationReplyMailer.with(account: message.account).email_reply(message)
reply_mail.deliver_now
Rails.logger.info("Email message #{message.id} sent with source_id: #{reply_mail.message_id}")
message.update(source_id: reply_mail.message_id)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
end
@@ -1,38 +0,0 @@
class Messages::SendEmailNotificationService
pattr_initialize [:message!]
def perform
return unless should_send_email_notification?
conversation = message.conversation
conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
# Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than
# the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if
# the worker never manages to clean up.
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, message.id)
end
private
def should_send_email_notification?
return false unless message.email_notifiable_message?
return false if message.conversation.contact.email.blank?
email_reply_enabled?
end
def email_reply_enabled?
inbox = message.inbox
case inbox.channel.class.to_s
when 'Channel::WebWidget'
inbox.channel.continuity_via_email
when 'Channel::Api'
inbox.account.feature_enabled?('email_continuity_on_api_channel')
else
false
end
end
end
@@ -1,11 +1,9 @@
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
<% elsif @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% end %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
<% end %>
<% end %>
+16
View File
@@ -0,0 +1,16 @@
class EmailReplyWorker
include Sidekiq::Worker
sidekiq_options queue: :mailers, retry: 3
def perform(message_id)
message = Message.find(message_id)
return unless message.email_notifiable_message?
# send the email
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.7.0'
version: '4.6.0'
development:
<<: *shared
-6
View File
@@ -75,11 +75,7 @@ module Chatwoot
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.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
# 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
end
end
@@ -98,8 +94,6 @@ module Chatwoot
end
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
# MFA/2FA features should only be enabled when proper keys are set
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
-2
View File
@@ -202,8 +202,6 @@ en:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
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:
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
-1
View File
@@ -27,7 +27,6 @@
- purgable
- housekeeping
- async_database_migration
- bulk_reindex_low
- active_storage_analysis
- active_storage_purge
- action_mailbox_incineration
+11 -25
View File
@@ -16,11 +16,8 @@ class Seeders::Reports::ConversationCreator
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
end
# rubocop:disable Metrics/MethodLength
def create_conversation(created_at:)
conversation = nil
should_resolve = false
resolution_time = nil
ActiveRecord::Base.transaction do
travel_to(created_at) do
@@ -29,35 +26,14 @@ class Seeders::Reports::ConversationCreator
add_labels_to_conversation(conversation)
create_messages_for_conversation(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
resolve_conversation_if_needed(conversation)
end
travel_back
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
end
# rubocop:enable Metrics/MethodLength
private
@@ -109,6 +85,16 @@ class Seeders::Reports::ConversationCreator
message_creator.create_messages
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)
event_data = { conversation: conversation }
+4 -23
View File
@@ -31,33 +31,14 @@ class Webhooks::Trigger
end
def handle_error(error)
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless should_handle_error?
return unless message
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
update_message_status(error)
end
def create_agent_bot_error_activity(conversation)
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
}
def should_handle_error?
@webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
end
def update_message_status(error)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.7.0",
"version": "4.6.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
-58
View File
@@ -1,58 +0,0 @@
# 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
View File
@@ -1,19 +0,0 @@
# 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
View File
@@ -1,58 +0,0 @@
# 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
@@ -179,63 +179,6 @@ describe Messages::MessageBuilder do
expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
end
context 'when custom email content is provided' do
before do
account.enable_features('quoted_email_reply')
end
it 'creates message with custom HTML email content' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom <strong>HTML</strong> content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content'
expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
end
it 'does not process custom email content when quoted_email_reply feature is disabled' do
account.disable_features('quoted_email_reply')
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'does not process custom email content for private messages' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>',
private: true
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'falls back to default behavior when no custom email content is provided' do
params = ActionController::Parameters.new({
content: 'Regular **markdown** content'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('<strong>markdown</strong>')
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
end
end
end
end
end
-27
View File
@@ -108,32 +108,5 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Email::SendOnEmailService when its email message' do
email_channel = create(:channel_email)
message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox))
allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service)
expect(Email::SendOnEmailService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do
webwidget_channel = create(:channel_widget)
message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox))
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Messages::SendEmailNotificationService when its api channel message' do
api_channel = create(:channel_api)
message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox))
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
end
end
+1 -64
View File
@@ -1,8 +1,6 @@
require 'rails_helper'
describe Webhooks::Trigger do
include ActiveJob::TestHelper
subject(:trigger) { described_class }
let!(:account) { create(:account) }
@@ -10,18 +8,8 @@ describe Webhooks::Trigger do
let!(:conversation) { create(:conversation, inbox: inbox) }
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(: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
it 'triggers webhook' do
@@ -66,57 +54,6 @@ describe Webhooks::Trigger do
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
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
it 'does not update message status if webhook fails for other events' do
@@ -335,118 +335,6 @@ RSpec.describe ConversationReplyMailer do
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
end
end
context 'with custom email content' do
it 'uses custom HTML content when available and creates multipart email' do
message_with_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
html_content: {
reply: '<p>Custom <strong>HTML</strong> content for email</p>'
},
text_content: {
reply: 'Custom text content for email'
}
}
})
mail = described_class.email_reply(message_with_custom_content).deliver_now
# Check HTML part contains custom HTML content
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<p>Custom <strong>HTML</strong> content for email</p>')
expect(html_part.body.encoded).not_to include('Regular message content')
# Check text part contains custom text content
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content for email')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
it 'falls back to markdown rendering when custom HTML content is not available' do
message_without_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content')
mail = described_class.email_reply(message_without_custom_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles empty custom HTML content gracefully' do
message_with_empty_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: ''
}
}
})
mail = described_class.email_reply(message_with_empty_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles nil custom HTML content gracefully' do
message_with_nil_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: nil
}
}
})
mail = described_class.email_reply(message_with_nil_content).deliver_now
expect(mail.body.encoded).to include('<strong>markdown</strong>')
expect(mail.body.encoded).to include('Regular')
end
it 'uses custom text content in text part when only text is provided' do
message_with_text_only = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
text_content: {
reply: 'Custom text content only'
}
}
})
mail = described_class.email_reply(message_with_text_only).deliver_now
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content only')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
end
end
context 'when smtp enabled for email channel' do
@@ -1,113 +0,0 @@
# 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
+35 -61
View File
@@ -316,69 +316,43 @@ RSpec.describe Message do
end
context 'with conversation continuity' do
let(:inbox_with_continuity) do
create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: true))
end
it 'schedules email notification for outgoing messages in website channel' do
message.inbox = inbox_with_continuity
message.conversation.update!(inbox: inbox_with_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
# Perform jobs inline to test full integration
perform_enqueued_jobs do
message.save!
end
# Verify the email worker is eventually scheduled through the service
jobs_for_conversation_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id }
expect(jobs_for_conversation_count).to eq(1)
end
it 'does not schedule email for website channel if continuity is disabled' do
inbox_without_continuity = create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: false))
message.inbox = inbox_without_continuity
message.conversation.update!(inbox: inbox_without_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
initial_job_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id }
perform_enqueued_jobs do
message.save!
end
# No new jobs should be scheduled for this conversation
jobs_for_conversation_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id }
expect(jobs_for_conversation_count).to eq(initial_job_count)
end
it 'does not schedule email for private notes' do
message.inbox = inbox_with_continuity
message.conversation.update!(inbox: inbox_with_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.private = true
message.message_type = 'outgoing'
initial_job_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id }
perform_enqueued_jobs do
message.save!
end
# No new jobs should be scheduled for this conversation
jobs_for_conversation_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id }
expect(jobs_for_conversation_count).to eq(initial_job_count)
end
it 'calls SendReplyJob for all channels' do
allow(SendReplyJob).to receive(:perform_later).and_return(true)
it 'calls notify email method on after save for outgoing messages in website channel' do
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
message.message_type = 'outgoing'
message.save!
expect(SendReplyJob).to have_received(:perform_later).with(message.id)
expect(ConversationReplyEmailWorker).to have_received(:perform_in)
end
it 'does not call notify email for website channel if continuity is disabled' do
message.inbox = create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: false))
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
message.message_type = 'outgoing'
message.save!
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
it 'wont call notify email method for private notes' do
message.private = true
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
message.save!
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
it 'calls EmailReply worker if the channel is email' do
message.inbox = create(:inbox, account: message.account, channel: build(:channel_email, account: message.account))
allow(EmailReplyWorker).to receive(:perform_in).and_return(true)
message.message_type = 'outgoing'
message.content_attributes = { email: { text_content: { quoted: 'quoted text' } } }
message.save!
expect(EmailReplyWorker).to have_received(:perform_in).with(1.second, message.id)
end
it 'wont call notify email method unless its website or email channel' do
message.inbox = create(:inbox, account: message.account, channel: build(:channel_api, account: message.account))
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
message.save!
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
end
end
@@ -1,87 +0,0 @@
require 'rails_helper'
describe Email::SendOnEmailService do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, account: account) }
let(:inbox) { create(:inbox, account: account, channel: email_channel) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
let(:service) { described_class.new(message: message) }
describe '#perform' do
let(:mailer_context) { instance_double(ConversationReplyMailer) }
let(:delivery) { instance_double(ActionMailer::MessageDelivery) }
before do
allow(ConversationReplyMailer).to receive(:with).with(account: message.account).and_return(mailer_context)
end
context 'when message is email notifiable' do
before do
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
allow(delivery).to receive(:deliver_now)
end
it 'sends email via ConversationReplyMailer' do
service.perform
expect(ConversationReplyMailer).to have_received(:with).with(account: message.account)
expect(mailer_context).to have_received(:email_reply).with(message)
expect(delivery).to have_received(:deliver_now)
end
end
context 'when message is not email notifiable' do
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
before do
allow(mailer_context).to receive(:email_reply)
end
it 'does not send email' do
service.perform
expect(ConversationReplyMailer).not_to have_received(:with)
expect(mailer_context).not_to have_received(:email_reply)
end
end
context 'when an error occurs' do
let(:error_message) { 'SMTP connection failed' }
let(:error) { StandardError.new(error_message) }
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
let(:status_service) { instance_double(Messages::StatusUpdateService, perform: true) }
before do
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
allow(delivery).to receive(:deliver_now).and_raise(error)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_service)
end
it 'captures the exception' do
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: message.account)
service.perform
end
it 'updates message status to failed' do
expect(Messages::StatusUpdateService).to receive(:new).with(message, 'failed', error_message)
service.perform
end
end
end
describe '#channel_class' do
it 'returns Channel::Email' do
expect(service.send(:channel_class)).to eq(Channel::Email)
end
end
describe 'inheritance' do
it 'inherits from Base::SendOnChannelService' do
expect(described_class.superclass).to eq(Base::SendOnChannelService)
end
end
end
@@ -1,190 +0,0 @@
require 'rails_helper'
describe Messages::SendEmailNotificationService do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
let(:service) { described_class.new(message: message) }
describe '#perform' do
context 'when email notification should be sent' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
allow(Redis::Alfred).to receive(:set).and_return(true)
allow(ConversationReplyEmailWorker).to receive(:perform_in)
end
it 'schedules ConversationReplyEmailWorker' do
service.perform
expect(ConversationReplyEmailWorker).to have_received(:perform_in).with(
2.minutes,
conversation.id,
message.id
)
end
it 'atomically sets redis key to prevent duplicate emails' do
expected_key = format(Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
service.perform
expect(Redis::Alfred).to have_received(:set).with(expected_key, message.id, nx: true, ex: 1.hour.to_i)
end
context 'when redis key already exists' do
before do
allow(Redis::Alfred).to receive(:set).and_return(false)
end
it 'does not schedule worker' do
service.perform
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
it 'attempts atomic set once' do
service.perform
expect(Redis::Alfred).to have_received(:set).once
end
end
end
context 'when handling concurrent requests' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'prevents duplicate workers under race conditions' do
# Create 5 threads that simultaneously try to enqueue workers for the same conversation
threads = Array.new(5) do
Thread.new do
msg = create(:message, conversation: conversation, message_type: 'outgoing')
described_class.new(message: msg).perform
end
end
threads.each(&:join)
# Only ONE worker should be scheduled despite 5 concurrent attempts
jobs_for_conversation = ConversationReplyEmailWorker.jobs.select { |job| job['args'].first == conversation.id }
expect(jobs_for_conversation.size).to eq(1)
end
end
context 'when email notification should not be sent' do
before do
allow(ConversationReplyEmailWorker).to receive(:perform_in)
end
context 'when message is not email notifiable' do
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
it 'does not schedule worker' do
service.perform
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
end
context 'when contact has no email' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: nil)
end
it 'does not schedule worker' do
service.perform
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
end
context 'when channel does not support email notifications' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'does not schedule worker' do
service.perform
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
end
end
end
describe '#should_send_email_notification?' do
context 'with WebWidget channel' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'returns true when continuity_via_email is enabled' do
expect(service.send(:should_send_email_notification?)).to be true
end
context 'when continuity_via_email is disabled' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: false)) }
it 'returns false' do
expect(service.send(:should_send_email_notification?)).to be false
end
end
end
context 'with API channel' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_api, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(true)
end
it 'returns true when email_continuity_on_api_channel feature is enabled' do
expect(service.send(:should_send_email_notification?)).to be true
end
context 'when email_continuity_on_api_channel feature is disabled' do
before do
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(false)
end
it 'returns false' do
expect(service.send(:should_send_email_notification?)).to be false
end
end
end
context 'with other channels' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'returns false' do
expect(service.send(:should_send_email_notification?)).to be false
end
end
end
end
@@ -1,21 +0,0 @@
# 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
+58
View File
@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe EmailReplyWorker, type: :worker do
let(:account) { create(:account) }
let(:channel) { create(:channel_email, account: account) }
let(:message) { create(:message, message_type: :outgoing, inbox: channel.inbox, account: account) }
let(:private_message) { create(:message, private: true, message_type: :outgoing, inbox: channel.inbox, account: account) }
let(:incoming_message) { create(:message, message_type: :incoming, inbox: channel.inbox, account: account) }
let(:template_message) { create(:message, message_type: :template, content_type: :input_csat, inbox: channel.inbox, account: account) }
let(:mailer) { double }
let(:mailer_action) { double }
describe '#perform' do
context 'when emails are successfully sent' do
before do
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:email_reply).and_return(mailer_action)
allow(mailer_action).to receive(:deliver_now).and_return(true)
end
it 'calls mailer action with message' do
described_class.new.perform(message.id)
expect(mailer).to have_received(:email_reply).with(message)
expect(mailer_action).to have_received(:deliver_now)
end
it 'does not call mailer action with a private message' do
described_class.new.perform(private_message.id)
expect(mailer).not_to have_received(:email_reply)
expect(mailer_action).not_to have_received(:deliver_now)
end
it 'calls mailer action with a CSAT message' do
described_class.new.perform(template_message.id)
expect(mailer).to have_received(:email_reply).with(template_message)
expect(mailer_action).to have_received(:deliver_now)
end
it 'does not call mailer action with an incoming message' do
described_class.new.perform(incoming_message.id)
expect(mailer).not_to have_received(:email_reply)
expect(mailer_action).not_to have_received(:deliver_now)
end
end
context 'when emails are not sent' do
before do
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:email_reply).and_return(mailer_action)
allow(mailer_action).to receive(:deliver_now).and_raise(ArgumentError)
end
it 'mark message as failed' do
expect { described_class.new.perform(message.id) }.to change { message.reload.status }.from('sent').to('failed')
end
end
end
end