Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28c7d13c15 | ||
|
|
5caf3f5705 | ||
|
|
716a5b0dc3 |
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+26
@@ -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>
|
||||
-36
@@ -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];
|
||||
};
|
||||
-267
@@ -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>
|
||||
-52
@@ -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>
|
||||
-32
@@ -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>
|
||||
-162
@@ -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>
|
||||
-53
@@ -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>
|
||||
-41
@@ -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>
|
||||
+128
-56
@@ -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>
|
||||
|
||||
-75
@@ -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>
|
||||
-101
@@ -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>
|
||||
-28
@@ -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>
|
||||
+89
-173
@@ -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>
|
||||
|
||||
-36
@@ -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>
|
||||
+22
-20
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+10
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.10.1'
|
||||
version: '4.9.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
-12
@@ -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
|
||||
|
||||
@@ -5,4 +5,3 @@
|
||||
- sla
|
||||
- captain_integration
|
||||
- custom_roles
|
||||
- csat_review_notes
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.10.1",
|
||||
"version": "4.9.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
-85
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user