Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot] 3d7bb40a11 Initial plan 2025-12-15 08:17:56 +00:00
Muhsin 03e0ee8e7a feat: add conversation workflow be changes 2025-12-15 09:33:22 +05:30
59 changed files with 56 additions and 2717 deletions
-2
View File
@@ -48,7 +48,6 @@
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
## Project-Specific
@@ -79,4 +78,3 @@ Practical checklist for any change impacting core logic or public APIs
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
-74
View File
@@ -1,74 +0,0 @@
class YearInReviewBuilder
attr_reader :account, :user_id, :year
def initialize(account:, user_id:, year:)
@account = account
@user_id = user_id
@year = year
end
def build
{
year: year,
total_conversations: total_conversations_count,
busiest_day: busiest_day_data,
support_personality: support_personality_data
}
end
private
def year_range
@year_range ||= begin
start_time = Time.zone.local(year, 1, 1).beginning_of_day
end_time = Time.zone.local(year, 12, 31).end_of_day
start_time..end_time
end
end
def total_conversations_count
account.conversations
.where(assignee_id: user_id, created_at: year_range)
.count
end
def busiest_day_data
daily_counts = account.conversations
.where(assignee_id: user_id, created_at: year_range)
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
.count
return nil if daily_counts.empty?
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
return nil if count.zero?
{
date: busiest_date.strftime('%b %d'),
count: count
}
end
def support_personality_data
response_time = average_response_time
return { avg_response_time_seconds: 0 } if response_time.nil?
{
avg_response_time_seconds: response_time.to_i
}
end
def average_response_time
avg_time = account.reporting_events
.where(
name: 'first_response',
user_id: user_id,
created_at: year_range
)
.average(:value)
avg_time&.to_f
end
end
@@ -1,26 +0,0 @@
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
def show
year = params[:year] || 2025
cache_key = "year_in_review_#{Current.account.id}_#{year}"
cached_data = Current.user.ui_settings&.dig(cache_key)
if cached_data.present?
render json: cached_data
else
builder = YearInReviewBuilder.new(
account: Current.account,
user_id: Current.user.id,
year: year
)
data = builder.build
ui_settings = Current.user.ui_settings || {}
ui_settings[cache_key] = data
Current.user.update(ui_settings: ui_settings)
render json: data
end
end
end
@@ -1,16 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class YearInReviewAPI extends ApiClient {
constructor() {
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
}
get(year) {
return axios.get(`${this.url}`, {
params: { year },
});
}
}
export default new YearInReviewAPI();
@@ -9,8 +9,6 @@ import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createNewContact,
@@ -228,8 +226,6 @@ const keyboardEvents = {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
@@ -164,20 +163,6 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents);
const onPaste = e => {
if (!props.isEmailOrWebWidgetInbox) return;
const files = e.clipboardData?.files;
if (!files?.length) return;
Array.from(files).forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
};
useEventListener(document, 'paste', onPaste);
</script>
<template>
@@ -1,6 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useBranding } from 'shared/composables/useBranding';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -10,8 +9,6 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
};
@@ -38,7 +35,7 @@ const onClick = () => {
v-for="(document, index) in documentsList.slice(0, 5)"
:id="document.id"
:key="`document-${index}`"
:name="replaceInstallationName(document.name)"
:name="document.name"
:assistant="document.assistant"
:external-link="document.external_link"
:created-at="document.created_at"
@@ -1,6 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useBranding } from 'shared/composables/useBranding';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
@@ -27,7 +26,6 @@ const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
@@ -65,8 +63,8 @@ const onClearFilters = () => {
v-for="(response, index) in responsesList.slice(0, 5)"
:id="response.id"
:key="`response-${index}`"
:question="replaceInstallationName(response.question)"
:answer="replaceInstallationName(response.answer)"
:question="response.question"
:answer="response.answer"
:status="response.status"
:assistant="response.assistant"
:created-at="response.created_at"
@@ -9,14 +9,11 @@ import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
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';
@@ -99,15 +96,6 @@ const closeMobileSidebar = () => {
emit('closeMobileSidebar');
};
const onComposeOpen = toggleFn => {
toggleFn();
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onComposeClose = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -635,14 +623,14 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="onComposeOpen(toggle)"
@click="toggle"
/>
</template>
</ComposeConversation>
@@ -663,7 +651,6 @@ 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,14 +1,11 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { 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,
@@ -25,8 +22,6 @@ defineOptions({
});
const { t } = useI18n();
const route = useRoute();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -36,29 +31,6 @@ 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 route.query['year-in-review'] === 'true' && isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -70,13 +42,6 @@ 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,
@@ -192,9 +157,4 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -1,239 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { toPng } from 'html-to-image';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
slideElement: {
type: Object,
default: null,
},
slideBackground: {
type: String,
required: true,
},
year: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const isGenerating = ref(false);
const shareImageUrl = ref(null);
const generateImage = async () => {
if (!props.slideElement) return;
isGenerating.value = true;
try {
let slideElement = props.slideElement;
if (slideElement && '$el' in slideElement) {
slideElement = slideElement.$el;
}
if (!slideElement) {
// eslint-disable-next-line no-console
console.error('No slide element found');
return;
}
const colorMap = {
'bg-[#5BD58A]': '#5BD58A',
'bg-[#60a5fa]': '#60a5fa',
'bg-[#fb923c]': '#fb923c',
'bg-[#f87171]': '#f87171',
'bg-[#fbbf24]': '#fbbf24',
};
const bgColor = colorMap[props.slideBackground] || '#ffffff';
const dataUrl = await toPng(slideElement, {
pixelRatio: 1.2,
backgroundColor: bgColor,
// Skip font/CSS embedding to avoid CORS issues with CDN stylesheets
// See: https://github.com/bubkoo/html-to-image/issues/49#issuecomment-762222100
fontEmbedCSS: '',
cacheBust: true,
});
const img = new Image();
img.src = dataUrl;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const finalCanvas = document.createElement('canvas');
const borderSize = 20;
const bottomPadding = 50;
finalCanvas.width = img.width + borderSize * 2;
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
const ctx = finalCanvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
ctx.drawImage(img, borderSize, borderSize);
ctx.fillStyle = '#1f2d3d';
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
borderSize,
img.height + borderSize + 35
);
const logo = new Image();
logo.src = '/brand-assets/logo.svg';
await new Promise(resolve => {
logo.onload = resolve;
});
const logoHeight = 30;
const logoWidth = (logo.width / logo.height) * logoHeight;
const logoX = finalCanvas.width - borderSize - logoWidth;
const logoY = img.height + borderSize + 15;
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
shareImageUrl.value = finalCanvas.toDataURL('image/png');
} catch (err) {
// Handle errors silently for now
// eslint-disable-next-line no-console
console.error('Failed to generate image:', err);
} finally {
isGenerating.value = false;
}
};
const downloadImage = () => {
if (!shareImageUrl.value) return;
const link = document.createElement('a');
link.href = shareImageUrl.value;
link.download = `chatwoot-year-in-review-${props.year}.png`;
link.click();
};
const shareImage = async () => {
if (!shareImageUrl.value) return;
try {
const response = await fetch(shareImageUrl.value);
const blob = await response.blob();
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
type: 'image/png',
});
if (
navigator.share &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
await navigator.share({
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
year: props.year,
}),
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
files: [file],
});
return;
}
downloadImage();
} catch (err) {
// Fallback to download if sharing fails
downloadImage();
}
};
const close = () => {
shareImageUrl.value = null;
emit('close');
};
const handleOpen = async () => {
if (props.show && !shareImageUrl.value) {
await generateImage();
}
};
defineExpose({ handleOpen });
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
@click="close"
>
<div v-if="isGenerating" class="flex items-center justify-center">
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
/>
<p class="mt-4 text-sm text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
</p>
</div>
</div>
<div
v-else-if="shareImageUrl"
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
@click.stop
>
<div class="flex items-center justify-between">
<h3 class="text-xl font-medium text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
</h3>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
<div>
<img
:src="shareImageUrl"
alt="Year in Review"
class="w-full h-auto"
/>
</div>
<div class="flex gap-3">
<button
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="downloadImage"
>
<i class="i-lucide-download w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
}}</span>
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareImage"
>
<i class="i-lucide-share-2 w-5 h-5" />
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,93 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import YearInReviewModal from './YearInReviewModal.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const yearInReviewBannerImage =
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
const route = useRoute();
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const getters = useStoreGetters();
const showModal = ref(false);
const modalRef = ref(null);
const currentYear = 2025;
const isACustomBrandedInstance =
getters['globalConfig/isACustomBrandedInstance'];
const bannerClosedKey = computed(() => {
const accountId = getters.getCurrentAccountId.value;
return `yir_closed_${accountId}_${currentYear}`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const shouldShowBanner = computed(
() =>
route.query['year-in-review'] === 'true' &&
!isBannerClosed.value &&
!isACustomBrandedInstance.value
);
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
};
const closeBanner = event => {
event.stopPropagation();
updateUISettings({ [bannerClosedKey.value]: true });
};
</script>
<template>
<div v-if="shouldShowBanner" class="relative">
<div
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
@click="openModal"
>
<div class="flex items-start justify-between gap-2 mb-3">
<span
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
>
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
</span>
<button
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
@click="closeBanner"
>
<Icon
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
/>
</button>
</div>
<div class="flex flex-col gap-3">
<img
:src="yearInReviewBannerImage"
alt="Year in Review"
class="w-full h-auto rounded"
/>
<button
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
@click.stop="openModal"
>
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
</button>
</div>
</div>
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
</div>
</template>
@@ -1,384 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import YearInReviewAPI from 'dashboard/api/yearInReview';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import IntroSlide from './slides/IntroSlide.vue';
import ConversationsSlide from './slides/ConversationsSlide.vue';
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
import PersonalitySlide from './slides/PersonalitySlide.vue';
import ThankYouSlide from './slides/ThankYouSlide.vue';
import ShareModal from './ShareModal.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const getters = useStoreGetters();
const isOpen = ref(false);
const currentSlide = ref(0);
const yearData = ref(null);
const isLoading = ref(false);
const error = ref(null);
const slideRefs = ref([]);
const showShareModal = ref(false);
const shareModalRef = ref(null);
const drumrollAudio = ref(null);
const hasConversations = computed(() => {
return yearData.value?.total_conversations > 0;
});
const totalSlides = computed(() => {
if (!hasConversations.value) {
return 3;
}
return 5;
});
const slideIndexMap = computed(() => {
if (!hasConversations.value) {
return [0, 1, 4];
}
return [0, 1, 2, 3, 4];
});
const currentVisualSlide = computed(() => {
return slideIndexMap.value.indexOf(currentSlide.value);
});
const slideBackgrounds = [
'bg-[#5BD58A]',
'bg-[#60a5fa]',
'bg-[#fb923c]',
'bg-[#f87171]',
'bg-[#fbbf24]',
];
const playDrumroll = () => {
try {
if (!drumrollAudio.value) {
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
drumrollAudio.value.volume = 0.5;
}
drumrollAudio.value.currentTime = 0;
drumrollAudio.value.play().catch(err => {
// eslint-disable-next-line no-console
console.log('Could not play drumroll:', err);
});
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error playing drumroll:', err);
}
};
const fetchYearInReviewData = async () => {
const year = 2025;
const accountId = getters.getCurrentAccountId.value;
const cacheKey = `year_in_review_${accountId}_${year}`;
const cachedData = uiSettings.value?.[cacheKey];
if (cachedData) {
yearData.value = cachedData;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await YearInReviewAPI.get(year);
yearData.value = response.data;
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
};
const nextSlide = () => {
if (currentSlide.value < 4) {
if (!hasConversations.value && currentSlide.value === 1) {
currentSlide.value = 4;
} else {
currentSlide.value += 1;
}
}
};
const previousSlide = () => {
if (currentSlide.value > 0) {
if (!hasConversations.value && currentSlide.value === 4) {
currentSlide.value = 1;
} else {
currentSlide.value -= 1;
}
}
};
const goToSlide = visualIndex => {
currentSlide.value = slideIndexMap.value[visualIndex];
};
const close = () => {
currentSlide.value = 0;
isOpen.value = false;
yearData.value = null;
isLoading.value = false;
error.value = null;
emit('close');
};
const open = () => {
isOpen.value = true;
fetchYearInReviewData();
playDrumroll();
};
const currentSlideBackground = computed(
() => slideBackgrounds[currentSlide.value]
);
const shareCurrentSlide = async () => {
showShareModal.value = true;
nextTick(() => {
if (shareModalRef.value) {
shareModalRef.value.handleOpen();
}
});
};
const closeShareModal = () => {
showShareModal.value = false;
};
const keyboardEvents = {
Escape: { action: close },
ArrowLeft: { action: previousSlide },
ArrowRight: { action: nextSlide },
};
useKeyboardEvents(keyboardEvents);
defineExpose({ open, close });
watch(
() => props.show,
newValue => {
if (newValue) {
open();
}
}
);
</script>
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
>
<div class="relative w-full h-full overflow-hidden">
<div
v-if="isLoading"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
/>
<p class="mt-4 text-sm text-n-slate-11">
{{ t('YEAR_IN_REVIEW.LOADING') }}
</p>
</div>
</div>
<div
v-else-if="error"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<p class="text-lg font-semibold text-red-600">
{{ t('YEAR_IN_REVIEW.ERROR') }}
</p>
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
<button
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="close"
>
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.CLOSE')
}}</span>
</button>
</div>
</div>
<div
v-else-if="yearData"
class="relative w-full h-full"
:class="currentSlideBackground"
>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<IntroSlide
v-if="currentSlide === 0"
:key="0"
:ref="el => (slideRefs[0] = el)"
:year="yearData.year"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ConversationsSlide
v-if="currentSlide === 1"
:key="1"
:ref="el => (slideRefs[1] = el)"
:total-conversations="yearData.total_conversations"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<BusiestDaySlide
v-if="
currentSlide === 2 && hasConversations && yearData.busiest_day
"
:key="2"
:ref="el => (slideRefs[2] = el)"
:busiest-day="yearData.busiest_day"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<PersonalitySlide
v-if="
currentSlide === 3 &&
hasConversations &&
yearData.support_personality
"
:key="3"
:ref="el => (slideRefs[3] = el)"
:support-personality="yearData.support_personality"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ThankYouSlide
v-if="currentSlide === 4"
:key="4"
:ref="el => (slideRefs[4] = el)"
:year="yearData.year"
/>
</Transition>
<div
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
>
<button
v-if="currentSlide > 0"
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="previousSlide"
>
<i class="i-lucide-chevron-left w-5 h-5" />
<span class="text-sm font-medium">
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
</span>
</button>
<div v-else class="w-20" />
<div class="flex gap-2">
<button
v-for="index in totalSlides"
:key="index"
class="w-2 h-2 rounded-full transition-all"
:class="
currentVisualSlide === index - 1
? 'bg-white w-8'
: 'bg-white bg-opacity-50'
"
@click="goToSlide(index - 1)"
/>
</div>
<button
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
@click="nextSlide"
>
<span
v-if="currentVisualSlide < totalSlides - 1"
class="text-sm font-medium"
>
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
</span>
<i
v-if="currentVisualSlide < totalSlides - 1"
class="i-lucide-chevron-right w-5 h-5"
/>
</button>
</div>
<button
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareCurrentSlide"
>
<i class="i-lucide-share-2 w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
}}</span>
</button>
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
</div>
</div>
<ShareModal
ref="shareModalRef"
:show="showShareModal"
:slide-element="slideRefs[currentSlide]"
:slide-background="currentSlideBackground"
:year="yearData?.year"
@close="closeShareModal"
/>
</Teleport>
</template>
@@ -1,76 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
busiestDay: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const coffeeImage =
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const performanceHelperText = computed(() => {
const count = props.busiestDay.count;
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-4 w-full max-w-3xl">
<div class="flex items-center justify-center flex-1">
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
<div class="text-white flex gap-2 flex-col">
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
{{ busiestDay.date }}
</div>
</div>
<img
:src="coffeeImage"
alt="Coffee"
class="w-auto h-32 md:h-56 lg:h-72"
/>
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="flex items-center justify-center gap-3 md:gap-8">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<div class="flex-1">
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
count: busiestDay.count,
})
}}
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,94 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
totalConversations: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const cloudImage =
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const hasData = computed(() => {
return props.totalConversations > 0;
});
const formatNumber = num => {
if (num >= 100000) {
return '100k+';
}
return new Intl.NumberFormat().format(num);
};
const performanceHelperText = computed(() => {
if (!hasData.value) {
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
}
const count = props.totalConversations;
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
if (count <= 2000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
if (count <= 10000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
});
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div
class="flex flex-col gap-16"
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
>
<div class="flex items-center justify-center flex-1">
<div
class="flex items-center justify-between gap-6 flex-1"
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
>
<div class="text-white flex gap-3 flex-col">
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
{{ formatNumber(totalConversations) }}
</div>
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
</div>
</div>
<img
:src="cloudImage"
alt="Cloud"
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
/>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const candlesImagePath =
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
const { t } = useI18n();
</script>
<template>
<div
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
:style="{
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
}"
>
<div class="text-center max-w-3xl">
<h1
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ year }}
</h1>
<h2
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.TITLE') }}
</h2>
</div>
<img
:src="candlesImagePath"
alt="Candles"
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
/>
</div>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportPersonality: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const clockImage =
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const formatResponseTime = seconds => {
if (seconds < 60) {
return 'less than a minute';
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return hours === 1 ? '1 hour' : `${hours} hours`;
}
return 'more than a day';
};
const personality = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const minutes = seconds / 60;
if (minutes < 2) {
return 'Swift Helper';
}
if (minutes < 5) {
return 'Quick Responder';
}
if (minutes < 15) {
return 'Steady Support';
}
return 'Thoughtful Advisor';
});
const personalityMessage = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const time = formatResponseTime(seconds);
const personalityKeyMap = {
'Swift Helper': 'SWIFT_HELPER',
'Quick Responder': 'QUICK_RESPONDER',
'Steady Support': 'STEADY_SUPPORT',
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
};
const key = personalityKeyMap[personality.value];
if (!key) return '';
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-9 max-w-3xl">
<div class="mb-4 md:mb-6">
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
<div class="flex items-center justify-start flex-1 mt-9">
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
<div class="text-2xl md:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
</div>
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
{{ personality }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ personalityMessage }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const signatureImage =
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div class="flex flex-col items-start max-w-4xl">
<div
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
</div>
<div
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
}}
</div>
<div class="mt-12">
<img
:src="signatureImage"
alt="Chatwoot Team Signature"
class="w-auto h-8 md:h-10"
/>
</div>
</div>
</div>
</template>
@@ -688,10 +688,6 @@ export default {
);
},
onPaste(e) {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) {
return;
}
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
@@ -38,7 +38,6 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
@@ -81,5 +80,4 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...yearInReview,
};
@@ -234,7 +234,6 @@
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile settings",
"YEAR_IN_REVIEW": "Year in Review",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
@@ -1,64 +0,0 @@
{
"YEAR_IN_REVIEW": {
"TITLE": "Year in Review",
"LOADING": "Loading your year in review...",
"ERROR": "Failed to load year in review",
"CLOSE": "Close",
"CONVERSATIONS": {
"TITLE": "You have handled",
"SUBTITLE": "conversations",
"FALLBACK": "This year wasn't about the numbers. It was about showing up.",
"COMPARISON": {
"0_50": "You showed up, and that's how every good inbox begins.",
"50_100": "You kept the replies flowing and the conversations alive.",
"100_500": "You handled serious volume and kept everything on track.",
"500_2000": "You kept things moving while the volume kept climbing.",
"2000_10000": "You ran high traffic through your inbox without breaking a sweat.",
"10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless."
}
},
"BUSIEST_DAY": {
"TITLE": "Your busiest day was",
"MESSAGE": "{count} conversations that day.",
"COMPARISON": {
"0_5": "A warm-up lap that barely woke the inbox.",
"5_10": "Enough action to justify a second cup of coffee.",
"10_25": "Things got busy and the inbox stayed on its toes.",
"25_50": "A proper rush that barely broke a sweat.",
"50_100": "Controlled chaos, handled like a normal Tuesday.",
"100_500": "Absolute dumpster fire, somehow still shipping replies.",
"500_PLUS": "The inbox lost all chill and never slowed down."
}
},
"PERSONALITY": {
"TITLE": "Your support personality is",
"MESSAGES": {
"SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.",
"QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.",
"STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.",
"THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right."
}
},
"THANK_YOU": {
"TITLE": "Congratulations on surviving the inbox of {year}.",
"MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!"
},
"SHARE_MODAL": {
"TITLE": "Share Your Year in Review",
"PREPARING": "Preparing your image...",
"DOWNLOAD": "Download",
"SHARE_TITLE": "My {year} Year in Review",
"SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!",
"BRANDING": "Made with Chatwoot"
},
"BANNER": {
"TITLE": "Your {year} Year in Review is here",
"BUTTON": "See your impact"
},
"NAVIGATION": {
"PREVIOUS": "Previous",
"NEXT": "Next",
"SHARE": "Share"
}
}
}
@@ -1,14 +1,6 @@
class MessageTemplates::Template::OutOfOffice
pattr_initialize [:conversation!]
def self.perform_if_applicable(conversation)
inbox = conversation.inbox
return unless inbox.out_of_office?
return if inbox.out_of_office_message.blank?
new(conversation: conversation).perform
end
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params)
+7 -10
View File
@@ -203,15 +203,9 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
end
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
delete :destroy
@@ -425,7 +419,6 @@ Rails.application.routes.draw do
get :bot_metrics
end
end
resource :year_in_review, only: [:show]
resources :live_reports, only: [] do
collection do
get :conversation_metrics
@@ -557,9 +550,13 @@ Rails.application.routes.draw do
resources :delivery_status, only: [:create]
if ChatwootApp.enterprise?
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
resource :voice, only: [], controller: 'voice' do
collection do
post 'call/:phone', action: :call_twiml
post 'status/:phone', action: :status
post 'conference_status/:phone', action: :conference_status
end
end
end
end
@@ -1,58 +0,0 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
def token
render json: Voice::Provider::Twilio::TokenService.new(
inbox: @voice_inbox,
user: Current.user,
account: Current.account
).generate
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @voice_inbox, :show?
end
def fetch_conversation_by_display_id
cid = params[:conversation_id]
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
authorize conversation, :show?
conversation
end
end
@@ -151,8 +151,9 @@ class Twilio::VoiceController < ApplicationController
end
def conference_status_callback_url
host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
"#{host}/twilio/voice/conference_status/#{phone_digits}"
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
@@ -87,15 +87,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
send_out_of_office_message_if_applicable
end
end
end
def send_out_of_office_message_if_applicable
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
def create_handoff_message
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
+6 -7
View File
@@ -42,13 +42,11 @@ class Channel::Voice < ApplicationRecord
false
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
def initiate_call(to:)
case provider
when 'twilio'
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
Voice::Provider::TwilioAdapter.new(self).initiate_call(
to: to
)
else
raise "Unsupported voice provider: #{provider}"
@@ -58,12 +56,12 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
end
private
@@ -89,6 +87,7 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
def provider_config_hash
if provider_config.is_a?(Hash)
@@ -9,24 +9,6 @@ module Enterprise::MessageTemplates::HookExecutionService
schedule_captain_response
end
def should_send_greeting?
return false if captain_handling_conversation?
super
end
def should_send_out_of_office_message?
return false if captain_handling_conversation?
super
end
def should_send_email_collect?
return false if captain_handling_conversation?
super
end
private
def schedule_captain_response
@@ -64,14 +46,5 @@ module Enterprise::MessageTemplates::HookExecutionService
content: 'Transferring to another agent for further assistance.'
)
conversation.bot_handoff!
send_out_of_office_message_after_handoff
end
def send_out_of_office_message_after_handoff
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
end
end
@@ -1,52 +0,0 @@
class Voice::Provider::Twilio::Adapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
call = twilio_client.calls.create(**call_params(to))
{
provider: 'twilio',
call_sid: call.sid,
status: call.status,
call_direction: 'outbound',
requires_agent_join: true,
agent_id: agent_id,
conference_sid: conference_sid
}
end
private
def call_params(to)
phone_digits = @channel.phone_number.delete_prefix('+')
{
from: @channel.phone_number,
to: to,
url: twilio_call_twiml_url(phone_digits),
status_callback: twilio_call_status_url(phone_digits),
status_callback_event: %w[
initiated ringing answered completed failed busy no-answer canceled
],
status_callback_method: 'POST'
}
end
def twilio_call_twiml_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
end
def twilio_call_status_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
end
end
@@ -1,46 +0,0 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
end
def end_conference
twilio_client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end
@@ -1,62 +0,0 @@
class Voice::Provider::Twilio::TokenService
pattr_initialize [:inbox!, :user!, :account!]
def generate
{
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
end
def identity
@identity ||= "agent-#{user.id}-account-#{account.id}"
end
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
end
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_params = outgoing_params
end
end
def outgoing_params
{
account_id: account.id,
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end
@@ -0,0 +1,32 @@
class Voice::Provider::TwilioAdapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
cfg = @channel.provider_config_hash
host = ENV.fetch('FRONTEND_URL')
phone_digits = @channel.phone_number.delete_prefix('+')
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
params = {
from: @channel.phone_number,
to: to,
url: callback_url,
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
status_callback_event: %w[initiated ringing answered completed],
status_callback_method: 'POST'
}
call = twilio_client(cfg).calls.create(**params)
{ call_sid: call.sid }
end
private
def twilio_client(config)
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
end
@@ -36,13 +36,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
send_out_of_office_message_if_applicable(conversation)
end
def send_out_of_office_message_if_applicable(conversation)
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# TODO: Future enhancement - Add team assignment capability
-2
View File
@@ -69,8 +69,6 @@
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"idb": "^8.0.0",
"js-cookie": "^3.0.5",
"json-logic-js": "^2.0.5",
-47
View File
@@ -127,12 +127,6 @@ importers:
highlight.js:
specifier: ^11.10.0
version: 11.10.0
html-to-image:
specifier: ^1.11.13
version: 1.11.13
html2canvas:
specifier: ^1.4.1
version: 1.4.1
idb:
specifier: ^8.0.0
version: 8.0.0
@@ -1678,10 +1672,6 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -1928,9 +1918,6 @@ packages:
peerDependencies:
postcss: ^8.4
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-prefers-color-scheme@8.0.2:
resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==}
engines: {node: ^14 || ^16 || >=18}
@@ -2699,13 +2686,6 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -4237,9 +4217,6 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -4436,9 +4413,6 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
video.js@7.18.1:
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
@@ -6274,8 +6248,6 @@ snapshots:
balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -6558,10 +6530,6 @@ snapshots:
postcss-selector-parser: 6.1.1
postcss-value-parser: 4.2.0
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-prefers-color-scheme@8.0.2(postcss@8.4.47):
dependencies:
postcss: 8.4.47
@@ -7496,13 +7464,6 @@ snapshots:
html-escaper@2.0.2: {}
html-to-image@1.11.13: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -9197,10 +9158,6 @@ snapshots:
glob: 10.4.5
minimatch: 9.0.5
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {}
thenify-all@1.6.0:
@@ -9401,10 +9358,6 @@ snapshots:
utils-merge@1.0.1: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
video.js@7.18.1:
dependencies:
'@babel/runtime': 7.25.6
Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.
@@ -1,64 +0,0 @@
require 'rails_helper'
RSpec.describe YearInReviewBuilder, type: :model do
subject(:builder) { described_class.new(account: account, user_id: user.id, year: year) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:year) { 2025 }
describe '#build' do
context 'when there is no data for the year' do
it 'returns empty aggregates' do
result = builder.build
expect(result[:year]).to eq(year)
expect(result[:total_conversations]).to eq(0)
expect(result[:busiest_day]).to be_nil
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 0 })
end
end
context 'when there is data for the year' do
let(:busiest_date) { Time.zone.local(year, 3, 10, 10, 0, 0) }
let(:other_date) { Time.zone.local(year, 3, 11, 10, 0, 0) }
before do
create(:conversation, account: account, assignee: user, created_at: busiest_date)
create(:conversation, account: account, assignee: user, created_at: busiest_date + 1.hour)
create(:conversation, account: account, assignee: user, created_at: other_date)
create(
:reporting_event,
account: account,
user: user,
name: 'first_response',
value: 12.7,
created_at: busiest_date
)
end
it 'returns total conversations count' do
expect(builder.build[:total_conversations]).to eq(3)
end
it 'returns busiest day data' do
expect(builder.build[:busiest_day]).to eq({ date: busiest_date.strftime('%b %d'), count: 2 })
end
it 'returns support personality data' do
expect(builder.build[:support_personality]).to eq({ avg_response_time_seconds: 12 })
end
it 'scopes data to the provided year' do
create(:conversation, account: account, assignee: user, created_at: Time.zone.local(year - 1, 6, 1))
create(:reporting_event, account: account, user: user, name: 'first_response', value: 99, created_at: Time.zone.local(year - 1, 6, 1))
result = builder.build
expect(result[:total_conversations]).to eq(3)
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 12 })
end
end
end
end
@@ -1,142 +0,0 @@
require 'rails_helper'
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
let(:conference_service) do
instance_double(
Voice::Provider::Twilio::ConferenceService,
ensure_conference_sid: 'CF123',
mark_agent_joined: true,
end_conference: true
)
end
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
allow(voice_grant).to receive(:outgoing_application_sid=)
allow(voice_grant).to receive(:outgoing_application_params=)
allow(voice_grant).to receive(:incoming_allow=)
allow(Voice::Provider::Twilio::ConferenceService).to receive(:new).and_return(conference_service)
end
describe 'GET /conference/token' do
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
it 'returns token payload' do
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['token']).to eq('jwt-token')
expect(body['account_id']).to eq(account.id)
expect(body['inbox_id']).to eq(voice_inbox.id)
end
end
end
describe 'POST /conference' do
context 'when unauthenticated' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
it 'creates conference and sets identifier' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['conference_sid']).to be_present
conversation.reload
expect(conversation.identifier).to eq('CALL123')
expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined)
end
it 'does not allow accessing conversations from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:not_found)
other_conversation.reload
expect(other_conversation.identifier).to be_nil
end
it 'returns conflict when call_sid missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:unprocessable_content)
end
end
end
describe 'DELETE /conference' do
context 'when unauthenticated' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
it 'ends conference and returns success' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:end_conference)
end
it 'does not allow ending conferences for conversations from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id }
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -229,106 +229,4 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
end
end
describe 'out of office message after handoff' do
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
end
context 'when handoff occurs outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'sends out of office message after handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when handoff occurs within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'does not send out of office message after handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
end
end
context 'when handoff occurs due to error outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(StandardError, 'API error')
end
it 'sends out of office message after error-triggered handoff' do
expect do
described_class.perform_now(conversation, assistant)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed.')
end
end
context 'when no out of office message is configured' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: nil
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'does not send out of office message' do
expect do
described_class.perform_now(conversation, assistant)
end.not_to(change { conversation.messages.template.count })
end
end
end
end
@@ -163,66 +163,4 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
expect(tool.active?).to be true
end
end
describe 'out of office message after handoff' do
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'sends out of office message after handoff' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.to change { conversation.messages.template.count }.by(1)
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'does not send out of office message after handoff' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.not_to(change { conversation.messages.template.count })
end
end
context 'when no out of office message is configured' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: nil
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'does not send out of office message' do
expect do
tool.perform(tool_context, reason: 'Customer needs help')
end.not_to(change { conversation.messages.template.count })
end
end
end
end
@@ -102,9 +102,8 @@ RSpec.describe Captain::CustomTool, type: :model do
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
enabled_ids = described_class.enabled.pluck(:id)
expect(enabled_ids).to include(enabled_tool.id)
expect(enabled_ids).not_to include(disabled_tool.id)
expect(described_class.enabled).to include(enabled_tool)
expect(described_class.enabled).not_to include(disabled_tool)
end
end
end
@@ -1,295 +0,0 @@
require 'rails_helper'
RSpec.describe MessageTemplates::HookExecutionService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when captain assistant is configured' do
context 'when within business hours' do
before do
inbox.update!(working_hours_enabled: true)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'schedules captain response job for incoming messages on pending conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
it 'does not send out of office message when Captain is handling' do
out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation, message_type: :incoming)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when business hours are not enabled' do
before do
inbox.update!(working_hours_enabled: false)
end
it 'schedules captain response job regardless of time' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when captain quota is exceeded within business hours' do
before do
inbox.update!(working_hours_enabled: true)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
it 'performs handoff within business hours when quota exceeded' do
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
end
end
context 'when no captain assistant is configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when conversation is not pending' do
before do
conversation.update!(status: :open)
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when message is outgoing' do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :outgoing)
end
end
context 'when greeting and out of office messages with Captain enabled' do
context 'when conversation is pending (Captain is handling)' do
before do
conversation.update!(status: :pending)
end
it 'does not create greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
it 'does not create out of office message in conversation' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
end
context 'when conversation is open (transferred to agent)' do
before do
conversation.update!(status: :open)
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
end
context 'when Captain is not configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
context 'when Captain quota is exceeded and handoff happens' do
before do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
context 'when outside business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed. Please leave your email.',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
end
it 'sends out of office message after handoff due to quota exceeded' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
ooo_message = conversation.messages.template.last
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
end
end
context 'when within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.',
enable_email_collect: false
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
open_all_day: true,
closed_all_day: false
)
end
it 'does not send out of office message after handoff' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
end
end
end
end
@@ -1,43 +0,0 @@
require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
let(:call_instance) do
instance_double(Twilio::REST::Api::V2010::AccountContext::CallInstance, sid: 'CA123', status: 'queued')
end
let(:client_double) { instance_double(Twilio::REST::Client, calls: calls_double) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
it 'initiates an outbound call with expected params' do
allow(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new)
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.and_return(client_double)
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
phone_digits = channel.phone_number.delete_prefix('+')
expected_url = Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
expected_status_callback = Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
expect(calls_double).to have_received(:create).with(hash_including(
from: channel.phone_number,
to: '+15550001111',
url: expected_url,
status_callback: expected_status_callback,
status_callback_event: array_including('completed', 'failed', 'busy', 'no-answer',
'canceled')
))
expect(result[:call_sid]).to eq('CA123')
expect(result[:conference_sid]).to eq('CF999')
expect(result[:agent_id]).to eq(42)
expect(result[:call_direction]).to eq('outbound')
end
end
@@ -1,60 +0,0 @@
require 'rails_helper'
describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
describe '#ensure_conference_sid' do
it 'returns existing sid if present' do
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
end
it 'sets and returns generated sid when missing' do
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
sid = service.ensure_conference_sid
expect(sid).to eq('CF_GEN')
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
end
end
describe '#mark_agent_joined' do
it 'stores agent join metadata' do
agent = create(:user, account: account)
service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
end
end
describe '#end_conference' do
it 'completes in-progress conferences' do
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
allow(conf_context).to receive(:update).with(status: 'completed')
service.end_conference
expect(conf_context).to have_received(:update).with(status: 'completed')
end
end
end
@@ -1,33 +0,0 @@
require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
allow(voice_grant).to receive(:outgoing_application_sid=)
allow(voice_grant).to receive(:outgoing_application_params=)
allow(voice_grant).to receive(:incoming_allow=)
end
it 'returns a token payload with expected keys' do
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
payload = described_class.new(inbox: inbox, user: user, account: account).generate
expect(payload[:token]).to eq('jwt-token')
expect(payload[:identity]).to include("agent-#{user.id}")
expect(payload[:inbox_id]).to eq(inbox.id)
expect(payload[:account_id]).to eq(account.id)
expect(payload[:voice_enabled]).to be true
expect(payload[:twiml_endpoint]).to include(voice_channel.phone_number.delete_prefix('+'))
end
end
@@ -1,55 +0,0 @@
tags:
- Contacts
operationId: contactMerge
summary: Merge Contacts
security:
- userApiKey: []
description: |
Merge two contacts into a single contact. The base contact remains and receives all
data from the mergee contact. After the merge, the mergee contact is permanently deleted.
This action is irreversible. All conversations, labels, and custom attributes from the
mergee contact will be moved to the base contact.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- base_contact_id
- mergee_contact_id
properties:
base_contact_id:
type: integer
description: ID of the contact that will remain after the merge and receive all data
example: 1
mergee_contact_id:
type: integer
description: ID of the contact that will be merged into the base contact and deleted
example: 2
responses:
'200':
description: Contacts merged successfully
content:
application/json:
schema:
$ref: '#/components/schemas/contact_base'
'400':
description: Bad request - invalid contact IDs or contacts cannot be merged
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
'404':
description: One or both contacts not found
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
-6
View File
@@ -275,12 +275,6 @@
/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes:
$ref: ./application/contactable_inboxes/get.yml
# Contact Merge
/api/v1/accounts/{account_id}/actions/contact_merge:
parameters:
- $ref: '#/components/parameters/account_id'
post:
$ref: ./application/contacts/merge.yml
# Automation Rule
/api/v1/accounts/{account_id}/automation_rules:
-88
View File
@@ -3424,94 +3424,6 @@
}
}
},
"/api/v1/accounts/{account_id}/actions/contact_merge": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
}
],
"post": {
"tags": [
"Contacts"
],
"operationId": "contactMerge",
"summary": "Merge Contacts",
"security": [
{
"userApiKey": []
}
],
"description": "Merge two contacts into a single contact. The base contact remains and receives all\ndata from the mergee contact. After the merge, the mergee contact is permanently deleted.\n\nThis action is irreversible. All conversations, labels, and custom attributes from the\nmergee contact will be moved to the base contact.\n",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"base_contact_id",
"mergee_contact_id"
],
"properties": {
"base_contact_id": {
"type": "integer",
"description": "ID of the contact that will remain after the merge and receive all data",
"example": 1
},
"mergee_contact_id": {
"type": "integer",
"description": "ID of the contact that will be merged into the base contact and deleted",
"example": 2
}
}
}
}
}
},
"responses": {
"200": {
"description": "Contacts merged successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/contact_base"
}
}
}
},
"400": {
"description": "Bad request - invalid contact IDs or contacts cannot be merged",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"404": {
"description": "One or both contacts not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v1/accounts/{account_id}/automation_rules": {
"parameters": [
{
@@ -1967,94 +1967,6 @@
}
}
},
"/api/v1/accounts/{account_id}/actions/contact_merge": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
}
],
"post": {
"tags": [
"Contacts"
],
"operationId": "contactMerge",
"summary": "Merge Contacts",
"security": [
{
"userApiKey": []
}
],
"description": "Merge two contacts into a single contact. The base contact remains and receives all\ndata from the mergee contact. After the merge, the mergee contact is permanently deleted.\n\nThis action is irreversible. All conversations, labels, and custom attributes from the\nmergee contact will be moved to the base contact.\n",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"base_contact_id",
"mergee_contact_id"
],
"properties": {
"base_contact_id": {
"type": "integer",
"description": "ID of the contact that will remain after the merge and receive all data",
"example": 1
},
"mergee_contact_id": {
"type": "integer",
"description": "ID of the contact that will be merged into the base contact and deleted",
"example": 2
}
}
}
}
}
},
"responses": {
"200": {
"description": "Contacts merged successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/contact_base"
}
}
}
},
"400": {
"description": "Bad request - invalid contact IDs or contacts cannot be merged",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"404": {
"description": "One or both contacts not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v1/accounts/{account_id}/automation_rules": {
"parameters": [
{
+1 -1
View File
@@ -41,7 +41,7 @@ const tailwindConfig = {
fontFamily: {
sans: defaultSansFonts,
inter: ['Inter', ...defaultSansFonts],
interDisplay: ['InterDisplay', ...defaultSansFonts],
interDisplay: ['Inter Display', ...defaultSansFonts],
},
typography: {
bubble: {