Compare commits

..
Author SHA1 Message Date
Vinay KeerthiandGitHub 54a1db2e0a Merge branch 'develop' into fix/email-original-timestamp 2025-12-17 19:45:35 +05:30
Vinay Keerthi c20fcb5529 fix: disable AbcSize for create_message 2025-12-17 19:43:49 +05:30
Vinay Keerthi a989c3976b feat: preserve original email timestamp when importing emails 2025-12-17 19:43:49 +05:30
116ed54c7e fix: Prioritize SDK enableFileUpload flag when explicitly set (#13091)
### Problem
Currently, the attachment button visibility in the widget uses both the
SDK's `enableFileUpload` flag AND the inbox's attachment settings with
an AND condition. This creates an issue for users who want to control
attachments solely through inbox settings, since the SDK flag defaults
to `true` even when not explicitly provided.

  **Before:**
- SDK not set → `enableFileUpload: true` (default) + inbox setting =
attachment shown only if both true
- SDK set to false → `enableFileUpload: false` + inbox setting =
attachment always hidden
- SDK set to true → `enableFileUpload: true` + inbox setting =
attachment shown only if both true
  
This meant users couldn't rely solely on inbox settings when the SDK
flag wasn't explicitly provided.

  ### Solution

Changed the logic to prioritize explicit SDK configuration when
provided, and fall back to inbox settings when not provided:

  **After:**
  - SDK not set → `enableFileUpload: undefined` → use inbox setting only
- SDK set to false → `enableFileUpload: false` → hide attachment (SDK
controls)
- SDK set to true → `enableFileUpload: true` → show attachment (SDK
controls)

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-12-17 19:03:54 +05:30
cfa0bb953b feat: Custom attribute page redesign (#13087)
Fixes
https://linear.app/chatwoot/issue/CW-6096/custom-attributes-page-redesign
<img width="2390" height="1822"
alt="524664368-b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5"
src="https://github.com/user-attachments/assets/a29726e7-3d28-4811-8429-6483056d57cb"
/>

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
2025-12-17 14:30:49 +05:30
PranavandGitHub b6a14bda48 chore: Enable YearInReview for everyone, include analytics (#13090)
Remove the query param condition and enable it for everyone.
2025-12-16 18:20:10 -08:00
Sivin VargheseandGitHub fd8919b901 chore: Replace installation name in captain empty state (#13083)
# Pull Request Template

## Description

This PR fixes the installation name in empty states on the Captain
Documents and Captain FAQs pages.

Fixes
https://linear.app/chatwoot/issue/CW-6159/display-brand-name-in-empty-state-messages-on-the-captain-page

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screenshots
<img width="986" height="700" alt="image"
src="https://github.com/user-attachments/assets/7ba32fbb-ea93-4206-9e8d-ef037a83f72e"
/>
<img width="1062" height="699" alt="image"
src="https://github.com/user-attachments/assets/a70bec15-9bfe-4600-b355-f486f93a6839"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-12-16 15:04:34 +05:30
0d490640f2 feat: Conversation workflow backend changes (#13070)
Extracted the backend changes from
https://github.com/chatwoot/chatwoot/pull/13040

- Added the support for saving `conversation_required_attributes` in
account
- Delete `conversation_required_attributes` if custom attribute deleted.

Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
2025-12-16 14:43:15 +05:30
Sivin VargheseandGitHub 02216471c3 feat: Enable attachment paste in new conversation modal (#13082) 2025-12-16 14:35:42 +05:30
Sivin VargheseandGitHub 68d8e62a5c fix: Share modal not working in year-in-review (#13079) 2025-12-16 12:32:52 +05:30
PranavandGitHub bb8bafe3dc feat(ce): Add Year in review feature (#13078)
<img width="1502" height="813" alt="Screenshot 2025-12-15 at 5 01 57 PM"
src="https://github.com/user-attachments/assets/ea721f00-403c-4adc-8410-5c0fa4ee4122"
/>
2025-12-15 17:24:45 -08:00
Sojan JoseandGitHub d2ba9a2ad3 feat(enterprise): add voice conference API (#13064)
The backend APIs for the voice call channel 
ref: #11602
2025-12-15 15:11:59 -08:00
74 changed files with 3992 additions and 498 deletions
+2
View File
@@ -48,6 +48,7 @@
- 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
@@ -78,3 +79,4 @@ 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
@@ -0,0 +1,74 @@
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
@@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
end
def check_signup_enabled
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,16 @@
/* 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();
@@ -0,0 +1,88 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
badges: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
</div>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
type: {
type: String,
default: 'resolution',
validator: value => ['pre-chat', 'resolution'].includes(value),
},
});
const { t } = useI18n();
const attributeConfig = {
'pre-chat': {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
},
};
const config = computed(
() => attributeConfig[props.type] || attributeConfig.resolution
);
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template>
@@ -9,6 +9,8 @@ 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,
@@ -226,6 +228,8 @@ const keyboardEvents = {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
@@ -4,6 +4,7 @@ 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';
@@ -163,6 +164,20 @@ 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,5 +1,6 @@
<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';
@@ -9,6 +10,8 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
};
@@ -35,7 +38,7 @@ const onClick = () => {
v-for="(document, index) in documentsList.slice(0, 5)"
:id="document.id"
:key="`document-${index}`"
:name="document.name"
:name="replaceInstallationName(document.name)"
:assistant="document.assistant"
:external-link="document.external_link"
:created-at="document.created_at"
@@ -1,5 +1,6 @@
<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';
@@ -26,6 +27,7 @@ const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
@@ -63,8 +65,8 @@ const onClearFilters = () => {
v-for="(response, index) in responsesList.slice(0, 5)"
:id="response.id"
:key="`response-${index}`"
:question="response.question"
:answer="response.answer"
:question="replaceInstallationName(response.question)"
:answer="replaceInstallationName(response.answer)"
:status="response.status"
:assistant="response.assistant"
:created-at="response.created_at"
@@ -9,11 +9,14 @@ 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';
@@ -96,6 +99,15 @@ 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',
@@ -623,14 +635,14 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<ComposeConversation align-position="right">
<ComposeConversation align-position="right" @close="onComposeClose">
<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="toggle"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
@@ -651,6 +663,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>
@@ -0,0 +1,239 @@
<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>
@@ -0,0 +1,88 @@
<script setup>
import { ref, computed } from 'vue';
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 { 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(
() => !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>
@@ -0,0 +1,389 @@
<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 { useTrack } from 'dashboard/composables';
import { YEAR_IN_REVIEW_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
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) {
useTrack(YEAR_IN_REVIEW_EVENTS.NEXT_CLICKED);
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 = () => {
useTrack(YEAR_IN_REVIEW_EVENTS.MODAL_OPENED);
isOpen.value = true;
fetchYearInReviewData();
playDrumroll();
};
const currentSlideBackground = computed(
() => slideBackgrounds[currentSlide.value]
);
const shareCurrentSlide = async () => {
useTrack(YEAR_IN_REVIEW_EVENTS.SHARE_CLICKED);
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>
@@ -0,0 +1,76 @@
<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>
@@ -0,0 +1,94 @@
<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>
@@ -0,0 +1,43 @@
<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>
@@ -0,0 +1,99 @@
<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>
@@ -0,0 +1,43 @@
<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,6 +688,10 @@ 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();
@@ -131,3 +131,9 @@ export const LINEAR_EVENTS = Object.freeze({
LINK_ISSUE: 'Linked a linear issue',
UNLINK_ISSUE: 'Unlinked a linear issue',
});
export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
MODAL_OPENED: 'Year in Review: Modal opened',
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});
@@ -129,6 +129,10 @@
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
}
},
"BADGES": {
"PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolution"
}
}
}
@@ -38,6 +38,7 @@ 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,
@@ -80,4 +81,5 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...yearInReview,
};
@@ -234,6 +234,7 @@
"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",
@@ -0,0 +1,64 @@
{
"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,27 +1,49 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
import EditAttribute from './EditAttribute.vue';
import SettingsLayout from '../SettingsLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import {
useStoreGetters,
useStore,
useMapGetter,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
const { t } = useI18n();
const getters = useStoreGetters();
const store = useStore();
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const showAddPopup = ref(false);
const [showAddPopup, toggleAddPopup] = useToggle(false);
const selectedTabIndex = ref(0);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const [showEditPopup, toggleEditPopup] = useToggle(false);
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
const selectedAttribute = ref({});
const openAddPopup = () => {
showAddPopup.value = true;
toggleAddPopup(true);
};
const hideAddPopup = () => {
showAddPopup.value = false;
toggleAddPopup(false);
};
const hideEditPopup = () => {
toggleEditPopup(false);
selectedAttribute.value = {};
};
const closeDelete = () => {
toggleDeletePopup(false);
selectedAttribute.value = {};
};
const tabs = computed(() => {
@@ -37,6 +59,10 @@ const tabs = computed(() => {
];
});
const tabsForTabBar = computed(() =>
tabs.value.map(tab => ({ label: tab.name, key: tab.key }))
);
onMounted(() => {
store.dispatch('attributes/get');
});
@@ -49,9 +75,75 @@ const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(attributeModel.value)
);
const onClickTabChange = index => {
selectedTabIndex.value = index;
const onClickTabChange = tab => {
selectedTabIndex.value = tab.key;
};
const handleEditAttribute = attribute => {
selectedAttribute.value = attribute;
toggleEditPopup(true);
};
const handleDeleteAttribute = attribute => {
selectedAttribute.value = attribute;
toggleDeletePopup(true);
};
const confirmDeleteAttribute = async () => {
try {
await store.dispatch('attributes/delete', selectedAttribute.value.id);
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
closeDelete();
} catch (error) {
const errorMessage =
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const hasPreChatBadge = attribute => {
return (inboxes.value || []).some(inbox => {
const fields =
inbox?.pre_chat_form_options?.pre_chat_fields ||
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
[];
return fields.some(field => field.name === attribute.attribute_key);
});
};
const buildBadges = attribute => {
const badges = [];
if (hasPreChatBadge(attribute)) {
badges.push({
type: 'pre-chat',
});
}
if (
attribute.attribute_model === 'conversation_attribute' &&
requiredAttributeKeys.value.includes(attribute.attribute_key)
) {
badges.push({
type: 'resolution',
});
}
return badges;
};
const derivedAttributes = computed(() =>
attributes.value.map(attribute => ({
...attribute,
label: attribute.attribute_display_name,
type: attribute.attribute_display_type,
value: attribute.attribute_key,
badges: buildBadges(attribute),
}))
);
</script>
<template>
@@ -77,27 +169,25 @@ const onClickTabChange = index => {
</template>
</BaseSettingsHeader>
</template>
<template #preBody>
<woot-tabs
class="font-medium [&_ul]:p-0 mb-4"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
</template>
<template #body>
<CustomAttribute
:key="attributeModel"
:attribute-model="attributeModel"
/>
<div class="flex flex-col gap-6">
<TabBar
:tabs="tabsForTabBar"
:initial-active-tab="selectedTabIndex"
class="max-w-xl"
@tab-changed="onClickTabChange"
/>
<div class="grid gap-3">
<AttributeListItem
v-for="attribute in derivedAttributes"
:key="attribute.id"
:attribute="attribute"
:badges="attribute.badges"
@edit="handleEditAttribute"
@delete="handleDeleteAttribute"
/>
</div>
</div>
</template>
<AddAttribute
v-if="showAddPopup"
@@ -105,5 +195,34 @@ const onClickTabChange = index => {
:on-close="hideAddPopup"
:selected-attribute-model-tab="selectedTabIndex"
/>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
:is-updating="uiFlags.isUpdating"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-confirm-delete-modal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: selectedAttribute.attribute_display_name,
})
"
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
selectedAttribute.attribute_display_name || ''
}`"
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
:confirm-value="selectedAttribute.attribute_display_name"
:confirm-place-holder-text="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: selectedAttribute.attribute_display_name,
})
"
@on-confirm="confirmDeleteAttribute"
@on-close="closeDelete"
/>
</SettingsLayout>
</template>
+1 -1
View File
@@ -76,7 +76,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
welcomeDescription: chatwootSettings.welcomeDescription || '',
availableMessage: chatwootSettings.availableMessage || '',
unavailableMessage: chatwootSettings.unavailableMessage || '',
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
enableFileUpload: chatwootSettings.enableFileUpload,
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
@@ -11,6 +11,7 @@ import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { DirectUpload } from 'activestorage';
import { mapGetters } from 'vuex';
import { emitter } from 'shared/helpers/mitt';
import { useAttachments } from '../composables/useAttachments';
export default {
components: { FluentIcon, FileUpload, Spinner },
@@ -20,13 +21,16 @@ export default {
default: () => {},
},
},
setup() {
const { canHandleAttachments } = useAttachments();
return { canHandleAttachments };
},
data() {
return { isUploading: false };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
}),
fileUploadSizeLimit() {
return resolveMaximumFileUploadSize(
@@ -46,7 +50,7 @@ export default {
methods: {
handleClipboardPaste(e) {
// If file picker is not enabled, do not allow paste
if (!this.shouldShowFilePicker) return;
if (!this.canHandleAttachments) return;
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
// items is a DataTransferItemList object which does not have forEach method
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import ChatAttachmentButton from 'widget/components/ChatAttachment.vue';
import ChatSendButton from 'widget/components/ChatSendButton.vue';
import configMixin from '../mixins/configMixin';
import { useAttachments } from '../composables/useAttachments';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
@@ -18,7 +18,6 @@ export default {
FluentIcon,
ResizableTextArea,
},
mixins: [configMixin],
props: {
onSendMessage: {
type: Function,
@@ -29,6 +28,18 @@ export default {
default: () => {},
},
},
setup() {
const {
canHandleAttachments,
shouldShowEmojiPicker,
hasEmojiPickerEnabled,
} = useAttachments();
return {
canHandleAttachments,
shouldShowEmojiPicker,
hasEmojiPickerEnabled,
};
},
data() {
return {
userInput: '',
@@ -41,15 +52,10 @@ export default {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
}),
showAttachment() {
return (
this.shouldShowFilePicker &&
this.hasAttachmentsEnabled &&
this.userInput.length === 0
);
return this.canHandleAttachments && this.userInput.length === 0;
},
showSendButton() {
return this.userInput.length > 0;
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAttachments } from '../useAttachments';
import { useStore } from 'vuex';
import { computed } from 'vue';
// Mock Vue's useStore
vi.mock('vuex', () => ({
useStore: vi.fn(),
}));
// Mock Vue's computed
vi.mock('vue', () => ({
computed: vi.fn(fn => ({ value: fn() })),
}));
describe('useAttachments', () => {
let mockStore;
let mockGetters;
beforeEach(() => {
// Reset window.chatwootWebChannel
delete window.chatwootWebChannel;
// Create mock store
mockGetters = {};
mockStore = {
getters: mockGetters,
};
vi.mocked(useStore).mockReturnValue(mockStore);
// Mock computed to return a reactive-like object
vi.mocked(computed).mockImplementation(fn => ({ value: fn() }));
});
afterEach(() => {
vi.clearAllMocks();
});
describe('shouldShowFilePicker', () => {
it('returns value from store getter', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
const { shouldShowFilePicker } = useAttachments();
expect(shouldShowFilePicker.value).toBe(true);
});
it('returns undefined when not set in store', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
const { shouldShowFilePicker } = useAttachments();
expect(shouldShowFilePicker.value).toBeUndefined();
});
});
describe('hasAttachmentsEnabled', () => {
it('returns true when attachments are enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['attachments', 'emoji'],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(true);
});
it('returns false when attachments are not enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['emoji'],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when channel config has no enabled features', () => {
window.chatwootWebChannel = {
enabledFeatures: [],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when channel config is missing', () => {
window.chatwootWebChannel = undefined;
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when enabledFeatures is missing', () => {
window.chatwootWebChannel = {};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
});
describe('canHandleAttachments', () => {
beforeEach(() => {
// Set up a default channel config
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
});
it('prioritizes SDK flag when explicitly set to true', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
it('prioritizes SDK flag when explicitly set to false', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = false;
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('falls back to inbox settings when SDK flag is undefined', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
it('falls back to inbox settings when SDK flag is undefined and attachments disabled', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
window.chatwootWebChannel = {
enabledFeatures: ['emoji'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('prioritizes SDK false over inbox settings true', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = false;
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('prioritizes SDK true over inbox settings false', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
window.chatwootWebChannel = {
enabledFeatures: ['emoji'], // no attachments
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
});
describe('hasEmojiPickerEnabled', () => {
it('returns true when emoji picker is enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['emoji_picker', 'attachments'],
};
const { hasEmojiPickerEnabled } = useAttachments();
expect(hasEmojiPickerEnabled.value).toBe(true);
});
it('returns false when emoji picker is not enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { hasEmojiPickerEnabled } = useAttachments();
expect(hasEmojiPickerEnabled.value).toBe(false);
});
});
describe('shouldShowEmojiPicker', () => {
it('returns value from store getter', () => {
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
const { shouldShowEmojiPicker } = useAttachments();
expect(shouldShowEmojiPicker.value).toBe(true);
});
});
describe('integration test', () => {
it('returns all expected properties', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
window.chatwootWebChannel = {
enabledFeatures: ['attachments', 'emoji_picker'],
};
const result = useAttachments();
expect(result).toHaveProperty('shouldShowFilePicker');
expect(result).toHaveProperty('shouldShowEmojiPicker');
expect(result).toHaveProperty('hasAttachmentsEnabled');
expect(result).toHaveProperty('hasEmojiPickerEnabled');
expect(result).toHaveProperty('canHandleAttachments');
expect(Object.keys(result)).toHaveLength(5);
});
});
});
@@ -0,0 +1,42 @@
import { computed } from 'vue';
import { useStore } from 'vuex';
export function useAttachments() {
const store = useStore();
const shouldShowFilePicker = computed(
() => store.getters['appConfig/getShouldShowFilePicker']
);
const shouldShowEmojiPicker = computed(
() => store.getters['appConfig/getShouldShowEmojiPicker']
);
const hasAttachmentsEnabled = computed(() => {
const channelConfig = window.chatwootWebChannel;
return channelConfig?.enabledFeatures?.includes('attachments') || false;
});
const hasEmojiPickerEnabled = computed(() => {
const channelConfig = window.chatwootWebChannel;
return channelConfig?.enabledFeatures?.includes('emoji_picker') || false;
});
const canHandleAttachments = computed(() => {
// If enableFileUpload was explicitly set via SDK, prioritize that
if (shouldShowFilePicker.value !== undefined) {
return shouldShowFilePicker.value;
}
// Otherwise, fall back to inbox settings only
return hasAttachmentsEnabled.value;
});
return {
shouldShowFilePicker,
shouldShowEmojiPicker,
hasAttachmentsEnabled,
hasEmojiPickerEnabled,
canHandleAttachments,
};
}
@@ -25,7 +25,7 @@ const state = {
welcomeDescription: '',
availableMessage: '',
unavailableMessage: '',
enableFileUpload: true,
enableFileUpload: undefined,
enableEmojiPicker: true,
enableEndConversation: true,
};
@@ -64,7 +64,7 @@ export const actions = {
welcomeDescription = '',
availableMessage = '',
unavailableMessage = '',
enableFileUpload = true,
enableFileUpload = undefined,
enableEmojiPicker = true,
enableEndConversation = true,
}
+103 -156
View File
@@ -1,176 +1,123 @@
# TODO: logic is written tailored to contact import since its the only import available
# let's break this logic and clean this up in future
# let's break this logic and clean this up in future
class DataImportJob < ApplicationJob
queue_as :low
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
class DataImportJob < ApplicationJob
queue_as :low
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
def perform(data_import)
@data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account)
begin
process_import_file
send_import_notification_to_admin
rescue CSV::MalformedCSVError => e
handle_csv_error(e)
end
end
private
def process_import_file
@data_import.update!(status: :processing)
identifiable_contacts, non_identifiable_contacts, rejected_contacts, total_csv_records = parse_csv_and_build_contacts
all_valid_contacts = identifiable_contacts + non_identifiable_contacts
# Count contacts before import
contacts_before = @data_import.account.contacts.count
import_contacts(all_valid_contacts)
# Count contacts after import to get actual imported count
contacts_after = @data_import.account.contacts.count
actual_imported_count = contacts_after - contacts_before
# Calculate actual imported counts based on what we built vs what was imported
actual_identifiable_count, actual_non_identifiable_count = calculate_actual_import_counts(
identifiable_contacts, non_identifiable_contacts, actual_imported_count
)
update_data_import_status(actual_identifiable_count, actual_non_identifiable_count, rejected_contacts.length, total_csv_records)
save_failed_records_csv(rejected_contacts)
end
# rubocop:disable Metrics/MethodLength
def parse_csv_and_build_contacts
identifiable_contacts = []
non_identifiable_contacts = []
rejected_contacts = []
total_csv_records = 0
with_import_file do |file|
csv_reader(file).each do |row|
total_csv_records += 1
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
is_identifiable = @contact_manager.contact_identifiable?(current_contact)
if is_identifiable
identifiable_contacts << current_contact
else
non_identifiable_contacts << current_contact
end
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
end
[identifiable_contacts, non_identifiable_contacts, rejected_contacts, total_csv_records]
end
# rubocop:enable Metrics/MethodLength
def append_rejected_contact(row, contact, rejected_contacts)
row['errors'] = contact.errors.full_messages.join(', ')
rejected_contacts << row
end
def import_contacts(contacts)
# Returns ActiveRecord::Import::Result with info about what was actually imported
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
end
def calculate_actual_import_counts(identifiable_contacts, non_identifiable_contacts, actual_imported_count)
Rails.logger.info "Actual imported: #{actual_imported_count}, expected identifiable: #{identifiable_contacts.length}, " \
"expected non-identifiable: #{non_identifiable_contacts.length}"
expected_count = identifiable_contacts.length + non_identifiable_contacts.length
if actual_imported_count <= expected_count
# Prioritize identifiable contacts in the count since they're more important
actual_identifiable_count = [actual_imported_count, identifiable_contacts.length].min
actual_non_identifiable_count = actual_imported_count - actual_identifiable_count
else
# This shouldn't happen, but fallback to expected counts
actual_identifiable_count = identifiable_contacts.length
actual_non_identifiable_count = non_identifiable_contacts.length
end
Rails.logger.info "Final counts: identifiable=#{actual_identifiable_count}, non-identifiable=#{actual_non_identifiable_count}"
[actual_identifiable_count, actual_non_identifiable_count]
end
def update_data_import_status(identifiable_records, non_identifiable_records, _rejected_records, total_csv_records)
processed_records = identifiable_records + non_identifiable_records
@data_import.update!(
status: :completed,
processed_records: processed_records,
non_identifiable_records: non_identifiable_records,
total_records: total_csv_records
)
end
def save_failed_records_csv(rejected_contacts)
csv_data = generate_csv_data(rejected_contacts)
return if csv_data.blank?
@data_import.failed_records.attach(io: StringIO.new(csv_data), filename: "#{Time.zone.today.strftime('%Y%m%d')}_contacts.csv",
content_type: 'text/csv')
def perform(data_import)
@data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account)
begin
process_import_file
send_import_notification_to_admin
rescue CSV::MalformedCSVError => e
handle_csv_error(e)
end
end
def generate_csv_data(rejected_contacts)
headers = csv_headers
headers << 'errors'
return if rejected_contacts.blank?
private
CSV.generate do |csv|
csv << headers
rejected_contacts.each do |record|
csv << record
def process_import_file
@data_import.update!(status: :processing)
contacts, rejected_contacts = parse_csv_and_build_contacts
import_contacts(contacts)
update_data_import_status(contacts.length, rejected_contacts.length)
save_failed_records_csv(rejected_contacts)
end
def parse_csv_and_build_contacts
contacts = []
rejected_contacts = []
with_import_file do |file|
csv_reader(file).each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
end
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
@data_import.update!(status: :failed)
send_import_failed_notification_to_admin
end
[contacts, rejected_contacts]
end
def send_import_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end
def append_rejected_contact(row, contact, rejected_contacts)
row['errors'] = contact.errors.full_messages.join(', ')
rejected_contacts << row
end
def send_import_failed_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
def import_contacts(contacts)
# <struct ActiveRecord::Import::Result failed_instances=[], num_inserts=1, ids=[444, 445], results=[]>
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
end
def csv_headers
header_row = nil
with_import_file do |file|
header_row = csv_reader(file).first
end
header_row&.headers || []
end
def update_data_import_status(processed_records, rejected_records)
@data_import.update!(status: :completed, processed_records: processed_records, total_records: processed_records + rejected_records)
end
def csv_reader(file)
file.rewind
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
def save_failed_records_csv(rejected_contacts)
csv_data = generate_csv_data(rejected_contacts)
return if csv_data.blank?
CSV.new(StringIO.new(clean_data), headers: true)
end
@data_import.failed_records.attach(io: StringIO.new(csv_data), filename: "#{Time.zone.today.strftime('%Y%m%d')}_contacts.csv",
content_type: 'text/csv')
send_import_notification_to_admin
end
def with_import_file
temp_dir = Rails.root.join('tmp/imports')
FileUtils.mkdir_p(temp_dir)
def generate_csv_data(rejected_contacts)
headers = csv_headers
headers << 'errors'
return if rejected_contacts.blank?
@data_import.import_file.open(tmpdir: temp_dir) do |file|
file.binmode
yield file
CSV.generate do |csv|
csv << headers
rejected_contacts.each do |record|
csv << record
end
end
end
end
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
@data_import.update!(status: :failed)
send_import_failed_notification_to_admin
end
def send_import_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end
def send_import_failed_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
def csv_headers
header_row = nil
with_import_file do |file|
header_row = csv_reader(file).first
end
header_row&.headers || []
end
def csv_reader(file)
file.rewind
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
CSV.new(StringIO.new(clean_data), headers: true)
end
def with_import_file
temp_dir = Rails.root.join('tmp/imports')
FileUtils.mkdir_p(temp_dir)
@data_import.import_file.open(tmpdir: temp_dir) do |file|
file.binmode
yield file
end
end
end
+1 -1
View File
@@ -102,7 +102,7 @@ class Imap::ImapMailbox
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
timestamp: @processed_mail.date&.utc || Time.now.utc
}
}
}
+3
View File
@@ -1,6 +1,7 @@
module MailboxHelper
private
# rubocop:disable Metrics/AbcSize
def create_message
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
@@ -13,6 +14,7 @@ module MailboxHelper
message_type: 'incoming',
content_type: 'incoming_email',
source_id: processed_mail.message_id,
created_at: processed_mail.date&.utc || Time.current,
content_attributes: {
email: processed_mail.serialized_data,
cc_email: processed_mail.cc,
@@ -20,6 +22,7 @@ module MailboxHelper
}
)
end
# rubocop:enable Metrics/AbcSize
def add_attachments_to_message
return if @message.blank?
@@ -32,16 +32,9 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts"
end
# Calculate metrics with backward compatibility
non_identifiable_count = resource.respond_to?(:non_identifiable_records) ? resource.non_identifiable_records : 0
identifiable_count = resource.processed_records - non_identifiable_count
failed_count = resource.total_records - resource.processed_records
meta = {
'identifiable_contacts' => identifiable_count,
'non_identifiable_contacts' => non_identifiable_count,
'failed_contacts' => failed_count,
'total_contacts' => resource.total_records
'failed_contacts' => resource.total_records - resource.processed_records,
'imported_contacts' => resource.processed_records
}
send_notification(subject, action_url: action_url, meta: meta)
+6 -2
View File
@@ -37,7 +37,11 @@ class Account < ApplicationRecord
'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }
'auto_resolve_label': { 'type': %w[string null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
}
},
'required': [],
'additionalProperties': true
@@ -55,7 +59,7 @@ class Account < ApplicationRecord
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -45,6 +45,7 @@ class CustomAttributeDefinition < ApplicationRecord
belongs_to :account
after_update :update_widget_pre_chat_custom_fields
after_destroy :sync_widget_pre_chat_custom_fields
after_destroy :cleanup_conversation_required_attributes
private
@@ -56,6 +57,13 @@ class CustomAttributeDefinition < ApplicationRecord
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
def cleanup_conversation_required_attributes
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
account.save!
end
def attribute_must_not_conflict
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
+9 -10
View File
@@ -2,16 +2,15 @@
#
# Table name: data_imports
#
# id :bigint not null, primary key
# data_type :string not null
# non_identifiable_records :integer default(0), not null
# processed_records :integer
# processing_errors :text
# status :integer default("pending"), not null
# total_records :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# data_type :string not null
# processed_records :integer
# processing_errors :text
# status :integer default("pending"), not null
# total_records :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -9,10 +9,6 @@ class DataImport::ContactManager
contact
end
def contact_identifiable?(contact)
contact.email.present? || contact.phone_number.present? || contact.identifier.present?
end
def find_or_initialize_contact(params)
contact = find_existing_contact(params)
contact_params = params.slice(:email, :identifier, :phone_number)
@@ -1,21 +1,10 @@
<p>Hello,</p>
<p>Your contact import has been completed. Here's a summary of the results:</p>
<p>Your contact import has been completed. Please check the contacts tab to view the imported contacts.</p>
<p><strong>Contacts imported successfully:</strong> {{meta['identifiable_contacts']}}</p>
<p><em>These contacts are visible in your contacts list and can be used for conversations.</em></p>
<p>Number of records imported: {{meta['imported_contacts']}}</p>
{% if meta['non_identifiable_contacts'] > 0 %}
<p><strong>Contacts created but not visible:</strong> {{meta['non_identifiable_contacts']}}</p>
<p><em>These contacts were created successfully but don't have email, phone number, or identifier, so they won't appear in your contacts list until you add this information.</em></p>
{% endif %}
{% if meta['failed_contacts'] > 0 %}
<p><strong>Contacts that failed to import:</strong> {{meta['failed_contacts']}}</p>
<p><em>These contacts had validation errors and could not be imported.</em></p>
{% endif %}
<p><strong>Total contacts processed:</strong> {{meta['total_contacts']}}</p>
<p>Number of records failed: {{meta['failed_contacts']}}</p>
{% if meta['failed_contacts'] == 0 %}
<p>
@@ -23,6 +12,6 @@
</p>
{% else %}
<p>
Click <a href="{{ action_url }}" target="_blank">here</a> to download the failed records and see specific error messages.
Click <a href="{{ action_url }}" target="_blank">here</a> to view failed records.
</p>
{% endif %}
+10 -7
View File
@@ -203,9 +203,15 @@ 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
@@ -419,6 +425,7 @@ 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
@@ -550,13 +557,9 @@ Rails.application.routes.draw do
resources :delivery_status, only: [:create]
if ChatwootApp.enterprise?
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
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
end
end
@@ -1,5 +0,0 @@
class AddNonIdentifiableRecordsToDataImports < ActiveRecord::Migration[7.0]
def change
add_column :data_imports, :non_identifiable_records, :integer, default: 0, null: false
end
end
+1257 -147
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
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,9 +151,8 @@ class Twilio::VoiceController < ApplicationController
end
def conference_status_callback_url
host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
"#{host}/twilio/voice/conference_status/#{phone_digits}"
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
+7 -6
View File
@@ -42,11 +42,13 @@ class Channel::Voice < ApplicationRecord
false
end
def initiate_call(to:)
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::TwilioAdapter.new(self).initiate_call(
to: to
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
@@ -56,12 +58,12 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
@@ -87,7 +89,6 @@ 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)
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,46 @@
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
@@ -0,0 +1,62 @@
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
@@ -1,32 +0,0 @@
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
+2
View File
@@ -69,6 +69,8 @@
"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,6 +127,12 @@ 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
@@ -1672,6 +1678,10 @@ 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==}
@@ -1918,6 +1928,9 @@ 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}
@@ -2686,6 +2699,13 @@ 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==}
@@ -4217,6 +4237,9 @@ 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==}
@@ -4413,6 +4436,9 @@ 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==}
@@ -6248,6 +6274,8 @@ snapshots:
balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -6530,6 +6558,10 @@ 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
@@ -7464,6 +7496,13 @@ 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
@@ -9158,6 +9197,10 @@ 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:
@@ -9358,6 +9401,10 @@ 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.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.
@@ -0,0 +1,64 @@
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
@@ -0,0 +1,142 @@
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
@@ -102,8 +102,9 @@ 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)
expect(described_class.enabled).to include(enabled_tool)
expect(described_class.enabled).not_to include(disabled_tool)
enabled_ids = described_class.enabled.pluck(:id)
expect(enabled_ids).to include(enabled_tool.id)
expect(enabled_ids).not_to include(disabled_tool.id)
end
end
end
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,33 @@
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 -39
View File
@@ -62,8 +62,7 @@ RSpec.describe DataImportJob do
described_class.perform_now(invalid_data_import)
expect(invalid_data_import.account.contacts.count).to eq(csv_length - 1)
expect(invalid_data_import.reload.total_records).to eq(csv_length)
# Only 2 contacts actually imported (duplicate email fails for the 3rd)
expect(invalid_data_import.reload.processed_records).to eq(csv_length - 1)
expect(invalid_data_import.reload.processed_records).to eq(csv_length)
end
it 'will preserve emojis' do
@@ -151,48 +150,11 @@ RSpec.describe DataImportJob do
expect(phone_contact.reload.email).to be_nil
expect(email_contact.reload.phone_number).to be_nil
expect(existing_data_import.total_records).to eq(csv_length)
# Only 2 contacts imported (1 has conflicting phone/email so fails)
expect(existing_data_import.processed_records).to eq(csv_length - 1)
end
end
end
context 'with non_identifiable_records tracking' do
it 'tracks non-identifiable records correctly' do
# Test that the new non_identifiable_records field is properly tracked
described_class.perform_now(data_import)
data_import.reload
expect(data_import.non_identifiable_records).to eq(0) # Default CSV has all identifiable contacts
end
end
context 'when all contacts have duplicate identifiers' do
it 'imports only the first contact and reports failures accurately' do
duplicate_data = [
%w[id name email identifier phone_number],
['1', 'First Contact', '', 'DUPLICATE-ID', '+918484848484'], # Will succeed
['2', 'Second Contact', '', 'DUPLICATE-ID', '+918484848485'], # Will fail (duplicate identifier)
['3', 'Third Contact', '', 'DUPLICATE-ID', '+918484848486'] # Will fail (duplicate identifier)
]
duplicate_data_import = create(:data_import, import_file: generate_csv_file(duplicate_data))
CSV.parse(duplicate_data_import.import_file.download, headers: true)
described_class.perform_now(duplicate_data_import)
# Only first contact should be imported
expect(duplicate_data_import.account.contacts.count).to eq(1)
expect(duplicate_data_import.account.contacts.first.name).to eq('First Contact')
# Check metrics
duplicate_data_import.reload
expect(duplicate_data_import.total_records).to eq(3)
expect(duplicate_data_import.processed_records).to eq(1) # Only first contact imported
expect(duplicate_data_import.non_identifiable_records).to eq(0) # First contact is identifiable
end
end
context 'when the CSV file is invalid' do
let(:invalid_csv_content) do
"id,name,email,phone_number,company\n1,\"Clarice Uzzell,\"missing_quote,918080808080,Acmecorp\n2,Marieann Creegan,,+918080808081,Acmecorp"
@@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CustomAttributeDefinition do
describe 'callbacks' do
describe '#cleanup_conversation_required_attributes' do
let(:account) { create(:account) }
let(:attribute_key) { 'test_attribute' }
let!(:custom_attribute) do
create(:custom_attribute_definition,
account: account,
attribute_key: attribute_key,
attribute_model: 'conversation_attribute')
end
context 'when conversation attribute is in required attributes list' do
before do
account.update!(conversation_required_attributes: [attribute_key, 'other_attribute'])
end
it 'removes the attribute from conversation_required_attributes when destroyed' do
expect { custom_attribute.destroy! }
.to change { account.reload.conversation_required_attributes }
.from([attribute_key, 'other_attribute'])
.to(['other_attribute'])
end
end
context 'when attribute is contact_attribute' do
let!(:contact_attribute) do
create(:custom_attribute_definition,
account: account,
attribute_key: attribute_key,
attribute_model: 'contact_attribute')
end
before do
account.update!(conversation_required_attributes: [attribute_key])
end
it 'does not modify conversation_required_attributes when destroyed' do
expect { contact_attribute.destroy! }
.not_to(change { account.reload.conversation_required_attributes })
end
end
end
end
end
-13
View File
@@ -11,19 +11,6 @@ RSpec.describe DataImport do
end
end
describe 'fields' do
let(:data_import) { create(:data_import) }
it 'has non_identifiable_records field with default value' do
expect(data_import.non_identifiable_records).to eq(0)
end
it 'allows setting non_identifiable_records' do
data_import.update!(non_identifiable_records: 5)
expect(data_import.reload.non_identifiable_records).to eq(5)
end
end
describe 'callbacks' do
let(:data_import) { build(:data_import) }
+1 -1
View File
@@ -41,7 +41,7 @@ const tailwindConfig = {
fontFamily: {
sans: defaultSansFonts,
inter: ['Inter', ...defaultSansFonts],
interDisplay: ['Inter Display', ...defaultSansFonts],
interDisplay: ['InterDisplay', ...defaultSansFonts],
},
typography: {
bubble: {