Compare commits

..
3 Commits
Author SHA1 Message Date
iamsivin 28c7d13c15 chore: Minor fix 2026-01-15 14:07:45 +05:30
iamsivin 5caf3f5705 chore: Clean up 2026-01-15 13:47:05 +05:30
iamsivin 716a5b0dc3 fix: TypeError: can't access property "emitsOptions", r is null 2026-01-15 13:40:05 +05:30
71 changed files with 518 additions and 1861 deletions
+3 -3
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '24.13'
node-version: '24.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.13'
node-version: '24.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.13'
node-version: '24.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
NODE_VERSION: '24.12.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
NODE_VERSION: '24.12.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -1 +1 @@
24.13.0
24.12.0
+1 -1
View File
@@ -1 +1 @@
4.10.1
4.9.2
@@ -50,5 +50,3 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -148,7 +148,12 @@ const contextMenuActions = {
},
};
onBeforeMount(contextMenuActions.close);
// Reset context menu state on mount without emitting events
// to prevent event storm when multiple cards mount simultaneously
onBeforeMount(() => {
isContextMenuOpen.value = false;
contextMenuPosition.value = { x: null, y: null };
});
</script>
<template>
@@ -15,6 +15,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -660,6 +661,7 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,11 +1,13 @@
<script setup>
import { computed } from 'vue';
import { ref, computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -22,6 +24,7 @@ defineOptions({
});
const { t } = useI18n();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -31,6 +34,29 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -42,6 +68,13 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -157,4 +190,9 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -206,14 +206,10 @@ const emitDateRange = () => {
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
const closeDatePicker = () => {
showDatePicker.value = false;
};
</script>
<template>
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<div class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
@@ -38,7 +38,7 @@ const toggleShowMore = () => {
<button
v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top"
@click.stop="toggleShowMore"
@click="toggleShowMore"
>
{{ buttonLabel }}
</button>
@@ -402,48 +402,22 @@
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "No responses yet",
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
"NO_RECORDS": "There are no CSAT survey responses available.",
"DOWNLOAD": "Download CSAT Reports",
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
"FILTERS": {
"ADD_FILTER": "Add filter",
"CLEAR_ALL": "Clear all",
"NO_FILTER": "No filters available",
"INPUT_PLACEHOLDER": {
"AGENTS": "Search agents",
"INBOXES": "Search inboxes",
"TEAMS": "Search teams",
"RATINGS": "Search ratings"
},
"AGENTS": {
"LABEL": "Agent"
},
"INBOXES": {
"LABEL": "Inbox"
},
"TEAMS": {
"LABEL": "Team"
},
"RATINGS": {
"LABEL": "Rating"
"PLACEHOLDER": "Choose Agents"
}
},
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",
"AGENT_NAME": "Agent",
"AGENT_NAME": "Assigned agent",
"RATING": "Rating",
"FEEDBACK_TEXT": "Feedback comment",
"CONVERSATION": "Conversation",
"CUSTOMER": "Customer",
"RESPONSE": "Response",
"HANDLED_BY": "Handled by"
},
"UNKNOWN_CUSTOMER": "Unknown customer"
"FEEDBACK_TEXT": "Feedback comment"
}
},
"NO_AGENT": "No assigned agent",
"NO_FEEDBACK": "No feedback provided",
"METRIC": {
"TOTAL_RESPONSES": {
"LABEL": "Total responses",
@@ -456,25 +430,6 @@
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
},
"RATING_DISTRIBUTION": "Rating distribution"
},
"REVIEW_NOTES": {
"TITLE": "Review notes",
"PLACEHOLDER": "Add review notes about this rating...",
"SAVE": "Save",
"CANCEL": "Cancel",
"SAVING": "Saving...",
"SAVED": "Notes saved successfully",
"SAVE_ERROR": "Failed to save notes",
"UPDATED_BY": "Updated by {name} {time}",
"UPDATED_BY_LABEL": "Updated by",
"PAYWALL": {
"TITLE": "Upgrade to add review notes",
"AVAILABLE_ON": "The review notes feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Add internal context to every CSAT response with review notes. Capture what really happened, spot patterns faster, and make better decisions from your feedback.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
}
}
},
@@ -1,5 +1,5 @@
<script setup>
import { computed, watch, ref, nextTick } from 'vue';
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -49,14 +49,10 @@ const handleCreateClose = () => {
selectedInbox.value = null;
};
watch(
assistantId,
newId => {
store.dispatch('captainInboxes/get', {
assistantId: newId,
});
},
{ immediate: true }
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId.value,
})
);
</script>
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables';
import CsatMetrics from './components/CsatMetrics.vue';
import CsatTable from './components/CsatTable.vue';
import CsatFilters from './components/Csat/CsatFilters.vue';
import ReportFilterSelector from './components/FilterSelector.vue';
import { generateFileName } from '../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { FEATURE_FLAGS } from '../../../../featureFlags';
@@ -15,7 +15,7 @@ export default {
components: {
CsatMetrics,
CsatTable,
CsatFilters,
ReportFilterSelector,
ReportHeader,
V4Button,
},
@@ -90,7 +90,7 @@ export default {
selectedTeam,
selectedRating,
}) {
// do not track filter change on initial load
// do not track filter change on inital load
if (this.from !== 0 && this.to !== 0) {
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'date',
@@ -121,11 +121,16 @@ export default {
/>
</ReportHeader>
<div class="flex flex-col gap-6">
<CsatFilters
<div class="flex flex-col gap-4">
<ReportFilterSelector
show-agents-filter
show-inbox-filter
show-rating-filter
:show-team-filter="isTeamsEnabled"
:show-business-hours-switch="false"
@filter-change="onFilterChange"
/>
<CsatMetrics :filters="requestPayload" />
<CsatTable :page-index="pageIndex" @page-change="onPageNumberChange" />
</div>
@@ -0,0 +1,26 @@
<script setup>
import { computed } from 'vue';
const { row } = defineProps({
row: {
type: Object,
required: true,
},
});
const routerParams = computed(() => ({
name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId },
}));
</script>
<template>
<div class="text-right">
<router-link :to="routerParams" class="hover:underline">
{{ `#${row.original.conversationId}` }}
</router-link>
<div v-tooltip="row.original.createdAt" class="text-n-slate-11 text-sm">
{{ row.original.createdAgo }}
</div>
</div>
</template>
@@ -1,36 +0,0 @@
import { CSAT_RATINGS } from 'shared/constants/messages';
export const buildFilterList = (items, type) =>
items.map(item => ({
id: item.id,
name: type === 'ratings' ? item.emoji : item.name,
type,
}));
export const buildRatingsList = t =>
CSAT_RATINGS.map(rating => ({
id: rating.value,
name: `${t(rating.translationKey)}`,
type: 'ratings',
}));
export const getActiveFilter = (filters, type, key) =>
filters.find(item => item.id.toString() === key.toString());
export const getFilterType = (input, direction) => {
const filterMap = {
keyToType: {
user_ids: 'agents',
inbox_id: 'inboxes',
team_id: 'teams',
rating: 'ratings',
},
typeToKey: {
agents: 'user_ids',
inboxes: 'inbox_id',
teams: 'team_id',
ratings: 'rating',
},
};
return filterMap[direction][input];
};
@@ -1,267 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import {
buildFilterList,
buildRatingsList,
getActiveFilter,
getFilterType,
} from './CsatFilterHelpers';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
const props = defineProps({
showTeamFilter: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['filterChange']);
const { t } = useI18n();
const store = useStore();
const showDropdownMenu = ref(false);
const showSubDropdownMenu = ref(false);
const activeFilterType = ref('');
const customDateRange = ref([new Date(), new Date()]);
const appliedFilters = ref({
user_ids: null,
inbox_id: null,
team_id: null,
rating: null,
});
const agents = computed(() => store.getters['agents/getAgents']);
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
const teams = computed(() => store.getters['teams/getTeams']);
const ratings = computed(() => buildRatingsList(t));
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
const getFilterSource = type => {
const sources = {
agents: agents.value,
inboxes: inboxes.value,
teams: teams.value,
ratings: ratings.value,
};
return sources[type] || [];
};
const getFilterOptions = type => {
if (type === 'ratings') {
return ratings.value;
}
return buildFilterList(getFilterSource(type), type);
};
const filterListMenuItems = computed(() => {
const filterTypes = [
{
id: '1',
name: t('CSAT_REPORTS.FILTERS.AGENTS.LABEL'),
type: 'agents',
},
{
id: '2',
name: t('CSAT_REPORTS.FILTERS.INBOXES.LABEL'),
type: 'inboxes',
},
{
id: '3',
name: t('CSAT_REPORTS.FILTERS.RATINGS.LABEL'),
type: 'ratings',
},
];
if (props.showTeamFilter) {
filterTypes.splice(2, 0, {
id: '4',
name: t('CSAT_REPORTS.FILTERS.TEAMS.LABEL'),
type: 'teams',
});
}
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
const activeFilterTypes = activeFilterKeys.map(key =>
getFilterType(key, 'keyToType')
);
return filterTypes
.filter(({ type }) => !activeFilterTypes.includes(type))
.map(({ id, name, type }) => ({
id,
name,
type,
options: getFilterOptions(type),
}));
});
const activeFilters = computed(() => {
const activeKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
return activeKeys.map(key => {
const filterType = getFilterType(key, 'keyToType');
const items = getFilterSource(filterType);
const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
return {
id: item?.id,
name: item?.name || '',
type: filterType,
options: getFilterOptions(filterType),
};
});
});
const hasActiveFilters = computed(() =>
Object.values(appliedFilters.value).some(value => value !== null)
);
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
const emitChange = () => {
emit('filterChange', {
from: from.value,
to: to.value,
selectedAgents: appliedFilters.value.user_ids
? [{ id: appliedFilters.value.user_ids }]
: [],
selectedInbox: appliedFilters.value.inbox_id
? { id: appliedFilters.value.inbox_id }
: null,
selectedTeam: appliedFilters.value.team_id
? { id: appliedFilters.value.team_id }
: null,
selectedRating: appliedFilters.value.rating
? { value: appliedFilters.value.rating }
: null,
});
};
const closeDropdown = () => {
showDropdownMenu.value = false;
};
const closeActiveFilterDropdown = () => {
activeFilterType.value = '';
showSubDropdownMenu.value = false;
};
const resetDropdown = () => {
closeDropdown();
closeActiveFilterDropdown();
};
const addFilter = item => {
const { type, id } = item;
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = id;
emitChange();
resetDropdown();
};
const removeFilter = type => {
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = null;
emitChange();
};
const clearAllFilters = () => {
appliedFilters.value = {
user_ids: null,
inbox_id: null,
team_id: null,
rating: null,
};
emitChange();
resetDropdown();
};
const showDropdown = () => {
showSubDropdownMenu.value = false;
showDropdownMenu.value = !showDropdownMenu.value;
};
const openActiveFilterDropdown = filterType => {
closeDropdown();
activeFilterType.value = filterType;
showSubDropdownMenu.value = !showSubDropdownMenu.value;
};
const onDateRangeChange = value => {
customDateRange.value = value;
emitChange();
};
onMounted(() => {
emitChange();
});
</script>
<template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<WootDatePicker @date-range-changed="onDateRangeChange" />
<div
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
>
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 md:flex-nowrap">
<ActiveFilterChip
v-for="filter in activeFilters"
v-bind="filter"
:key="filter.type"
:placeholder="
$t(
`CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER.${filter.type.toUpperCase()}`
)
"
:active-filter-type="activeFilterType"
:show-menu="showSubDropdownMenu"
enable-search
@toggle-dropdown="openActiveFilterDropdown"
@close-dropdown="closeActiveFilterDropdown"
@add-filter="addFilter"
@remove-filter="removeFilter"
/>
</div>
<div
v-if="hasActiveFilters && !isAllFilterSelected"
class="w-full h-px border md:w-px md:h-5 border-n-weak"
/>
<div class="flex items-center gap-2">
<AddFilterChip
v-if="!isAllFilterSelected"
placeholder-i18n-key="CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER"
:name="$t('CSAT_REPORTS.FILTERS.ADD_FILTER')"
:menu-option="filterListMenuItems"
:show-menu="showDropdownMenu"
:empty-state-message="$t('CSAT_REPORTS.FILTERS.NO_FILTER')"
@toggle-dropdown="showDropdown"
@close-dropdown="closeDropdown"
@add-filter="addFilter"
/>
<div v-if="hasActiveFilters" class="w-px h-5 border border-n-weak" />
<FilterButton
v-if="hasActiveFilters"
:button-text="$t('CSAT_REPORTS.FILTERS.CLEAR_ALL')"
@click="clearAllFilters"
/>
</div>
</div>
</div>
</template>
@@ -1,52 +0,0 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
contact: {
type: Object,
default: null,
},
conversationId: {
type: Number,
required: true,
},
createdAgo: {
type: String,
default: '',
},
createdAt: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex items-center gap-2">
<Avatar
:src="contact?.thumbnail || ''"
:name="contact?.name || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col gap-0.5">
<span class="text-sm text-n-slate-12 font-medium capitalize">
{{ contact?.name || '—' }}
</span>
<div
class="flex items-center gap-1 text-xs text-n-slate-10 whitespace-nowrap"
>
<a
:href="`/app/accounts/${$route.params.accountId}/conversations/${conversationId}`"
class="flex items-center text-xs gap-0.5 hover:text-n-brand hover:underline"
target="_blank"
rel="noopener noreferrer nofollow"
>
<span>#{{ conversationId }}</span>
</a>
<span>·</span>
<span :title="createdAt">{{ createdAgo }}</span>
</div>
</div>
</div>
</template>
@@ -1,32 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
icon: {
type: String,
default: 'i-lucide-message-square-off',
},
});
</script>
<template>
<div class="flex flex-col items-center justify-center py-16 px-6 text-center">
<div
class="size-16 rounded-full bg-n-alpha-2 flex items-center justify-center mb-4"
>
<i :class="icon" class="size-8 text-n-slate-10" />
</div>
<h3 class="text-base font-medium text-n-slate-12 mb-1">
{{ title }}
</h3>
<p v-if="description" class="text-sm text-n-slate-10 max-w-sm">
{{ description }}
</p>
</div>
</template>
@@ -1,162 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CsatReviewNotesPaywall from './CsatReviewNotesPaywall.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
const props = defineProps({
response: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
const { formatMessage } = useMessageFormatter();
const isFeatureEnabled = computed(() =>
isCloudFeatureEnabled('csat_review_notes')
);
const showPaywall = computed(
() => !isFeatureEnabled.value && isOnChatwootCloud.value
);
const reviewNotes = ref(props.response.csat_review_notes || '');
const isEditing = ref(!props.response.csat_review_notes);
const isSaving = ref(false);
const hasExistingReviewNotes = computed(
() => !!props.response.csat_review_notes
);
const hasChanges = computed(
() => reviewNotes.value !== (props.response.csat_review_notes || '')
);
const startEditing = () => {
isEditing.value = true;
};
const cancelEditing = () => {
reviewNotes.value = props.response.csat_review_notes || '';
if (hasExistingReviewNotes.value) {
isEditing.value = false;
}
};
const saveReviewNotes = async () => {
isSaving.value = true;
try {
await store.dispatch('csat/update', {
id: props.response.id,
reviewNotes: reviewNotes.value,
});
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVED'));
isEditing.value = false;
} catch {
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVE_ERROR'));
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="py-4 px-5 border-t border-n-container bg-n-background">
<CsatReviewNotesPaywall v-if="showPaywall" />
<div v-else-if="isFeatureEnabled" class="flex flex-col gap-3">
<div class="flex items-start gap-4">
<div
class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36 pt-3"
>
<i class="i-lucide-notebook-pen size-4" />
<span class="text-sm font-medium">
{{ $t('CSAT_REPORTS.REVIEW_NOTES.TITLE') }}
</span>
</div>
<div class="flex-1 max-w-2xl">
<div
v-if="hasExistingReviewNotes && !isEditing"
class="group flex items-start gap-2 py-2 px-3 rounded-lg hover:bg-n-slate-2 dark:hover:bg-n-solid-3 cursor-pointer transition-colors"
@click.stop="startEditing"
>
<p
v-dompurify-html="formatMessage(response.csat_review_notes || '')"
class="flex-1 text-sm text-n-slate-12 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0"
/>
<i
class="i-lucide-pencil size-4 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5"
/>
</div>
<div
v-else
class="flex flex-col gap-3 [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:max-h-64 [&_.ProseMirror-menubar]:!mt-0"
@click.stop
>
<Editor
v-model="reviewNotes"
:placeholder="$t('CSAT_REPORTS.REVIEW_NOTES.PLACEHOLDER')"
:show-character-count="false"
:enable-canned-responses="false"
focus-on-mount
>
<template #actions>
<div class="flex items-center gap-2 py-2">
<Button
v-if="hasExistingReviewNotes"
variant="ghost"
size="xs"
:label="$t('CSAT_REPORTS.REVIEW_NOTES.CANCEL')"
@click.stop="cancelEditing"
/>
<Button
:label="$t('CSAT_REPORTS.REVIEW_NOTES.SAVE')"
:disabled="!hasChanges || isSaving"
:loading="isSaving"
size="xs"
@click.stop="saveReviewNotes"
/>
</div>
</template>
</Editor>
</div>
</div>
</div>
<div
v-if="
hasExistingReviewNotes &&
!isEditing &&
response.review_notes_updated_by
"
class="flex items-center gap-4"
>
<div class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36">
<i class="i-lucide-user-pen size-4" />
<span class="text-sm font-medium">
{{ $t('CSAT_REPORTS.REVIEW_NOTES.UPDATED_BY_LABEL') }}
</span>
</div>
<div class="flex items-center gap-1 flex-1 max-w-2xl px-3">
<span class="text-sm text-n-slate-12">
{{ response.review_notes_updated_by.name }}
</span>
<span class="text-n-slate-10">·</span>
<span class="text-sm text-n-slate-10">
{{ dynamicTime(response.review_notes_updated_at) }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -1,53 +0,0 @@
<script setup>
import CsatMetricCard from './CsatMetricCard.vue';
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Reports/CsatMetricCard"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Default">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Total Responses"
tooltip="Total number of responses received"
value="1,234"
/>
</div>
</Variant>
<Variant title="Percentage Value">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Satisfaction Score"
tooltip="Percentage of positive responses"
value="85%"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Response Rate"
tooltip="Percentage of conversations with responses"
value="0"
is-loading
/>
</div>
</Variant>
<Variant title="Zero Value">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Total Responses"
tooltip="Total number of responses received"
value="0"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,41 +0,0 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
tooltip: {
type: String,
required: true,
},
value: {
type: [String, Number],
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div class="flex flex-col gap-2 items-start justify-center min-w-[10rem]">
<span
class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11"
>
{{ label }}
<span
v-tooltip.right="tooltip"
class="i-lucide-info flex flex-shrink-0 text-n-slate-10 size-3.5"
/>
</span>
<div
v-if="isLoading"
class="w-16 h-8 rounded-md bg-n-slate-3 animate-pulse"
/>
<span v-else class="text-2xl font-medium text-n-slate-12">
{{ value }}
</span>
</div>
</template>
@@ -1,63 +1,135 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import CsatMetricCard from './CsatMetricCard.vue';
import CsatRatingDistribution from './CsatRatingDistribution.vue';
<script>
import { mapGetters } from 'vuex';
import CsatMetricCard from './ReportMetricCard.vue';
import { CSAT_RATINGS } from 'shared/constants/messages';
import BarChart from 'shared/components/charts/BarChart.vue';
const metrics = useMapGetter('csat/getMetrics');
const ratingPercentage = useMapGetter('csat/getRatingPercentage');
const ratingCount = useMapGetter('csat/getRatingCount');
const satisfactionScore = useMapGetter('csat/getSatisfactionScore');
const responseRate = useMapGetter('csat/getResponseRate');
const uiFlags = useMapGetter('csat/getUIFlags');
const isLoading = computed(() => uiFlags.value.isFetchingMetrics);
const responseCount = computed(() =>
metrics.value.totalResponseCount
? metrics.value.totalResponseCount.toLocaleString()
: '0'
);
const formatPercent = value => (value ? `${value}%` : '0%');
export default {
components: { BarChart, CsatMetricCard },
props: {
filters: {
type: Object,
required: true,
},
},
data() {
return {
csatRatings: CSAT_RATINGS,
};
},
computed: {
...mapGetters({
metrics: 'csat/getMetrics',
ratingPercentage: 'csat/getRatingPercentage',
satisfactionScore: 'csat/getSatisfactionScore',
responseRate: 'csat/getResponseRate',
}),
ratingFilterEnabled() {
return Boolean(this.filters.rating);
},
chartData() {
const sortedRatings = [...CSAT_RATINGS].sort((a, b) => b.value - a.value);
return {
labels: ['Rating'],
datasets: sortedRatings.map(rating => ({
label: rating.emoji,
data: [this.ratingPercentage[rating.value]],
backgroundColor: rating.color,
})),
};
},
responseCount() {
return this.metrics.totalResponseCount
? this.metrics.totalResponseCount.toLocaleString()
: '--';
},
chartOptions() {
return {
indexAxis: 'y',
responsive: true,
plugins: {
legend: {
display: false,
},
title: {
display: false,
},
tooltip: {
enabled: false,
},
},
scales: {
x: {
display: false,
stacked: true,
},
y: {
display: false,
stacked: true,
},
},
};
},
},
methods: {
formatToPercent(value) {
return value ? `${value}%` : '--';
},
ratingToEmoji(value) {
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
},
},
};
</script>
<!-- eslint-disable vue/no-unused-refs -->
<!-- Added ref for writing specs -->
<template>
<div class="flex flex-col gap-4">
<div
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="responseCount"
:is-loading="isLoading"
/>
<div class="w-full sm:w-px bg-n-strong" />
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="formatPercent(satisfactionScore)"
:is-loading="isLoading"
/>
<div class="w-full sm:w-px bg-n-strong" />
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatPercent(responseRate)"
:is-loading="isLoading"
/>
</div>
<CsatRatingDistribution
:rating-percentage="ratingPercentage"
:rating-count="ratingCount"
:total-response-count="metrics.totalResponseCount"
:is-loading="isLoading"
<div
class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4"
>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="responseCount"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<CsatMetricCard
:disabled="ratingFilterEnabled"
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatToPercent(responseRate)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<div
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
ref="csatBarChart"
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial]"
>
<h3
class="flex items-center m-0 text-xs font-medium md:text-sm text-n-slate-12"
>
<div class="flex flex-row-reverse justify-end">
<div
v-for="(rating, key, index) in ratingPercentage"
:key="rating + key + index"
class="ltr:pr-4 rtl:pl-4"
>
<span class="my-0 mx-0.5">{{ ratingToEmoji(key) }}</span>
<span>{{ formatToPercent(rating) }}</span>
</div>
</div>
</h3>
<div class="mt-2 h-6">
<BarChart :collection="chartData" :chart-options="chartOptions" />
</div>
</div>
</div>
</template>
@@ -1,75 +0,0 @@
<script setup>
import CsatRatingDistribution from './CsatRatingDistribution.vue';
const sampleRatingPercentage = {
1: 5,
2: 10,
3: 15,
4: 25,
5: 45,
};
const sampleRatingCount = {
1: 50,
2: 100,
3: 150,
4: 250,
5: 450,
};
const emptyRatingPercentage = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
const emptyRatingCount = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Reports/CsatRatingDistribution"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With Data">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="sampleRatingPercentage"
:rating-count="sampleRatingCount"
:total-response-count="1000"
/>
</div>
</Variant>
<Variant title="Empty State">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="emptyRatingPercentage"
:rating-count="emptyRatingCount"
:total-response-count="0"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="emptyRatingPercentage"
:rating-count="emptyRatingCount"
:total-response-count="0"
is-loading
/>
</div>
</Variant>
</Story>
</template>
@@ -1,101 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS } from 'shared/constants/messages';
const props = defineProps({
ratingPercentage: {
type: Object,
default: () => ({}),
},
ratingCount: {
type: Object,
default: () => ({}),
},
totalResponseCount: {
type: Number,
default: 0,
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const sortedRatings = computed(() =>
[...CSAT_RATINGS].sort((a, b) => b.value - a.value)
);
const formatPercent = value => (value ? `${value}%` : '0%');
const getRatingLabel = value => {
const rating = CSAT_RATINGS.find(r => r.value === value);
return rating ? t(rating.translationKey) : '';
};
const getRatingCount = value => {
return props.ratingCount[value] || 0;
};
</script>
<template>
<div
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<span class="text-sm font-medium text-n-slate-11">
{{ $t('CSAT_REPORTS.METRIC.RATING_DISTRIBUTION') }}
</span>
<div v-if="isLoading" class="mt-4">
<div class="h-6 w-full rounded-full bg-n-slate-3 animate-pulse" />
<div class="flex gap-6 mt-4">
<div
v-for="n in 5"
:key="n"
class="h-4 w-20 rounded bg-n-slate-3 animate-pulse"
/>
</div>
</div>
<div v-else class="mt-4">
<div
v-if="totalResponseCount"
class="flex h-6 w-full rounded-full overflow-hidden bg-n-alpha-2"
>
<div
v-for="rating in sortedRatings"
:key="rating.value"
v-tooltip="
`${getRatingLabel(rating.value)}: ${formatPercent(ratingPercentage[rating.value])} (${getRatingCount(rating.value)})`
"
:style="{
width: `${ratingPercentage[rating.value]}%`,
backgroundColor: rating.color,
}"
class="h-full transition-all duration-300 first:rounded-s-full last:rounded-e-full cursor-default"
/>
</div>
<div v-else class="h-6 w-full rounded-full bg-n-alpha-2" />
<div class="flex flex-wrap gap-x-6 gap-y-2 mt-4">
<div
v-for="rating in sortedRatings"
:key="rating.value"
class="flex items-center gap-2"
>
<span class="text-sm text-n-slate-11">
{{ getRatingLabel(rating.value) }}
</span>
<span class="text-sm font-medium text-n-slate-12">
{{ formatPercent(ratingPercentage[rating.value]) }}
</span>
<span class="text-xs text-n-slate-10">
({{ getRatingCount(rating.value) }})
</span>
</div>
</div>
</div>
</div>
</template>
@@ -1,28 +0,0 @@
<script setup>
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentAccountId = useMapGetter('getCurrentAccountId');
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: currentAccountId.value },
});
};
</script>
<template>
<div class="py-4 px-5 bg-n-background">
<div class="flex justify-center">
<BasePaywallModal
feature-prefix="CSAT_REPORTS.REVIEW_NOTES"
i18n-key="PAYWALL"
is-on-chatwoot-cloud
@upgrade="goToBillingSettings"
/>
</div>
</div>
</template>
@@ -1,19 +1,19 @@
<script setup>
import { ref, computed } from 'vue';
import { defineEmits, computed, h } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
// [TODO] Instead of converting the values to their reprentation when building the tableData
// We should do the change in the cell
import { messageStamp, dynamicTime } from 'shared/helpers/timeHelper';
// components
import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import CsatContactCell from './CsatContactCell.vue';
import CsatExpandedRow from './CsatExpandedRow.vue';
import CsatEmptyState from './CsatEmptyState.vue';
import CsatTableLoader from './CsatTableLoader.vue';
import ConversationCell from './ConversationCell.vue';
// constants
import { CSAT_RATINGS } from 'shared/constants/messages';
import {
@@ -31,82 +31,97 @@ const { pageIndex } = defineProps({
const emit = defineEmits(['pageChange']);
const { t } = useI18n();
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
// const isRTL = useMapGetter('accounts/isRTL');
const csatResponses = useMapGetter('csat/getCSATResponses');
const isFeatureEnabled = computed(() =>
isCloudFeatureEnabled('csat_review_notes')
);
const showExpandableRows = computed(
() => isFeatureEnabled.value || isOnChatwootCloud.value
);
const metrics = useMapGetter('csat/getMetrics');
const uiFlags = useMapGetter('csat/getUIFlags');
const isLoading = computed(() => uiFlags.value.isFetching);
const expandedRows = ref({});
const toggleRow = id => {
expandedRows.value = {
...expandedRows.value,
[id]: !expandedRows.value[id],
};
};
const isRowExpanded = id => !!expandedRows.value[id];
const tableData = computed(() => {
return csatResponses.value.map(response => ({
id: response.id,
contact: response.contact,
assignedAgent: response.assigned_agent,
rating: response.rating,
feedbackText: response.feedback_message || '',
feedbackText: response.feedback_message || '---',
conversationId: response.conversation_id,
csatReviewNotes: response.csat_review_notes,
createdAgo: dynamicTime(response.created_at),
createdAt: messageStamp(response.created_at, 'LLL d yyyy, h:mm a'),
_original: response,
}));
});
const getRatingData = rating => {
return CSAT_RATINGS.find(r => r.value === rating) || {};
const defaultSpanRender = cellProps => {
const value = cellProps.getValue() || '---';
return h(
'span',
{
class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
title: value,
},
value
);
};
const columnHelper = createColumnHelper();
const columns = computed(() => {
const baseColumns = [
columnHelper.accessor('contact', {
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
}),
columnHelper.accessor('rating', {
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
size: 120,
}),
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
size: 500,
}),
columnHelper.accessor('assignedAgent', {
header: t('CSAT_REPORTS.TABLE.HEADER.HANDLED_BY'),
size: 160,
}),
];
const columns = computed(() => [
columnHelper.accessor('contact', {
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
width: 200,
cell: cellProps => {
const { contact } = cellProps.row.original;
if (contact) {
return h(UserAvatarWithName, {
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
}),
columnHelper.accessor('assignedAgent', {
header: t('CSAT_REPORTS.TABLE.HEADER.AGENT_NAME'),
width: 200,
cell: cellProps => {
const { assignedAgent } = cellProps.row.original;
if (assignedAgent) {
return h(UserAvatarWithName, {
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
}),
columnHelper.accessor('rating', {
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
align: 'center',
width: 80,
cell: cellProps => {
const { rating: giveRating } = cellProps.row.original;
const [ratingObject = {}] = CSAT_RATINGS.filter(
rating => rating.value === giveRating
);
if (showExpandableRows.value) {
baseColumns.push(
columnHelper.accessor('actions', {
header: '',
size: 50,
})
);
}
return baseColumns;
});
return h(
'span',
{
class: ratingObject.emoji
? 'emoji-response text-lg'
: 'text-n-slate-10',
},
ratingObject.emoji || '---'
);
},
}),
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400,
cell: defaultSpanRender,
}),
columnHelper.accessor('conversationId', {
header: '',
width: 100,
cell: cellProps => h(ConversationCell, cellProps),
}),
]);
const paginationParams = computed(() => {
return {
@@ -134,124 +149,25 @@ const table = useVueTable({
},
},
onPaginationChange: updater => {
const newPagination = updater(paginationParams.value);
emit('pageChange', newPagination.pageIndex);
const newPagintaion = updater(paginationParams.value);
emit('pageChange', newPagintaion.pageIndex);
},
});
</script>
<template>
<div
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 overflow-hidden"
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<CsatTableLoader v-if="isLoading" />
<div v-else-if="tableData.length" class="overflow-x-auto">
<table class="w-full">
<thead class="bg-n-solid-2 border-b border-n-container">
<tr>
<th
v-for="header in table.getFlatHeaders()"
:key="header.id"
:style="{
width: header.getSize() ? `${header.getSize()}px` : 'auto',
}"
class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
>
{{ header.column.columnDef.header }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-container">
<template v-for="row in tableData" :key="row.id">
<tr
class="group hover:bg-n-slate-2 dark:hover:bg-n-solid-3 transition-colors"
:class="{
'bg-n-slate-2 dark:bg-n-solid-3': isRowExpanded(row.id),
'cursor-pointer': showExpandableRows,
}"
@click="showExpandableRows && toggleRow(row.id)"
>
<td class="py-4 px-5">
<CsatContactCell
:contact="row.contact"
:conversation-id="row.conversationId"
:created-ago="row.createdAgo"
:created-at="row.createdAt"
/>
</td>
<td class="py-4 px-5">
<div
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg"
:style="{
backgroundColor: `${getRatingData(row.rating).color}20`,
}"
>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ $t(getRatingData(row.rating).translationKey) }}
</span>
</div>
</td>
<td class="py-4 px-5">
<span
v-if="!row.feedbackText"
class="text-n-slate-10 italic text-sm"
>
{{ $t('CSAT_REPORTS.NO_FEEDBACK') }}
</span>
<div v-else class="text-sm text-n-slate-12">
<ShowMore :text="row.feedbackText" :limit="100" />
</div>
</td>
<td class="py-4 px-5">
<UserAvatarWithName
v-if="row.assignedAgent"
:user="row.assignedAgent"
:size="28"
/>
<span v-else class="text-n-slate-10 text-sm italic">
{{ $t('CSAT_REPORTS.NO_AGENT') }}
</span>
</td>
<td v-if="showExpandableRows" class="py-4 px-5">
<div
class="p-1.5 rounded-md text-n-slate-10 group-hover:text-n-slate-12 transition-colors"
>
<i
class="size-4 block transition-transform duration-200"
:class="
isRowExpanded(row.id)
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
/>
</div>
</td>
</tr>
<tr
v-if="showExpandableRows && isRowExpanded(row.id)"
class="!border-t-0"
>
<td colspan="5" class="p-0 !border-t-0">
<CsatExpandedRow :response="row._original" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<CsatEmptyState
v-else
:title="$t('CSAT_REPORTS.NO_RECORDS')"
:description="$t('CSAT_REPORTS.NO_RECORDS_DESCRIPTION')"
/>
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<div
v-if="metrics.totalResponseCount"
class="px-6 py-4 border-t border-n-weak"
v-show="!tableData.length"
class="h-48 flex items-center justify-center text-n-slate-12 text-sm"
>
<Pagination :table="table" />
{{ $t('CSAT_REPORTS.NO_RECORDS') }}
</div>
<div v-if="metrics.totalResponseCount" class="table-pagination">
<Pagination class="mt-2" :table="table" />
</div>
</div>
</template>
@@ -1,36 +0,0 @@
<script setup>
defineProps({
rows: {
type: Number,
default: 5,
},
});
</script>
<template>
<div class="p-6">
<div class="space-y-4">
<div class="flex gap-4 pb-3 border-b border-n-weak">
<div class="h-4 w-32 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-28 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-for="n in rows" :key="n" class="flex items-center gap-4 py-3">
<div class="flex items-center gap-2 w-48">
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
<div class="h-4 w-24 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="flex items-center gap-2 w-44">
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="h-7 w-24 rounded-lg bg-n-slate-3 animate-pulse" />
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-16 rounded bg-n-slate-3 animate-pulse" />
</div>
</div>
</div>
</template>
@@ -6,6 +6,7 @@ describe('CsatMetrics.vue', () => {
let getters;
let store;
let wrapper;
const filters = { rating: 3 };
beforeEach(() => {
getters = {
@@ -17,16 +18,8 @@ describe('CsatMetrics.vue', () => {
4: 30,
5: 10,
}),
'csat/getRatingCount': () => ({
1: 10,
2: 20,
3: 30,
4: 30,
5: 10,
}),
'csat/getSatisfactionScore': () => 85,
'csat/getResponseRate': () => 90,
'csat/getUIFlags': () => ({ isFetchingMetrics: false }),
};
store = createStore({
@@ -35,31 +28,40 @@ describe('CsatMetrics.vue', () => {
wrapper = shallowMount(CsatMetrics, {
global: {
plugins: [store],
plugins: [store], // Ensure the store is injected here
mocks: {
$t: msg => msg,
$t: msg => msg, // mock translation function
},
stubs: {
CsatMetricCard: true,
CsatRatingDistribution: true,
CsatMetricCard: '<csat-metric-card/>',
BarChart: '<woot-horizontal-bar/>',
},
},
props: { filters },
});
});
it('computes response count correctly', () => {
expect(wrapper.vm.responseCount).toBe('100');
expect(wrapper.html()).toMatchSnapshot();
});
it('renders metric cards with correct values', () => {
const metricCards = wrapper.findAllComponents({ name: 'CsatMetricCard' });
expect(metricCards).toHaveLength(3);
it('formats values to percent correctly', () => {
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
expect(wrapper.vm.formatToPercent(null)).toBe('--');
});
it('renders rating distribution component', () => {
const distribution = wrapper.findComponent({
name: 'CsatRatingDistribution',
});
expect(distribution.exists()).toBe(true);
it('maps rating value to emoji correctly', () => {
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
});
it('hides report card if rating filter is enabled', () => {
expect(wrapper.html()).not.toContain('bar-chart-stub');
});
it('shows report card if rating filter is not enabled', async () => {
await wrapper.setProps({ filters: {} });
expect(wrapper.html()).toContain('bar-chart-stub');
});
});
@@ -0,0 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CsatMetrics.vue > computes response count correctly 1`] = `
"<div class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4">
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="100"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="--"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="90%"></csat-metric-card-stub>
<!--v-if-->
</div>"
`;
@@ -82,9 +82,6 @@ export const getters = {
),
};
},
getRatingCount(_state) {
return _state.metrics.ratingsCount;
},
};
export const actions = {
@@ -118,13 +115,6 @@ export const actions = {
});
});
},
update: async ({ commit }, { id, reviewNotes }) => {
const response = await CSATReports.update(id, {
csat_review_notes: reviewNotes,
});
commit(types.UPDATE_CSAT_RESPONSE, response.data);
return response.data;
},
};
export const mutations = {
@@ -154,7 +144,6 @@ export const mutations = {
};
_state.metrics.totalSentMessagesCount = totalSentMessagesCount || 0;
},
[types.UPDATE_CSAT_RESPONSE]: MutationHelpers.update,
};
export default {
@@ -86,19 +86,4 @@ describe('#getters', () => {
})
).toEqual('50.00');
});
it('getRatingCount', () => {
const state = {
metrics: {
ratingsCount: { 1: 10, 2: 20, 3: 15, 4: 3, 5: 2 },
},
};
expect(getters.getRatingCount(state)).toEqual({
1: 10,
2: 20,
3: 15,
4: 3,
5: 2,
});
});
});
@@ -241,7 +241,6 @@ export default {
SET_CSAT_RESPONSE_UI_FLAG: 'SET_CSAT_RESPONSE_UI_FLAG',
SET_CSAT_RESPONSE: 'SET_CSAT_RESPONSE',
SET_CSAT_RESPONSE_METRICS: 'SET_CSAT_RESPONSE_METRICS',
UPDATE_CSAT_RESPONSE: 'UPDATE_CSAT_RESPONSE',
// Custom Attributes
SET_CUSTOM_ATTRIBUTE_UI_FLAG: 'SET_CUSTOM_ATTRIBUTE_UI_FLAG',
+5 -7
View File
@@ -16,12 +16,10 @@ class Conversations::ResolutionJob < ApplicationJob
private
def conversation_scope(account)
base_scope = if account.auto_resolve_ignore_waiting
account.conversations.resolvable_not_waiting(account.auto_resolve_after)
else
account.conversations.resolvable_all(account.auto_resolve_after)
end
# Exclude orphan conversations where contact was deleted but conversation cleanup is pending
base_scope.where.not(contact_id: nil)
if account.auto_resolve_ignore_waiting
account.conversations.resolvable_not_waiting(account.auto_resolve_after)
else
account.conversations.resolvable_all(account.auto_resolve_after)
end
end
end
-7
View File
@@ -34,7 +34,6 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates
before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
def name
'Whatsapp'
@@ -87,10 +86,4 @@ class Channel::Whatsapp < ApplicationRecord
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
def should_auto_setup_webhooks?
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
end
end
@@ -2,21 +2,17 @@ module ConversationMuteHelpers
extend ActiveSupport::Concern
def mute!
return unless contact
resolved!
contact.update(blocked: true)
create_muted_message
end
def unmute!
return unless contact
contact.update(blocked: false)
create_unmuted_message
end
def muted?
contact&.blocked? || false
contact.blocked?
end
end
-1
View File
@@ -27,7 +27,6 @@ class CsatSurveyResponse < ApplicationRecord
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
belongs_to :review_notes_updated_by, class_name: 'User', optional: true
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
-2
View File
@@ -90,8 +90,6 @@ class User < ApplicationRecord
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
dependent: :nullify, inverse_of: :review_notes_updated_by
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation
+1 -36
View File
@@ -20,7 +20,7 @@ class CsatSurveyService
delegate :inbox, :contact, to: :conversation
def should_send_csat_survey?
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
conversation_allows_csat? && csat_enabled? && !csat_already_sent?
end
def conversation_allows_csat?
@@ -39,37 +39,6 @@ class CsatSurveyService
conversation.can_reply?
end
def csat_allowed_by_survey_rules?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
rule_values.any?
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
@@ -144,10 +113,6 @@ class CsatSurveyService
)
end
def csat_config
inbox.csat_config || {}
end
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
@@ -2,6 +2,8 @@ class MessageTemplates::Template::CsatSurvey
pattr_initialize [:conversation!]
def perform
return unless should_send_csat_survey?
ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params)
end
@@ -11,6 +13,39 @@ class MessageTemplates::Template::CsatSurvey
delegate :contact, :account, :inbox, to: :conversation
def should_send_csat_survey?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
return false if rule_values.empty?
true
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def message_content
return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
@@ -16,10 +16,6 @@ class Whatsapp::EmbeddedSignupService
validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
# 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
channel.setup_webhooks
check_channel_health_and_prompt_reauth(channel)
channel
@@ -10,7 +10,10 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
def download_attachment_file(attachment_payload)
url_response = HTTParty.get(
inbox.channel.media_url(attachment_payload[:id]),
inbox.channel.media_url(
attachment_payload[:id],
inbox.channel.provider_config['phone_number_id']
),
headers: inbox.channel.api_headers
)
# This url response will be failure if the access token has expired.
@@ -75,8 +75,10 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
csat_template_service.get_template_status(template_name)
end
def media_url(media_id)
"#{api_base_path}/v13.0/#{media_id}"
def media_url(media_id, phone_number_id = nil)
url = "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
end
private
@@ -1,5 +1,5 @@
<%
headers = [
<%=
CSV.generate_line([
I18n.t('reports.csat.headers.agent_name'),
I18n.t('reports.csat.headers.rating'),
I18n.t('reports.csat.headers.feedback'),
@@ -8,28 +8,24 @@
I18n.t('reports.csat.headers.contact_phone_number'),
I18n.t('reports.csat.headers.link_to_the_conversation'),
I18n.t('reports.csat.headers.recorded_at')
]
headers << I18n.t('reports.csat.headers.review_notes') if ChatwootApp.enterprise?
])
-%>
<%= CSV.generate_line(headers) -%>
<% @csat_survey_responses.each do |csat_response| %>
<% assigned_agent = csat_response.assigned_agent %>
<% contact = csat_response.contact %>
<% conversation = csat_response.conversation %>
<%
row = [
<%=
CSV.generate_line([
assigned_agent ? "#{assigned_agent.name} (#{assigned_agent.email})" : nil,
csat_response.rating,
csat_response.feedback_message.presence,
contact&.name.presence,
contact&.email.presence,
contact&.phone_number.presence,
conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id) : nil,
csat_response.created_at
]
row << csat_response.csat_review_notes if ChatwootApp.enterprise?
csat_response.feedback_message.present? ? csat_response.feedback_message : nil,
contact&.name.present? ? contact&.name: nil,
contact&.email.present? ? contact&.email: nil,
contact&.phone_number.present? ? contact&.phone_number: nil,
conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id): nil,
csat_response.created_at,
]).html_safe
-%>
<%= CSV.generate_line(row).html_safe -%>
<% end %>
<%=
CSV.generate_line([
@@ -1 +0,0 @@
json.partial! 'api/v1/models/csat_survey_response', formats: [:json], resource: @csat_survey_response
@@ -1,14 +1,6 @@
json.id resource.id
json.rating resource.rating
json.feedback_message resource.feedback_message
json.csat_review_notes resource.csat_review_notes
json.review_notes_updated_at resource.review_notes_updated_at&.to_i
if resource.review_notes_updated_by
json.review_notes_updated_by do
json.id resource.review_notes_updated_by.id
json.name resource.review_notes_updated_by.name
end
end
json.account_id resource.account_id
json.message_id resource.message_id
if resource.contact
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.10.1'
version: '4.9.2'
development:
<<: *shared
-4
View File
@@ -230,7 +230,3 @@
- name: channel_tiktok
display_name: TikTok Channel
enabled: true
- name: csat_review_notes
display_name: CSAT Review Notes
enabled: false
premium: true
-1
View File
@@ -202,7 +202,6 @@ en:
rating: Rating
feedback: Feedback Comment
recorded_at: Recorded date
review_notes: Review Notes
notifications:
notification_title:
conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
-3
View File
@@ -189,9 +189,6 @@ Rails.application.routes.draw do
get :metrics
get :download
end
member do
patch :update if ChatwootApp.enterprise?
end
end
resources :applied_slas, only: [:index] do
collection do
@@ -1,5 +0,0 @@
class AddInternalObservationsToCsatSurveyResponses < ActiveRecord::Migration[7.1]
def change
add_column :csat_survey_responses, :csat_review_notes, :text
end
end
@@ -1,6 +0,0 @@
class AddObservationsAuditToCsatSurveyResponses < ActiveRecord::Migration[7.1]
def change
add_column :csat_survey_responses, :review_notes_updated_at, :datetime
add_reference :csat_survey_responses, :review_notes_updated_by, index: true
end
end
+1 -5
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_14_201315) do
ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -733,15 +733,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_14_201315) do
t.bigint "assigned_agent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "csat_review_notes"
t.datetime "review_notes_updated_at"
t.bigint "review_notes_updated_by_id"
t.index ["account_id"], name: "index_csat_survey_responses_on_account_id"
t.index ["assigned_agent_id"], name: "index_csat_survey_responses_on_assigned_agent_id"
t.index ["contact_id"], name: "index_csat_survey_responses_on_contact_id"
t.index ["conversation_id"], name: "index_csat_survey_responses_on_conversation_id"
t.index ["message_id"], name: "index_csat_survey_responses_on_message_id", unique: true
t.index ["review_notes_updated_by_id"], name: "index_csat_survey_responses_on_review_notes_updated_by_id"
end
create_table "custom_attribute_definitions", force: :cascade do |t|
+6 -6
View File
@@ -2,7 +2,7 @@
FROM node:24-alpine as node
FROM ruby:3.4.4-alpine3.21 AS pre-builder
ARG NODE_VERSION="24.13.0"
ARG NODE_VERSION="24.12.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -11,7 +11,7 @@ ENV PNPM_VERSION=${PNPM_VERSION}
# For development docker-compose file overrides ARGS
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
ENV BUNDLER_VERSION=2.5.16
ENV BUNDLER_VERSION=2.5.11
ARG RAILS_SERVE_STATIC_FILES=true
ENV RAILS_SERVE_STATIC_FILES ${RAILS_SERVE_STATIC_FILES}
@@ -35,7 +35,7 @@ RUN apk update && apk add --no-cache \
curl \
xz \
&& mkdir -p /var/app \
&& gem install bundler -v "$BUNDLER_VERSION"
&& gem install bundler
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
@@ -98,14 +98,14 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
# final build stage
FROM ruby:3.4.4-alpine3.21
ARG NODE_VERSION="24.13.0"
ARG NODE_VERSION="24.12.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
ENV BUNDLER_VERSION=2.5.16
ENV BUNDLER_VERSION=2.5.11
ARG EXECJS_RUNTIME="Disabled"
ENV EXECJS_RUNTIME ${EXECJS_RUNTIME}
@@ -128,7 +128,7 @@ RUN apk update && apk add --no-cache \
imagemagick \
git \
vips \
&& gem install bundler -v "$BUNDLER_VERSION"
&& gem install bundler
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
@@ -1,12 +0,0 @@
module Enterprise::Api::V1::Accounts::CsatSurveyResponsesController
def update
@csat_survey_response = Current.account.csat_survey_responses.find(params[:id])
authorize @csat_survey_response
@csat_survey_response.update!(
csat_review_notes: params[:csat_review_notes],
review_notes_updated_by: Current.user,
review_notes_updated_at: Time.current
)
end
end
@@ -10,8 +10,4 @@ module Enterprise::CsatSurveyResponsePolicy
def download?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def update?
@account_user.administrator? || @account_user.custom_role&.permissions&.include?('report_manage')
end
end
@@ -4,9 +4,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
end
description 'Search conversations based on parameters'
param :status, type: :string, desc: 'Status of the conversation (open, resolved, pending, snoozed). Leave empty to search all statuses.'
param :status, type: :string, desc: 'Status of the conversation'
param :contact_id, type: :number, desc: 'Contact id'
param :priority, type: :string, desc: 'Priority of conversation (low, medium, high, urgent). Leave empty to search all priorities.'
param :priority, type: :string, desc: 'Priority of conversation'
param :labels, type: :string, desc: 'Labels available'
def execute(status: nil, contact_id: nil, priority: nil, labels: nil)
@@ -19,7 +19,7 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
<<~RESPONSE
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true, include_private_messages: true) }.join("\n---\n")}
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
RESPONSE
end
@@ -34,20 +34,12 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
def get_conversations(status, contact_id, priority, labels)
conversations = permissible_conversations
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
conversations = conversations.where(status: status) if valid_status?(status)
conversations = conversations.where(priority: priority) if valid_priority?(priority)
conversations = conversations.where(status: status) if status.present?
conversations = conversations.where(priority: priority) if priority.present?
conversations = conversations.tagged_with(labels, any: true) if labels.present?
conversations
end
def valid_status?(status)
status.present? && Conversation.statuses.key?(status)
end
def valid_priority?(priority)
priority.present? && Conversation.priorities.key?(priority)
end
def permissible_conversations
Conversations::PermissionFilterService.new(
@assistant.account.conversations,
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
-1
View File
@@ -5,4 +5,3 @@
- sla
- captain_integration
- custom_roles
- csat_review_notes
-183
View File
@@ -1,183 +0,0 @@
# Download Report Rake Tasks
#
# Usage:
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:agent
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:inbox
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:label
#
# The task will prompt for:
# - Account ID
# - Start Date (YYYY-MM-DD)
# - End Date (YYYY-MM-DD)
# - Timezone Offset (e.g., 0, 5.5, -5)
# - Business Hours (y/n) - whether to use business hours for time metrics
#
# Output: <account_id>_<type>_<start_date>_<end_date>.csv
require 'csv'
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/ModuleLength
module DownloadReportTasks
def self.prompt(message)
print "#{message}: "
$stdin.gets.chomp
end
def self.collect_params
account_id = prompt('Enter Account ID')
abort 'Error: Account ID is required' if account_id.blank?
account = Account.find_by(id: account_id)
abort "Error: Account with ID '#{account_id}' not found" unless account
start_date = prompt('Enter Start Date (YYYY-MM-DD)')
abort 'Error: Start date is required' if start_date.blank?
end_date = prompt('Enter End Date (YYYY-MM-DD)')
abort 'Error: End date is required' if end_date.blank?
timezone_offset = prompt('Enter Timezone Offset (e.g., 0, 5.5, -5)')
timezone_offset = timezone_offset.blank? ? 0 : timezone_offset.to_f
business_hours = prompt('Use Business Hours? (y/n)')
business_hours = business_hours.downcase == 'y'
begin
tz = ActiveSupport::TimeZone[timezone_offset]
abort "Error: Invalid timezone offset '#{timezone_offset}'" unless tz
since = tz.parse("#{start_date} 00:00:00").to_i.to_s
until_date = tz.parse("#{end_date} 23:59:59").to_i.to_s
rescue StandardError => e
abort "Error parsing dates: #{e.message}"
end
{
account: account,
params: { since: since, until: until_date, timezone_offset: timezone_offset, business_hours: business_hours },
start_date: start_date,
end_date: end_date
}
end
def self.save_csv(filename, headers, rows)
CSV.open(filename, 'w') do |csv|
csv << headers
rows.each { |row| csv << row }
end
puts "Report saved to: #{filename}"
end
def self.format_time(seconds)
return '' if seconds.nil? || seconds.zero?
seconds.round(2)
end
def self.download_agent_report
data = collect_params
account = data[:account]
puts "\nGenerating agent report..."
builder = V2::Reports::AgentSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
users = account.users.index_by(&:id)
headers = %w[id name email conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
user = users[row[:id]]
[
row[:id],
user&.name || 'Unknown',
user&.email || 'Unknown',
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_agent_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
def self.download_inbox_report
data = collect_params
account = data[:account]
puts "\nGenerating inbox report..."
builder = V2::Reports::InboxSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
inboxes = account.inboxes.index_by(&:id)
headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
inbox = inboxes[row[:id]]
[
row[:id],
inbox&.name || 'Unknown',
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_inbox_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
def self.download_label_report
data = collect_params
account = data[:account]
puts "\nGenerating label report..."
builder = V2::Reports::LabelSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
[
row[:id],
row[:name],
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_label_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
end
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/ModuleLength
namespace :download_report do
desc 'Download agent summary report as CSV'
task agent: :environment do
DownloadReportTasks.download_agent_report
end
desc 'Download inbox summary report as CSV'
task inbox: :environment do
DownloadReportTasks.download_inbox_report
end
desc 'Download label summary report as CSV'
task label: :environment do
DownloadReportTasks.download_label_report
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.10.1",
"version": "4.9.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -1,85 +0,0 @@
require 'rails_helper'
RSpec.describe 'Enterprise CSAT Survey Responses API', type: :request do
let(:account) { create(:account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:csat_survey_response) { create(:csat_survey_response, account: account) }
describe 'PATCH /api/v1/accounts/{account.id}/csat_survey_responses/:id' do
let(:update_params) { { csat_review_notes: 'Customer was very satisfied with the resolution' } }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
params: update_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent without permissions' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
headers: agent.create_new_auth_token,
params: update_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated administrator' do
it 'updates the csat survey response review notes' do
freeze_time do
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
headers: administrator.create_new_auth_token,
params: update_params,
as: :json
expect(response).to have_http_status(:success)
csat_survey_response.reload
expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
expect(csat_survey_response.review_notes_updated_by).to eq(administrator)
expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
end
end
end
context 'when it is an agent with report_manage permission' do
let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
let(:agent_with_role) { create(:user) }
before do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
it 'updates the csat survey response review notes' do
freeze_time do
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
headers: agent_with_role.create_new_auth_token,
params: update_params,
as: :json
expect(response).to have_http_status(:success)
csat_survey_response.reload
expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
expect(csat_survey_response.review_notes_updated_by).to eq(agent_with_role)
expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
end
end
end
context 'when csat survey response does not exist' do
it 'returns not found' do
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/0",
headers: administrator.create_new_auth_token,
params: update_params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -119,42 +119,5 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
result = service.execute(status: 'snoozed')
expect(result).to eq('No conversations found')
end
context 'when invalid status is provided' do
it 'ignores invalid status and returns all conversations' do
result = service.execute(status: 'all')
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'ignores random invalid status values' do
result = service.execute(status: 'invalid_status')
expect(result).to include('Total number of conversations: 2')
end
end
context 'when invalid priority is provided' do
it 'ignores invalid priority and returns all conversations' do
result = service.execute(priority: 'all')
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'ignores random invalid priority values' do
result = service.execute(priority: 'invalid_priority')
expect(result).to include('Total number of conversations: 2')
end
end
context 'when combining valid and invalid parameters' do
it 'applies valid filters and ignores invalid ones' do
result = service.execute(status: 'all', contact_id: contact.id)
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
end
end
end
+2 -10
View File
@@ -96,16 +96,8 @@ FactoryBot.define do
channel_whatsapp.define_singleton_method(:sync_templates) { nil } unless options.sync_templates
channel_whatsapp.define_singleton_method(:validate_provider_config) { nil } unless options.validate_provider_config
if channel_whatsapp.provider == 'whatsapp_cloud'
# Add 'source' => 'embedded_signup' to skip after_commit :setup_webhooks callback in tests
# The callback is for manual setup flow; embedded signup handles webhook setup explicitly
# Only set source if not already provided (allows tests to override)
default_config = {
'api_key' => 'test_key',
'phone_number_id' => '123456789',
'business_account_id' => '123456789'
}
default_config['source'] = 'embedded_signup' unless channel_whatsapp.provider_config.key?('source')
channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge(default_config)
channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge({ 'api_key' => 'test_key', 'phone_number_id' => '123456789',
'business_account_id' => '123456789' })
end
end
@@ -48,21 +48,6 @@ RSpec.describe Conversations::ResolutionJob do
end
end
# When a contact is deleted, there's a brief window (~50-150ms) where contact_id becomes nil
# but conversations still exist. If ResolutionJob runs during this window, muted? can crash
# trying to call blocked? on nil. Fixes # (issue).
it 'skips orphan conversations without a contact' do
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
orphan_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
orphan_conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
resolvable_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
described_class.perform_now(account: account)
expect(orphan_conversation.reload.status).to eq('open')
expect(resolvable_conversation.reload.status).to eq('resolved')
end
it 'adds a label after resolution' do
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
+9 -40
View File
@@ -47,39 +47,16 @@ RSpec.describe Channel::Whatsapp do
end
describe 'webhook_verify_token' do
before do
# Stub webhook setup to prevent HTTP calls during channel creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
end
it 'generates webhook_verify_token if not present' do
channel = create(:channel_whatsapp,
provider_config: {
'webhook_verify_token' => nil,
'api_key' => 'test_key',
'business_account_id' => '123456789'
},
provider: 'whatsapp_cloud',
account: create(:account),
validate_provider_config: false,
sync_templates: false)
channel = create(:channel_whatsapp, provider_config: { webhook_verify_token: nil }, provider: 'whatsapp_cloud', account: create(:account),
validate_provider_config: false, sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).not_to be_nil
end
it 'does not generate webhook_verify_token if present' do
channel = create(:channel_whatsapp,
provider: 'whatsapp_cloud',
provider_config: {
'webhook_verify_token' => '123',
'api_key' => 'test_key',
'business_account_id' => '123456789'
},
account: create(:account),
validate_provider_config: false,
sync_templates: false)
channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', provider_config: { webhook_verify_token: '123' }, account: create(:account),
validate_provider_config: false, sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).to eq '123'
end
@@ -114,18 +91,15 @@ RSpec.describe Channel::Whatsapp do
end
context 'when channel is created through manual setup' do
it 'setups webhooks via after_commit callback' do
expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
expect(webhook_service).to receive(:perform)
it 'does not setup webhooks' do
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
# Explicitly set source to nil to test manual setup behavior (not embedded_signup)
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token',
'source' => nil
'api_key' => 'test_access_token'
},
validate_provider_config: false,
sync_templates: false)
@@ -183,17 +157,12 @@ RSpec.describe Channel::Whatsapp do
end
context 'when channel is not embedded_signup' do
it 'calls WebhookTeardownService on destroy' do
# Mock the setup service to prevent HTTP calls during creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
it 'does not call WebhookTeardownService on destroy' do
channel = create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'source' => 'manual',
'api_key' => 'test_access_token'
},
validate_provider_config: false,
-40
View File
@@ -390,20 +390,6 @@ RSpec.describe Conversation do
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has muted the conversation" }))
end
context 'when contact is missing' do
before do
conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
end
it 'does not change conversation status' do
expect { mute! }.not_to(change { conversation.reload.status })
end
it 'does not enqueue an activity message' do
expect { mute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
end
describe '#unmute!' do
@@ -432,22 +418,6 @@ RSpec.describe Conversation do
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has unmuted the conversation" }))
end
context 'when contact is missing' do
let(:conversation) { create(:conversation) }
before do
conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
end
it 'does not change conversation status' do
expect { unmute! }.not_to(change { conversation.reload.status })
end
it 'does not enqueue an activity message' do
expect { unmute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
end
describe '#muted?' do
@@ -463,16 +433,6 @@ RSpec.describe Conversation do
it 'returns false if conversation is not muted' do
expect(muted?).to be(false)
end
context 'when contact is missing' do
before do
conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
end
it 'returns false' do
expect(muted?).to be(false)
end
end
end
describe 'unread_messages' do
-42
View File
@@ -88,25 +88,6 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
end
context 'when survey rules block sending' do
before do
inbox.update(csat_config: {
'survey_rules' => {
'operator' => 'does_not_contain',
'values' => ['bot-detectado']
}
})
conversation.update(label_list: ['bot-detectado'])
end
it 'does not send CSAT' do
service.perform
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
expect(conversation.messages.where(content_type: :input_csat)).to be_empty
end
end
end
context 'when it is a WhatsApp channel' do
@@ -325,29 +306,6 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
end
context 'when survey rules block sending' do
before do
whatsapp_inbox.update(csat_config: {
'template' => { 'name' => 'customer_survey_template', 'language' => 'en' },
'message' => 'Please rate your experience',
'survey_rules' => {
'operator' => 'does_not_contain',
'values' => ['bot-detectado']
}
})
whatsapp_conversation.update(label_list: ['bot-detectado'])
end
it 'does not call WhatsApp template or create a CSAT message' do
expect(mock_provider_service).not_to receive(:get_template_status)
expect(mock_provider_service).not_to receive(:send_template)
whatsapp_service.perform
expect(whatsapp_conversation.messages.where(content_type: :input_csat)).to be_empty
end
end
end
end
@@ -17,24 +17,83 @@ describe MessageTemplates::Template::CsatSurvey do
expect(conversation.messages.template.first.content_type).to eq('input_csat')
end
end
end
context 'when csat config is provided' do
let(:csat_config) do
{
'display_type' => 'star',
'message' => 'Please rate your experience'
describe '#perform with contains operator' do
let(:csat_config) do
{
'display_type' => 'emoji',
'message' => 'Please rate your experience',
'survey_rules' => {
'operator' => 'contains',
'values' => %w[support help]
}
end
}
end
before { inbox.update(csat_config: csat_config) }
before do
inbox.update(csat_config: csat_config)
end
context 'when conversation has matching labels' do
it 'creates a CSAT survey message' do
conversation.update(label_list: %w[support urgent])
it 'creates a CSAT message with configured attributes' do
service.perform
message = conversation.messages.template.last
expect(conversation.messages.template.count).to eq(1)
message = conversation.messages.template.first
expect(message.content_type).to eq('input_csat')
expect(message.content).to eq('Please rate your experience')
expect(message.content_attributes['display_type']).to eq('star')
expect(message.content_attributes['display_type']).to eq('emoji')
end
end
context 'when conversation has no matching labels' do
it 'does not create a CSAT survey message' do
conversation.update(label_list: %w[billing-support payment])
service.perform
expect(conversation.messages.template.count).to eq(0)
end
end
end
describe '#perform with does_not_contain operator' do
let(:csat_config) do
{
'display_type' => 'emoji',
'message' => 'Please rate your experience',
'survey_rules' => {
'operator' => 'does_not_contain',
'values' => %w[support help]
}
}
end
before do
inbox.update(csat_config: csat_config)
end
context 'when conversation does not have matching labels' do
it 'creates a CSAT survey message' do
conversation.update(label_list: %w[billing payment])
service.perform
expect(conversation.messages.template.count).to eq(1)
expect(conversation.messages.template.first.content_type).to eq('input_csat')
end
end
context 'when conversation has matching labels' do
it 'does not create a CSAT survey message' do
conversation.update(label_list: %w[support urgent])
service.perform
expect(conversation.messages.template.count).to eq(0)
end
end
end
@@ -41,7 +41,10 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
it 'increments reauthorization count if fetching attachment fails' do
stub_request(
:get,
whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
whatsapp_channel.media_url(
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
whatsapp_channel.provider_config['phone_number_id']
)
).to_return(
status: 401
)
@@ -109,7 +112,10 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
def stub_media_url_request
stub_request(
:get,
whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
whatsapp_channel.media_url(
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
whatsapp_channel.provider_config['phone_number_id']
)
).to_return(
status: 200,
body: {
@@ -6,8 +6,7 @@ describe Whatsapp::WebhookSetupService do
phone_number: '+1234567890',
provider_config: {
'phone_number_id' => '123456789',
'webhook_verify_token' => 'test_verify_token',
'source' => 'embedded_signup'
'webhook_verify_token' => 'test_verify_token'
},
provider: 'whatsapp_cloud',
sync_templates: false,
@@ -262,8 +261,7 @@ describe Whatsapp::WebhookSetupService do
'phone_number_id' => '123456789',
'webhook_verify_token' => 'existing_verify_token',
'business_id' => 'existing_business_id',
'waba_id' => 'existing_waba_id',
'source' => 'embedded_signup'
'waba_id' => 'existing_waba_id'
},
provider: 'whatsapp_cloud',
sync_templates: false,