+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue b/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue
index e5350c1fb..10068ec3d 100644
--- a/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
+import Popover from 'dashboard/components-next/popover/Popover.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
const searchQuery = ref('');
const selectedTemplate = ref(null);
-const showTemplatesMenu = ref(false);
-
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
-const handleTriggerClick = () => {
+const handlePopoverShow = () => {
searchQuery.value = '';
- showTemplatesMenu.value = !showTemplatesMenu.value;
+ selectedTemplate.value = null;
+};
+
+const handlePopoverHide = () => {
+ selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
- showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
- showTemplatesMenu.value = true;
};
-const handleSendMessage = template => {
+const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
- selectedTemplate.value = null;
+ hide();
};
-
+
+ handleSendMessage(payload, hide)"
+ @back="handleBack"
+ />
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplate.vue b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplate.vue
index 8e655fcfb..03789839a 100644
--- a/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplate.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplate.vue
@@ -24,9 +24,7 @@ const handleBack = () => {
-
+
+import { ref, computed, watch, nextTick } from 'vue';
+import { vOnClickOutside } from '@vueuse/components';
+import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
+import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
+import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
+import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
+
+const props = defineProps({
+ align: {
+ type: String,
+ default: 'end',
+ validator: v => ['start', 'end'].includes(v),
+ },
+ disableMobileView: {
+ type: Boolean,
+ default: false,
+ },
+ showContentBorder: {
+ type: Boolean,
+ default: true,
+ },
+});
+
+const emit = defineEmits(['show', 'hide']);
+
+const isActive = ref(false);
+const triggerRef = ref(null);
+const popoverRef = ref(null);
+const mobileContentRef = ref(null);
+
+const breakpoints = useBreakpoints(breakpointsTailwind);
+const belowMd = breakpoints.smaller('md');
+const isMobile = computed(() => !props.disableMobileView && belowMd.value);
+const showPopover = computed(() => isActive.value && !isMobile.value);
+
+const { fixedPosition, updatePosition } = useDropdownPosition(
+ triggerRef,
+ popoverRef,
+ showPopover,
+ { align: props.align }
+);
+
+const show = async () => {
+ isActive.value = true;
+ if (!isMobile.value) {
+ await nextTick();
+ updatePosition();
+ }
+ emit('show');
+};
+
+const hide = () => {
+ if (!isActive.value) return;
+ isActive.value = false;
+ emit('hide');
+};
+
+const toggle = async () => {
+ if (isActive.value) hide();
+ else await show();
+};
+
+// Recalculate position when switching from mobile to desktop while open
+watch(isMobile, async mobile => {
+ if (!isActive.value || mobile) return;
+ await nextTick();
+ updatePosition();
+});
+
+const handleClickOutside = event => {
+ if (triggerRef.value?.contains(event.target)) return;
+ hide();
+};
+
+// Selectors for teleported elements that should not trigger close
+const clickOutsideIgnore = [
+ 'dialog.ProseMirror-prompt-backdrop',
+ '[data-popover-content]',
+];
+
+useKeyboardEvents({
+ Escape: {
+ action: () => isActive.value && hide(),
+ allowOnFocusedInput: true,
+ },
+});
+
+defineExpose({ show, hide, toggle });
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 9fd25c481..cbb3b2099 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
-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';
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
emit('closeMobileSidebar');
};
-const onComposeOpen = toggleFn => {
- toggleFn();
- emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
-};
-
-const onComposeClose = () => {
- emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
-};
-
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -734,7 +723,13 @@ const menuItems = computed(() => {
-
-
+
+
diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue
index f311de4b2..ddc350811 100644
--- a/app/javascript/dashboard/components/ChatList.vue
+++ b/app/javascript/dashboard/components/ChatList.vue
@@ -1,15 +1,5 @@
-
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/ConversationList.vue b/app/javascript/dashboard/components/ConversationList.vue
new file mode 100644
index 000000000..f02a0b801
--- /dev/null
+++ b/app/javascript/dashboard/components/ConversationList.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('CHAT_LIST.EOF') }}
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
index f50485723..ae0f3109c 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
@@ -1,98 +1,41 @@
{
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
- rounded-full
>
-
+
{
:conversation-id="chat.id"
/>
-
- {{ unreadCount > 9 ? '9+' : unreadCount }}
-
+
{
-
-
-
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationSidebar.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationSidebar.vue
index 00c944f9e..89b01cb8d 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationSidebar.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationSidebar.vue
@@ -41,7 +41,16 @@ const closeContactPanel = () => {
';
+ });
+
+ describe('verticalClass (relative mode)', () => {
+ it('places below when enough space', () => {
+ // Trigger at y=100, dropdown 200px tall
+ // Space below = 768 - 140 = 628 → fits (628 > 216)
+ setTrigger({ top: 100, bottom: 140 });
+ setDropdown({ height: 200 });
+
+ const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
+ expect(position.value.class).toBe('top-full mt-2');
+ });
+
+ it('places above when not enough space below but enough above', () => {
+ // Trigger near bottom at y=600
+ // Space below = 128 → doesn't fit. Space above = 600 → fits
+ setTrigger({ top: 600, bottom: 640 });
+ setDropdown({ height: 200 });
+
+ const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
+ expect(position.value.class).toBe('bottom-full mb-2');
+ });
+
+ it('picks the side with more space when dropdown fits neither', () => {
+ // Dropdown 500px tall, won't fit above (300) or below (428)
+ // Below has more room → stays below
+ setTrigger({ top: 300, bottom: 340 });
+ setDropdown({ height: 500 });
+
+ const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
+ expect(position.value.class).toBe('top-full mt-2');
+ });
+
+ it('picks above when above has more space and neither fits', () => {
+ // Dropdown 600px tall, won't fit above (500) or below (228)
+ // Above has more room → flips above
+ setTrigger({ top: 500, bottom: 540 });
+ setDropdown({ height: 600 });
+
+ const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
+ expect(position.value.class).toBe('bottom-full mb-2');
+ });
+
+ it('returns default when disabled', () => {
+ setTrigger({ top: 700, bottom: 740 });
+ setDropdown({ height: 200 });
+
+ const { position } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(false)
+ );
+ expect(position.value.class).toBe('top-full mt-2');
+ expect(position.value.style).toEqual({});
+ });
+ });
+
+ describe('fixedPosition', () => {
+ it('places below with correct top and maxHeight', () => {
+ // Trigger at y=140, space below = 628
+ // top = 140 + 8(gap) = 148
+ // maxHeight = 628 - 8(gap) - 16(margin) = 604
+ setTrigger({ top: 100, bottom: 140 });
+ setDropdown({ height: 200, width: 200 });
+
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(true)
+ );
+
+ expect(fixedPosition.value.style.top).toBe('148px');
+ expect(fixedPosition.value.style.bottom).toBeUndefined();
+ expect(fixedPosition.value.style.maxHeight).toBe('604px');
+ });
+
+ it('flips above with correct bottom and maxHeight', () => {
+ // Trigger near bottom at y=650, space below = 78 → doesn't fit
+ // Flips above: bottom = 768 - 650 + 8 = 126
+ // maxHeight = 650 - 8 - 16 = 626
+ setTrigger({ top: 650, bottom: 690 });
+ setDropdown({ height: 200, width: 200 });
+
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(true)
+ );
+
+ expect(fixedPosition.value.style.bottom).toBe('126px');
+ expect(fixedPosition.value.style.top).toBeUndefined();
+ expect(fixedPosition.value.style.maxHeight).toBe('626px');
+ });
+
+ it('constrains maxHeight to available space on short viewports', () => {
+ // Short viewport (400px), trigger in the middle, dropdown 500px tall
+ // Neither side fits → above (200) > below (160) → places above
+ // maxHeight capped to 200 - 8 - 16 = 176
+ winHeight.value = 400;
+ setTrigger({ top: 200, bottom: 240 });
+ setDropdown({ height: 500, width: 200 });
+
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(true)
+ );
+
+ expect(fixedPosition.value.style.bottom).toBeDefined();
+ expect(fixedPosition.value.style.maxHeight).toBe('176px');
+ });
+
+ it('returns defaults when disabled', () => {
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(false)
+ );
+ expect(fixedPosition.value.class).toBe('fixed z-[9999]');
+ expect(fixedPosition.value.style).toEqual({});
+ });
+ });
+
+ describe('horizontal positioning (fixedPosition)', () => {
+ it('anchors to the right edge by default (align=end, LTR)', () => {
+ // align=end + LTR → anchorLeft=false → uses style.right
+ // right = 1024 - 900 = 124
+ setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
+ setDropdown({ height: 100, width: 200 });
+
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(true)
+ );
+
+ expect(fixedPosition.value.style.right).toBe('124px');
+ });
+
+ it('anchors to the left edge when align=start (LTR)', () => {
+ // align=start + LTR → anchorLeft=true → uses style.left
+ setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
+ setDropdown({ height: 100, width: 200 });
+
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(true),
+ { align: 'start' }
+ );
+
+ expect(fixedPosition.value.style.left).toBe('100px');
+ });
+
+ it('shifts left when dropdown overflows right edge', () => {
+ // Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
+ // Falls back to right: 16px (margin)
+ setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
+ setDropdown({ height: 100, width: 300 });
+
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(true),
+ { align: 'start' }
+ );
+
+ expect(fixedPosition.value.style.right).toBe('16px');
+ });
+ });
+
+ describe('RTL', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('flips anchor direction in RTL (align=end anchors left)', () => {
+ // align=end + RTL → anchorLeft=true → uses style.left
+ setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
+ setDropdown({ height: 100, width: 200 });
+
+ const { fixedPosition } = useDropdownPosition(
+ ref(null),
+ ref(null),
+ ref(true)
+ );
+
+ expect(fixedPosition.value.style.left).toBe('100px');
+ });
+ });
+});
diff --git a/app/javascript/dashboard/composables/useDropdownPosition.js b/app/javascript/dashboard/composables/useDropdownPosition.js
new file mode 100644
index 000000000..93afc30b1
--- /dev/null
+++ b/app/javascript/dashboard/composables/useDropdownPosition.js
@@ -0,0 +1,128 @@
+import { computed, unref, watch } from 'vue';
+import { useElementBounding, useWindowSize } from '@vueuse/core';
+
+const FALLBACK_SIZE = 200;
+const SAFE_MARGIN = 16;
+const GAP = 8;
+
+/**
+ * Auto-position a floating element based on available viewport space.
+ *
+ * @param {Ref} triggerRef - Trigger element ref
+ * @param {Ref} dropdownRef - Dropdown/popover element ref
+ * @param {Ref} enabled - Whether to calculate position
+ * @param {Object} options
+ * @param {Ref} [options.container] - Constraining container ref
+ * @param {number} [options.margin=16] - Min distance from viewport/container edges
+ * @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
+ */
+export function useDropdownPosition(
+ triggerRef,
+ dropdownRef,
+ enabled,
+ { container = null, margin = SAFE_MARGIN, align = 'end' } = {}
+) {
+ const trigger = useElementBounding(triggerRef);
+ const dropdown = useElementBounding(dropdownRef);
+ const bounds = useElementBounding(container);
+ const { width: winWidth, height: winHeight } = useWindowSize();
+
+ const isRTL = computed(
+ () => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
+ );
+
+ // Whether to anchor to the left edge of the trigger
+ const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
+
+ const verticalClass = computed(() => {
+ if (!unref(enabled)) return 'top-full mt-2';
+ const dh = dropdown.height.value || FALLBACK_SIZE;
+ const spaceBelow = winHeight.value - trigger.bottom.value;
+ const spaceAbove = trigger.top.value;
+ // Only flip above if it fits there; otherwise stay below (more room or equal)
+ if (spaceBelow >= dh + margin) return 'top-full mt-2';
+ if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
+ return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
+ });
+
+ // Relative mode: Tailwind class + style for absolute-in-parent dropdowns
+ const position = computed(() => {
+ if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
+
+ const dw = dropdown.width.value || FALLBACK_SIZE;
+ const leftBound = container ? bounds.left.value : 0;
+ const rightBound = container ? bounds.right.value : winWidth.value;
+ const style = {};
+
+ if (anchorLeft.value) {
+ const available = rightBound - trigger.left.value;
+ const overflow = dw - available;
+ style.left = overflow > 0 ? `-${overflow}px` : '0px';
+ } else {
+ const available = trigger.right.value - leftBound;
+ const overflow = dw - available;
+ style.right = overflow > 0 ? `-${overflow}px` : '0px';
+ }
+
+ return { class: verticalClass.value, style };
+ });
+
+ // Fixed mode: styles for teleported popovers
+ const fixedPosition = computed(() => {
+ if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
+
+ const dh = dropdown.height.value || FALLBACK_SIZE;
+ const dw = dropdown.width.value || FALLBACK_SIZE;
+ const spaceBelow = winHeight.value - trigger.bottom.value;
+ const style = {};
+
+ // Vertical: prefer below, flip above only if it fits, else pick the larger side
+ const spaceAbove = trigger.top.value;
+ const placeAbove =
+ spaceBelow < dh + margin &&
+ (spaceAbove >= dh + margin || spaceAbove > spaceBelow);
+
+ if (placeAbove) {
+ style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
+ style.maxHeight = `${spaceAbove - GAP - margin}px`;
+ } else {
+ style.top = `${trigger.bottom.value + GAP}px`;
+ style.maxHeight = `${spaceBelow - GAP - margin}px`;
+ }
+
+ // Horizontal
+ if (anchorLeft.value) {
+ const left = trigger.left.value;
+ if (left + dw > winWidth.value - margin) {
+ style.right = `${margin}px`;
+ } else {
+ style.left = `${Math.max(margin, left)}px`;
+ }
+ } else {
+ const right = winWidth.value - trigger.right.value;
+ if (trigger.right.value - dw < margin) {
+ style.left = `${margin}px`;
+ } else {
+ style.right = `${right}px`;
+ }
+ }
+
+ return { class: 'fixed z-[9999]', style };
+ });
+
+ const updatePosition = () => {
+ trigger.update();
+ dropdown.update();
+ if (container) bounds.update();
+ };
+
+ // Update position when dropdown opens to ensure RTL state is current
+ watch(
+ () => unref(enabled),
+ isEnabled => {
+ if (isEnabled) updatePosition();
+ }
+ );
+
+ return { position, fixedPosition, updatePosition };
+}
diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js
index 4fbc8174b..889ece96a 100644
--- a/app/javascript/dashboard/constants/globals.js
+++ b/app/javascript/dashboard/constants/globals.js
@@ -45,6 +45,7 @@ export default {
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
SMALL_SCREEN_BREAKPOINT: 768,
+ LARGE_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
diff --git a/app/javascript/dashboard/helper/portalHelper.js b/app/javascript/dashboard/helper/portalHelper.js
index 37d09337f..33a2a822a 100644
--- a/app/javascript/dashboard/helper/portalHelper.js
+++ b/app/javascript/dashboard/helper/portalHelper.js
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
+ translate: {
+ label:
+ 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
+ value: 'translate',
+ action: 'translate',
+ icon: 'i-lucide-languages',
+ },
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
- [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
- [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
- [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
+ [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
+ [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
+ [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json
index 0e8e87a04..1384dae2b 100644
--- a/app/javascript/dashboard/i18n/locale/en/chatlist.json
+++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json
@@ -140,6 +140,7 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
- "SENDING": "Sending"
+ "SENDING": "Sending",
+ "UNREAD_COUNT_OVERFLOW": "9+"
}
}
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 835a7e512..1a2edb2b0 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
- "HIDE_LABELS": "Hide labels"
+ "HIDE_LABELS": "Hide labels",
+ "LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index ffeca222a..9ae849d25 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
+ "TRANSLATE": "Translate",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
+ },
+ "BULK_TRANSLATE": {
+ "TITLE": "Translate article | Translate {count} articles",
+ "DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
+ "LOCALE_LABEL": "Target language",
+ "LOCALE_PLACEHOLDER": "Select a language",
+ "CATEGORY_LABEL": "Target category",
+ "CATEGORY_PLACEHOLDER": "Select a category",
+ "OPTIONAL": "(optional)",
+ "CONFIRM": "Translate",
+ "SELECT_ALL": "Select all ({count})",
+ "SELECTED_COUNT": "{count} selected",
+ "CLEAR_SELECTION": "Clear selection",
+ "TRANSLATE_BUTTON": "Translate",
+ "CONFIRM_OVERWRITE": "Overwrite and translate",
+ "DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
+ "DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
+ "API": {
+ "SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
+ "ERROR_MESSAGE": "Failed to start translation. Please try again."
+ }
+ },
+ "BULK_ACTIONS": {
+ "PUBLISH": "Publish",
+ "DRAFT": "Draft",
+ "ARCHIVE": "Archive",
+ "TRANSLATE": "Translate",
+ "DELETE": "Delete",
+ "STATUS_SUCCESS": "Articles updated successfully",
+ "STATUS_ERROR": "Failed to update articles",
+ "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
+ "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
+ "DELETE_CONFIRM": "Delete",
+ "DELETE_SUCCESS": "Articles deleted successfully",
+ "DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index 51f855689..6b1ff20b1 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -745,6 +745,7 @@
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
+ "ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "Inbox Settings",
diff --git a/app/javascript/dashboard/modules/contact/ContactDeleteModal.vue b/app/javascript/dashboard/modules/contact/ContactDeleteModal.vue
new file mode 100644
index 000000000..617b0456f
--- /dev/null
+++ b/app/javascript/dashboard/modules/contact/ContactDeleteModal.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
+
+
+ {{ confirmMessage }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/modules/contact/ContactMergeModal.vue b/app/javascript/dashboard/modules/contact/ContactMergeModal.vue
index 8db16d9e5..be06581a6 100644
--- a/app/javascript/dashboard/modules/contact/ContactMergeModal.vue
+++ b/app/javascript/dashboard/modules/contact/ContactMergeModal.vue
@@ -5,8 +5,8 @@ import { useStore } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
+import Popover from 'dashboard/components-next/popover/Popover.vue';
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
-import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ContactAPI from 'dashboard/api/contacts';
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -23,7 +23,6 @@ const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('contacts/getUIFlags');
-const dialogRef = ref(null);
const isSearching = ref(false);
const searchResults = ref([]);
@@ -35,21 +34,6 @@ watch(
}
);
-const open = () => {
- dialogRef.value?.open();
-};
-
-const close = () => {
- dialogRef.value?.close();
-};
-
-defineExpose({ open, close });
-
-const onClose = () => {
- close();
- emit('close');
-};
-
const onContactSearch = async query => {
isSearching.value = true;
searchResults.value = [];
@@ -68,7 +52,7 @@ const onContactSearch = async query => {
}
};
-const onMergeContacts = async parentContactId => {
+const onMergeContacts = async (parentContactId, hide) => {
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
@@ -76,7 +60,7 @@ const onMergeContacts = async parentContactId => {
parentId: parentContactId,
});
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
- close();
+ hide();
emit('close');
} catch (error) {
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
@@ -85,24 +69,29 @@ const onMergeContacts = async parentContactId => {
-
+
+
+
+
+
+
+ {{ $t('MERGE_CONTACTS.TITLE') }}
+
+
+ {{ $t('MERGE_CONTACTS.DESCRIPTION') }}
+
+
+
onMergeContacts(id, hide)"
+ />
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue
index 734950f69..50268db62 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue
@@ -1,49 +1,131 @@
-
@@ -53,18 +135,43 @@ export default {
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
-
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
index 718a990bb..8543968fe 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
@@ -125,7 +125,7 @@ export default {
set(priorityItem) {
const conversationId = this.currentChat.id;
const oldValue = this.currentChat?.priority;
- const priority = priorityItem ? priorityItem.id : null;
+ const priority = priorityItem.id;
this.$store.dispatch('setCurrentChatPriority', {
priority,
@@ -203,7 +203,9 @@ export default {
this.assignedPriority &&
this.assignedPriority.id === selectedPriorityItem.id;
- this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
+ this.assignedPriority = isSamePriority
+ ? this.priorityOptions[0]
+ : selectedPriorityItem;
},
},
};
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
index 2b6001729..41c5854e0 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
@@ -12,19 +12,12 @@ import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
+import ContactDeleteModal from 'dashboard/modules/contact/ContactDeleteModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
-import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
-import {
- isAConversationRoute,
- isAInboxViewRoute,
- getConversationDashboardRoute,
-} from '../../../../helper/routeHelpers';
-import { emitter } from 'shared/helpers/mitt';
-
export default {
components: {
NextButton,
@@ -34,6 +27,7 @@ export default {
ComposeConversation,
SocialIcons,
ContactMergeModal,
+ ContactDeleteModal,
VoiceCallButton,
InlineInput,
},
@@ -57,7 +51,6 @@ export default {
data() {
return {
showEditModal: false,
- showDeleteModal: false,
isEditingName: false,
editName: '',
};
@@ -99,10 +92,6 @@ export default {
telegram,
};
},
- // Delete Modal
- confirmDeleteMessage() {
- return ` ${this.contact.name}?`;
- },
},
watch: {
'contact.id': {
@@ -117,28 +106,6 @@ export default {
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
- openComposeConversationModal(toggleFn) {
- toggleFn();
- // Flag to prevent triggering drag n drop,
- // When compose modal is active
- emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
- },
- closeComposeConversationModal() {
- // Flag to enable drag n drop,
- // When compose modal is closed
- emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
- },
- toggleDeleteModal() {
- this.showDeleteModal = !this.showDeleteModal;
- },
- confirmDeletion() {
- this.deleteContact(this.contact);
- this.closeDelete();
- },
- closeDelete() {
- this.showDeleteModal = false;
- this.showEditModal = false;
- },
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
@@ -151,36 +118,6 @@ export default {
return '';
}
},
- async deleteContact({ id }) {
- try {
- await this.$store.dispatch('contacts/delete', id);
- this.$emit('panelClose');
- useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
-
- if (isAConversationRoute(this.$route.name)) {
- this.$router.push({
- name: getConversationDashboardRoute(this.$route.name),
- });
- } else if (isAInboxViewRoute(this.$route.name)) {
- this.$router.push({
- name: 'inbox_view',
- });
- } else if (this.$route.name !== 'contacts_dashboard') {
- this.$router.push({
- name: 'contacts_dashboard',
- });
- }
- } catch (error) {
- useAlert(
- error.message
- ? error.message
- : this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
- );
- }
- },
- openMergeModal() {
- this.$refs.mergeModal?.open();
- },
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
@@ -354,19 +291,14 @@ export default {
-
-
+
+
@@ -387,45 +319,41 @@ export default {
sm
@click="toggleEditModal"
/>
-
-
+
+
+
+
+
+ :contact="contact"
+ @deleted="$emit('panelClose')"
+ >
+
+
+
+
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/EditContact.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/EditContact.vue
index 33a14fe65..eaa911a42 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/contact/EditContact.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/EditContact.vue
@@ -1,74 +1,70 @@
-
-
-
-
+
+
+
+
+ {{
+ `${$t('EDIT_CONTACT.TITLE')} - ${contact.name || contact.email}`
+ }}
+
+
+ {{ $t('EDIT_CONTACT.DESC') }}
+
+
+
+
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
index 7d636de70..c0fc800e6 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
@@ -119,6 +119,7 @@ watch(
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
+ @refresh-articles="fetchArticles"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index c059c57f1..11eb1f6aa 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -112,9 +112,33 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
+ isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
uiFlags: 'inboxes/getUIFlags',
portals: 'portals/allPortals',
}),
+ isInboundEmailEnabled() {
+ return this.isFeatureEnabledonAccount(
+ this.accountId,
+ FEATURE_FLAGS.INBOUND_EMAILS
+ );
+ },
+ showContinuityToggle() {
+ if (this.isInboundEmailEnabled) return true;
+ return this.isOnChatwootCloud;
+ },
+ isContinuityDisabled() {
+ return this.isOnChatwootCloud && !this.isInboundEmailEnabled;
+ },
+ continuityDescription() {
+ if (this.isContinuityDisabled) {
+ return this.$t(
+ 'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT'
+ );
+ }
+ return this.$t(
+ 'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
+ );
+ },
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
@@ -542,7 +566,8 @@ export default {
welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
reply_time: this.replyTime || 'in_a_few_minutes',
- continuity_via_email: this.continuityViaEmail,
+ continuity_via_email:
+ this.isInboundEmailEnabled && this.continuityViaEmail,
},
};
if (this.avatarFile) {
@@ -1148,15 +1173,15 @@ export default {
/>
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
index 13a2b4899..229a42a5d 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
@@ -166,4 +166,18 @@ export const actions = {
throw error;
}
},
+
+ bulkTranslate: async (
+ _,
+ { portalSlug, articleIds, locale, categoryId, force = false }
+ ) => {
+ const { data } = await articlesAPI.bulkTranslate({
+ portalSlug,
+ articleIds,
+ locale,
+ categoryId,
+ force,
+ });
+ return data;
+ },
};
diff --git a/app/javascript/shared/helpers/KeyboardHelpers.js b/app/javascript/shared/helpers/KeyboardHelpers.js
index fdd64a083..d89cbba4e 100644
--- a/app/javascript/shared/helpers/KeyboardHelpers.js
+++ b/app/javascript/shared/helpers/KeyboardHelpers.js
@@ -1,3 +1,5 @@
+import { isApple } from './platform';
+
export const isEnter = e => {
return e.key === 'Enter';
};
@@ -14,13 +16,20 @@ export const hasPressedCommand = e => {
return e.metaKey;
};
+// True when the platform's "command" modifier is held: Cmd (metaKey) on
+// Apple platforms (macOS, iOS/iPadOS hardware keyboards), Ctrl (ctrlKey)
+// elsewhere. Mirrors the `$mod` convention used by tinykeys and
+// prosemirror-keymap so the editor and the app agree on what counts as the
+// send modifier.
+export const hasPressedMod = e => Boolean(isApple() ? e.metaKey : e.ctrlKey);
+
export const hasPressedEnterAndNotCmdOrShift = e => {
- return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e);
+ return isEnter(e) && !hasPressedMod(e) && !hasPressedShift(e);
};
-export const hasPressedCommandAndEnter = e => {
- return hasPressedCommand(e) && isEnter(e);
-};
+// Detects the platform-aware "send" shortcut: Cmd+Enter on Apple platforms,
+// Ctrl+Enter on Windows/Linux.
+export const hasPressedCommandAndEnter = e => hasPressedMod(e) && isEnter(e);
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
diff --git a/app/javascript/shared/helpers/platform.js b/app/javascript/shared/helpers/platform.js
new file mode 100644
index 000000000..7ec897936
--- /dev/null
+++ b/app/javascript/shared/helpers/platform.js
@@ -0,0 +1,50 @@
+// Detects the current OS using the modern User-Agent Client Hints API,
+// falling back to userAgent parsing on Safari/Firefox where it is unavailable.
+// Treats iPad on iOS 13+ (which spoofs Macintosh) as iOS via maxTouchPoints.
+
+export const OS = Object.freeze({
+ MAC: 'macos',
+ WINDOWS: 'windows',
+ LINUX: 'linux',
+ ANDROID: 'android',
+ IOS: 'ios',
+ UNKNOWN: 'unknown',
+});
+
+// navigator.userAgentData.platform → OS constant (lowercased keys)
+const UAD_MAP = {
+ macos: OS.MAC,
+ windows: OS.WINDOWS,
+ linux: OS.LINUX,
+ android: OS.ANDROID,
+ ios: OS.IOS,
+};
+
+export function detectOS() {
+ if (typeof navigator === 'undefined') return OS.UNKNOWN;
+
+ // Trust userAgentData only when it maps to a known OS; otherwise fall
+ // through to UA parsing so unmapped values (e.g. "Chrome OS") don't leak.
+ const uad = navigator.userAgentData?.platform?.toLowerCase();
+ if (uad && UAD_MAP[uad]) return UAD_MAP[uad];
+
+ const ua = navigator.userAgent || '';
+ if (/android/i.test(ua)) return OS.ANDROID;
+ if (/iPhone|iPod/.test(ua)) return OS.IOS;
+ if (
+ /iPad/.test(ua) ||
+ (/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1)
+ ) {
+ return OS.IOS;
+ }
+ if (/Win/i.test(ua)) return OS.WINDOWS;
+ if (/Mac/i.test(ua)) return OS.MAC;
+ if (/Linux/i.test(ua)) return OS.LINUX;
+
+ return OS.UNKNOWN;
+}
+
+export const isApple = () => {
+ const os = detectOS();
+ return os === OS.MAC || os === OS.IOS;
+};
diff --git a/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js b/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js
index 06b490b6c..8e996d0fb 100644
--- a/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js
+++ b/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js
@@ -3,9 +3,29 @@ import {
isEscape,
hasPressedShift,
hasPressedCommand,
+ hasPressedMod,
+ hasPressedCommandAndEnter,
+ hasPressedEnterAndNotCmdOrShift,
isActiveElementTypeable,
} from '../KeyboardHelpers';
+const setNavigator = navigatorValue => {
+ Object.defineProperty(global, 'navigator', {
+ value: navigatorValue,
+ configurable: true,
+ writable: true,
+ });
+};
+
+const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } });
+const onWindows = () =>
+ setNavigator({ userAgentData: { platform: 'Windows' } });
+const onIOS = () =>
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
+ });
+
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
it('return correct values', () => {
@@ -30,6 +50,112 @@ describe('#KeyboardHelpers', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
+
+ describe('#hasPressedMod', () => {
+ const originalNavigator = global.navigator;
+
+ afterEach(() => {
+ setNavigator(originalNavigator);
+ });
+
+ it('uses metaKey on macOS', () => {
+ onMac();
+ expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
+ expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
+ });
+
+ it('uses ctrlKey on Windows', () => {
+ onWindows();
+ expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true);
+ expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false);
+ });
+
+ it('uses metaKey on iOS hardware keyboards', () => {
+ onIOS();
+ expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
+ expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
+ });
+
+ it('returns false when no modifier is held', () => {
+ onWindows();
+ expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false);
+ });
+ });
+
+ describe('#hasPressedCommandAndEnter', () => {
+ const originalNavigator = global.navigator;
+
+ afterEach(() => {
+ setNavigator(originalNavigator);
+ });
+
+ it('returns true for Cmd+Enter on macOS', () => {
+ onMac();
+ expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
+ true
+ );
+ });
+
+ it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => {
+ onWindows();
+ expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
+ true
+ );
+ });
+
+ it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => {
+ onMac();
+ expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
+ false
+ );
+ });
+
+ it('returns true for Cmd+Enter on iOS hardware keyboards', () => {
+ onIOS();
+ expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
+ true
+ );
+ });
+
+ it('returns false for plain Enter', () => {
+ onWindows();
+ expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false);
+ });
+ });
+
+ describe('#hasPressedEnterAndNotCmdOrShift', () => {
+ const originalNavigator = global.navigator;
+
+ afterEach(() => {
+ setNavigator(originalNavigator);
+ });
+
+ it('returns true for plain Enter on Windows', () => {
+ onWindows();
+ expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true);
+ });
+
+ it('returns false for Ctrl+Enter on Windows (mod is held)', () => {
+ onWindows();
+ expect(
+ hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true })
+ ).toBe(false);
+ });
+
+ it('returns false for Cmd+Enter on macOS (mod is held)', () => {
+ onMac();
+ expect(
+ hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true })
+ ).toBe(false);
+ });
+
+ it('returns false for Shift+Enter', () => {
+ onWindows();
+ expect(
+ hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true })
+ ).toBe(false);
+ });
+ });
});
describe('isActiveElementTypeable', () => {
diff --git a/app/javascript/shared/helpers/specs/platform.spec.js b/app/javascript/shared/helpers/specs/platform.spec.js
new file mode 100644
index 000000000..29bcc4339
--- /dev/null
+++ b/app/javascript/shared/helpers/specs/platform.spec.js
@@ -0,0 +1,186 @@
+import { detectOS, isApple, OS } from '../platform';
+
+const setNavigator = ({ userAgentData, userAgent, maxTouchPoints } = {}) => {
+ Object.defineProperty(global, 'navigator', {
+ value: { userAgentData, userAgent, maxTouchPoints },
+ configurable: true,
+ writable: true,
+ });
+};
+
+describe('detectOS', () => {
+ const originalNavigator = global.navigator;
+
+ afterEach(() => {
+ Object.defineProperty(global, 'navigator', {
+ value: originalNavigator,
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ describe('with userAgentData available', () => {
+ it('returns OS.MAC for macOS', () => {
+ setNavigator({ userAgentData: { platform: 'macOS' } });
+ expect(detectOS()).toBe(OS.MAC);
+ });
+
+ it('returns OS.WINDOWS for Windows', () => {
+ setNavigator({ userAgentData: { platform: 'Windows' } });
+ expect(detectOS()).toBe(OS.WINDOWS);
+ });
+
+ it('returns OS.LINUX for Linux', () => {
+ setNavigator({ userAgentData: { platform: 'Linux' } });
+ expect(detectOS()).toBe(OS.LINUX);
+ });
+
+ it('returns OS.ANDROID for Android', () => {
+ setNavigator({ userAgentData: { platform: 'Android' } });
+ expect(detectOS()).toBe(OS.ANDROID);
+ });
+
+ it('falls through to userAgent for unmapped values like "Chrome OS"', () => {
+ setNavigator({
+ userAgentData: { platform: 'Chrome OS' },
+ userAgent: 'Mozilla/5.0 (X11; CrOS x86_64) AppleWebKit/537.36',
+ });
+ // Not a mapped UAD value AND not a recognized UA pattern → unknown
+ expect(detectOS()).toBe(OS.UNKNOWN);
+ });
+
+ it('prefers userAgentData over userAgent when value is mapped', () => {
+ setNavigator({
+ userAgentData: { platform: 'Windows' },
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
+ });
+ expect(detectOS()).toBe(OS.WINDOWS);
+ });
+ });
+
+ describe('with userAgent fallback', () => {
+ it('detects macOS from Safari userAgent', () => {
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
+ });
+ expect(detectOS()).toBe(OS.MAC);
+ });
+
+ it('detects Windows from userAgent', () => {
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ });
+ expect(detectOS()).toBe(OS.WINDOWS);
+ });
+
+ it('detects Linux from userAgent', () => {
+ setNavigator({
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
+ });
+ expect(detectOS()).toBe(OS.LINUX);
+ });
+
+ it('detects Android from userAgent (before Linux match)', () => {
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
+ });
+ expect(detectOS()).toBe(OS.ANDROID);
+ });
+
+ it('detects iOS from iPhone userAgent', () => {
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
+ });
+ expect(detectOS()).toBe(OS.IOS);
+ });
+
+ it('detects iPadOS spoofing Macintosh via maxTouchPoints', () => {
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
+ maxTouchPoints: 5,
+ });
+ expect(detectOS()).toBe(OS.IOS);
+ });
+
+ it('returns OS.UNKNOWN when no match', () => {
+ setNavigator({ userAgent: 'SomeRandomBot/1.0' });
+ expect(detectOS()).toBe(OS.UNKNOWN);
+ });
+
+ it('returns OS.UNKNOWN when userAgent is missing', () => {
+ setNavigator({});
+ expect(detectOS()).toBe(OS.UNKNOWN);
+ });
+ });
+
+ describe('without navigator', () => {
+ it('returns OS.UNKNOWN when navigator is undefined', () => {
+ Object.defineProperty(global, 'navigator', {
+ value: undefined,
+ configurable: true,
+ writable: true,
+ });
+ expect(detectOS()).toBe(OS.UNKNOWN);
+ });
+ });
+});
+
+describe('isApple', () => {
+ const originalNavigator = global.navigator;
+
+ afterEach(() => {
+ Object.defineProperty(global, 'navigator', {
+ value: originalNavigator,
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ it('returns true on macOS', () => {
+ setNavigator({ userAgentData: { platform: 'macOS' } });
+ expect(isApple()).toBe(true);
+ });
+
+ it('returns true on iOS (iPhone)', () => {
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
+ });
+ expect(isApple()).toBe(true);
+ });
+
+ it('returns true on iPadOS spoofing Macintosh', () => {
+ setNavigator({
+ userAgent:
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
+ maxTouchPoints: 5,
+ });
+ expect(isApple()).toBe(true);
+ });
+
+ it('returns false on Windows', () => {
+ setNavigator({ userAgentData: { platform: 'Windows' } });
+ expect(isApple()).toBe(false);
+ });
+
+ it('returns false on Linux', () => {
+ setNavigator({ userAgentData: { platform: 'Linux' } });
+ expect(isApple()).toBe(false);
+ });
+
+ it('returns false on Android', () => {
+ setNavigator({ userAgentData: { platform: 'Android' } });
+ expect(isApple()).toBe(false);
+ });
+});
+
+describe('OS constants', () => {
+ it('is frozen so callers cannot mutate it', () => {
+ expect(Object.isFrozen(OS)).toBe(true);
+ });
+});
diff --git a/app/javascript/widget/i18n/locale/et.json b/app/javascript/widget/i18n/locale/et.json
index c3d6ddfc1..e4a1f135f 100644
--- a/app/javascript/widget/i18n/locale/et.json
+++ b/app/javascript/widget/i18n/locale/et.json
@@ -1,153 +1,153 @@
{
"COMPONENTS": {
"FILE_BUBBLE": {
- "DOWNLOAD": "Download",
- "UPLOADING": "Uploading..."
+ "DOWNLOAD": "Laadi alla",
+ "UPLOADING": "Üleslaadimine..."
},
"FORM_BUBBLE": {
- "SUBMIT": "Submit"
+ "SUBMIT": "Saada"
},
"MESSAGE_BUBBLE": {
- "RETRY": "Send message again",
- "ERROR_MESSAGE": "Couldn't send, try again"
+ "RETRY": "Saada sõnum uuesti",
+ "ERROR_MESSAGE": "Saatmine ebaõnnestus, proovi uuesti"
}
},
"THUMBNAIL": {
"AUTHOR": {
- "NOT_AVAILABLE": "Not available"
+ "NOT_AVAILABLE": "Pole saadaval"
}
},
"TEAM_AVAILABILITY": {
- "ONLINE": "We are online",
- "OFFLINE": "We are away at the moment",
- "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
+ "ONLINE": "Oleme võrgus",
+ "OFFLINE": "Oleme hetkel eemal",
+ "BACK_AS_SOON_AS_POSSIBLE": "Oleme tagasi esimesel võimalusel"
},
"REPLY_TIME": {
- "IN_A_FEW_MINUTES": "Typically replies in a few minutes",
- "IN_A_FEW_HOURS": "Typically replies in a few hours",
- "IN_A_DAY": "Typically replies in a day",
- "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
- "BACK_IN_MINUTES": "We will be back online in {time} minutes",
- "BACK_AT_TIME": "We will be back online at {time}",
- "BACK_ON_DAY": "We will be back online on {day}",
- "BACK_TOMORROW": "We will be back online tomorrow",
- "BACK_IN_SOME_TIME": "We will be back online in some time"
+ "IN_A_FEW_MINUTES": "Tavaliselt vastame mõne minuti jooksul",
+ "IN_A_FEW_HOURS": "Tavaliselt vastame mõne tunni jooksul",
+ "IN_A_DAY": "Tavaliselt vastame päeva jooksul",
+ "BACK_IN_HOURS": "Oleme tagasi {n} tunni pärast | Oleme tagasi {n} tunni pärast",
+ "BACK_IN_MINUTES": "Oleme tagasi {time} minuti pärast",
+ "BACK_AT_TIME": "Oleme tagasi kell {time}",
+ "BACK_ON_DAY": "Oleme tagasi {day}",
+ "BACK_TOMORROW": "Oleme tagasi homme",
+ "BACK_IN_SOME_TIME": "Oleme mõne aja pärast tagasi"
},
"DAY_NAMES": {
- "SUNDAY": "Sunday",
- "MONDAY": "Monday",
- "TUESDAY": "Tuesday",
- "WEDNESDAY": "Wednesday",
- "THURSDAY": "Thursday",
- "FRIDAY": "Friday",
- "SATURDAY": "Saturday"
+ "SUNDAY": "Pühapäev",
+ "MONDAY": "Esmaspäev",
+ "TUESDAY": "Teisipäev",
+ "WEDNESDAY": "Kolmapäev",
+ "THURSDAY": "Neljapäev",
+ "FRIDAY": "Reede",
+ "SATURDAY": "Laupäev"
},
- "START_CONVERSATION": "Start Conversation",
- "END_CONVERSATION": "End Conversation",
- "CONTINUE_CONVERSATION": "Continue conversation",
- "YOU": "You",
- "START_NEW_CONVERSATION": "Start a new conversation",
- "VIEW_UNREAD_MESSAGES": "You have unread messages",
+ "START_CONVERSATION": "Alusta vestlust",
+ "END_CONVERSATION": "Lõpeta vestlus",
+ "CONTINUE_CONVERSATION": "Jätka vestlust",
+ "YOU": "Sina",
+ "START_NEW_CONVERSATION": "Alusta uut vestlust",
+ "VIEW_UNREAD_MESSAGES": "Sul on lugemata sõnumeid",
"UNREAD_VIEW": {
- "VIEW_MESSAGES_BUTTON": "See new messages",
- "CLOSE_MESSAGES_BUTTON": "Close",
- "COMPANY_FROM": "from",
+ "VIEW_MESSAGES_BUTTON": "Vaata uusi sõnumeid",
+ "CLOSE_MESSAGES_BUTTON": "Sulge",
+ "COMPANY_FROM": "saatjalt",
"BOT": "Bot"
},
"BUBBLE": {
- "LABEL": "Chat with us"
+ "LABEL": "Vestle meiega"
},
- "POWERED_BY": "Powered by Chatwoot",
- "EMAIL_PLACEHOLDER": "Please enter your email",
- "CHAT_PLACEHOLDER": "Type your message",
- "TODAY": "Today",
- "YESTERDAY": "Yesterday",
+ "POWERED_BY": "Toetab Chatwoot",
+ "EMAIL_PLACEHOLDER": "Palun sisesta oma e-post",
+ "CHAT_PLACEHOLDER": "Kirjuta oma sõnum",
+ "TODAY": "Täna",
+ "YESTERDAY": "Eile",
"PRE_CHAT_FORM": {
"FIELDS": {
"FULL_NAME": {
- "LABEL": "Full Name",
- "PLACEHOLDER": "Please enter your full name",
- "REQUIRED_ERROR": "Full Name is required"
+ "LABEL": "Täisnimi",
+ "PLACEHOLDER": "Palun sisesta oma täisnimi",
+ "REQUIRED_ERROR": "Täisnimi on kohustuslik"
},
"EMAIL_ADDRESS": {
- "LABEL": "Email Address",
- "PLACEHOLDER": "Please enter your email address",
- "REQUIRED_ERROR": "Email Address is required",
- "VALID_ERROR": "Please enter a valid email address"
+ "LABEL": "E-posti aadress",
+ "PLACEHOLDER": "Palun sisesta oma e-posti aadress",
+ "REQUIRED_ERROR": "E-posti aadress on kohustuslik",
+ "VALID_ERROR": "Palun sisesta kehtiv e-posti aadress"
},
"PHONE_NUMBER": {
- "LABEL": "Phone Number",
- "PLACEHOLDER": "Please enter your phone number",
- "REQUIRED_ERROR": "Phone Number is required",
- "DIAL_CODE_VALID_ERROR": "Please select a country code",
- "VALID_ERROR": "Please enter a valid phone number",
- "DROPDOWN_EMPTY": "No results found",
- "DROPDOWN_SEARCH": "Search country"
+ "LABEL": "Telefoninumber",
+ "PLACEHOLDER": "Palun sisesta oma telefoninumber",
+ "REQUIRED_ERROR": "Telefoninumber on kohustuslik",
+ "DIAL_CODE_VALID_ERROR": "Palun vali riigikood",
+ "VALID_ERROR": "Palun sisesta kehtiv telefoninumber",
+ "DROPDOWN_EMPTY": "Tulemusi ei leitud",
+ "DROPDOWN_SEARCH": "Otsi riiki"
},
"MESSAGE": {
- "LABEL": "Message",
- "PLACEHOLDER": "Please enter your message",
- "ERROR": "Message too short"
+ "LABEL": "Sõnum",
+ "PLACEHOLDER": "Palun sisesta oma sõnum",
+ "ERROR": "Sõnum on liiga lühike"
}
},
- "CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
- "IS_REQUIRED": "is required",
- "REQUIRED": "Required",
- "REGEX_ERROR": "Please provide a valid input"
+ "CAMPAIGN_HEADER": "Palun sisesta enne vestluse alustamist oma nimi ja e-post",
+ "IS_REQUIRED": "on kohustuslik",
+ "REQUIRED": "Kohustuslik",
+ "REGEX_ERROR": "Palun sisesta korrektne väärtus"
},
- "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
+ "FILE_SIZE_LIMIT": "Fail ületab {MAXIMUM_FILE_UPLOAD_SIZE} manuse limiidi",
"CHAT_FORM": {
"INVALID": {
- "FIELD": "Invalid field"
+ "FIELD": "Vigane väli"
}
},
"EMOJI": {
- "PLACEHOLDER": "Search emojis",
- "NOT_FOUND": "No emoji match your search",
- "ARIA_LABEL": "Emoji picker"
+ "PLACEHOLDER": "Otsi emotikone",
+ "NOT_FOUND": "Ühtegi emotikoni ei leitud",
+ "ARIA_LABEL": "Emotikonide valija"
},
"CSAT": {
- "TITLE": "Rate your conversation",
- "SUBMITTED_TITLE": "Thank you for submitting the rating",
- "PLACEHOLDER": "Tell us more..."
+ "TITLE": "Hinda oma vestlust",
+ "SUBMITTED_TITLE": "Täname hinnangu eest",
+ "PLACEHOLDER": "Räägi meile rohkem..."
},
"EMAIL_TRANSCRIPT": {
- "BUTTON_TEXT": "Request a conversation transcript",
- "SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
- "SEND_EMAIL_ERROR": "There was an error, please try again"
+ "BUTTON_TEXT": "Taotle vestluse koopiat",
+ "SEND_EMAIL_SUCCESS": "Vestluse koopia saadeti edukalt",
+ "SEND_EMAIL_ERROR": "Tekkis viga, palun proovi uuesti"
},
"INTEGRATIONS": {
"DYTE": {
- "CLICK_HERE_TO_JOIN": "Click here to join",
- "LEAVE_THE_ROOM": "Leave the call"
+ "CLICK_HERE_TO_JOIN": "Klõpsa siia liitumiseks",
+ "LEAVE_THE_ROOM": "Lahku kõnest"
}
},
"PORTAL": {
- "POPULAR_ARTICLES": "Popular Articles",
- "VIEW_ALL_ARTICLES": "View all articles",
- "IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
+ "POPULAR_ARTICLES": "Populaarsed artiklid",
+ "VIEW_ALL_ARTICLES": "Vaata kõiki artikleid",
+ "IFRAME_LOAD_ERROR": "Artikli laadimisel tekkis viga, palun värskenda lehte ja proovi uuesti."
},
"ATTACHMENTS": {
"image": {
- "CONTENT": "Picture message"
+ "CONTENT": "Pildisõnum"
},
"audio": {
- "CONTENT": "Audio message"
+ "CONTENT": "Helisõnum"
},
"video": {
- "CONTENT": "Video message"
+ "CONTENT": "Videosõnum"
},
"file": {
- "CONTENT": "File Attachment"
+ "CONTENT": "Faili manus"
},
"location": {
- "CONTENT": "Location"
+ "CONTENT": "Asukoht"
},
"fallback": {
- "CONTENT": "has shared a url"
+ "CONTENT": "jagas URL-i"
}
},
"FOOTER_REPLY_TO": {
- "REPLY_TO": "Replying to:"
+ "REPLY_TO": "Vastus sõnumile:"
}
}
diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb
index 952795604..a66928a79 100644
--- a/app/jobs/account/contacts_export_job.rb
+++ b/app/jobs/account/contacts_export_job.rb
@@ -42,8 +42,13 @@ class Account::ContactsExportJob < ApplicationJob
def attach_export_file(csv_data)
return if csv_data.blank?
+ # Prepend UTF-8 BOM so that spreadsheet applications (e.g. Excel)
+ # correctly recognise the file encoding for non-ASCII characters
+ # such as Arabic, Japanese, and Chinese.
+ bom = "\xEF\xBB\xBF"
+
@account.contacts_export.attach(
- io: StringIO.new(csv_data),
+ io: StringIO.new("#{bom}#{csv_data}"),
filename: "#{@account.name}_#{@account.id}_contacts.csv",
content_type: 'text/csv'
)
diff --git a/app/jobs/avatar/avatar_from_url_job.rb b/app/jobs/avatar/avatar_from_url_job.rb
index 929e76597..49bf25803 100644
--- a/app/jobs/avatar/avatar_from_url_job.rb
+++ b/app/jobs/avatar/avatar_from_url_job.rb
@@ -9,27 +9,17 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
include UrlHelper
queue_as :purgable
- MAX_DOWNLOAD_SIZE = 15 * 1024 * 1024
+ ALLOWED_CONTENT_TYPES = Avatarable::ALLOWED_AVATAR_CONTENT_TYPES
+ MAX_DOWNLOAD_SIZE = 15.megabytes
RATE_LIMIT_WINDOW = 1.minute
def perform(avatarable, avatar_url)
- return unless avatarable.respond_to?(:avatar)
- return unless url_valid?(avatar_url)
+ return unless syncable_avatar?(avatarable, avatar_url)
- return unless should_sync_avatar?(avatarable, avatar_url)
-
- avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE)
- raise Down::Error, 'Invalid file' unless valid_file?(avatar_file)
-
- avatarable.avatar.attach(
- io: avatar_file,
- filename: avatar_file.original_filename,
- content_type: avatar_file.content_type
- )
-
- rescue Down::NotFound
- Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
- rescue Down::Error => e
+ fetch_and_attach_avatar(avatarable, avatar_url)
+ rescue SafeFetch::HttpError => e
+ log_http_error(avatar_url, e)
+ rescue SafeFetch::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure
update_avatar_sync_attributes(avatarable, avatar_url)
@@ -37,6 +27,41 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
private
+ def syncable_avatar?(avatarable, avatar_url)
+ avatarable.respond_to?(:avatar) &&
+ url_valid?(avatar_url) &&
+ should_sync_avatar?(avatarable, avatar_url)
+ end
+
+ def fetch_and_attach_avatar(avatarable, avatar_url)
+ SafeFetch.fetch(
+ avatar_url,
+ max_bytes: MAX_DOWNLOAD_SIZE,
+ allowed_content_type_prefixes: [],
+ allowed_content_types: ALLOWED_CONTENT_TYPES
+ ) do |avatar_file|
+ attach_avatar(avatarable, avatar_file)
+ end
+ end
+
+ def attach_avatar(avatarable, avatar_file)
+ raise SafeFetch::FetchError, 'Invalid file' unless valid_file?(avatar_file)
+
+ avatarable.avatar.attach(
+ io: avatar_file.tempfile,
+ filename: avatar_file.original_filename,
+ content_type: avatar_file.content_type
+ )
+ end
+
+ def log_http_error(avatar_url, error)
+ if error.message.start_with?('404')
+ Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
+ else
+ Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{error.class} - #{error.message}"
+ end
+ end
+
def should_sync_avatar?(avatarable, avatar_url)
# Only Contacts are rate-limited and hash-gated.
return true unless avatarable.is_a?(Contact)
diff --git a/app/jobs/data_import_job.rb b/app/jobs/data_import_job.rb
index d8bbeb992..4149ee16f 100644
--- a/app/jobs/data_import_job.rb
+++ b/app/jobs/data_import_job.rb
@@ -106,6 +106,7 @@ class DataImportJob < ApplicationJob
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')
+ clean_data = clean_data.delete_prefix("\xEF\xBB\xBF")
CSV.new(StringIO.new(clean_data), headers: true)
end
diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb
index d9e6ec8e0..20e194fed 100644
--- a/app/mailers/conversation_reply_mailer.rb
+++ b/app/mailers/conversation_reply_mailer.rb
@@ -105,7 +105,7 @@ class ConversationReplyMailer < ApplicationMailer
end
def business_name
- @inbox.business_name || @inbox.sanitized_name
+ @inbox.sanitized_business_name
end
def from_email
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 94ca55037..3057f8d44 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -4,6 +4,8 @@ module Avatarable
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
+ ALLOWED_AVATAR_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
+
included do
has_one_attached :avatar
validate :acceptable_avatar, if: -> { avatar.changed? }
@@ -30,7 +32,6 @@ module Avatarable
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
- acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
- errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
+ errors.add(:avatar, 'filetype not supported') unless ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
end
end
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index 0a26462ad..82b250560 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -102,7 +102,7 @@ class Inbox < ApplicationRecord
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
- # REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
+ # REMOVES: Forbidden chars (\<>@"()) + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
@@ -110,6 +110,10 @@ class Inbox < ApplicationRecord
sanitized.blank? && email? ? display_name_from_email : sanitized
end
+ def sanitized_business_name
+ sanitize_raw_name(business_name) || sanitized_name
+ end
+
def sms?
channel_type == 'Channel::Sms'
end
@@ -209,8 +213,15 @@ class Inbox < ApplicationRecord
email? ? display_name_from_email : ''
end
+ def sanitize_raw_name(raw)
+ return nil if raw.blank?
+
+ result = apply_sanitization_rules(raw)
+ result.presence
+ end
+
def apply_sanitization_rules(name)
- name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
+ name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;()]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
diff --git a/app/presenters/messages/search_data_presenter.rb b/app/presenters/messages/search_data_presenter.rb
index 7a6260686..e90c9b600 100644
--- a/app/presenters/messages/search_data_presenter.rb
+++ b/app/presenters/messages/search_data_presenter.rb
@@ -39,7 +39,8 @@ class Messages::SearchDataPresenter < SimpleDelegator
end
def content_attributes_data
- email_subject = content_attributes.dig(:email, :subject)
+ email_subject = content_attributes.dig(:email, :subject).presence ||
+ conversation.additional_attributes&.dig('mail_subject').presence
return {} if email_subject.blank?
{ email: { subject: email_subject } }
diff --git a/app/services/notification/push_test_service.rb b/app/services/notification/push_test_service.rb
new file mode 100644
index 000000000..4b2bec177
--- /dev/null
+++ b/app/services/notification/push_test_service.rb
@@ -0,0 +1,160 @@
+class Notification::PushTestService
+ pattr_initialize [:user!, :subscription_ids!, :title, :body]
+
+ DEFAULT_TITLE = '%
s notification test'.freeze
+ DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze
+
+ def self.default_title
+ format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot'))
+ end
+
+ def self.default_body
+ DEFAULT_BODY
+ end
+
+ def perform
+ selected_subscriptions.map { |subscription| test_send(subscription) }
+ end
+
+ private
+
+ def resolved_title
+ title.presence || self.class.default_title
+ end
+
+ def resolved_body
+ body.presence || self.class.default_body
+ end
+
+ def selected_subscriptions
+ user.notification_subscriptions.where(id: subscription_ids).order(:id)
+ end
+
+ def test_send(subscription)
+ if subscription.browser_push?
+ test_browser_push(subscription)
+ elsif subscription.fcm?
+ test_fcm(subscription)
+ else
+ result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type')
+ end
+ end
+
+ def test_browser_push(subscription)
+ return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key
+
+ WebPush.payload_send(**browser_push_payload(subscription))
+ result(subscription, 'browser_push', :success, 'Web push accepted by endpoint')
+ rescue StandardError => e
+ result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}")
+ end
+
+ def test_fcm(subscription)
+ if firebase_credentials_present?
+ test_fcm_direct(subscription)
+ elsif chatwoot_hub_enabled?
+ test_fcm_via_hub(subscription)
+ else
+ result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled')
+ end
+ end
+
+ def test_fcm_direct(subscription)
+ fcm_service = Notification::FcmService.new(
+ GlobalConfigService.load('FIREBASE_PROJECT_ID', nil),
+ GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
+ )
+ response = fcm_service.fcm_client.send_v1(fcm_options(subscription))
+ status_code = response[:status_code].to_i
+ status = status_code.between?(200, 299) ? :success : :failure
+ result(subscription, 'fcm', status, "HTTP #{status_code} — #{response[:body]}")
+ rescue StandardError => e
+ result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}")
+ end
+
+ def test_fcm_via_hub(subscription)
+ response = ChatwootHub.send_push_with_response(fcm_options(subscription))
+ result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code} — #{response.body}")
+ rescue RestClient::ExceptionWithResponse => e
+ result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code} — #{e.response&.body}")
+ rescue StandardError => e
+ result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}")
+ end
+
+ def firebase_credentials_present?
+ GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
+ end
+
+ def chatwoot_hub_enabled?
+ ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
+ end
+
+ def browser_push_payload(subscription)
+ {
+ message: JSON.generate(
+ title: resolved_title,
+ tag: "super_admin_test_#{Time.zone.now.to_i}",
+ url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
+ ),
+ endpoint: subscription.subscription_attributes['endpoint'],
+ p256dh: subscription.subscription_attributes['p256dh'],
+ auth: subscription.subscription_attributes['auth'],
+ vapid: {
+ subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'),
+ public_key: VapidService.public_key,
+ private_key: VapidService.private_key
+ },
+ ssl_timeout: 5,
+ open_timeout: 5,
+ read_timeout: 5
+ }
+ end
+
+ def fcm_options(subscription)
+ {
+ 'token': subscription.subscription_attributes['push_token'],
+ 'data': { payload: { data: { notification: { type: 'test' } } }.to_json },
+ 'notification': { title: resolved_title, body: resolved_body },
+ 'android': { priority: 'high' },
+ 'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } },
+ 'fcm_options': { analytics_label: 'SuperAdminTest' }
+ }
+ end
+
+ def result(subscription, type, status, message)
+ attrs = subscription.subscription_attributes || {}
+ {
+ id: subscription.id,
+ type: type.to_s,
+ device: device_label(subscription, attrs),
+ token_tail: token_tail(subscription, attrs),
+ status: status,
+ message: message
+ }
+ end
+
+ def device_label(subscription, attrs)
+ if subscription.browser_push?
+ endpoint_host(attrs['endpoint'].to_s)
+ else
+ attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—'
+ end
+ end
+
+ def endpoint_host(endpoint)
+ return '—' if endpoint.blank?
+
+ URI.parse(endpoint).host.presence || endpoint
+ rescue URI::InvalidURIError
+ endpoint
+ end
+
+ def token_tail(subscription, attrs)
+ if subscription.browser_push?
+ endpoint = attrs['endpoint'].to_s
+ endpoint.present? ? "…#{endpoint.last(6)}" : '—'
+ else
+ attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—'
+ end
+ end
+end
diff --git a/app/services/reports/data_source.rb b/app/services/reports/data_source.rb
new file mode 100644
index 000000000..dff392c12
--- /dev/null
+++ b/app/services/reports/data_source.rb
@@ -0,0 +1,64 @@
+class Reports::DataSource
+ include TimezoneHelper
+
+ attr_reader :account, :metric, :dimension_type, :dimension_id,
+ :scope, :range, :group_by, :timezone_offset,
+ :business_hours
+
+ class << self
+ def for(**context)
+ # TODO: Route to Reports::RollupDataSource when rollup reads are implemented
+ Reports::RawDataSource.new(**context)
+ end
+ end
+
+ def initialize(**context)
+ @account = context[:account]
+ @metric = context[:metric]
+ @dimension_type = (context[:dimension_type].presence || 'account').to_s
+ @dimension_id = context[:dimension_id]
+ @scope = context[:scope]
+ @range = context[:range]
+ @group_by = context[:group_by].to_s.presence || 'day'
+ @timezone_offset = context[:timezone_offset]
+ @business_hours = context[:business_hours]
+ end
+
+ private
+
+ def report_metric
+ @report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
+ end
+
+ def average_metric?
+ report_metric&.average?
+ end
+
+ def count_metric?
+ !average_metric?
+ end
+
+ def rollup_metric
+ report_metric&.rollup_metric
+ end
+
+ def raw_event_name
+ report_metric&.raw_event_name
+ end
+
+ def raw_count_strategy
+ report_metric&.raw_count_strategy
+ end
+
+ def summary_metrics
+ @summary_metrics ||= Reports::ReportMetricRegistry.summary_metrics
+ end
+
+ def timezone
+ @timezone ||= timezone_name_from_offset(timezone_offset)
+ end
+
+ def use_business_hours?
+ ActiveModel::Type::Boolean.new.cast(business_hours)
+ end
+end
diff --git a/app/services/reports/raw_data_source.rb b/app/services/reports/raw_data_source.rb
new file mode 100644
index 000000000..f37b3c454
--- /dev/null
+++ b/app/services/reports/raw_data_source.rb
@@ -0,0 +1,156 @@
+class Reports::RawDataSource < Reports::DataSource
+ def timeseries
+ average_metric? ? average_timeseries : count_timeseries
+ end
+
+ def aggregate
+ average_metric? ? average_scope.average(average_value_key) : count_scope.count
+ end
+
+ def summary
+ metric_results = summary_scope
+ .select(*summary_select_fields)
+ .group(summary_group_by_key)
+ .index_by { |record| record.public_send(summary_index_key) }
+
+ merge_summary_results(metric_results, summary_conversation_counts)
+ end
+
+ private
+
+ def count_timeseries
+ grouped_count.map do |event_date, event_count|
+ { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
+ end
+ end
+
+ def average_timeseries
+ grouped_average_time = grouped_average_scope.average(average_value_key)
+ grouped_event_count = grouped_average_scope.count
+
+ grouped_average_time.each_with_object([]) do |(event_date, average_time), results|
+ results << {
+ value: average_time,
+ timestamp: event_date.in_time_zone(timezone).to_i,
+ count: grouped_event_count[event_date]
+ }
+ end
+ end
+
+ def grouped_average_scope
+ average_scope.group_by_period(
+ group_by,
+ :created_at,
+ default_value: 0,
+ range: range,
+ permit: %w[day week month year hour],
+ time_zone: timezone
+ )
+ end
+
+ def grouped_count
+ count_scope.group_by_period(
+ group_by,
+ :created_at,
+ default_value: 0,
+ range: range,
+ permit: %w[day week month year hour],
+ time_zone: timezone
+ ).count
+ end
+
+ def average_scope
+ scope.reporting_events.where(name: raw_event_name, created_at: range, account_id: account.id)
+ end
+
+ def count_scope
+ case metric.to_s
+ when 'conversations_count'
+ scope.conversations.where(account_id: account.id, created_at: range)
+ when 'incoming_messages_count'
+ scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
+ when 'outgoing_messages_count'
+ scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
+ else
+ reporting_event_count_scope
+ end
+ end
+
+ def reporting_event_count_scope
+ events = scope.reporting_events.where(
+ name: raw_event_name,
+ account_id: account.id,
+ created_at: range
+ )
+
+ return events unless raw_count_strategy == :distinct_conversation
+
+ events.joins(:conversation).select(:conversation_id).distinct
+ end
+
+ def summary_scope
+ scope = account.reporting_events.where(created_at: range)
+ return scope.joins(:conversation) if dimension_type == 'team'
+
+ scope
+ end
+
+ def summary_conversation_counts
+ account.conversations
+ .where(created_at: range)
+ .group(summary_conversation_group_by_key)
+ .count
+ end
+
+ def merge_summary_results(metric_results, conversation_counts)
+ (metric_results.keys | conversation_counts.keys).each_with_object({}) do |dimension_id, results|
+ record = metric_results[dimension_id]
+ results[dimension_id] = summary_attributes_for(record, conversation_counts[dimension_id])
+ end
+ end
+
+ def summary_select_fields
+ ["#{summary_group_by_key} as #{summary_index_key}"] + summary_metrics.map { |definition| summary_select_field(definition) }
+ end
+
+ def summary_select_field(definition)
+ if definition.count?
+ "COUNT(CASE WHEN name = '#{definition.raw_event_name}' THEN 1 END) as #{definition.summary_key}"
+ else
+ "AVG(CASE WHEN name = '#{definition.raw_event_name}' THEN #{average_value_key} END) as #{definition.summary_key}"
+ end
+ end
+
+ def summary_attributes_for(record, conversations_count = 0)
+ summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
+ value = record&.public_send(definition.summary_key)
+ attributes[definition.summary_key] = definition.count? ? value.to_i : value
+ end
+ end
+
+ def summary_group_by_key
+ {
+ 'account' => :account_id,
+ 'agent' => :user_id,
+ 'inbox' => :inbox_id,
+ 'team' => 'conversations.team_id'
+ }[dimension_type]
+ end
+
+ def summary_conversation_group_by_key
+ {
+ 'account' => :account_id,
+ 'agent' => :assignee_id,
+ 'inbox' => :inbox_id,
+ 'team' => :team_id
+ }[dimension_type]
+ end
+
+ def summary_index_key
+ summary_group_by_key.to_s.split('.').last
+ end
+
+ def average_value_key
+ use_business_hours? ? :value_in_business_hours : :value
+ end
+end
diff --git a/app/services/reports/report_metric_registry.rb b/app/services/reports/report_metric_registry.rb
new file mode 100644
index 000000000..8df2e091d
--- /dev/null
+++ b/app/services/reports/report_metric_registry.rb
@@ -0,0 +1,120 @@
+module Reports::ReportMetricRegistry
+ # Describes one public report metric.
+ # name: API-facing metric name requested by reports.
+ # aggregate: whether the metric is a count or average.
+ # raw_event_name: source reporting_events name for raw queries.
+ # rollup_metric: source reporting_events_rollups metric for rollup queries.
+ # summary_key: key used when this metric appears in grouped summary responses.
+ # raw_count_strategy: optional raw-query counting rule, such as distinct conversations.
+ Metric = Data.define(
+ :name,
+ :aggregate,
+ :raw_event_name,
+ :rollup_metric,
+ :summary_key,
+ :raw_count_strategy
+ ) do
+ def initialize(name:, aggregate:, raw_event_name: nil, rollup_metric: nil, summary_key: nil, raw_count_strategy: nil) # rubocop:disable Metrics/ParameterLists
+ super
+ end
+
+ def average?
+ aggregate == :average
+ end
+
+ def count?
+ aggregate == :count
+ end
+
+ def rollup_supported?
+ rollup_metric.present?
+ end
+
+ def summary?
+ summary_key.present?
+ end
+ end
+
+ METRICS = {
+ conversations_count: Metric.new(
+ name: :conversations_count,
+ aggregate: :count
+ ),
+ incoming_messages_count: Metric.new(
+ name: :incoming_messages_count,
+ aggregate: :count
+ ),
+ outgoing_messages_count: Metric.new(
+ name: :outgoing_messages_count,
+ aggregate: :count
+ ),
+ avg_first_response_time: Metric.new(
+ name: :avg_first_response_time,
+ aggregate: :average,
+ raw_event_name: :first_response,
+ rollup_metric: :first_response,
+ summary_key: :avg_first_response_time
+ ),
+ avg_resolution_time: Metric.new(
+ name: :avg_resolution_time,
+ aggregate: :average,
+ raw_event_name: :conversation_resolved,
+ rollup_metric: :resolution_time,
+ summary_key: :avg_resolution_time
+ ),
+ reply_time: Metric.new(
+ name: :reply_time,
+ aggregate: :average,
+ raw_event_name: :reply_time,
+ rollup_metric: :reply_time,
+ summary_key: :avg_reply_time
+ ),
+ resolutions_count: Metric.new(
+ name: :resolutions_count,
+ aggregate: :count,
+ raw_event_name: :conversation_resolved,
+ rollup_metric: :resolutions_count,
+ summary_key: :resolved_conversations_count
+ ),
+ bot_resolutions_count: Metric.new(
+ name: :bot_resolutions_count,
+ aggregate: :count,
+ raw_event_name: :conversation_bot_resolved,
+ rollup_metric: :bot_resolutions_count
+ ),
+ bot_handoffs_count: Metric.new(
+ name: :bot_handoffs_count,
+ aggregate: :count,
+ raw_event_name: :conversation_bot_handoff,
+ rollup_metric: :bot_handoffs_count,
+ raw_count_strategy: :distinct_conversation
+ )
+ }.freeze
+
+ SUMMARY_METRIC_NAMES = %i[
+ resolutions_count
+ avg_resolution_time
+ avg_first_response_time
+ reply_time
+ ].freeze
+
+ module_function
+
+ def fetch(name)
+ return if name.blank?
+
+ METRICS[name.to_sym]
+ end
+
+ def supported?(name)
+ fetch(name).present?
+ end
+
+ def rollup_supported?(name)
+ fetch(name)&.rollup_supported? || false
+ end
+
+ def summary_metrics
+ SUMMARY_METRIC_NAMES.map { |metric_name| METRICS.fetch(metric_name) }
+ end
+end
diff --git a/app/views/devise/mailer/_confirmation_body.html.erb b/app/views/devise/mailer/_confirmation_body.html.erb
new file mode 100644
index 000000000..b256539f3
--- /dev/null
+++ b/app/views/devise/mailer/_confirmation_body.html.erb
@@ -0,0 +1,89 @@
+
+ |
+
+ <%= eyebrow %>
+
+ |
+
+
+
+ <%= heading %>
+ |
+
+
+ |
+ Hi <%= recipient_name %>,
+ |
+
+
+ |
+ <%= intro_text %>
+ |
+
+
+ |
+ <%= supporting_text %>
+ |
+
+<% if detail_rows.any? %>
+
+
+
+ <% detail_rows.each_with_index do |(label, value), index| %>
+
+ |
+
+ <%= label %>
+
+ <%= value %>
+ |
+
+ <% end %>
+
+ |
+
+<% end %>
+<% if action_url.present? %>
+
+
+
+
+ |
+ <%= link_to(
+ action_text,
+ action_url,
+ style: 'display:block; width:100%; box-sizing:border-box; padding:12px 24px; font-size:14px; line-height:20px; font-weight:700; color:#FFFFFF; text-align:center; text-decoration:none;'
+ ) %>
+ |
+
+
+ |
+
+
+ |
+
+ If the button does not work, open
+ <%= link_to(
+ 'this secure link',
+ action_url,
+ style: 'color:#2781F6; text-decoration:none; font-weight:600;'
+ ) %>.
+
+ |
+
+<% elsif info_title.present? %>
+
+
+
+
+ |
+
+ <%= info_title %>
+
+ <%= info_text %>
+ |
+
+
+ |
+
+<% end %>
diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb
index 4a3b450a3..619c9f5ec 100644
--- a/app/views/devise/mailer/confirmation_instructions.html.erb
+++ b/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -1,29 +1,65 @@
-Hi <%= @resource.name %>,
+<%
+ brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
+ recipient_name = @resource.name.presence || @resource.email
+ account_user = @resource&.account_users&.first
+ inviter = account_user&.inviter
+ account_name = account_user&.account&.name
+ invited_user = inviter.present? && @resource.unconfirmed_email.blank?
-<% account_user = @resource&.account_users&.first %>
+ eyebrow = 'Welcome'
+ heading = 'Confirm your email to get started'
+ intro_text =
+ "Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
+ supporting_text = 'This only takes a moment.'
+ action_text = 'Confirm my account'
+ action_url = frontend_url('auth/confirmation', confirmation_token: @token)
+ info_title = nil
+ info_text = nil
+ detail_rows = []
+ detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
-<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
- <%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.
-<% end %>
+ if @resource.unconfirmed_email.present?
+ eyebrow = 'Email update'
+ heading = 'Confirm your new email address'
+ intro_text = "We received a request to update the email address on your #{brand_name} account."
+ supporting_text = 'Confirm the new address below to finish the change.'
+ action_text = 'Confirm email address'
+ elsif @resource.confirmed?
+ eyebrow = 'Account ready'
+ heading = 'Your account is ready'
+ intro_text = "Your #{brand_name} account is already active."
+ supporting_text = 'Use the button below to sign in and continue where you left off.'
+ action_text = 'Open my account'
+ action_url = frontend_url('auth/sign_in')
+ detail_rows = []
+ elsif invited_user
+ eyebrow = 'Workspace invitation'
+ heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
+ intro_text = if account_name.present?
+ "#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
+ else
+ "#{inviter.name} invited you to try #{brand_name}."
+ end
+ supporting_text = 'Create your account to start collaborating with your team.'
+ action_text = 'Accept invitation'
+ action_url = frontend_url(
+ 'auth/password/edit',
+ reset_password_token: @resource.send(:set_reset_password_token)
+ )
+ detail_rows = [['Invited by', inviter.name]]
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ end
+%>
-<% if @resource.confirmed? %>
- You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:
-<% else %>
- <% if account_user&.inviter.blank? %>
-
- Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
-
- <% end %>
- Please take a moment and click the link below and activate your account.
-<% end %>
-
-
-<% if @resource.unconfirmed_email.present? %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% elsif @resource.confirmed? %>
- <%= link_to 'Login to my account', frontend_url('auth/sign_in') %>
-<% elsif account_user&.inviter.present? %>
- <%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %>
-<% else %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% end %>
\ No newline at end of file
+<%= render partial: 'devise/mailer/confirmation_body', locals: {
+ action_text: action_text,
+ action_url: action_url,
+ detail_rows: detail_rows,
+ eyebrow: eyebrow,
+ heading: heading,
+ info_text: info_text,
+ info_title: info_title,
+ intro_text: intro_text,
+ recipient_name: recipient_name,
+ supporting_text: supporting_text
+} %>
diff --git a/app/views/layouts/mailer/base.liquid b/app/views/layouts/mailer/base.liquid
index 5fa07e139..e6c70b3e5 100644
--- a/app/views/layouts/mailer/base.liquid
+++ b/app/views/layouts/mailer/base.liquid
@@ -7,86 +7,129 @@
-
-
+ {% assign brand_name = global_config['BRAND_NAME'] %}
+ {% if brand_name == nil %}
+ {% assign brand_name = 'Chatwoot' %}
+ {% endif %}
+ {% assign brand_url = global_config['BRAND_URL'] %}
+
+
+
-
-
-
+
+
+
+
+
+
+ | |
+
+
+
+
+ {{ content_for_layout }}
+
+ |
+
+
+ |
+
+ {% if brand_name != '' %}
-
-
- {{ content_for_layout }}
-
+ |
-
-
-
+ {% endif %}
+ |
|
diff --git a/app/views/super_admin/application/_navigation.html.erb b/app/views/super_admin/application/_navigation.html.erb
index 787576d33..da3a024f8 100644
--- a/app/views/super_admin/application/_navigation.html.erb
+++ b/app/views/super_admin/application/_navigation.html.erb
@@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
- <% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings"].include? resource.resource %>
+ <% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
@@ -48,6 +48,7 @@ as defined by the routes in the `admin/` namespace
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
+ <%= render partial: "nav_item", locals: { icon: 'icon-mail-send-fill', url: super_admin_push_diagnostics_url, label: 'Push Diagnostics' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-logout-circle-r-line', url: super_admin_logout_url, label: 'Logout' } %>
diff --git a/app/views/super_admin/push_diagnostics/show.html.erb b/app/views/super_admin/push_diagnostics/show.html.erb
new file mode 100644
index 000000000..736a77cd8
--- /dev/null
+++ b/app/views/super_admin/push_diagnostics/show.html.erb
@@ -0,0 +1,190 @@
+<% content_for(:title) do %>Push Diagnostics<% end %>
+
+
+
+
+
+ Send a test push notification to a specific user's registered devices to diagnose delivery issues.
+ Results show the raw FCM / Web Push / relay response so you can see exactly what failed.
+
+
+
+
1. Look up user
+ <%= form_with url: super_admin_push_diagnostics_path, method: :get, local: true, class: 'flex gap-2 items-center' do |f| %>
+ <%= f.text_field :user_query,
+ value: @query,
+ placeholder: 'user@example.com or numeric user ID',
+ class: 'border border-slate-100 p-1.5 rounded-md w-80' %>
+ <%= f.submit 'Look up', class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer' %>
+ <% end %>
+ <% if @query.present? && @user.nil? %>
+
No user found for "<%= @query %>".
+ <% end %>
+
+
+ <% if @user %>
+
+
+ <%= @user.name %>
+ · <%= @user.email %>
+ · ID <%= @user.id %>
+
+ <% if @user.accounts.any? %>
+
+ Accounts:
+ <% @user.accounts.each_with_index do |account, index| %>
+ <%= ', ' if index.positive? %>
+ <%= account.name %> (ID <%= account.id %>)
+ <% end %>
+
+ <% end %>
+
+
+
+
2. Select subscriptions (<%= @subscriptions.count %>)
+
+ ⚠️ This sends a real push to every selected device. Use only when diagnosing a reported issue.
+
+
+ <% if @subscriptions.empty? %>
+
This user has no push subscriptions registered.
+ <% else %>
+ <%= form_with url: super_admin_push_diagnostics_path, method: :post, local: true do |f| %>
+ <%= f.hidden_field :user_id, value: @user.id %>
+
+
+ <%= label_tag :push_title, 'Title', class: 'block text-xs font-medium text-slate-600 mb-1' %>
+ <%= text_field_tag :push_title,
+ params[:push_title].presence || Notification::PushTestService.default_title,
+ class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
+
+
+ <%= label_tag :push_body, 'Body', class: 'block text-xs font-medium text-slate-600 mb-1' %>
+ <%= text_area_tag :push_body,
+ params[:push_body].presence || Notification::PushTestService.default_body,
+ rows: 2,
+ class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
+
+
Customers will see this text as a real push notification on their device.
+
+
+
+ <%= f.submit 'Send Test Push to Selected',
+ class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
+ <%= submit_tag 'Delete Selected Subscriptions',
+ formaction: destroy_subscriptions_super_admin_push_diagnostics_path,
+ formmethod: 'post',
+ data: { confirm: "Delete the selected subscription(s)? The user won't receive pushes on those devices until their app re-registers." },
+ class: 'border border-red-200 bg-red-50 text-red-700 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
+
+ <% end %>
+ <% end %>
+
+
+ <% if @results.present? %>
+
+
3. Results
+
+
+
+ | Sub ID |
+ Type |
+ Device |
+ Push token |
+ Status |
+ Details |
+
+
+
+ <% @results.each do |r| %>
+
+ | <%= r[:id] %> |
+ <%= r[:type] %> |
+ <%= r[:device] %> |
+ <%= r[:token_tail] %> |
+
+ <%
+ color = {
+ success: 'bg-green-100 text-green-800',
+ failure: 'bg-red-100 text-red-800',
+ skipped: 'bg-slate-100 text-slate-600'
+ }[r[:status]]
+ %>
+ <%= r[:status] %>
+ |
+ <%= r[:message] %> |
+
+ <% end %>
+
+
+
+ <% end %>
+ <% end %>
+
+
+<% content_for :javascript do %>
+
+<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 057f41b81..9ffd3f3d5 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -488,6 +488,12 @@ en:
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
portals:
+ articles:
+ captain_not_available: 'Translation requires Captain to be enabled for this account'
+ locale_not_available: 'Locale not available in this portal'
+ category_not_found: 'Category not found in this portal'
+ no_articles_found: 'No articles found to process'
+ invalid_status: 'Invalid status value'
send_instructions:
email_required: 'Email is required'
invalid_email_format: 'Invalid email format'
@@ -496,3 +502,9 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
+ super_admin:
+ push_diagnostics:
+ user_not_found: 'User not found.'
+ no_subscriptions_to_test: 'Select at least one subscription to test.'
+ no_subscriptions_to_delete: 'Select at least one subscription to delete.'
+ subscriptions_deleted: "Deleted %{count} subscription(s). The user's device(s) will re-register on next app launch."
diff --git a/config/routes.rb b/config/routes.rb
index 31453b157..a1d3d088e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -358,6 +358,13 @@ Rails.application.routes.draw do
resources :categories do
post :reorder, on: :collection
end
+ namespace :articles do
+ resource :bulk_actions, only: [] do
+ post :translate
+ patch :update_status
+ delete :delete_articles
+ end
+ end
resources :articles do
post :reorder, on: :collection
end
@@ -625,6 +632,9 @@ Rails.application.routes.draw do
root to: 'dashboard#index'
resource :app_config, only: [:show, :create]
+ resource :push_diagnostics, only: [:show, :create] do
+ post :destroy_subscriptions, on: :collection
+ end
# order of resources affect the order of sidebar navigation in super admin
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb
new file mode 100644
index 000000000..87ad54d24
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb
@@ -0,0 +1,68 @@
+module Enterprise::Api::V1::Accounts::Articles::BulkActionsController
+ def translate
+ return unless validate_translate_params?
+
+ duplicates = find_existing_translations
+ if duplicates.any? && !ActiveModel::Type::Boolean.new.cast(permitted_params[:force])
+ return render json: {
+ duplicate_articles: duplicates.map { |a| { id: a.id, title: a.title } }
+ }, status: :conflict
+ end
+
+ @articles.find_each do |article|
+ Captain::Articles::TranslateJob.perform_later(
+ Current.account, article.id, @locale, @category&.id, Current.user
+ )
+ end
+
+ head :ok
+ end
+
+ private
+
+ def permitted_params
+ params.permit(:locale, :category_id, :force, ids: [])
+ end
+
+ def validate_translate_params?
+ @locale = permitted_params[:locale]
+ @category = @portal.categories.find_by(id: permitted_params[:category_id], locale: @locale)
+ @articles = @portal.articles.where(id: permitted_params[:ids])
+
+ captain_available? && valid_locale? && valid_category? && valid_articles?
+ end
+
+ def find_existing_translations
+ root_ids = @articles.map { |a| Article.find_root_article_id(a) }
+ @portal.articles.where(associated_article_id: root_ids, locale: @locale)
+ end
+
+ def captain_available?
+ return true if Current.account.feature_enabled?('captain_tasks')
+
+ render_could_not_create_error(I18n.t('portals.articles.captain_not_available'))
+ false
+ end
+
+ def valid_locale?
+ return true if @portal.config['allowed_locales']&.include?(@locale)
+
+ render_could_not_create_error(I18n.t('portals.articles.locale_not_available'))
+ false
+ end
+
+ def valid_category?
+ return true if permitted_params[:category_id].blank?
+ return true if @category.present?
+
+ render_could_not_create_error(I18n.t('portals.articles.category_not_found'))
+ false
+ end
+
+ def valid_articles?
+ return true if @articles.any?
+
+ render_could_not_create_error(I18n.t('portals.articles.no_articles_found'))
+ false
+ end
+end
diff --git a/enterprise/app/jobs/captain/articles/translate_job.rb b/enterprise/app/jobs/captain/articles/translate_job.rb
new file mode 100644
index 000000000..c3524cbff
--- /dev/null
+++ b/enterprise/app/jobs/captain/articles/translate_job.rb
@@ -0,0 +1,59 @@
+class Captain::Articles::TranslateJob < ApplicationJob
+ queue_as :low
+
+ def perform(account, article_id, target_locale, target_category_id, user)
+ @account = account
+ @source_article = account.articles.find(article_id)
+
+ target_language = language_name_for(target_locale)
+
+ translated_title = translate(@source_article.title, target_language: target_language, type: :title)
+ translated_content = if @source_article.content.present?
+ translate(@source_article.content, target_language: target_language, type: :content)
+ else
+ @source_article.content
+ end
+
+ existing = find_existing_translation(target_locale)
+
+ if existing
+ existing.update!(title: translated_title, content: translated_content, description: @source_article.description)
+ else
+ create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
+ end
+ end
+
+ private
+
+ def translate(text, target_language:, type:)
+ response = Captain::Llm::ArticleTranslationService.new(
+ account: @account, text: text, target_language: target_language, type: type
+ ).perform
+ raise "Translation failed: #{response[:error]}" if response[:error]
+
+ response[:message]
+ end
+
+ def find_existing_translation(target_locale)
+ root_id = Article.find_root_article_id(@source_article)
+ @source_article.portal.articles.find_by(associated_article_id: root_id, locale: target_locale)
+ end
+
+ def create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
+ @source_article.portal.articles.create!(
+ title: translated_title,
+ content: translated_content,
+ description: @source_article.description,
+ category_id: target_category_id,
+ locale: target_locale,
+ author_id: user.id,
+ status: :draft,
+ associated_article_id: Article.find_root_article_id(@source_article)
+ )
+ end
+
+ def language_name_for(locale_code)
+ language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
+ language_map[locale_code] || locale_code
+ end
+end
diff --git a/enterprise/app/jobs/captain/documents/perform_sync_job.rb b/enterprise/app/jobs/captain/documents/perform_sync_job.rb
new file mode 100644
index 000000000..baa9c03d2
--- /dev/null
+++ b/enterprise/app/jobs/captain/documents/perform_sync_job.rb
@@ -0,0 +1,97 @@
+class Captain::Documents::PerformSyncJob < MutexApplicationJob
+ queue_as :low
+
+ LOCK_TIMEOUT = 10.minutes
+
+ # Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
+ # random infra issues. Three attempts lets a real hiccup recover. The exhaustion block
+ # absorbs the final exception so Sidekiq doesn't layer its own retry policy on top, and
+ # is the single place we report to Sentry — handle_unexpected_failure logs but does not
+ # capture, so a deterministic bug emits one Sentry event instead of one per attempt.
+ # Goes first because retry_on handlers dispatch bottom-to-top.
+ retry_on StandardError, wait: 5.seconds, attempts: 3 do |job, error|
+ document = job.arguments.first
+ ChatwootExceptionTracker.new(error, account: document.account).capture_exception
+ job.send(:log_sync_outcome, document, result: :unexpected_retry_exhausted,
+ error_code: 'sync_error',
+ exception_class: error.class.name)
+ end
+
+ # Permanent errors (404, 403, empty content) — no point retrying, discard immediately.
+ # Document is already marked failed by SyncService before the exception reaches here.
+ discard_on(Captain::Documents::SyncService::PermanentSyncError)
+
+ # TransientSyncError is raised by SyncService when the customer's site is unreachable —
+ # timeouts, TLS errors, 5xx, connection drops. Four attempts with backoff gives the site
+ # a chance to recover before we give up.
+ #
+ # The exhaustion block absorbs the exception so it doesn't propagate to Sentry —
+ # site flakiness isn't an application bug.
+ retry_on(
+ Captain::Documents::SyncService::TransientSyncError,
+ wait: ->(executions) { [30.seconds, 2.minutes, 5.minutes][executions - 1] || 5.minutes },
+ attempts: 4
+ ) do |job, error|
+ document = job.arguments.first
+ job.send(:log_sync_outcome, document, result: :transient_retry_exhausted, error_code: error.message)
+ end
+
+ discard_on ActiveJob::DeserializationError
+ discard_on ActiveRecord::RecordNotFound
+
+ def perform(document)
+ start_time = Time.current
+ return if document.pdf_document?
+
+ with_lock(lock_key(document), LOCK_TIMEOUT) do
+ document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
+ result = Captain::Documents::SyncService.new(document.reload).perform
+ log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
+ end
+ rescue LockAcquisitionError
+ log_sync_outcome(document, result: :already_syncing)
+ rescue Captain::Documents::SyncService::PermanentSyncError => e
+ log_failure_and_raise(document, :permanent_failure, e, start_time)
+ rescue Captain::Documents::SyncService::TransientSyncError => e
+ log_failure_and_raise(document, :transient_failure, e, start_time)
+ rescue StandardError => e
+ handle_unexpected_failure(document, e, start_time)
+ end
+
+ private
+
+ def log_sync_outcome(document, **fields)
+ payload = {
+ document_id: document.id,
+ account_id: document.account_id,
+ assistant_id: document.assistant_id
+ }.merge(fields)
+ Rails.logger.info("[Captain::Documents::PerformSyncJob] #{payload.to_json}")
+ end
+
+ def log_failure_and_raise(document, result, error, start_time)
+ log_sync_outcome(document, result: result, error_code: error.message,
+ duration_ms: duration_ms_since(start_time))
+ raise error
+ end
+
+ def handle_unexpected_failure(document, error, start_time)
+ document.update!(
+ sync_status: :failed,
+ last_sync_error_code: 'sync_error',
+ last_sync_attempted_at: Time.current
+ )
+ log_sync_outcome(document, result: :unexpected_failure, error_code: 'sync_error',
+ exception_class: error.class.name,
+ duration_ms: duration_ms_since(start_time))
+ raise error
+ end
+
+ def lock_key(document)
+ format(::Redis::Alfred::CAPTAIN_DOCUMENT_SYNC_MUTEX, document_id: document.id)
+ end
+
+ def duration_ms_since(start_time)
+ ((Time.current - start_time) * 1000).round
+ end
+end
diff --git a/enterprise/app/jobs/captain/documents/response_builder_job.rb b/enterprise/app/jobs/captain/documents/response_builder_job.rb
index 553cec110..8f6643e36 100644
--- a/enterprise/app/jobs/captain/documents/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/documents/response_builder_job.rb
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
- Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
+ Captain::Llm::FaqGeneratorService.new(document: document).generate
end
def build_paginated_service(document, options)
@@ -62,7 +62,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def reset_previous_responses(response_document)
- response_document.responses.destroy_all
+ response_document.responses.where(edited: false).destroy_all
end
def create_response(faq, document)
diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb
index d8e07a2d9..c2b5fa214 100644
--- a/enterprise/app/models/captain/document.rb
+++ b/enterprise/app/models/captain/document.rb
@@ -62,6 +62,7 @@ class Captain::Document < ApplicationRecord
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
+ return true if external_link&.start_with?('PDF:')
external_link&.ends_with?('.pdf')
end
@@ -90,6 +91,14 @@ class Captain::Document < ApplicationRecord
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
end
+ def sync_step
+ metadata&.dig('sync_step')
+ end
+
+ def store_sync_step(step)
+ update!(metadata: (metadata || {}).merge('sync_step' => step))
+ end
+
def openai_file_id
metadata&.dig('openai_file_id')
end
@@ -108,6 +117,10 @@ class Captain::Document < ApplicationRecord
end
end
+ def to_llm_metadata
+ { document_id: id, assistant_id: assistant_id, external_link: external_link }
+ end
+
private
def enqueue_crawl_job
diff --git a/enterprise/app/services/captain/documents/single_page_fetcher.rb b/enterprise/app/services/captain/documents/single_page_fetcher.rb
new file mode 100644
index 000000000..115cd55b0
--- /dev/null
+++ b/enterprise/app/services/captain/documents/single_page_fetcher.rb
@@ -0,0 +1,80 @@
+class Captain::Documents::SinglePageFetcher
+ Result = Struct.new(:success, :title, :content, :error_code, keyword_init: true)
+
+ CONTENT_MAX_LENGTH = 200_000
+ TITLE_MAX_LENGTH = 255 # captain_documents.name is a varchar(255)
+
+ def initialize(url)
+ @url = url
+ end
+
+ def fetch
+ result = firecrawl_configured? ? fetch_with_firecrawl : fetch_with_fallback
+ validate_content(result)
+ rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ETIMEDOUT
+ Result.new(success: false, error_code: 'timeout')
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, OpenSSL::SSL::SSLError
+ Result.new(success: false, error_code: 'fetch_failed')
+ end
+
+ private
+
+ def firecrawl_configured?
+ InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
+ end
+
+ def fetch_with_firecrawl
+ response = Captain::Tools::FirecrawlService.new.scrape(@url)
+ handle_firecrawl_response(response)
+ end
+
+ def handle_firecrawl_response(response)
+ return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
+
+ data = response.parsed_response&.dig('data')
+ target_error = firecrawl_target_error_code(data)
+ return Result.new(success: false, error_code: target_error) if target_error
+
+ Result.new(
+ success: true,
+ title: data&.dig('metadata', 'title')&.truncate(TITLE_MAX_LENGTH, omission: ''),
+ content: data&.dig('markdown')&.truncate(CONTENT_MAX_LENGTH, omission: '')
+ )
+ end
+
+ # Firecrawl returns API 200 even when the scraped page itself failed —
+ # the target page's real status lives in data.metadata.statusCode.
+ def firecrawl_target_error_code(data)
+ status = data&.dig('metadata', 'statusCode')
+ return nil if status.blank? || (200..299).cover?(status)
+
+ http_error_code(status)
+ end
+
+ def fetch_with_fallback
+ response = HTTParty.get(@url)
+ return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
+
+ parser = Captain::Tools::HtmlPageParser.new(response.body)
+ Result.new(
+ success: true,
+ title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
+ content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
+ )
+ end
+
+ def validate_content(result)
+ return result unless result.success && result.content.blank?
+
+ Result.new(success: false, error_code: 'content_empty')
+ end
+
+ def http_error_code(status_code)
+ case status_code
+ when 404 then 'not_found'
+ when 401, 403 then 'access_denied'
+ when 408, 504 then 'timeout'
+ else 'fetch_failed'
+ end
+ end
+end
diff --git a/enterprise/app/services/captain/documents/sync_service.rb b/enterprise/app/services/captain/documents/sync_service.rb
new file mode 100644
index 000000000..5dcb52447
--- /dev/null
+++ b/enterprise/app/services/captain/documents/sync_service.rb
@@ -0,0 +1,76 @@
+class Captain::Documents::SyncService
+ class PermanentSyncError < StandardError
+ end
+
+ class TransientSyncError < StandardError
+ end
+
+ PERMANENT_ERROR_CODES = %w[not_found access_denied content_empty].freeze
+
+ def initialize(document)
+ @document = document
+ end
+
+ def perform
+ @document.store_sync_step('fetching')
+ result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
+
+ unless result.success
+ mark_failed(result.error_code)
+ raise_for_error_code(result.error_code)
+ end
+
+ @document.store_sync_step('comparing')
+ fingerprint = compute_fingerprint(result.content)
+
+ if fingerprint == @document.content_fingerprint
+ mark_synced
+ return :unchanged
+ end
+
+ @document.store_sync_step('updating')
+ update_content(result, fingerprint)
+ :updated
+ end
+
+ private
+
+ def compute_fingerprint(content)
+ Digest::SHA256.hexdigest(content.gsub(/\s+/, ' ').strip)
+ end
+
+ def mark_failed(error_code)
+ @document.update!(
+ sync_status: :failed,
+ last_sync_error_code: error_code,
+ last_sync_attempted_at: Time.current
+ )
+ end
+
+ def mark_synced
+ @document.update!(
+ sync_status: :synced,
+ last_synced_at: Time.current,
+ last_sync_attempted_at: Time.current,
+ last_sync_error_code: nil
+ )
+ end
+
+ def update_content(result, fingerprint)
+ @document.update!(
+ content: result.content,
+ name: result.title.presence || @document.name,
+ content_fingerprint: fingerprint,
+ sync_status: :synced,
+ last_synced_at: Time.current,
+ last_sync_attempted_at: Time.current,
+ last_sync_error_code: nil
+ )
+ end
+
+ def raise_for_error_code(error_code)
+ raise PermanentSyncError, error_code if PERMANENT_ERROR_CODES.include?(error_code)
+
+ raise TransientSyncError, error_code
+ end
+end
diff --git a/enterprise/app/services/captain/llm/article_translation_service.rb b/enterprise/app/services/captain/llm/article_translation_service.rb
new file mode 100644
index 000000000..5db26088e
--- /dev/null
+++ b/enterprise/app/services/captain/llm/article_translation_service.rb
@@ -0,0 +1,62 @@
+class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService
+ TYPES = %i[title content].freeze
+
+ pattr_initialize [:account!, :text!, :target_language!, :type!]
+
+ def perform
+ raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type)
+
+ response = make_api_call(model: translation_model, messages: messages)
+ return response if response[:error]
+
+ response.merge(message: response[:message].strip)
+ end
+
+ private
+
+ def messages
+ [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: text }
+ ]
+ end
+
+ def system_prompt
+ type == :title ? title_system_prompt : content_system_prompt
+ end
+
+ def event_name
+ 'article_translation'
+ end
+
+ def llm_credential
+ @llm_credential ||= system_llm_credential
+ end
+
+ def translation_model
+ @translation_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
+ end
+
+ def title_system_prompt
+ <<~SYSTEM_PROMPT_MESSAGE
+ You are a professional translator.
+ Translate the following text to #{target_language}.
+ Return only the translated text, no explanations or extra formatting.
+ SYSTEM_PROMPT_MESSAGE
+ end
+
+ def content_system_prompt
+ <<~SYSTEM_PROMPT_MESSAGE
+ You are a professional translator. Translate the following content to #{target_language}.
+ The content is markdown that may contain embedded HTML blocks.
+ Rules:
+ - Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
+ - Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
+ - Preserve ALL HTML tags, attributes, and structure exactly as they are.
+ - Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
+ - Keep all image tags (both markdown  and HTML
), iframes, and embedded media completely unchanged.
+ - Preserve all line breaks, blank lines, and whitespace patterns.
+ - Return ONLY the translated content, no wrapping or explanations.
+ SYSTEM_PROMPT_MESSAGE
+ end
+end
diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb
index 5f85ae467..b80382b3e 100644
--- a/enterprise/app/services/captain/llm/faq_generator_service.rb
+++ b/enterprise/app/services/captain/llm/faq_generator_service.rb
@@ -1,11 +1,12 @@
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
include Integrations::LlmInstrumentation
- def initialize(content, language = 'english', account_id: nil)
+ def initialize(document:)
super()
- @language = language
- @content = content
- @account_id = account_id
+ @document = document
+ @content = document.content
+ @language = document.account.locale_english_name
+ @account_id = document.account_id
end
def generate
@@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
- ]
+ ],
+ metadata: document_metadata
}
end
+ def document_metadata
+ @document&.to_llm_metadata || {}
+ end
+
def parse_response(content)
return [] if content.nil?
diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
index 3fe81c2ae..b567609e8 100644
--- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
+++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
@@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
account_id: @document&.account_id,
feature_name: 'faq_generation',
model: @model,
- messages: params[:messages]
+ messages: params[:messages],
+ metadata: document_metadata
}
response = instrument_llm_call(instrumentation_params) do
@@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
feature_name: 'paginated_faq_generation',
model: @model,
messages: params[:messages],
- metadata: {
- document_id: @document&.id,
- start_page: start_page,
- end_page: end_page,
- iteration: @iterations_completed + 1
- }
+ metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
}
end
+
+ def document_metadata
+ @document&.to_llm_metadata || {}
+ end
end
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index 9868f0360..dab147301 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService
class << self
def faq_generator(language = 'english')
<<~PROMPT
- You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
+ You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information.
## Core Requirements
- **Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
+ **Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely.
+
+ **Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found — without containing that information itself — omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all.
+
+ **Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome — navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
@@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService
## Guidelines
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
- - **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
- - **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
+ - **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source.
+ - **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity.
+ - **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it.
- **JSON Validity**: Always return properly formatted, valid JSON
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
## Process
1. Read the entire provided content carefully
- 2. Identify all key information points, procedures, and examples
- 3. Create questions that cover each information point
- 4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
- 5. Verify that combined FAQs represent the complete original content.
- 6. Format as valid JSON
+ 2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
+ 3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
+ 4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
+ 5. Create questions that cover each remaining substantive information point
+ 6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
+ 7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
+ 8. Format as valid JSON
PROMPT
end
diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb
index 397c3ad63..3d1b53b7a 100644
--- a/enterprise/app/services/captain/tools/firecrawl_service.rb
+++ b/enterprise/app/services/captain/tools/firecrawl_service.rb
@@ -1,4 +1,7 @@
class Captain::Tools::FirecrawlService
+ BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
+ FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
+
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.empty?
@@ -6,7 +9,7 @@ class Captain::Tools::FirecrawlService
def perform(url, webhook_url, crawl_limit = 10)
HTTParty.post(
- 'https://api.firecrawl.dev/v1/crawl',
+ "#{BASE_URL}/crawl",
body: crawl_payload(url, webhook_url, crawl_limit),
headers: headers
)
@@ -14,6 +17,14 @@ class Captain::Tools::FirecrawlService
raise "Failed to crawl URL: #{e.message}"
end
+ def scrape(url)
+ HTTParty.post(
+ "#{BASE_URL}/scrape",
+ body: scrape_payload(url),
+ headers: headers
+ )
+ end
+
private
def crawl_payload(url, webhook_url, crawl_limit)
@@ -23,14 +34,22 @@ class Captain::Tools::FirecrawlService
ignoreSitemap: false,
limit: crawl_limit,
webhook: webhook_url,
- scrapeOptions: {
- onlyMainContent: false,
- formats: ['markdown'],
- excludeTags: ['iframe']
- }
+ scrapeOptions: scrape_options
}.to_json
end
+ def scrape_payload(url)
+ { url: url }.merge(scrape_options).to_json
+ end
+
+ def scrape_options
+ {
+ onlyMainContent: true,
+ formats: ['markdown'],
+ excludeTags: FIRECRAWL_EXCLUDE_TAGS
+ }
+ end
+
def headers
{
'Authorization' => "Bearer #{@api_key}",
diff --git a/enterprise/app/services/captain/tools/html_page_parser.rb b/enterprise/app/services/captain/tools/html_page_parser.rb
new file mode 100644
index 000000000..a7afb52cf
--- /dev/null
+++ b/enterprise/app/services/captain/tools/html_page_parser.rb
@@ -0,0 +1,15 @@
+class Captain::Tools::HtmlPageParser
+ attr_reader :doc
+
+ def initialize(html)
+ @doc = Nokogiri::HTML(html)
+ end
+
+ def title
+ @doc.at_xpath('//title')&.text&.strip
+ end
+
+ def body_markdown
+ ReverseMarkdown.convert(@doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true)
+ end
+end
diff --git a/enterprise/app/services/captain/tools/simple_page_crawl_service.rb b/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
index 65731ad90..5eaee6e5e 100644
--- a/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
+++ b/enterprise/app/services/captain/tools/simple_page_crawl_service.rb
@@ -3,7 +3,8 @@ class Captain::Tools::SimplePageCrawlService
def initialize(external_link)
@external_link = external_link
- @doc = Nokogiri::HTML(HTTParty.get(external_link).body)
+ @parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
+ @doc = @parser.doc
end
def page_links
@@ -11,12 +12,11 @@ class Captain::Tools::SimplePageCrawlService
end
def page_title
- title_element = @doc.at_xpath('//title')
- title_element&.text&.strip
+ @parser.title
end
def body_text_content
- ReverseMarkdown.convert @doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true
+ @parser.body_markdown
end
def meta_description
diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
index d0ec3e372..00e2b1646 100644
--- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
+++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
@@ -79,7 +79,12 @@ class Enterprise::Billing::TopupCheckoutService
def finalize_and_pay(invoice_id)
Stripe::Invoice.finalize_invoice(invoice_id, { auto_advance: false })
invoice = Stripe::Invoice.retrieve(invoice_id)
- Stripe::Invoice.pay(invoice_id) unless invoice.status == 'paid'
+ return if invoice.status == 'paid'
+
+ Stripe::Invoice.pay(invoice_id)
+ rescue Stripe::CardError
+ Stripe::Invoice.void_invoice(invoice_id)
+ raise
end
def fulfill_credits(credits, topup_option)
diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
index 116d0a3fc..d119d6345 100644
--- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb
+++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
@@ -52,9 +52,9 @@ class Internal::Accounts::InternalAttributesService
# Get list of valid features that can be manually managed
def valid_feature_list
- # Business and Enterprise plan features only
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
- Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
+ Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
+ %w[inbound_emails]
end
# Account notes functionality removed for now
diff --git a/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb b/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb
index 91837f980..5032db646 100644
--- a/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb
+++ b/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -1,45 +1,99 @@
-Hi <%= @resource.name %>,
+<%
+ brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
+ recipient_name = @resource.name.presence || @resource.email
+ account_user = @resource&.account_users&.first
+ inviter = account_user&.inviter
+ account_name = account_user&.account&.name
+ is_saml_account = account_user&.account&.saml_enabled?
+ invited_user = inviter.present? && @resource.unconfirmed_email.blank?
-<% account_user = @resource&.account_users&.first %>
-<% is_saml_account = account_user&.account&.saml_enabled? %>
+ eyebrow = 'Welcome'
+ heading = 'Confirm your email to get started'
+ intro_text =
+ "Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
+ supporting_text = 'This only takes a moment.'
+ action_text = 'Confirm my account'
+ action_url = frontend_url('auth/confirmation', confirmation_token: @token)
+ info_title = nil
+ info_text = nil
+ detail_rows = []
+ detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
-<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
- <% if is_saml_account %>
- <%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).
- Your organization uses SSO for secure authentication. You will not need a password to access your account.
- <% else %>
- <%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.
- <% end %>
-<% end %>
+ if @resource.unconfirmed_email.present?
+ eyebrow = 'Email update'
+ heading = 'Confirm your new email address'
+ intro_text = "We received a request to update the email address on your #{brand_name} account."
+ supporting_text = 'Confirm the new address below to finish the change.'
+ action_text = 'Confirm email address'
+ elsif @resource.confirmed?
+ eyebrow = 'Account ready'
-<% if @resource.confirmed? %>
- You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:
-<% else %>
- <% if account_user&.inviter.blank? %>
-
- Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
-
- <% end %>
- <% unless is_saml_account %>
- Please take a moment and click the link below and activate your account.
- <% end %>
-<% end %>
+ if is_saml_account
+ heading = 'Your access is ready'
+ intro_text = "Your #{brand_name} access is already set up."
+ supporting_text = "Use your organization's Single Sign-On (SSO) portal to access #{brand_name}."
+ action_text = nil
+ action_url = nil
+ info_title = "Sign in with your organization's SSO"
+ info_text =
+ "You won't need a separate password for #{brand_name}. Start from your company identity provider portal."
+ detail_rows = []
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
+ else
+ heading = 'Your account is ready'
+ intro_text = "Your #{brand_name} account is already active."
+ supporting_text = 'Use the button below to sign in and continue where you left off.'
+ action_text = 'Open my account'
+ action_url = frontend_url('auth/sign_in')
+ detail_rows = []
+ end
+ elsif invited_user
+ eyebrow = 'Workspace invitation'
+ heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
+ if is_saml_account
+ intro_text = if account_name.present?
+ "#{inviter.name} invited you to access the #{account_name} workspace on #{brand_name}."
+ else
+ "#{inviter.name} invited you to access #{brand_name}."
+ end
+ supporting_text =
+ "Your organization uses Single Sign-On (SSO), so you won't need to create a separate password."
+ action_text = nil
+ action_url = nil
+ info_title = "Use your organization's SSO portal"
+ info_text = "Continue from your company identity provider portal to access #{brand_name}."
+ detail_rows = [['Invited by', inviter.name]]
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
+ else
+ intro_text = if account_name.present?
+ "#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
+ else
+ "#{inviter.name} invited you to try #{brand_name}."
+ end
+ supporting_text = 'Create your account to start collaborating with your team.'
+ action_text = 'Accept invitation'
+ action_url = frontend_url(
+ 'auth/password/edit',
+ reset_password_token: @resource.send(:set_reset_password_token)
+ )
+ detail_rows = [['Invited by', inviter.name]]
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ end
+ end
+%>
-<% if @resource.unconfirmed_email.present? %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% elsif @resource.confirmed? %>
- <% if is_saml_account %>
- You can now access your account by logging in through your organization's SSO portal.
- <% else %>
- <%= link_to 'Login to my account', frontend_url('auth/sign_in') %>
- <% end %>
-<% elsif account_user&.inviter.present? %>
- <% if is_saml_account %>
- You can access your account by logging in through your organization's SSO portal.
- <% else %>
- <%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %>
- <% end %>
-<% else %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% end %>
+<%= render partial: 'devise/mailer/confirmation_body', locals: {
+ action_text: action_text,
+ action_url: action_url,
+ detail_rows: detail_rows,
+ eyebrow: eyebrow,
+ heading: heading,
+ info_text: info_text,
+ info_title: info_title,
+ intro_text: intro_text,
+ recipient_name: recipient_name,
+ supporting_text: supporting_text
+} %>
diff --git a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb
index 97b21090a..a4a18a9b9 100644
--- a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb
+++ b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb
@@ -2,9 +2,8 @@
# Get all feature names and their display names
all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names
- # Business and Enterprise plan features only
- premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
- Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
+ # Features that can be manually managed
+ premium_features = Internal::Accounts::InternalAttributesService.new(field.resource).valid_feature_list
# Get only premium features with display names
premium_features_with_display = premium_features.map do |feature|
diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb
index df49918b3..f530e71ee 100644
--- a/lib/base_markdown_renderer.rb
+++ b/lib/base_markdown_renderer.rb
@@ -29,11 +29,15 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
def render_img_tag(src, title, height = nil)
title_attribute = title.present? ? " title=\"#{title}\"" : ''
- height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : ''
+ # Use inline style instead of the HTML height attribute: email clients and
+ # the in-app Letter view both run images through CSS (e.g. prose /
+ # lettersanitizer's `img { height: auto }`) which overrides presentational
+ # attributes. Inline style has higher specificity and survives.
+ style_attribute = height ? " style=\"height: #{height};\"" : ''
plain do
# plain ensures that the content is not wrapped in a paragraph tag
- out("
")
+ out("
")
end
end
end
diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb
index be5a07e05..95679ed73 100644
--- a/lib/chatwoot_hub.rb
+++ b/lib/chatwoot_hub.rb
@@ -106,14 +106,18 @@ class ChatwootHub
end
def self.send_push(fcm_options)
- info = { fcm_options: fcm_options }
- RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
+ send_push_with_response(fcm_options)
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
+ def self.send_push_with_response(fcm_options)
+ info = { fcm_options: fcm_options }
+ RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
+ end
+
def self.emit_event(event_name, event_data)
return if ENV['DISABLE_TELEMETRY']
diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb
index 59e33036d..893fb50ef 100644
--- a/lib/redis/redis_keys.rb
+++ b/lib/redis/redis_keys.rb
@@ -44,6 +44,7 @@ module Redis::RedisKeys
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%s::%s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%s'.freeze
+ CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%s'.freeze
## Auto Assignment Keys
# Track conversation assignments to agents for rate limiting
diff --git a/lib/safe_fetch.rb b/lib/safe_fetch.rb
index e6635c9c3..2264b2850 100644
--- a/lib/safe_fetch.rb
+++ b/lib/safe_fetch.rb
@@ -6,7 +6,11 @@ module SafeFetch
DEFAULT_READ_TIMEOUT = 20
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
- Result = Data.define(:tempfile, :filename, :content_type)
+ Result = Data.define(:tempfile, :filename, :content_type) do
+ def original_filename
+ filename
+ end
+ end
class Error < StandardError; end
class InvalidUrlError < Error; end
@@ -18,19 +22,15 @@ module SafeFetch
def self.fetch(url,
max_bytes: nil,
- allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
+ allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
+ allowed_content_types: [])
raise ArgumentError, 'block required' unless block_given?
effective_max_bytes = max_bytes || default_max_bytes
- uri = parse_and_validate_url!(url)
- filename = filename_for(uri)
+ filename = filename_for(parse_and_validate_url!(url))
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
-
- response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
- raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
-
- tempfile.rewind
- yield Result.new(tempfile: tempfile, filename: filename, content_type: response['content-type'])
+ response = fetch_response(url, tempfile, effective_max_bytes, allowed_content_type_prefixes, allowed_content_types)
+ yield build_result(tempfile, filename, response)
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
raise InvalidUrlError, e.message
rescue SsrfFilter::Error, Resolv::ResolvError => e
@@ -44,18 +44,23 @@ module SafeFetch
class << self
private
- def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
+ def fetch_response(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
+ stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
+ end
+
+ def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
response = nil
bytes_written = 0
SsrfFilter.get(
url,
+ request_proc: ->(request) { apply_url_basic_auth(request) },
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
) do |res|
response = res
next unless res.is_a?(Net::HTTPSuccess)
- unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes)
+ unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes, allowed_content_types)
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
end
@@ -74,6 +79,14 @@ module SafeFetch
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
+ def build_result(tempfile, filename, response)
+ raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
+
+ tempfile.rewind
+ content_type = normalized_content_type(response['content-type'])
+ Result.new(tempfile: tempfile, filename: filename, content_type: content_type)
+ end
+
def default_max_bytes
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
@@ -88,11 +101,24 @@ module SafeFetch
uri
end
- def allowed_content_type?(value, prefixes)
- mime = value.to_s.split(';').first&.strip&.downcase
+ def allowed_content_type?(value, prefixes, content_types)
+ mime = normalized_content_type(value)
return false if mime.blank?
- prefixes.any? { |prefix| mime.start_with?(prefix) }
+ prefixes.any? { |prefix| mime.start_with?(prefix) } || content_types.include?(mime)
+ end
+
+ def normalized_content_type(value)
+ value.to_s.split(';').first&.strip&.downcase
+ end
+
+ def apply_url_basic_auth(request)
+ uri = request.uri
+ return if uri.user.blank?
+
+ username = URI.decode_uri_component(uri.user)
+ password = URI.decode_uri_component(uri.password.to_s)
+ request.basic_auth(username, password)
end
end
end
diff --git a/spec/builders/v2/reports/conversations/metric_builder_spec.rb b/spec/builders/v2/reports/conversations/metric_builder_spec.rb
index 1b0ed7a38..018bd567b 100644
--- a/spec/builders/v2/reports/conversations/metric_builder_spec.rb
+++ b/spec/builders/v2/reports/conversations/metric_builder_spec.rb
@@ -5,12 +5,10 @@ RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
let(:account) { create(:account) }
let(:params) { { since: '2023-01-01', until: '2024-01-01' } }
- let(:count_builder_instance) { instance_double(V2::Reports::Timeseries::CountReportBuilder, aggregate_value: 42) }
- let(:avg_builder_instance) { instance_double(V2::Reports::Timeseries::AverageReportBuilder, aggregate_value: 42) }
+ let(:builder_instance) { instance_double(V2::Reports::Timeseries::ReportBuilder, aggregate_value: 42) }
before do
- allow(V2::Reports::Timeseries::CountReportBuilder).to receive(:new).and_return(count_builder_instance)
- allow(V2::Reports::Timeseries::AverageReportBuilder).to receive(:new).and_return(avg_builder_instance)
+ allow(V2::Reports::Timeseries::ReportBuilder).to receive(:new).and_return(builder_instance)
end
describe '#summary' do
@@ -31,8 +29,8 @@ RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
it 'creates builders with proper params' do
subject.summary
- expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
- expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
+ expect(V2::Reports::Timeseries::ReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
+ expect(V2::Reports::Timeseries::ReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
end
end
diff --git a/spec/builders/v2/reports/conversations/report_builder_spec.rb b/spec/builders/v2/reports/conversations/report_builder_spec.rb
index db7a0ac45..a2b293461 100644
--- a/spec/builders/v2/reports/conversations/report_builder_spec.rb
+++ b/spec/builders/v2/reports/conversations/report_builder_spec.rb
@@ -4,19 +4,19 @@ describe V2::Reports::Conversations::ReportBuilder do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
- let(:average_builder) { V2::Reports::Timeseries::AverageReportBuilder }
- let(:count_builder) { V2::Reports::Timeseries::CountReportBuilder }
+ let(:builder) { V2::Reports::Timeseries::ReportBuilder }
- shared_examples 'valid metric handler' do |metric, method, builder|
+ shared_examples 'valid metric handler' do |metric, method|
context 'when a valid metric is given' do
let(:params) { { metric: metric } }
- it "calls the correct #{method} builder for #{metric}" do
+ it "calls the shared #{method} builder for #{metric}" do
builder_instance = instance_double(builder)
allow(builder).to receive(:new).and_return(builder_instance)
- allow(builder_instance).to receive(method)
+ allow(builder_instance).to receive(method).and_return(:result)
- builder_instance.public_send(method)
+ expect(subject.public_send(method)).to eq(:result)
+ expect(builder).to have_received(:new).with(account, params)
expect(builder_instance).to have_received(method)
end
end
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
end
describe '#timeseries' do
- it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
- it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
+ it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries
+ it_behaves_like 'valid metric handler', 'conversations_count', :timeseries
end
describe '#aggregate_value' do
- it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
- it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
+ it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value
+ it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value
end
end
diff --git a/spec/builders/v2/reports/timeseries/average_report_builder_spec.rb b/spec/builders/v2/reports/timeseries/average_report_builder_spec.rb
deleted file mode 100644
index 4f6036f07..000000000
--- a/spec/builders/v2/reports/timeseries/average_report_builder_spec.rb
+++ /dev/null
@@ -1,174 +0,0 @@
-require 'rails_helper'
-
-describe V2::Reports::Timeseries::AverageReportBuilder do
- subject { described_class.new(account, params) }
-
- let(:account) { create(:account) }
- let(:team) { create(:team, account: account) }
- let(:inbox) { create(:inbox, account: account) }
- let(:label) { create(:label, title: 'spec-billing', account: account) }
- let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) }
- let(:current_time) { '26.10.2020 10:00'.to_datetime }
-
- let(:params) do
- {
- type: filter_type,
- business_hours: business_hours,
- timezone_offset: timezone_offset,
- group_by: group_by,
- metric: metric,
- since: (current_time - 1.week).beginning_of_day.to_i.to_s,
- until: current_time.end_of_day.to_i.to_s,
- id: filter_id
- }
- end
- let(:timezone_offset) { nil }
- let(:group_by) { 'day' }
- let(:metric) { 'avg_first_response_time' }
- let(:business_hours) { false }
- let(:filter_type) { :account }
- let(:filter_id) { '' }
-
- before do
- travel_to current_time
- conversation.label_list.add(label.title)
- conversation.save!
- create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now,
- conversation: conversation, inbox: inbox)
- create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago)
- create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago)
- end
-
- describe '#timeseries' do
- context 'when there is no filter applied' do
- it 'returns the correct values' do
- timeseries_values = subject.timeseries
-
- expect(timeseries_values).to eq(
- [
- { count: 1, timestamp: 1_603_065_600, value: 93.0 },
- { count: 0, timestamp: 1_603_152_000, value: 0 },
- { count: 0, timestamp: 1_603_238_400, value: 0 },
- { count: 0, timestamp: 1_603_324_800, value: 0 },
- { count: 0, timestamp: 1_603_411_200, value: 0 },
- { count: 0, timestamp: 1_603_497_600, value: 0 },
- { count: 0, timestamp: 1_603_584_000, value: 0 },
- { count: 2, timestamp: 1_603_670_400, value: 90.0 }
- ]
- )
- end
-
- context 'when business hours is provided' do
- let(:business_hours) { true }
-
- it 'returns correct timeseries' do
- timeseries_values = subject.timeseries
-
- expect(timeseries_values).to eq(
- [
- { count: 1, timestamp: 1_603_065_600, value: 30.0 },
- { count: 0, timestamp: 1_603_152_000, value: 0 },
- { count: 0, timestamp: 1_603_238_400, value: 0 },
- { count: 0, timestamp: 1_603_324_800, value: 0 },
- { count: 0, timestamp: 1_603_411_200, value: 0 },
- { count: 0, timestamp: 1_603_497_600, value: 0 },
- { count: 0, timestamp: 1_603_584_000, value: 0 },
- { count: 2, timestamp: 1_603_670_400, value: 15.0 }
- ]
- )
- end
- end
-
- context 'when group_by is provided' do
- let(:group_by) { 'week' }
-
- it 'returns correct timeseries' do
- timeseries_values = subject.timeseries
- expect(timeseries_values).to eq(
- [
- { count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 },
- { count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 }
- ]
- )
- end
- end
-
- context 'when timezone offset is provided' do
- let(:timezone_offset) { '5.5' }
- let(:group_by) { 'week' }
-
- it 'returns correct timeseries' do
- timeseries_values = subject.timeseries
- expect(timeseries_values).to eq(
- [
- { count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 },
- { count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
- ]
- )
- end
- end
- end
-
- context 'when the label filter is applied' do
- let(:group_by) { 'week' }
- let(:filter_type) { 'label' }
- let(:filter_id) { label.id }
-
- it 'returns correct timeseries' do
- timeseries_values = subject.timeseries
- start_of_the_week = current_time.beginning_of_week(:sunday).to_i
- last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
- expect(timeseries_values).to eq(
- [
- { count: 0, timestamp: last_week_start_of_the_week, value: 0 },
- { count: 1, timestamp: start_of_the_week, value: 80.0 }
- ]
- )
- end
- end
-
- context 'when the inbox filter is applied' do
- let(:group_by) { 'week' }
- let(:filter_type) { 'inbox' }
- let(:filter_id) { inbox.id }
-
- it 'returns correct timeseries' do
- timeseries_values = subject.timeseries
- start_of_the_week = current_time.beginning_of_week(:sunday).to_i
- last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
- expect(timeseries_values).to eq(
- [
- { count: 0, timestamp: last_week_start_of_the_week, value: 0 },
- { count: 1, timestamp: start_of_the_week, value: 80.0 }
- ]
- )
- end
- end
-
- context 'when the team filter is applied' do
- let(:group_by) { 'week' }
- let(:filter_type) { 'team' }
- let(:filter_id) { team.id }
-
- it 'returns correct timeseries' do
- timeseries_values = subject.timeseries
- start_of_the_week = current_time.beginning_of_week(:sunday).to_i
- last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
- expect(timeseries_values).to eq(
- [
- { count: 0, timestamp: last_week_start_of_the_week, value: 0 },
- { count: 1, timestamp: start_of_the_week, value: 80.0 }
- ]
- )
- end
- end
- end
-
- describe '#aggregate_value' do
- context 'when there is no filter applied' do
- it 'returns the correct average value' do
- expect(subject.aggregate_value).to eq 91.0
- end
- end
- end
-end
diff --git a/spec/builders/v2/reports/timeseries/count_report_builder_spec.rb b/spec/builders/v2/reports/timeseries/count_report_builder_spec.rb
deleted file mode 100644
index 038bd61c2..000000000
--- a/spec/builders/v2/reports/timeseries/count_report_builder_spec.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-require 'rails_helper'
-
-describe V2::Reports::Timeseries::CountReportBuilder do
- subject { described_class.new(account, params) }
-
- let(:account) { create(:account) }
- let(:account2) { create(:account) }
- let(:user) { create(:user, email: 'agent1@example.com') }
- let(:inbox) { create(:inbox, account: account) }
- let(:inbox2) { create(:inbox, account: account2) }
- let(:current_time) { Time.current }
-
- let(:params) do
- {
- type: 'agent',
- metric: 'resolutions_count',
- since: (current_time - 1.day).beginning_of_day.to_i.to_s,
- until: current_time.end_of_day.to_i.to_s,
- id: user.id.to_s
- }
- end
-
- before do
- travel_to current_time
-
- # Add the same user to both accounts
- create(:account_user, account: account, user: user)
- create(:account_user, account: account2, user: user)
-
- # Create conversations in account1
- conversation1 = create(:conversation, account: account, inbox: inbox, assignee: user)
- conversation2 = create(:conversation, account: account, inbox: inbox, assignee: user)
-
- # Create conversations in account2
- conversation3 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
- conversation4 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
-
- # User resolves 2 conversations in account1
- create(:reporting_event,
- name: 'conversation_resolved',
- account: account,
- user: user,
- conversation: conversation1,
- created_at: current_time - 12.hours)
-
- create(:reporting_event,
- name: 'conversation_resolved',
- account: account,
- user: user,
- conversation: conversation2,
- created_at: current_time - 6.hours)
-
- # Same user resolves 3 conversations in account2 - these should NOT be counted for account1
- create(:reporting_event,
- name: 'conversation_resolved',
- account: account2,
- user: user,
- conversation: conversation3,
- created_at: current_time - 8.hours)
-
- create(:reporting_event,
- name: 'conversation_resolved',
- account: account2,
- user: user,
- conversation: conversation4,
- created_at: current_time - 4.hours)
-
- # Create another conversation in account2 for testing
- conversation5 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
- create(:reporting_event,
- name: 'conversation_resolved',
- account: account2,
- user: user,
- conversation: conversation5,
- created_at: current_time - 2.hours)
- end
-
- describe '#aggregate_value' do
- it 'returns only resolutions performed by the user in the specified account' do
- # User should have 2 resolutions in account1, not 5 (total across both accounts)
- expect(subject.aggregate_value).to eq(2)
- end
-
- context 'when querying account2' do
- subject { described_class.new(account2, params) }
-
- it 'returns only resolutions for account2' do
- # User should have 3 resolutions in account2
- expect(subject.aggregate_value).to eq(3)
- end
- end
- end
-
- describe '#timeseries' do
- it 'filters resolutions by account' do
- result = subject.timeseries
- # Should only count the 2 resolutions from account1
- total_count = result.sum { |r| r[:value] }
- expect(total_count).to eq(2)
- end
- end
-
- describe 'account isolation' do
- it 'does not leak data between accounts' do
- # If account isolation works correctly, the counts should be different
- account1_count = described_class.new(account, params).aggregate_value
- account2_count = described_class.new(account2, params).aggregate_value
-
- expect(account1_count).to eq(2)
- expect(account2_count).to eq(3)
- end
- end
-end
diff --git a/spec/builders/v2/reports/timeseries/report_builder_spec.rb b/spec/builders/v2/reports/timeseries/report_builder_spec.rb
new file mode 100644
index 000000000..eec3e99da
--- /dev/null
+++ b/spec/builders/v2/reports/timeseries/report_builder_spec.rb
@@ -0,0 +1,313 @@
+require 'rails_helper'
+
+describe V2::Reports::Timeseries::ReportBuilder do
+ describe 'average metrics' do
+ subject { described_class.new(account, params) }
+
+ let(:account) { create(:account) }
+ let(:team) { create(:team, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:label) { create(:label, title: 'spec-billing', account: account) }
+ let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) }
+ let(:current_time) { '26.10.2020 10:00'.to_datetime }
+
+ let(:params) do
+ {
+ type: filter_type,
+ business_hours: business_hours,
+ timezone_offset: timezone_offset,
+ group_by: group_by,
+ metric: metric,
+ since: (current_time - 1.week).beginning_of_day.to_i.to_s,
+ until: current_time.end_of_day.to_i.to_s,
+ id: filter_id
+ }
+ end
+ let(:timezone_offset) { nil }
+ let(:group_by) { 'day' }
+ let(:metric) { 'avg_first_response_time' }
+ let(:business_hours) { false }
+ let(:filter_type) { :account }
+ let(:filter_id) { '' }
+
+ before do
+ travel_to current_time
+ conversation.label_list.add(label.title)
+ conversation.save!
+ create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now,
+ conversation: conversation, inbox: inbox)
+ create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago)
+ create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago)
+ end
+
+ describe '#timeseries' do
+ it 'returns the correct values' do
+ timeseries_values = subject.timeseries
+
+ expect(timeseries_values).to eq(
+ [
+ { count: 1, timestamp: 1_603_065_600, value: 93.0 },
+ { count: 0, timestamp: 1_603_152_000, value: 0 },
+ { count: 0, timestamp: 1_603_238_400, value: 0 },
+ { count: 0, timestamp: 1_603_324_800, value: 0 },
+ { count: 0, timestamp: 1_603_411_200, value: 0 },
+ { count: 0, timestamp: 1_603_497_600, value: 0 },
+ { count: 0, timestamp: 1_603_584_000, value: 0 },
+ { count: 2, timestamp: 1_603_670_400, value: 90.0 }
+ ]
+ )
+ end
+
+ context 'when business hours is provided' do
+ let(:business_hours) { true }
+
+ it 'returns correct timeseries' do
+ timeseries_values = subject.timeseries
+
+ expect(timeseries_values).to eq(
+ [
+ { count: 1, timestamp: 1_603_065_600, value: 30.0 },
+ { count: 0, timestamp: 1_603_152_000, value: 0 },
+ { count: 0, timestamp: 1_603_238_400, value: 0 },
+ { count: 0, timestamp: 1_603_324_800, value: 0 },
+ { count: 0, timestamp: 1_603_411_200, value: 0 },
+ { count: 0, timestamp: 1_603_497_600, value: 0 },
+ { count: 0, timestamp: 1_603_584_000, value: 0 },
+ { count: 2, timestamp: 1_603_670_400, value: 15.0 }
+ ]
+ )
+ end
+ end
+
+ context 'when group_by is provided' do
+ let(:group_by) { 'week' }
+
+ it 'returns correct timeseries' do
+ timeseries_values = subject.timeseries
+ expect(timeseries_values).to eq(
+ [
+ { count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 },
+ { count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 }
+ ]
+ )
+ end
+ end
+
+ context 'when timezone offset is provided' do
+ let(:timezone_offset) { '5.5' }
+ let(:group_by) { 'week' }
+
+ it 'returns correct timeseries' do
+ timeseries_values = subject.timeseries
+ expect(timeseries_values).to eq(
+ [
+ { count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 },
+ { count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
+ ]
+ )
+ end
+ end
+
+ context 'when the label filter is applied' do
+ let(:group_by) { 'week' }
+ let(:filter_type) { 'label' }
+ let(:filter_id) { label.id }
+
+ it 'returns correct timeseries' do
+ timeseries_values = subject.timeseries
+ start_of_the_week = current_time.beginning_of_week(:sunday).to_i
+ last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
+ expect(timeseries_values).to eq(
+ [
+ { count: 0, timestamp: last_week_start_of_the_week, value: 0 },
+ { count: 1, timestamp: start_of_the_week, value: 80.0 }
+ ]
+ )
+ end
+ end
+
+ context 'when the inbox filter is applied' do
+ let(:group_by) { 'week' }
+ let(:filter_type) { 'inbox' }
+ let(:filter_id) { inbox.id }
+
+ it 'returns correct timeseries' do
+ timeseries_values = subject.timeseries
+ start_of_the_week = current_time.beginning_of_week(:sunday).to_i
+ last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
+ expect(timeseries_values).to eq(
+ [
+ { count: 0, timestamp: last_week_start_of_the_week, value: 0 },
+ { count: 1, timestamp: start_of_the_week, value: 80.0 }
+ ]
+ )
+ end
+ end
+
+ context 'when the team filter is applied' do
+ let(:group_by) { 'week' }
+ let(:filter_type) { 'team' }
+ let(:filter_id) { team.id }
+
+ it 'returns correct timeseries' do
+ timeseries_values = subject.timeseries
+ start_of_the_week = current_time.beginning_of_week(:sunday).to_i
+ last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
+ expect(timeseries_values).to eq(
+ [
+ { count: 0, timestamp: last_week_start_of_the_week, value: 0 },
+ { count: 1, timestamp: start_of_the_week, value: 80.0 }
+ ]
+ )
+ end
+ end
+ end
+
+ describe '#aggregate_value' do
+ context 'when there is no filter applied' do
+ it 'returns the correct average value' do
+ expect(subject.aggregate_value).to eq 91.0
+ end
+ end
+
+ context 'when rollups are enabled and the agent does not exist' do
+ let(:filter_type) { :agent }
+ let(:filter_id) { '999999' }
+ let(:timezone_offset) { '0' }
+
+ before do
+ account.update!(reporting_timezone: 'Etc/UTC')
+ allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
+ end
+
+ it 'raises record not found to preserve raw path behavior' do
+ expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+ end
+
+ describe 'count metrics' do
+ subject { described_class.new(account, params) }
+
+ let(:account) { create(:account) }
+ let(:account2) { create(:account) }
+ let(:user) { create(:user, email: 'agent1@example.com') }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:inbox2) { create(:inbox, account: account2) }
+ let(:current_time) { Time.current }
+
+ let(:params) do
+ {
+ type: 'agent',
+ metric: 'resolutions_count',
+ since: since_time.beginning_of_day.to_i.to_s,
+ until: current_time.end_of_day.to_i.to_s,
+ timezone_offset: timezone_offset,
+ group_by: group_by,
+ id: user.id.to_s
+ }
+ end
+ let(:group_by) { 'day' }
+ let(:since_time) { current_time - 1.day }
+ let(:timezone_offset) { nil }
+
+ before do
+ travel_to current_time
+
+ create(:account_user, account: account, user: user)
+ create(:account_user, account: account2, user: user)
+
+ conversation1 = create(:conversation, account: account, inbox: inbox, assignee: user)
+ conversation2 = create(:conversation, account: account, inbox: inbox, assignee: user)
+
+ conversation3 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
+ conversation4 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
+
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account: account,
+ user: user,
+ conversation: conversation1,
+ created_at: current_time - 12.hours)
+
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account: account,
+ user: user,
+ conversation: conversation2,
+ created_at: current_time - 6.hours)
+
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account: account2,
+ user: user,
+ conversation: conversation3,
+ created_at: current_time - 8.hours)
+
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account: account2,
+ user: user,
+ conversation: conversation4,
+ created_at: current_time - 4.hours)
+
+ conversation5 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
+ create(:reporting_event,
+ name: 'conversation_resolved',
+ account: account2,
+ user: user,
+ conversation: conversation5,
+ created_at: current_time - 2.hours)
+ end
+
+ describe '#aggregate_value' do
+ it 'returns only resolutions performed by the user in the specified account' do
+ expect(subject.aggregate_value).to eq(2)
+ end
+
+ context 'when rollups are enabled and the agent does not exist' do
+ let(:timezone_offset) { '0' }
+
+ let(:params) do
+ super().merge(id: '999999')
+ end
+
+ before do
+ account.update!(reporting_timezone: 'Etc/UTC')
+ allow(account).to receive(:feature_enabled?).with(:report_rollup).and_return(true)
+ end
+
+ it 'raises record not found to preserve raw path behavior' do
+ expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context 'when querying account2' do
+ subject { described_class.new(account2, params) }
+
+ it 'returns only resolutions for account2' do
+ expect(subject.aggregate_value).to eq(3)
+ end
+ end
+ end
+
+ describe '#timeseries' do
+ it 'filters resolutions by account' do
+ result = subject.timeseries
+ total_count = result.sum { |row| row[:value] }
+ expect(total_count).to eq(2)
+ end
+ end
+
+ describe 'account isolation' do
+ it 'does not leak data between accounts' do
+ account1_count = described_class.new(account, params).aggregate_value
+ account2_count = described_class.new(account2, params).aggregate_value
+
+ expect(account1_count).to eq(2)
+ expect(account2_count).to eq(3)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb
new file mode 100644
index 000000000..3dab5b60f
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb
@@ -0,0 +1,141 @@
+require 'rails_helper'
+
+RSpec.describe 'Article Bulk Actions API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es] }) }
+ let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
+ let!(:article_one) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
+ let!(:article_two) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
+ let!(:article_three) { create(:article, category: category, portal: portal, account: account, author: admin, status: :published) }
+
+ let(:base_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions" }
+
+ describe 'PATCH articles/bulk_actions/update_status' do
+ let(:update_status_url) { "#{base_url}/update_status" }
+
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ patch update_status_url, params: { ids: [article_one.id], status: 'published' }, as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as agent' do
+ it 'returns unauthorized' do
+ patch update_status_url,
+ headers: agent.create_new_auth_token,
+ params: { ids: [article_one.id], status: 'published' },
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as admin' do
+ it 'publishes multiple articles' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_two.id], status: 'published' },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(article_one.reload.status).to eq('published')
+ expect(article_two.reload.status).to eq('published')
+ end
+
+ it 'archives multiple articles' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_three.id], status: 'archived' },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(article_one.reload.status).to eq('archived')
+ expect(article_three.reload.status).to eq('archived')
+ end
+
+ it 'sets articles to draft' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_three.id], status: 'draft' },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(article_three.reload.status).to eq('draft')
+ end
+
+ it 'does not affect articles not in the list' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], status: 'published' },
+ as: :json
+
+ expect(article_one.reload.status).to eq('published')
+ expect(article_three.reload.status).to eq('published')
+ end
+
+ it 'returns unprocessable entity when no articles found' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [0], status: 'published' },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe 'DELETE articles/bulk_actions/delete_articles' do
+ let(:destroy_url) { "#{base_url}/delete_articles" }
+
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ delete destroy_url, params: { ids: [article_one.id] }, as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as agent' do
+ it 'returns unauthorized' do
+ delete destroy_url,
+ headers: agent.create_new_auth_token,
+ params: { ids: [article_one.id] },
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as admin' do
+ it 'deletes multiple articles' do
+ expect do
+ delete destroy_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_two.id] },
+ as: :json
+ end.to change(Article, :count).by(-2)
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'does not delete articles not in the list' do
+ delete destroy_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id] },
+ as: :json
+
+ expect(Article.exists?(article_one.id)).to be(false)
+ expect(Article.exists?(article_three.id)).to be(true)
+ end
+
+ it 'returns unprocessable entity when no articles found' do
+ delete destroy_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [0] },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb b/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb
index 38c66eeb0..696e1f53e 100644
--- a/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb
+++ b/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb
@@ -45,7 +45,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
- expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(account: account, params: params)
+ expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(
+ account: account,
+ params: params.merge(type: :agent)
+ )
expect(agent_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -96,7 +99,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
- expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(account: account, params: params)
+ expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(
+ account: account,
+ params: params.merge(type: :inbox)
+ )
expect(inbox_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -147,7 +153,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
- expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(account: account, params: params)
+ expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(
+ account: account,
+ params: params.merge(type: :team)
+ )
expect(team_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb
new file mode 100644
index 000000000..31f61d977
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb
@@ -0,0 +1,179 @@
+require 'rails_helper'
+
+RSpec.describe 'Article Bulk Actions API', type: :request do
+ include ActiveJob::TestHelper
+
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es fr] }) }
+ let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
+ let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') }
+ let!(:article_one) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) }
+ let!(:article_two) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) }
+
+ let(:translate_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions/translate" }
+
+ describe 'POST articles/bulk_actions/translate' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ post translate_url, params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as agent' do
+ it 'returns unauthorized' do
+ post translate_url,
+ headers: agent.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when captain is not enabled' do
+ it 'returns unprocessable entity' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'when authenticated as admin' do
+ before do
+ account.enable_features!('captain_tasks')
+ end
+
+ it 'enqueues translation jobs for each article' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_two.id], locale: 'es', category_id: category_es.id },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(2).times
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'enqueues job with correct arguments' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
+ account, article_one.id, 'es', category_es.id, admin
+ )
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns unprocessable entity for invalid locale' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'zh', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'returns unprocessable entity for invalid category' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: 0 },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'returns unprocessable entity when category locale does not match requested locale' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_en.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'enqueues job with nil category when category_id is omitted' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es' },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
+ account, article_one.id, 'es', nil, admin
+ )
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'enqueues job with nil category when category_id is blank' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: '' },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
+ account, article_one.id, 'es', nil, admin
+ )
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns unprocessable entity when no articles found' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [0], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ context 'when translations already exist' do
+ let!(:existing_translation) do
+ create(:article, portal: portal, category: category_es, account: account, author_id: admin.id,
+ locale: 'es', associated_article_id: article_one.id)
+ end
+
+ it 'returns conflict with duplicate articles' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:conflict)
+ body = response.parsed_body
+ expect(body['duplicate_articles'].length).to eq(1)
+ expect(body['duplicate_articles'].first['id']).to eq(existing_translation.id)
+ end
+
+ it 'does not enqueue jobs when duplicates found without force' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+ end.not_to have_enqueued_job(Captain::Articles::TranslateJob)
+ end
+
+ it 'enqueues jobs when force is true' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id, force: true },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(1).times
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/captain/articles/translate_job_spec.rb b/spec/enterprise/jobs/captain/articles/translate_job_spec.rb
new file mode 100644
index 000000000..119c93b1c
--- /dev/null
+++ b/spec/enterprise/jobs/captain/articles/translate_job_spec.rb
@@ -0,0 +1,134 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Articles::TranslateJob, type: :job do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account, role: :administrator) }
+ let!(:portal) { create(:portal, account: account, config: { allowed_locales: %w[en es] }) }
+ let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
+ let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') }
+ let!(:article) do
+ create(:article, portal: portal, category: category_en, account: account, author: user,
+ title: 'Getting Started', content: '# Welcome\nThis is a guide.')
+ end
+
+ let(:title_service) { instance_double(Captain::Llm::ArticleTranslationService) }
+ let(:content_service) { instance_double(Captain::Llm::ArticleTranslationService) }
+
+ before do
+ allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :title)).and_return(title_service)
+ allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :content)).and_return(content_service)
+ allow(title_service).to receive(:perform).and_return(message: 'Primeros pasos')
+ allow(content_service).to receive(:perform).and_return(message: '# Bienvenido\nEsta es una guía.')
+ end
+
+ it 'queues on the low queue' do
+ expect { described_class.perform_later(account, article.id, 'es', category_es.id, user) }
+ .to have_enqueued_job.on_queue('low')
+ end
+
+ it 'creates a translated article as draft' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+ end.to change(Article, :count).by(1)
+
+ translated = Article.last
+ expect(translated).to have_attributes(
+ title: 'Primeros pasos',
+ content: '# Bienvenido\nEsta es una guía.',
+ locale: 'es',
+ category_id: category_es.id,
+ author_id: user.id,
+ status: 'draft',
+ associated_article_id: article.id
+ )
+ end
+
+ it 'creates a translated article without a category when target_category_id is nil' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', nil, user)
+ end.to change(Article, :count).by(1)
+
+ translated = Article.last
+ expect(translated).to have_attributes(
+ title: 'Primeros pasos',
+ locale: 'es',
+ category_id: nil,
+ status: 'draft',
+ associated_article_id: article.id
+ )
+ end
+
+ it 'calls the translation service with the correct language' do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+
+ expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
+ account: account, text: 'Getting Started', target_language: 'Spanish', type: :title
+ )
+ expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
+ account: account, text: '# Welcome\nThis is a guide.', target_language: 'Spanish', type: :content
+ )
+ end
+
+ it 'uses language_map for locale name resolution' do
+ described_class.perform_now(account, article.id, 'pt_BR', category_es.id, user)
+
+ expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
+ hash_including(target_language: 'Portuguese (Brazil)', type: :title)
+ )
+ end
+
+ context 'when a translation already exists' do
+ let!(:existing_translation) do
+ create(:article, portal: portal, category: category_es, account: account, author: user,
+ title: 'Old title', content: 'Old content', locale: 'es',
+ associated_article_id: article.id)
+ end
+
+ it 'updates the existing translation instead of creating a new one' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+ end.not_to change(Article, :count)
+
+ existing_translation.reload
+ expect(existing_translation).to have_attributes(
+ title: 'Primeros pasos',
+ content: '# Bienvenido\nEsta es una guía.',
+ description: article.description
+ )
+ end
+ end
+
+ context 'when the source article has blank content' do
+ let!(:draft_article) do
+ create(:article, portal: portal, category: category_en, account: account, author: user,
+ title: 'Empty draft', content: nil, status: :draft)
+ end
+
+ it 'creates the translated article with the original blank content and skips the content LLM call' do
+ expect do
+ described_class.perform_now(account, draft_article.id, 'es', category_es.id, user)
+ end.to change(Article, :count).by(1)
+
+ expect(content_service).not_to have_received(:perform)
+ translated = Article.last
+ expect(translated).to have_attributes(
+ title: 'Primeros pasos',
+ content: nil,
+ locale: 'es',
+ associated_article_id: draft_article.id
+ )
+ end
+ end
+
+ context 'when translation service fails' do
+ before do
+ allow(title_service).to receive(:perform).and_return(error: 'LLM timeout')
+ end
+
+ it 'raises the error and does not create an article' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+ end.to raise_error(RuntimeError, /LLM timeout/).and not_change(Article, :count)
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
index c3e5eab1c..4d1a07aa7 100644
--- a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
@@ -12,9 +12,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
end
before do
- allow(Captain::Llm::FaqGeneratorService).to receive(:new)
- .with(document.content, document.account.locale_english_name, account_id: document.account_id)
- .and_return(faq_generator)
+ allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: document).and_return(faq_generator)
allow(faq_generator).to receive(:generate).and_return(faqs)
end
@@ -51,17 +49,14 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
let(:spanish_faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
before do
- allow(Captain::Llm::FaqGeneratorService).to receive(:new)
- .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
- .and_return(spanish_faq_generator)
+ allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: spanish_document).and_return(spanish_faq_generator)
allow(spanish_faq_generator).to receive(:generate).and_return(faqs)
end
- it 'passes the correct locale to FAQ generator' do
+ it 'passes the correct document to FAQ generator' do
described_class.new.perform(spanish_document)
- expect(Captain::Llm::FaqGeneratorService).to have_received(:new)
- .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
+ expect(Captain::Llm::FaqGeneratorService).to have_received(:new).with(document: spanish_document)
end
end
diff --git a/spec/enterprise/mailers/devise_mailer_spec.rb b/spec/enterprise/mailers/devise_mailer_spec.rb
index 286e863f7..61ce92047 100644
--- a/spec/enterprise/mailers/devise_mailer_spec.rb
+++ b/spec/enterprise/mailers/devise_mailer_spec.rb
@@ -8,12 +8,23 @@ RSpec.describe 'Devise::Mailer' do
let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
let(:inviter_val) { nil }
let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
+ let(:mail_body) { CGI.unescapeHTML(mail.body.to_s) }
before do
confirmable_user.update!(confirmed_at: nil)
confirmable_user.send(:generate_confirmation_token)
end
+ context 'when brand name is intentionally blank' do
+ before do
+ create(:installation_config, name: 'BRAND_NAME', value: '')
+ end
+
+ it 'preserves the blank brand override' do
+ expect(mail_body).not_to include('Chatwoot')
+ end
+ end
+
context 'with SAML enabled account' do
let(:saml_settings) { create(:account_saml_settings, account: account) }
@@ -21,12 +32,13 @@ RSpec.describe 'Devise::Mailer' do
context 'when user has no inviter' do
it 'shows standard welcome message without SSO references' do
- expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
- expect(mail.body).not_to match('via Single Sign-On')
+ expect(mail_body).to include('Confirm your email to get started')
+ expect(mail_body).to include('We just need to verify your email address before you can start using your account.')
+ expect(mail_body).not_to include('Single Sign-On (SSO)')
end
- it 'does not show activation instructions for SAML accounts' do
- expect(mail.body).not_to match('Please take a moment and click the link below and activate your account')
+ it 'shows the standard confirmation CTA' do
+ expect(mail_body).to include('Confirm my account')
end
it 'shows confirmation link' do
@@ -38,22 +50,21 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'mentions SSO invitation' do
- expect(mail.body).to match(
- "#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to access.*via Single Sign-On \\(SSO\\)"
- )
+ expect(mail_body).to include("You're invited to join #{account.name}")
+ expect(mail_body).to include("#{inviter_val.name} invited you to access the #{account.name} workspace on Chatwoot.")
end
it 'explains SSO authentication' do
- expect(mail.body).to match('Your organization uses SSO for secure authentication')
- expect(mail.body).to match('You will not need a password to access your account')
+ expect(mail_body).to include("Your organization uses Single Sign-On (SSO), so you won't need to create a separate password.")
end
it 'does not show standard invitation message' do
- expect(mail.body).not_to match('has invited you to try out')
+ expect(mail_body).not_to include('invited you to join')
+ expect(mail_body).not_to include('Accept invitation')
end
it 'directs to SSO portal instead of password reset' do
- expect(mail.body).to match('You can access your account by logging in through your organization\'s SSO portal')
+ expect(mail_body).to include("Use your organization's SSO portal")
expect(mail.body).not_to include('app/auth/password/edit')
end
end
@@ -66,7 +77,9 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows SSO login instructions' do
- expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
+ expect(mail_body).to include('Your access is ready')
+ expect(mail_body).to include("Sign in with your organization's SSO")
+ expect(mail_body).to include("Use your organization's Single Sign-On (SSO) portal to access")
expect(mail.body).not_to include('/auth/sign_in')
end
end
@@ -79,6 +92,7 @@ RSpec.describe 'Devise::Mailer' do
end
it 'still shows confirmation link for email verification' do
+ expect(mail_body).to include('Confirm your new email address')
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
@@ -90,7 +104,8 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows SSO login instructions instead of regular login' do
- expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
+ expect(mail_body).to include('Your access is ready')
+ expect(mail_body).to include("Sign in with your organization's SSO")
expect(mail.body).not_to include('/auth/sign_in')
end
end
@@ -101,9 +116,10 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'shows standard invitation without SSO references' do
- expect(mail.body).to match('has invited you to try out Chatwoot')
- expect(mail.body).not_to match('via Single Sign-On')
- expect(mail.body).not_to match('SSO portal')
+ expect(mail_body).to include("You're invited to join #{account.name}")
+ expect(mail_body).to include("#{inviter_val.name} invited you to join the #{account.name} workspace on")
+ expect(mail_body).not_to include('Single Sign-On (SSO)')
+ expect(mail_body).not_to include("Use your organization's SSO portal")
end
it 'shows password reset link' do
@@ -112,9 +128,10 @@ RSpec.describe 'Devise::Mailer' do
end
context 'when user has no inviter' do
- it 'shows standard welcome message and activation instructions' do
- expect(mail.body).to match('We have a suite of powerful tools ready for you to explore')
- expect(mail.body).to match('Please take a moment and click the link below and activate your account')
+ it 'shows the standard confirmation state' do
+ expect(mail_body).to include('Confirm your email to get started')
+ expect(mail_body).to include('We just need to verify your email address before you can start using your account.')
+ expect(mail_body).to include('Confirm my account')
end
it 'shows confirmation link' do
@@ -130,8 +147,9 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows regular login link' do
+ expect(mail_body).to include('Your account is ready')
expect(mail.body).to include('/auth/sign_in')
- expect(mail.body).not_to match('SSO portal')
+ expect(mail_body).not_to include('SSO portal')
end
end
@@ -141,6 +159,7 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows confirmation link for email verification' do
+ expect(mail_body).to include('Confirm your new email address')
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
diff --git a/spec/enterprise/models/captain/document_spec.rb b/spec/enterprise/models/captain/document_spec.rb
index e6543e0d7..af6c1cb58 100644
--- a/spec/enterprise/models/captain/document_spec.rb
+++ b/spec/enterprise/models/captain/document_spec.rb
@@ -55,6 +55,11 @@ RSpec.describe Captain::Document, type: :model do
expect(doc.pdf_document?).to be true
end
+ it 'returns true for PDF:-prefixed external links even when the blob is missing' do
+ doc = build(:captain_document, external_link: 'PDF: report_20250101120000')
+ expect(doc.pdf_document?).to be true
+ end
+
it 'returns false for non-PDF documents' do
doc = build(:captain_document, external_link: 'https://example.com')
expect(doc.pdf_document?).to be false
diff --git a/spec/enterprise/services/captain/llm/article_translation_service_spec.rb b/spec/enterprise/services/captain/llm/article_translation_service_spec.rb
new file mode 100644
index 000000000..1c0d83b65
--- /dev/null
+++ b/spec/enterprise/services/captain/llm/article_translation_service_spec.rb
@@ -0,0 +1,67 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Llm::ArticleTranslationService do
+ let(:account) { create(:account) }
+ let(:target_language) { 'Spanish' }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ describe '#perform with type: :title' do
+ let(:service) do
+ described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title)
+ end
+
+ it 'returns the stripped translated title' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to include('professional translator')
+ expect(args[:messages][0][:content]).to include(target_language)
+ expect(args[:messages][1][:content]).to eq('Getting Started')
+ { message: " Primeros pasos \n" }
+ end
+
+ expect(service.perform).to include(message: 'Primeros pasos')
+ end
+ end
+
+ describe '#perform with type: :content' do
+ let(:content) { "# Welcome\nSome markdown." }
+ let(:service) do
+ described_class.new(account: account, text: content, target_language: target_language, type: :content)
+ end
+
+ it 'returns the stripped translated content using the markdown system prompt' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to include('markdown')
+ expect(args[:messages][0][:content]).to include('Preserve ALL HTML tags')
+ expect(args[:messages][1][:content]).to eq(content)
+ { message: "# Bienvenido\nAlgo de markdown.\n" }
+ end
+
+ expect(service.perform).to include(message: "# Bienvenido\nAlgo de markdown.")
+ end
+ end
+
+ describe '#perform with an invalid type' do
+ it 'raises ArgumentError' do
+ service = described_class.new(account: account, text: 'hi', target_language: target_language, type: :invalid)
+
+ expect { service.perform }.to raise_error(ArgumentError, /Invalid type/)
+ end
+ end
+
+ describe '#perform when the API call fails' do
+ let(:service) do
+ described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title)
+ end
+
+ it 'returns the error hash unchanged' do
+ allow(service).to receive(:make_api_call).and_return(error: 'LLM timeout', error_code: 500)
+
+ expect(service.perform).to eq(error: 'LLM timeout', error_code: 500)
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
index 003d5b715..ff7138c9a 100644
--- a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
@@ -2,8 +2,8 @@ require 'rails_helper'
RSpec.describe Captain::Llm::FaqGeneratorService do
let(:content) { 'Sample content for FAQ generation' }
- let(:language) { 'english' }
- let(:service) { described_class.new(content, language) }
+ let(:document) { create(:captain_document, content: content) }
+ let(:service) { described_class.new(document: document) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:sample_faqs) do
[
@@ -36,14 +36,15 @@ RSpec.describe Captain::Llm::FaqGeneratorService do
service.generate
end
- it 'uses SystemPromptsService with the specified language' do
- expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language).at_least(:once).and_call_original
+ it 'uses SystemPromptsService with the account language' do
+ account_language = document.account.locale_english_name
+ expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(account_language).at_least(:once).and_call_original
service.generate
end
end
context 'with different language' do
- let(:language) { 'spanish' }
+ before { allow(document.account).to receive(:locale_english_name).and_return('spanish') }
it 'passes the correct language to SystemPromptsService' do
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish').at_least(:once).and_call_original
diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
index d6563b163..b46633a6e 100644
--- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
@@ -58,9 +58,9 @@ RSpec.describe Captain::Tools::FirecrawlService do
limit: crawl_limit,
webhook: webhook_url,
scrapeOptions: {
- onlyMainContent: false,
+ onlyMainContent: true,
formats: ['markdown'],
- excludeTags: ['iframe']
+ excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
}
}.to_json
end
diff --git a/spec/finders/email_channel_finder_spec.rb b/spec/finders/email_channel_finder_spec.rb
index d56d97008..06fe5c968 100644
--- a/spec/finders/email_channel_finder_spec.rb
+++ b/spec/finders/email_channel_finder_spec.rb
@@ -83,7 +83,7 @@ describe EmailChannelFinder do
reply_mail.mail['bcc'] = 'test@example.com'
# Configure other account IDs but not this one
- other_account_ids = [123, 456, 789]
+ other_account_ids = [channel_email.account_id + 1, channel_email.account_id + 2, channel_email.account_id + 3]
allow(GlobalConfigService).to receive(:load)
.with('SKIP_INCOMING_BCC_PROCESSING', '')
.and_return(other_account_ids.join(','))
diff --git a/spec/fixtures/data_import/with_bom.csv b/spec/fixtures/data_import/with_bom.csv
new file mode 100644
index 000000000..8b1850620
--- /dev/null
+++ b/spec/fixtures/data_import/with_bom.csv
@@ -0,0 +1,2 @@
+name,email,phone_number
+Ahmed,ahmed@example.com,+971501234567
diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb
index 9b9d74675..561015ea7 100644
--- a/spec/jobs/account/contacts_export_job_spec.rb
+++ b/spec/jobs/account/contacts_export_job_spec.rb
@@ -85,6 +85,13 @@ RSpec.describe Account::ContactsExportJob do
expect(phone_numbers).to include('+910808080818', '+910808080808')
end
+ it 'prepends UTF-8 BOM to the exported CSV for spreadsheet compatibility' do
+ described_class.perform_now(account.id, user.id, [], {})
+
+ raw = account.contacts_export.download
+ expect(raw.bytes[0..2]).to eq([0xEF, 0xBB, 0xBF])
+ end
+
it 'returns all resolved contacts as results when filter is not prvoided' do
create(:contact, account: account, email: nil, phone_number: nil)
described_class.perform_now(account.id, user.id, %w[id name email column_not_present], {})
diff --git a/spec/jobs/avatar/avatar_from_url_job_spec.rb b/spec/jobs/avatar/avatar_from_url_job_spec.rb
index 8db3769ad..e85c46390 100644
--- a/spec/jobs/avatar/avatar_from_url_job_spec.rb
+++ b/spec/jobs/avatar/avatar_from_url_job_spec.rb
@@ -1,9 +1,13 @@
require 'rails_helper'
RSpec.describe Avatar::AvatarFromUrlJob do
- let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
let(:valid_url) { 'https://example.com/avatar.png' }
+ before do
+ allow(Resolv).to receive(:getaddresses).and_call_original
+ allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
+ end
+
it 'enqueues the job' do
contact = create(:contact)
expect { described_class.perform_later(contact, 'https://example.com/avatar.png') }
@@ -14,7 +18,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
let(:avatarable) { create(:contact) }
it 'attaches and updates sync attributes' do
- expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
+ stub_request(:get, valid_url)
+ .to_return(
+ status: 200,
+ body: File.read(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
described_class.perform_now(avatarable, valid_url)
avatarable.reload
expect(avatarable.avatar).to be_attached
@@ -22,10 +32,71 @@ RSpec.describe Avatar::AvatarFromUrlJob do
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
end
+ it 'attaches webp avatars and updates sync attributes' do
+ webp_url = 'https://example.com/avatar.webp'
+
+ stub_request(:get, webp_url)
+ .to_return(
+ status: 200,
+ body: File.read(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/webp' }
+ )
+
+ described_class.perform_now(avatarable, webp_url)
+ avatarable.reload
+
+ expect(avatarable.avatar).to be_attached
+ expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(webp_url))
+ expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
+ end
+
+ it 'attaches avatars with parameterized content type headers' do
+ parameterized_url = 'https://example.com/avatar-parameterized.png'
+
+ stub_request(:get, parameterized_url)
+ .to_return(
+ status: 200,
+ body: File.read(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'IMAGE/PNG; charset=binary' }
+ )
+
+ described_class.perform_now(avatarable, parameterized_url)
+ avatarable.reload
+
+ expect(avatarable.avatar).to be_attached
+ expect(avatarable.avatar.blob.content_type).to eq('image/png')
+ expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(parameterized_url))
+ end
+
+ it 'attaches avatars from URLs with embedded basic auth credentials' do
+ authenticated_url = 'https://user:pass@example.com/avatar-authenticated.png'
+
+ stub_request(:get, 'https://example.com/avatar-authenticated.png')
+ .with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNz' })
+ .to_return(
+ status: 200,
+ body: File.read(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
+ described_class.perform_now(avatarable, authenticated_url)
+ avatarable.reload
+
+ expect(avatarable.avatar).to be_attached
+ expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(authenticated_url))
+ end
+
it 'returns early when rate limited' do
ts = 30.seconds.ago.iso8601
avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts })
- expect(Down).not_to receive(:download)
+
+ stub_request(:get, valid_url)
+ .to_return(
+ status: 200,
+ body: File.read(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
described_class.perform_now(avatarable, valid_url)
avatarable.reload
expect(avatarable.avatar).not_to be_attached
@@ -33,21 +104,29 @@ RSpec.describe Avatar::AvatarFromUrlJob do
expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at']))
.to be > Time.zone.parse(ts)
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
+ expect(WebMock).not_to have_requested(:get, valid_url)
end
it 'returns early when hash unchanged' do
avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) })
- expect(Down).not_to receive(:download)
+
+ stub_request(:get, valid_url)
+ .to_return(
+ status: 200,
+ body: File.read(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
described_class.perform_now(avatarable, valid_url)
expect(avatarable.avatar).not_to be_attached
avatarable.reload
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
+ expect(WebMock).not_to have_requested(:get, valid_url)
end
it 'updates sync attributes even when URL is invalid' do
invalid_url = 'invalid_url'
- expect(Down).not_to receive(:download)
described_class.perform_now(avatarable, invalid_url)
avatarable.reload
expect(avatarable.avatar).not_to be_attached
@@ -56,17 +135,12 @@ RSpec.describe Avatar::AvatarFromUrlJob do
end
it 'updates sync attributes when file download is valid but content type is unsupported' do
- temp_file = Tempfile.new(['invalid', '.xml'])
- temp_file.write('content')
- temp_file.rewind
-
- uploaded = ActionDispatch::Http::UploadedFile.new(
- tempfile: temp_file,
- filename: 'invalid.xml',
- type: 'application/xml'
- )
-
- expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(uploaded)
+ stub_request(:get, valid_url)
+ .to_return(
+ status: 200,
+ body: 'content',
+ headers: { 'Content-Type' => 'application/xml' }
+ )
described_class.perform_now(avatarable, valid_url)
avatarable.reload
@@ -74,9 +148,19 @@ RSpec.describe Avatar::AvatarFromUrlJob do
expect(avatarable.avatar).not_to be_attached
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
+ end
- temp_file.close
- temp_file.unlink
+ it 'updates sync attributes when the avatar URL is blocked by SSRF protection' do
+ blocked_url = 'http://127.0.0.1/avatar.png'
+
+ expect do
+ described_class.perform_now(avatarable, blocked_url)
+ end.not_to raise_error
+
+ avatarable.reload
+ expect(avatarable.avatar).not_to be_attached
+ expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
+ expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(blocked_url))
end
end
@@ -84,7 +168,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
let(:avatarable) { create(:agent_bot) }
it 'downloads and attaches avatar' do
- expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
+ stub_request(:get, valid_url)
+ .to_return(
+ status: 200,
+ body: File.read(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
described_class.perform_now(avatarable, valid_url)
expect(avatarable.avatar).to be_attached
end
@@ -93,22 +183,30 @@ RSpec.describe Avatar::AvatarFromUrlJob do
# ref: https://github.com/chatwoot/chatwoot/issues/10449
it 'does not raise error when downloaded file has no filename (invalid content)' do
contact = create(:contact)
- temp_file = Tempfile.new(['invalid', '.xml'])
- temp_file.write('content')
- temp_file.rewind
+ invalid_file = Tempfile.new('avatar-without-name')
- expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE)
- .and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml'))
+ allow(SafeFetch).to receive(:fetch)
+ .with(
+ valid_url,
+ max_bytes: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE,
+ allowed_content_type_prefixes: [],
+ allowed_content_types: Avatar::AvatarFromUrlJob::ALLOWED_CONTENT_TYPES
+ ).and_yield(
+ SafeFetch::Result.new(
+ tempfile: invalid_file,
+ filename: nil,
+ content_type: 'image/png'
+ )
+ )
expect { described_class.perform_now(contact, valid_url) }.not_to raise_error
-
- temp_file.close
- temp_file.unlink
+ expect(contact.reload.avatar).not_to be_attached
+ ensure
+ invalid_file.close!
end
it 'skips sync attribute updates when URL is nil' do
contact = create(:contact)
- expect(Down).not_to receive(:download)
expect { described_class.perform_now(contact, nil) }.not_to raise_error
diff --git a/spec/jobs/data_import_job_spec.rb b/spec/jobs/data_import_job_spec.rb
index 76c1908f9..618274a85 100644
--- a/spec/jobs/data_import_job_spec.rb
+++ b/spec/jobs/data_import_job_spec.rb
@@ -102,6 +102,20 @@ RSpec.describe DataImportJob do
expect(invalid_data_import.account.contacts.first.name).to eq(csv_data[0]['name'].encode('UTF-8', 'binary', invalid: :replace,
undef: :replace, replace: ''))
end
+
+ it 'will strip UTF-8 BOM and import contacts correctly' do
+ bom_data_import = create(:data_import,
+ import_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/data_import/with_bom.csv'),
+ 'text/csv'))
+
+ described_class.perform_now(bom_data_import)
+ expect(bom_data_import.account.contacts.count).to eq(1)
+
+ contact = bom_data_import.account.contacts.first
+ expect(contact.name).to eq('Ahmed')
+ expect(contact.email).to eq('ahmed@example.com')
+ expect(contact.phone_number).to eq('+971501234567')
+ end
end
context 'when the data contains existing records' do
diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb
index 262e78daf..f8bdae4be 100644
--- a/spec/lib/base_markdown_renderer_spec.rb
+++ b/spec/lib/base_markdown_renderer_spec.rb
@@ -12,7 +12,7 @@ describe BaseMarkdownRenderer do
context 'when image has a height' do
it 'renders the img tag with the correct attributes' do
markdown = ''
- expect(render_markdown(markdown)).to include('
')
+ expect(render_markdown(markdown)).to include('
')
end
end
diff --git a/spec/lib/safe_fetch_spec.rb b/spec/lib/safe_fetch_spec.rb
index 70f9b05de..e2c513587 100644
--- a/spec/lib/safe_fetch_spec.rb
+++ b/spec/lib/safe_fetch_spec.rb
@@ -65,58 +65,75 @@ RSpec.describe SafeFetch do
end
end
+ context 'with embedded basic auth credentials' do
+ it 'passes decoded credentials to the request' do
+ authenticated_url = 'http://user+avatar%40example.com:p%40ss+word%3A1@example.com/image.png'
+ stub_request(:get, url)
+ .with(headers: { 'Authorization' => 'Basic dXNlcithdmF0YXJAZXhhbXBsZS5jb206cEBzcyt3b3JkOjE=' })
+ .to_return(
+ status: 200,
+ body: File.new(Rails.root.join('spec/assets/avatar.png')),
+ headers: { 'Content-Type' => 'image/png' }
+ )
+
+ described_class.fetch(authenticated_url) do |result|
+ expect(result.content_type).to eq('image/png')
+ end
+ end
+ end
+
context 'with URL validation' do
it 'raises InvalidUrlError for javascript: URLs' do
expect { described_class.fetch('javascript:alert(1)') { nil } }
- .to raise_error(SafeFetch::InvalidUrlError)
+ .to raise_error(described_class::InvalidUrlError)
end
it 'raises InvalidUrlError for mailto: URLs' do
expect { described_class.fetch('mailto:test@example.com') { nil } }
- .to raise_error(SafeFetch::InvalidUrlError)
+ .to raise_error(described_class::InvalidUrlError)
end
it 'raises InvalidUrlError for data: URLs' do
expect { described_class.fetch('data:text/html,') { nil } }
- .to raise_error(SafeFetch::InvalidUrlError)
+ .to raise_error(described_class::InvalidUrlError)
end
it 'raises InvalidUrlError for ftp: URLs' do
expect { described_class.fetch('ftp://example.com/file') { nil } }
- .to raise_error(SafeFetch::InvalidUrlError)
+ .to raise_error(described_class::InvalidUrlError)
end
it 'raises InvalidUrlError for malformed URLs' do
expect { described_class.fetch('not_a_url') { nil } }
- .to raise_error(SafeFetch::InvalidUrlError)
+ .to raise_error(described_class::InvalidUrlError)
end
it 'raises InvalidUrlError when host is missing' do
expect { described_class.fetch('http:///path') { nil } }
- .to raise_error(SafeFetch::InvalidUrlError, /missing host/)
+ .to raise_error(described_class::InvalidUrlError, /missing host/)
end
end
context 'with SSRF protection (integration with ssrf_filter)' do
it 'raises UnsafeUrlError for private IP literals (10.x.x.x)' do
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }
- .to raise_error(SafeFetch::UnsafeUrlError)
+ .to raise_error(described_class::UnsafeUrlError)
end
it 'raises UnsafeUrlError for loopback addresses' do
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }
- .to raise_error(SafeFetch::UnsafeUrlError)
+ .to raise_error(described_class::UnsafeUrlError)
end
it 'raises UnsafeUrlError for AWS metadata IP (169.254.169.254)' do
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }
- .to raise_error(SafeFetch::UnsafeUrlError)
+ .to raise_error(described_class::UnsafeUrlError)
end
it 'raises UnsafeUrlError when hostname resolves to a private IP (DNS rebinding)' do
allow(Resolv).to receive(:getaddresses).with('evil.example.com').and_return(['10.0.0.1'])
expect { described_class.fetch('http://evil.example.com/secret') { nil } }
- .to raise_error(SafeFetch::UnsafeUrlError)
+ .to raise_error(described_class::UnsafeUrlError)
end
end
@@ -129,7 +146,7 @@ RSpec.describe SafeFetch do
)
expect { described_class.fetch(url) { nil } }
- .to raise_error(SafeFetch::UnsupportedContentTypeError)
+ .to raise_error(described_class::UnsupportedContentTypeError)
end
it 'rejects application/octet-stream responses' do
@@ -140,7 +157,7 @@ RSpec.describe SafeFetch do
)
expect { described_class.fetch(url) { nil } }
- .to raise_error(SafeFetch::UnsupportedContentTypeError)
+ .to raise_error(described_class::UnsupportedContentTypeError)
end
it 'allows video/mp4 responses' do
@@ -153,21 +170,56 @@ RSpec.describe SafeFetch do
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
- it 'strips charset/boundary parameters before comparing' do
+ it 'normalizes parameters and casing before yielding content_type' do
stub_request(:get, url).to_return(
status: 200,
body: 'x',
- headers: { 'Content-Type' => 'image/png; charset=binary' }
+ headers: { 'Content-Type' => 'IMAGE/PNG; charset=binary' }
)
- expect { described_class.fetch(url) { nil } }.not_to raise_error
+ described_class.fetch(url) do |result|
+ expect(result.content_type).to eq('image/png')
+ end
+ end
+
+ it 'allows exact content-type matches when prefixes are empty' do
+ pdf_url = 'http://example.com/file.pdf'
+ stub_request(:get, pdf_url).to_return(
+ status: 200,
+ body: 'pdf-data',
+ headers: { 'Content-Type' => 'application/pdf' }
+ )
+
+ expect do
+ described_class.fetch(
+ pdf_url,
+ allowed_content_type_prefixes: [],
+ allowed_content_types: ['application/pdf']
+ ) { nil }
+ end.not_to raise_error
+ end
+
+ it 'rejects exact content-type mismatches when prefixes are empty' do
+ stub_request(:get, url).to_return(
+ status: 200,
+ body: 'x',
+ headers: { 'Content-Type' => 'image/webp' }
+ )
+
+ expect do
+ described_class.fetch(
+ url,
+ allowed_content_type_prefixes: [],
+ allowed_content_types: ['image/png']
+ ) { nil }
+ end.to raise_error(described_class::UnsupportedContentTypeError)
end
it 'rejects when the content-type header is missing' do
stub_request(:get, url).to_return(status: 200, body: 'x', headers: {})
expect { described_class.fetch(url) { nil } }
- .to raise_error(SafeFetch::UnsupportedContentTypeError)
+ .to raise_error(described_class::UnsupportedContentTypeError)
end
end
@@ -180,7 +232,7 @@ RSpec.describe SafeFetch do
)
expect { described_class.fetch(url, max_bytes: 2) { nil } }
- .to raise_error(SafeFetch::FileTooLargeError)
+ .to raise_error(described_class::FileTooLargeError)
end
it 'reads the default cap from GlobalConfigService MAXIMUM_FILE_UPLOAD_SIZE (matching Attachment#validate_file_size)' do
@@ -195,7 +247,7 @@ RSpec.describe SafeFetch do
)
expect { described_class.fetch(url) { nil } }
- .to raise_error(SafeFetch::FileTooLargeError)
+ .to raise_error(described_class::FileTooLargeError)
end
it 'falls back to 40 MB when GlobalConfigService returns a non-positive value' do
@@ -234,14 +286,14 @@ RSpec.describe SafeFetch do
stub_request(:get, url).to_raise(Net::ReadTimeout)
expect { described_class.fetch(url) { nil } }
- .to raise_error(SafeFetch::FetchError)
+ .to raise_error(described_class::FetchError)
end
it 'maps SocketError to FetchError' do
stub_request(:get, url).to_raise(SocketError.new('connection refused'))
expect { described_class.fetch(url) { nil } }
- .to raise_error(SafeFetch::FetchError)
+ .to raise_error(described_class::FetchError)
end
end
@@ -250,7 +302,7 @@ RSpec.describe SafeFetch do
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
expect { described_class.fetch(url) { nil } }
- .to raise_error(SafeFetch::HttpError, /404/)
+ .to raise_error(described_class::HttpError, /404/)
end
end
end
diff --git a/spec/mailers/confirmation_instructions_spec.rb b/spec/mailers/confirmation_instructions_spec.rb
index 484001957..b82202c51 100644
--- a/spec/mailers/confirmation_instructions_spec.rb
+++ b/spec/mailers/confirmation_instructions_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe 'Devise::Mailer' do
let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
let(:inviter_val) { nil }
let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
+ let(:mail_body) { CGI.unescapeHTML(mail.body.to_s) }
before do
# to verify the token in email
@@ -22,12 +23,26 @@ RSpec.describe 'Devise::Mailer' do
end
it 'uses the user\'s name' do
- expect(mail.body).to match("Hi #{CGI.escapeHTML(confirmable_user.name)},")
+ expect(mail.body.to_s).to include("Hi #{CGI.escapeHTML(confirmable_user.name)},")
+ expect(mail_body).to include("Hi #{confirmable_user.name},")
end
- it 'does not refer to the inviter and their account' do
- expect(mail.body).not_to match('has invited you to try out Chatwoot!')
- expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
+ context 'when the user name contains HTML' do
+ before do
+ confirmable_user.update!(name: 'Sony ')
+ end
+
+ it 'escapes the name in the rendered email body' do
+ expect(mail.body.to_s).to include("Hi #{CGI.escapeHTML(confirmable_user.name)},")
+ expect(mail.body.to_s).not_to include("Hi #{confirmable_user.name},")
+ end
+ end
+
+ it 'shows the default confirmation state' do
+ expect(mail_body).to include('Confirm your email to get started')
+ expect(mail_body).to include('Welcome to Chatwoot. We just need to verify your email address before you can start using your account.')
+ expect(mail_body).to include('Confirm my account')
+ expect(mail_body).not_to include('Workspace invitation')
end
it 'sends a confirmation link' do
@@ -39,10 +54,10 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'refers to the inviter and their account' do
- expect(mail.body).to match(
- "#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to try out Chatwoot."
- )
- expect(mail.body).not_to match('We have a suite of powerful tools ready for you to explore.')
+ expect(mail_body).to include("You're invited to join #{account.name}")
+ expect(mail_body).to include("#{inviter_val.name} invited you to join the #{account.name} workspace on Chatwoot.")
+ expect(mail_body).to include('Accept invitation')
+ expect(mail_body).not_to include('Confirm your email to get started')
end
it 'sends a password reset link' do
@@ -58,7 +73,10 @@ RSpec.describe 'Devise::Mailer' do
it 'sends a confirmation link' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
+ confirmation_body = CGI.unescapeHTML(confirmation_mail.body.to_s)
+ expect(confirmation_body).to include('Confirm your new email address')
+ expect(confirmation_body).to include('New email')
expect(confirmation_mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmation_mail.body).not_to include('app/auth/password/edit')
expect(confirmable_user.unconfirmed_email.blank?).to be false
@@ -73,7 +91,9 @@ RSpec.describe 'Devise::Mailer' do
it 'sends a confirmation link' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
+ confirmation_body = CGI.unescapeHTML(confirmation_mail.body.to_s)
+ expect(confirmation_body).to include('Confirm your new email address')
expect(confirmation_mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmation_mail.body).not_to include('app/auth/password/edit')
expect(confirmable_user.unconfirmed_email.blank?).to be false
@@ -88,6 +108,10 @@ RSpec.describe 'Devise::Mailer' do
it 'send instructions with the link to login' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
+ confirmation_body = CGI.unescapeHTML(confirmation_mail.body.to_s)
+
+ expect(confirmation_body).to include('Your account is ready')
+ expect(confirmation_body).to include('Open my account')
expect(confirmation_mail.body).to include('/auth/sign_in')
end
end
diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb
index f21a81978..159454eec 100644
--- a/spec/models/contact_spec.rb
+++ b/spec/models/contact_spec.rb
@@ -16,6 +16,13 @@ RSpec.describe Contact do
describe 'concerns' do
it_behaves_like 'avatarable'
+
+ it 'accepts webp avatars' do
+ contact = build(:contact, account: create(:account))
+ contact.avatar.attach(get_blob_for(Rails.root.join('spec/assets/avatar.png'), 'image/webp'))
+
+ expect(contact).to be_valid
+ end
end
context 'when prepare contact attributes before validation' do
diff --git a/spec/presenters/messages/search_data_presenter_spec.rb b/spec/presenters/messages/search_data_presenter_spec.rb
index 7b3d4acb9..48faec12a 100644
--- a/spec/presenters/messages/search_data_presenter_spec.rb
+++ b/spec/presenters/messages/search_data_presenter_spec.rb
@@ -58,6 +58,35 @@ RSpec.describe Messages::SearchDataPresenter do
end
end
+ context 'when the message has no email subject but the conversation has a mail_subject' do
+ before do
+ conversation.update!(additional_attributes: { 'mail_subject' => 'Conversation Subject' })
+ end
+
+ it 'falls back to the conversation mail_subject' do
+ content_attrs = presenter.search_data[:content_attributes]
+ expect(content_attrs[:email][:subject]).to eq('Conversation Subject')
+ end
+ end
+
+ context 'when both the message email subject and conversation mail_subject are set' do
+ before do
+ conversation.update!(additional_attributes: { 'mail_subject' => 'Conversation subject' })
+ message.update!(content_attributes: { email: { subject: 'Message subject' } })
+ end
+
+ it 'prefers the message-level email subject' do
+ content_attrs = presenter.search_data[:content_attributes]
+ expect(content_attrs[:email][:subject]).to eq('Message subject')
+ end
+ end
+
+ context 'when neither message nor conversation has an email subject' do
+ it 'omits the email subject from content_attributes' do
+ expect(presenter.search_data[:content_attributes]).to eq({})
+ end
+ end
+
context 'with campaign and automation data' do
before do
message.update(
diff --git a/spec/services/reports/report_metric_registry_spec.rb b/spec/services/reports/report_metric_registry_spec.rb
new file mode 100644
index 000000000..33fcb41e5
--- /dev/null
+++ b/spec/services/reports/report_metric_registry_spec.rb
@@ -0,0 +1,74 @@
+require 'rails_helper'
+
+RSpec.describe Reports::ReportMetricRegistry do
+ describe '.fetch' do
+ it 'returns the definition for raw-only count metrics' do
+ metric = described_class.fetch(:conversations_count)
+
+ expect(metric.name).to eq(:conversations_count)
+ expect(metric.count?).to be(true)
+ expect(metric.rollup_supported?).to be(false)
+ expect(metric.raw_event_name).to be_nil
+ end
+
+ it 'returns the definition for avg_resolution_time' do
+ metric = described_class.fetch(:avg_resolution_time)
+
+ expect(metric.name).to eq(:avg_resolution_time)
+ expect(metric.average?).to be(true)
+ expect(metric.raw_event_name).to eq(:conversation_resolved)
+ expect(metric.rollup_metric).to eq(:resolution_time)
+ expect(metric.summary_key).to eq(:avg_resolution_time)
+ end
+
+ it 'locks the distinct conversation strategy for bot_handoffs_count' do
+ metric = described_class.fetch(:bot_handoffs_count)
+
+ expect(metric.count?).to be(true)
+ expect(metric.raw_event_name).to eq(:conversation_bot_handoff)
+ expect(metric.rollup_metric).to eq(:bot_handoffs_count)
+ expect(metric.raw_count_strategy).to eq(:distinct_conversation)
+ end
+
+ it 'returns nil for unsupported metrics' do
+ expect(described_class.fetch(:unknown_metric)).to be_nil
+ end
+ end
+
+ describe '.supported?' do
+ it 'returns true for supported raw-only metrics' do
+ expect(described_class.supported?(:conversations_count)).to be(true)
+ end
+
+ it 'returns false for unsupported metrics' do
+ expect(described_class.supported?(:unknown_metric)).to be(false)
+ end
+ end
+
+ describe '.rollup_supported?' do
+ it 'returns true for rollup-backed metrics' do
+ expect(described_class.rollup_supported?(:reply_time)).to be(true)
+ end
+
+ it 'returns false for raw-only metrics' do
+ expect(described_class.rollup_supported?(:conversations_count)).to be(false)
+ end
+ end
+
+ describe '.summary_metrics' do
+ it 'returns the summary metric definitions in registry order' do
+ expect(
+ described_class.summary_metrics.map do |metric|
+ [metric.name, metric.summary_key, metric.aggregate, metric.raw_event_name, metric.rollup_metric]
+ end
+ ).to eq(
+ [
+ [:resolutions_count, :resolved_conversations_count, :count, :conversation_resolved, :resolutions_count],
+ [:avg_resolution_time, :avg_resolution_time, :average, :conversation_resolved, :resolution_time],
+ [:avg_first_response_time, :avg_first_response_time, :average, :first_response, :first_response],
+ [:reply_time, :avg_reply_time, :average, :reply_time, :reply_time]
+ ]
+ )
+ end
+ end
+end
diff --git a/theme/icons.js b/theme/icons.js
index 37b0121e4..18d6c47f3 100644
--- a/theme/icons.js
+++ b/theme/icons.js
@@ -148,10 +148,59 @@ export const icons = {
width: 7,
height: 11,
},
+ 'empty-assignee': {
+ body: `
+ `,
+ width: 24,
+ height: 24,
+ },
+ party: {
+ body: ``,
+ width: 16,
+ height: 16,
+ },
+ 'expand-list': {
+ body: ``,
+ width: 16,
+ height: 16,
+ },
+ hash: {
+ body: ``,
+ width: 14,
+ height: 14,
+ },
+
+ /** Conversation Status Starts */
+ 'status-empty': {
+ body: ``,
+ width: 24,
+ height: 24,
+ },
+ 'status-pending': {
+ body: ``,
+ width: 24,
+ height: 24,
+ },
+ 'status-open': {
+ body: ``,
+ width: 24,
+ height: 24,
+ },
+ 'status-snoozed': {
+ body: ``,
+ width: 24,
+ height: 24,
+ },
+ 'status-resolved': {
+ body: ``,
+ width: 24,
+ height: 24,
+ },
+ /** Ends */
/** Conversation Priority Starts */
'priority-empty': {
- body: ``,
+ body: ``,
width: 24,
height: 24,
},