Compare commits

..
Author SHA1 Message Date
Sojan Jose 25c11ecc56 fix(contacts): expose root country code 2026-05-27 01:17:09 +05:30
Sojan Jose 4705a5c3ff fix(contacts): normalize country display reads 2026-05-26 18:55:11 +05:30
77 changed files with 467 additions and 670 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication # two-factor authentication
gem 'devise-two-factor', '>= 5.0.0' gem 'devise-two-factor', '>= 5.0.0'
# authorization # authorization
gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
+3 -3
View File
@@ -301,7 +301,7 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.2) faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -491,7 +491,7 @@ GEM
judoscale-sidekiq (1.8.2) judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2) judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0) sidekiq (>= 5.0)
jwt (2.10.3) jwt (2.10.1)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -1102,7 +1102,7 @@ DEPENDENCIES
json_schemer json_schemer
judoscale-rails judoscale-rails
judoscale-sidekiq judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3) jwt
kaminari kaminari
koala koala
letter_opener letter_opener
+1 -1
View File
@@ -1 +1 @@
4.14.1 4.14.0
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: scope,
state: state state: state,
prompt: 'consent'
} }
) )
if redirect_url if redirect_url
@@ -16,6 +16,7 @@ const props = defineProps({
name: { type: String, default: '' }, name: { type: String, default: '' },
email: { type: String, default: '' }, email: { type: String, default: '' },
additionalAttributes: { type: Object, default: () => ({}) }, additionalAttributes: { type: Object, default: () => ({}) },
countryCode: { type: String, default: '' },
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null }, availabilityStatus: { type: String, default: null },
@@ -42,6 +43,7 @@ const getInitialContactData = () => ({
name: props.name, name: props.name,
email: props.email, email: props.email,
phoneNumber: props.phoneNumber, phoneNumber: props.phoneNumber,
countryCode: props.countryCode,
additionalAttributes: props.additionalAttributes, additionalAttributes: props.additionalAttributes,
}); });
@@ -58,12 +60,13 @@ const countriesMap = computed(() => {
const countryDetails = computed(() => { const countryDetails = computed(() => {
const attributes = props.additionalAttributes || {}; const attributes = props.additionalAttributes || {};
const { country, countryCode, city } = attributes; const { country, countryCode: additionalCountryCode, city } = attributes;
const countryCode = props.countryCode || additionalCountryCode;
if (!country && !countryCode) return null; if (!country && !countryCode) return null;
const activeCountry = const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode]; countriesMap.value[countryCode] || countriesMap.value[country];
if (!activeCountry) return null; if (!activeCountry) return null;
@@ -96,6 +96,7 @@ const prepareStateBasedOnProps = () => {
name = '', name = '',
email: emailAddress, email: emailAddress,
phoneNumber, phoneNumber,
countryCode: contactCountryCode = '',
additionalAttributes = {}, additionalAttributes = {},
} = props.contactData || {}; } = props.contactData || {};
const { firstName, lastName } = splitName(name || ''); const { firstName, lastName } = splitName(name || '');
@@ -122,7 +123,7 @@ const prepareStateBasedOnProps = () => {
additionalAttributes: { additionalAttributes: {
description, description,
companyName, companyName,
countryCode, countryCode: contactCountryCode || countryCode,
country, country,
city, city,
socialProfiles: { socialProfiles: {
@@ -94,6 +94,7 @@ const handleAvatarHover = (id, isHovered) => {
:email="contact.email" :email="contact.email"
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber" :phone-number="contact.phoneNumber"
:country-code="contact.countryCode"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus" :availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id" :is-expanded="expandedCardId === contact.id"
@@ -35,14 +35,7 @@ const props = defineProps({
}, },
}); });
defineEmits([ defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']);
'accept',
'reject',
'end',
'toggleMute',
'goToConversation',
'dismiss',
]);
const { t } = useI18n(); const { t } = useI18n();
@@ -122,19 +115,6 @@ const channelIcon = computed(() => {
<span class="text-xs font-medium text-n-teal-9 tracking-tight"> <span class="text-xs font-medium text-n-teal-9 tracking-tight">
{{ statusLabel }} {{ statusLabel }}
</span> </span>
<!-- Dismiss: removes the notification from the UI without declining.
Incoming only — outgoing/ongoing calls are ended via the call
controls, not silently dismissed. -->
<NextButton
v-if="isIncoming"
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.DISMISS_CALL')"
icon="i-ph-x-bold"
slate
ghost
xs
class="!rounded-full -my-1"
@click="$emit('dismiss')"
/>
</div> </div>
</div> </div>
@@ -25,7 +25,6 @@ const {
joinCall, joinCall,
endCall: endCallSession, endCall: endCallSession,
rejectIncomingCall, rejectIncomingCall,
dismissCall,
formattedCallDuration, formattedCallDuration,
} = useCallSession(); } = useCallSession();
@@ -52,15 +51,6 @@ const mainCardState = computed(() => {
: VOICE_CALL_DIRECTION.INCOMING; : VOICE_CALL_DIRECTION.INCOMING;
}); });
// Stacked cards are always non-active (ringing) calls, so reflect each call's
// real direction. An outbound call must render as OUTGOING — otherwise it shows
// the incoming-only dismiss (✕) control and the agent could drop it locally
// without terminating, leaving the customer ringing with no widget to end it.
const stackedCardState = call =>
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND
? VOICE_CALL_DIRECTION.OUTGOING
: VOICE_CALL_DIRECTION.INCOMING;
const toggleMute = () => { const toggleMute = () => {
isMuted.value = !isMuted.value; isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value); setWhatsappCallMuted(isMuted.value);
@@ -240,11 +230,10 @@ onBeforeUnmount(stopRingtone);
v-for="call in stackedIncomingCalls" v-for="call in stackedIncomingCalls"
:key="call.callSid" :key="call.callSid"
:call="call" :call="call"
:state="stackedCardState(call)" :state="VOICE_CALL_DIRECTION.INCOMING"
:call-info="getCallInfo(call)" :call-info="getCallInfo(call)"
@accept="handleJoinCall(call)" @accept="handleJoinCall(call)"
@reject="rejectIncomingCall(call.callSid)" @reject="rejectIncomingCall(call.callSid)"
@dismiss="dismissCall(call.callSid)"
@go-to-conversation="goToConversation(call)" @go-to-conversation="goToConversation(call)"
/> />
@@ -259,7 +248,6 @@ onBeforeUnmount(stopRingtone);
:show-mute="hasActiveCall && isWhatsappActive" :show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(primaryIncomingCall)" @accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@end="handleEndCall" @end="handleEndCall"
@toggle-mute="toggleMute" @toggle-mute="toggleMute"
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)" @go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
@@ -25,7 +25,7 @@ const store = useStore();
const bulkDeleteDialog = ref(null); const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc => const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available'; !doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => { const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return []; if (!props.selectedIds.size) return [];
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
value: 'sync', value: 'sync',
action: 'sync', action: 'sync',
icon: 'i-lucide-refresh-cw', icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
}); });
} }
@@ -554,10 +554,11 @@ provideMessageContext({
<Avatar v-bind="avatarInfo" :size="24" /> <Avatar v-bind="avatarInfo" :size="24" />
</div> </div>
<div <div
class="[grid-area:bubble] flex min-w-0" class="[grid-area:bubble] flex"
:class="{ :class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT, 'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT, 'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}" }"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
> >
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
<template> <template>
<div <div
class="text-sm min-w-0" class="text-sm"
:class="[ :class="[
messageClass, messageClass,
{ {
@@ -91,6 +91,10 @@ const wasDeclinedByAgent = computed(
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
); );
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
const conversationAssignee = computed(() => { const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.( const conversation = store.getters.getConversationById?.(
conversationId?.value conversationId?.value
@@ -120,48 +124,43 @@ const durationSeconds = computed(() => {
const formattedDuration = computed(() => formatDuration(durationSeconds.value)); const formattedDuration = computed(() => formatDuration(durationSeconds.value));
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
// strictly from the persisted accept fields — never the conversation's current
// assignee, which would mis-attribute a historical call after a reassignment.
const handlerName = computed(() => {
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
if (!acceptedByAgentId.value) return null;
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
return agent?.available_name || agent?.name || null;
});
const handledBy = computed(() =>
handlerName.value
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
: null
);
const labelKey = computed(() => { const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (isFailed.value) { if (isFailed.value) {
return isOutbound.value return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL' ? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL'; : 'CONVERSATION.VOICE_CALL.MISSED_CALL';
} }
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
// outbound call never falls through to the "Incoming call" label.
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}); });
const subtext = computed(() => { const subtext = computed(() => {
// Completed: "Handled by {agent} · 0:42" (drops either part when absent). if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.CALLING')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) { if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return [handledBy.value, formattedDuration.value] return formattedDuration.value;
.filter(Boolean)
.join(' · ');
} }
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return handledBy.value; if (isOutbound.value) return null;
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return null;
} }
if (isFailed.value) { if (isFailed.value) {
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
if (isOutbound.value) { if (isOutbound.value) {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
} }
@@ -172,10 +171,6 @@ const subtext = computed(() => {
} }
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
} }
// RINGING or an as-yet-unknown/initial status.
if (isOutbound.value) {
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
}
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}); });
@@ -31,17 +31,6 @@ const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl); return timeStampAppendedURL(attachment.dataUrl);
}); });
const TRANSCRIPT_PREVIEW_LENGTH = 200;
const isTranscriptExpanded = ref(false);
const isTranscriptLong = computed(
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
);
const displayedTranscript = computed(() => {
const text = attachment.transcribedText || '';
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`;
});
const audioPlayer = useTemplateRef('audioPlayer'); const audioPlayer = useTemplateRef('audioPlayer');
const isPlaying = ref(false); const isPlaying = ref(false);
@@ -226,18 +215,7 @@ const downloadAudio = async () => {
v-if="attachment.transcribedText && showTranscribedText" v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
> >
{{ displayedTranscript }} {{ attachment.transcribedText }}
<button
v-if="isTranscriptLong"
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
@click="isTranscriptExpanded = !isTranscriptExpanded"
>
{{
isTranscriptExpanded
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
}}
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -78,27 +78,26 @@ function changeAvailabilityStatus(availability) {
<template> <template>
<DropdownSection class="[&>ul]:overflow-visible"> <DropdownSection class="[&>ul]:overflow-visible">
<div class="grid gap-0"> <div class="grid gap-0">
<DropdownItem preserve-open class="gap-1"> <DropdownItem preserve-open>
<div class="flex-grow flex items-center gap-1 min-w-0"> <div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }} {{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div> </div>
<DropdownContainer class="shrink-0"> <DropdownContainer>
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<Button <Button
size="sm" size="sm"
color="slate" color="slate"
variant="faded" variant="faded"
class="min-w-[96px]"
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
trailing-icon trailing-icon
@click="toggle" @click="toggle"
> >
<div class="flex gap-1 items-center min-w-0 text-sm"> <div class="flex gap-1 items-center flex-grow text-sm">
<div class="p-1 flex-shrink-0"> <div class="p-1 flex-shrink-0">
<div class="size-2 rounded-sm" :class="activeStatus.color" /> <div class="size-2 rounded-sm" :class="activeStatus.color" />
</div> </div>
<span class="truncate max-w-[7rem]"> <span>{{ activeStatus.label }}</span>
{{ activeStatus.label }}
</span>
</div> </div>
</Button> </Button>
</template> </template>
@@ -115,12 +114,12 @@ function changeAvailabilityStatus(availability) {
</DropdownContainer> </DropdownContainer>
</DropdownItem> </DropdownItem>
<DropdownItem> <DropdownItem>
<div class="flex-grow min-w-0"> <div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }} {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
<Icon <Icon
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')" v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
icon="i-lucide-info" icon="i-lucide-info"
class="inline-block align-middle ms-1 size-4 text-n-slate-10" class="size-4 text-n-slate-10"
/> />
</div> </div>
<ToggleSwitch v-model="autoOfflineToggle" /> <ToggleSwitch v-model="autoOfflineToggle" />
@@ -589,11 +589,11 @@ function isCmdPlusEnterToSendEnabled() {
useKeyboardEvents({ useKeyboardEvents({
'Alt+KeyP': { 'Alt+KeyP': {
action: focusEditorInputField, action: focusEditorInputField,
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
'Alt+KeyL': { 'Alt+KeyL': {
action: focusEditorInputField, action: focusEditorInputField,
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
}); });
@@ -105,11 +105,11 @@ export default {
const keyboardEvents = { const keyboardEvents = {
'Alt+KeyP': { 'Alt+KeyP': {
action: () => handleNoteClick(), action: () => handleNoteClick(),
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
'Alt+KeyL': { 'Alt+KeyL': {
action: () => handleReplyClick(), action: () => handleReplyClick(),
allowOnFocusedInput: false, allowOnFocusedInput: true,
}, },
}; };
useKeyboardEvents(keyboardEvents); useKeyboardEvents(keyboardEvents);
@@ -1,47 +1,6 @@
const keyboardEventsMock = vi.hoisted(() => ({
mountedCallbacks: [],
unmountedCallbacks: [],
registeredKeybindings: {},
}));
vi.mock('vue', async importOriginal => ({
...(await importOriginal()),
onMounted: vi.fn(callback =>
keyboardEventsMock.mountedCallbacks.push(callback)
),
onUnmounted: vi.fn(callback =>
keyboardEventsMock.unmountedCallbacks.push(callback)
),
}));
vi.mock('dashboard/composables/useDetectKeyboardLayout', () => ({
useDetectKeyboardLayout: vi.fn(() => Promise.resolve('QWERTY')),
}));
vi.mock('tinykeys', () => ({
createKeybindingsHandler: vi.fn(keybindings => {
keyboardEventsMock.registeredKeybindings = keybindings;
return event => keybindings[event.shortcut]?.(event);
}),
}));
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
const mount = async events => {
useKeyboardEvents(events);
await keyboardEventsMock.mountedCallbacks[0]();
return keyboardEventsMock.registeredKeybindings;
};
describe('useKeyboardEvents', () => { describe('useKeyboardEvents', () => {
beforeEach(() => {
keyboardEventsMock.mountedCallbacks = [];
keyboardEventsMock.unmountedCallbacks = [];
keyboardEventsMock.registeredKeybindings = {};
});
it('should be defined', () => { it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined(); expect(useKeyboardEvents).toBeDefined();
}); });
@@ -50,46 +9,18 @@ describe('useKeyboardEvents', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function); expect(useKeyboardEvents).toBeInstanceOf(Function);
}); });
it('registers keybindings on mount and removes the listener on unmount', async () => { it('should set up listeners on mount and remove them on unmount', async () => {
const addEventListener = vi.spyOn(document, 'addEventListener'); const events = {
'ALT+KeyL': () => {},
};
const keybindings = await mount({ 'ALT+KeyL': () => {} }); const mountedMock = vi.fn();
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']); const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
const { signal } = addEventListener.mock.calls[0][2]; expect(mountedMock).toHaveBeenCalled();
expect(signal.aborted).toBe(false); expect(unmountedMock).toHaveBeenCalled();
keyboardEventsMock.unmountedCallbacks[0]();
expect(signal.aborted).toBe(true);
});
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
const keybindings = await mount({ 'Alt+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
});
it('ignores shortcuts on focused typeable elements by default', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: false },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).not.toHaveBeenCalled();
});
it('runs shortcuts on focused typeable elements when allowed', async () => {
const action = vi.fn();
const keybindings = await mount({
'Alt+KeyL': { action, allowOnFocusedInput: true },
});
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
expect(action).toHaveBeenCalledTimes(1);
}); });
}); });
-5
View File
@@ -1,5 +1,4 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants'; import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls'; import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types'; import types from 'dashboard/store/mutation-types';
@@ -59,10 +58,6 @@ function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep // Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that // rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store). // had the conversation hydrated (and Vuex evicts it from the store).
// Only incoming messages carry the contact as the sender; on outbound calls
// the sender is the initiating agent, so skip the snapshot and let the widget
// fall back to the conversation's contact (conversation.meta.sender).
if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null;
const sender = message?.sender; const sender = message?.sender;
if (!sender) return null; if (!sender) return null;
return { return {
@@ -86,16 +86,13 @@
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up", "MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}", "MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended", "CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…", "CALLING": "Calling…",
"THEY_ANSWERED": "They answered", "THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered", "YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered", "AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"CALL_BACK": "Call back", "CALL_BACK": "Call back"
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",
@@ -315,7 +312,6 @@
"NOT_ANSWERED_YET": "Not answered yet", "NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject", "REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call", "JOIN_CALL": "Join call",
"END_CALL": "End call", "END_CALL": "End call",
"MUTE": "Mute mic", "MUTE": "Mute mic",
@@ -37,6 +37,10 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
countryCode: {
type: String,
default: '',
},
updatedAt: { updatedAt: {
type: Number, type: Number,
default: 0, default: 0,
@@ -60,12 +64,17 @@ const updatedAtTime = computed(() => {
}); });
const countryDetails = computed(() => { const countryDetails = computed(() => {
const { country, countryCode, city } = props.additionalAttributes; const {
country,
countryCode: additionalCountryCode,
city,
} = props.additionalAttributes;
const countryCode = props.countryCode || additionalCountryCode;
if (!country && !countryCode) return null; if (!country && !countryCode) return null;
const activeCountry = const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode]; countriesMap.value[countryCode] || countriesMap.value[country];
if (!activeCountry) return null; if (!activeCountry) return null;
@@ -41,6 +41,7 @@ const accountId = useMapGetter('getCurrentAccountId');
:name="contact.name" :name="contact.name"
:email="contact.email" :email="contact.email"
:phone="contact.phoneNumber" :phone="contact.phoneNumber"
:country-code="contact.countryCode"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:account-id="accountId" :account-id="accountId"
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
@@ -72,12 +72,14 @@ export default {
city = '', city = '',
country_code: countryCode, country_code: countryCode,
} = this.additionalAttributes; } = this.additionalAttributes;
const canonicalCountryCode =
this.contact.country_code || this.contact.countryCode || countryCode;
const cityAndCountry = [city, country].filter(item => !!item).join(', '); const cityAndCountry = [city, country].filter(item => !!item).join(', ');
if (!cityAndCountry) { if (!cityAndCountry) {
return ''; return '';
} }
return this.findCountryFlag(countryCode, cityAndCountry); return this.findCountryFlag(canonicalCountryCode, cityAndCountry);
}, },
socialProfiles() { socialProfiles() {
const { const {
@@ -253,6 +253,7 @@ export default {
if ( if (
this.isAWhatsAppCloudChannel && this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount( this.isFeatureEnabledonAccount(
this.accountId, this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE FEATURE_FLAGS.CHANNEL_VOICE
@@ -7,7 +7,7 @@ const state = {
records: [], records: [],
meta: { meta: {
currentPage: 1, currentPage: 1,
perPage: 25, perPage: 15,
totalEntries: 0, totalEntries: 0,
}, },
uiFlags: { uiFlags: {
+1 -3
View File
@@ -54,9 +54,7 @@ export const useCallsStore = defineStore('calls', {
return; return;
} }
// Prepend so the newest call surfaces as the primary card (incomingCalls[0]) this.calls.push({
// and older calls drop into the stack below it.
this.calls.unshift({
...callData, ...callData,
isActive: false, isActive: false,
}); });
+1
View File
@@ -32,6 +32,7 @@ class Account::ContactsExportJob < ApplicationJob
def value_for_header(contact, header) def value_for_header(contact, header)
return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN
return contact.canonical_country_code if header == 'country_code'
contact.send(header) contact.send(header)
end end
@@ -23,5 +23,3 @@ class Internal::TriggerDailyScheduledItemsJob < ApplicationJob
@designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440 @designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440
end end
end end
Internal::TriggerDailyScheduledItemsJob.prepend_mod_with('Internal::TriggerDailyScheduledItemsJob')
+8 -8
View File
@@ -41,19 +41,19 @@ class Channel::Whatsapp < ApplicationRecord
end end
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers. # Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
# Meta's Calling API is available to any whatsapp_cloud inbox (embedded-signup or manual keys); # Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# only 360dialog (default provider) can't reach the call APIs. # 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled? def voice_enabled?
voice_calling_supported? && voice_calling_supported? &&
provider_config['calling_enabled'].present? && provider_config['calling_enabled'].present? &&
account.feature_enabled?('channel_voice') account.feature_enabled?('channel_voice')
end end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is # Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled # reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud
# on even though calling_enabled would persist. # and 360dialog inboxes can't be toggled on even though calling_enabled would persist.
def voice_calling_supported? def voice_calling_supported?
provider == 'whatsapp_cloud' provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup'
end end
def provider_service def provider_service
@@ -69,7 +69,7 @@ class Channel::Whatsapp < ApplicationRecord
# Saved with validate: false to skip validate_provider_config's remote credential # Saved with validate: false to skip validate_provider_config's remote credential
# re-check, which could spuriously fail and desync the flag from Meta. # re-check, which could spuriously fail and desync the flag from Meta.
def enable_voice_calling! def enable_voice_calling!
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported? raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice') raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
provider_service.update_calling_status('ENABLED') provider_service.update_calling_status('ENABLED')
@@ -82,7 +82,7 @@ class Channel::Whatsapp < ApplicationRecord
# `calls` from the webhook subscription (best-effort, so a Meta outage can't # `calls` from the webhook subscription (best-effort, so a Meta outage can't
# trap admins). Leaves Meta's WABA calling.status untouched. # trap admins). Leaves Meta's WABA calling.status untouched.
def disable_voice_calling! def disable_voice_calling!
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported? raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
self.provider_config = provider_config.merge('calling_enabled' => false) self.provider_config = provider_config.merge('calling_enabled' => false)
save!(validate: false) save!(validate: false)
@@ -0,0 +1,26 @@
module ContactCountryHelpers
extend ActiveSupport::Concern
def canonical_country_code
attributes = additional_attributes || {}
CountryCodeNormalizer.normalize(self[:country_code]) ||
CountryCodeNormalizer.normalize(attributes['country_code']) ||
CountryCodeNormalizer.normalize(attributes['country'])
end
def canonical_country_name
CountryCodeNormalizer.name_for(canonical_country_code)
end
def additional_attributes_with_canonical_country
attributes = additional_attributes.deep_dup || {}
code = canonical_country_code
return attributes if code.blank?
attributes.merge(
'country_code' => code,
'country' => canonical_country_name || attributes['country']
)
end
end
+1
View File
@@ -44,6 +44,7 @@
class Contact < ApplicationRecord class Contact < ApplicationRecord
include Avatarable include Avatarable
include AvailabilityStatusable include AvailabilityStatusable
include ContactCountryHelpers
include Labelable include Labelable
include LlmFormattable include LlmFormattable
+42
View File
@@ -0,0 +1,42 @@
require 'tzinfo'
class CountryCodeNormalizer
COUNTRY_ALIASES = {
'united states of america' => 'US',
'usa' => 'US',
'uk' => 'GB',
'united kingdom' => 'GB'
}.freeze
class << self
def normalize(value)
return if value.blank?
value = value.to_s.strip
code = value.upcase
return code if country_by_code.key?(code)
country_by_name[normalized_name(value)] || COUNTRY_ALIASES[normalized_name(value)]
end
def name_for(code)
country_by_code[normalize(code)]&.name
end
private
def country_by_code
@country_by_code ||= TZInfo::Country.all_codes.index_with { |code| TZInfo::Country.get(code) }
end
def country_by_name
@country_by_name ||= country_by_code.each_with_object({}) do |(code, country), result|
result[normalized_name(country.name)] = code
end
end
def normalized_name(value)
value.to_s.strip.downcase
end
end
end
@@ -26,7 +26,7 @@ class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
attributes << "Email: #{@record.email}" attributes << "Email: #{@record.email}"
attributes << "Phone: #{@record.phone_number}" attributes << "Phone: #{@record.phone_number}"
attributes << "Location: #{@record.location}" attributes << "Location: #{@record.location}"
attributes << "Country Code: #{@record.country_code}" attributes << "Country Code: #{@record.canonical_country_code}"
@record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute| @record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute|
attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}" attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
end end
+1 -35
View File
@@ -1,5 +1,3 @@
require 'resolv'
class WebsiteBrandingService class WebsiteBrandingService
include SocialLinkParser include SocialLinkParser
@@ -26,8 +24,7 @@ class WebsiteBrandingService
colors: extract_colors(doc), colors: extract_colors(doc),
logos: extract_logos(doc), logos: extract_logos(doc),
socials: build_socials(links), socials: build_socials(links),
email: @email, email: @email
email_provider: detect_email_provider
}) })
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[WebsiteBranding] #{e.message}" Rails.logger.error "[WebsiteBranding] #{e.message}"
@@ -107,37 +104,6 @@ class WebsiteBrandingService
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil
end end
GOOGLE_MX_DOMAINS = %w[google.com googlemail.com].freeze
MICROSOFT_MX_DOMAINS = %w[outlook.com].freeze
# Probes the domain's MX records to infer the mailbox provider, returning
# 'google' or 'microsoft' (matching Channel::Email#provider) or nil when unknown.
def detect_email_provider
hosts = mx_records
return 'google' if mx_hosted_by?(hosts, GOOGLE_MX_DOMAINS)
return 'microsoft' if mx_hosted_by?(hosts, MICROSOFT_MX_DOMAINS)
nil
end
# Matches on the registrable domain of the MX host (anchored on a label
# boundary) so lookalikes like "notgoogle.com" don't get misclassified.
def mx_hosted_by?(hosts, provider_domains)
hosts.any? do |host|
provider_domains.any? { |domain| host == domain || host.end_with?(".#{domain}") }
end
end
def mx_records
Resolv::DNS.open do |resolver|
resolver.timeouts = 5
resolver.getresources(@domain, Resolv::DNS::Resource::IN::MX).map { |record| record.exchange.to_s.downcase }
end
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] MX probe failed for #{@domain}: #{e.message}"
[]
end
end end
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService') WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
@@ -3,5 +3,6 @@ json.id contact.id
json.name contact.name json.name contact.name
json.phone_number contact.phone_number json.phone_number contact.phone_number
json.identifier contact.identifier json.identifier contact.identifier
json.additional_attributes contact.additional_attributes json.country_code contact.canonical_country_code
json.additional_attributes contact.additional_attributes_with_canonical_country
json.last_activity_at contact.last_activity_at&.to_i json.last_activity_at contact.last_activity_at&.to_i
@@ -1,5 +1,6 @@
json.additional_attributes resource.additional_attributes json.additional_attributes resource.additional_attributes_with_canonical_country
json.availability_status resource.availability_status json.availability_status resource.availability_status
json.country_code resource.canonical_country_code
json.email resource.email json.email resource.email
json.id resource.id json.id resource.id
json.name resource.name json.name resource.name
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '4.14.1' version: '4.14.0'
development: development:
<<: *shared <<: *shared
-12
View File
@@ -215,18 +215,6 @@
value: value:
locked: false locked: false
type: code type: code
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT
display_title: 'Captain Document Auto Sync Per Account Batch Limit'
description: 'Maximum syncable Captain documents to enqueue per account in one scheduler run. Defaults to 50.'
value: 50
locked: false
type: number
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT
display_title: 'Captain Document Auto Sync Global Batch Limit'
description: 'Maximum syncable Captain documents to enqueue globally in one scheduler run. Defaults to 1000.'
value: 1000
locked: false
type: number
# End of Captain Config # End of Captain Config
# ------- Context.dev Config ------- # # ------- Context.dev Config ------- #
@@ -2,7 +2,7 @@ class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAcco
before_action :check_admin_authorization? before_action :check_admin_authorization?
before_action :fetch_audit before_action :fetch_audit
RESULTS_PER_PAGE = 25 RESULTS_PER_PAGE = 15
def show def show
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE) @audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
@@ -75,6 +75,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document| Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
next unless document.syncable? next unless document.syncable?
next unless document.available? next unless document.available?
next if document.sync_in_progress?
document.update!( document.update!(
sync_status: :syncing, sync_status: :syncing,
@@ -37,6 +37,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def sync def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable? return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available? return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!( @document.update!(
sync_status: :syncing, sync_status: :syncing,
@@ -35,14 +35,8 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def initiate def initiate
@call = create_outbound_call @call = create_outbound_call
# Link the call to its message in one transaction so the message.created @message = Voice::CallMessageBuilder.new(@call).perform!
# broadcast (an after_create_commit hook) fires only once call.message_id is @call.update!(message_id: @message.id)
# set. Otherwise the live ringing bubble receives a message with no `call`
# payload (no direction/agent) and renders "Calling…" instead of "Handled by …".
ActiveRecord::Base.transaction do
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
end end
private private
@@ -48,7 +48,9 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
return if document.pdf_document? return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do with_lock(lock_key(document), LOCK_TIMEOUT) do
perform_sync(document, start_time) mark_sync_started(document)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end end
rescue LockAcquisitionError rescue LockAcquisitionError
log_sync_outcome(document, result: :already_syncing) log_sync_outcome(document, result: :already_syncing)
@@ -62,12 +64,6 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
private private
def perform_sync(document, start_time)
mark_sync_started(document)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
def log_sync_outcome(document, **fields) def log_sync_outcome(document, **fields)
payload = { payload = {
document_id: document.id, document_id: document.id,
@@ -1,27 +1,21 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs queue_as :scheduled_jobs
DEFAULT_PER_ACCOUNT_BATCH_LIMIT = 50 PER_ACCOUNT_HOURLY_CAP = 50
DEFAULT_GLOBAL_BATCH_LIMIT = 1000 GLOBAL_HOURLY_CAP = 1000
DUE_DOCUMENT_BATCH_SIZE = PER_ACCOUNT_HOURLY_CAP * 2 # Inspite of skipping, we should at least reach the hourly cap
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
DAILY_SYNC_JITTER = 4.hours
WEEKLY_SYNC_JITTER = 1.day
MONTHLY_SYNC_JITTER = 4.days
def perform(plan_name = nil) def perform
@per_account_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', DEFAULT_PER_ACCOUNT_BATCH_LIMIT) @remaining_global_capacity = GLOBAL_HOURLY_CAP
@global_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', DEFAULT_GLOBAL_BATCH_LIMIT)
@remaining_global_capacity = @global_batch_limit
@plan_name = plan_name.to_s.downcase.presence
sync_intervals = Enterprise::Account.captain_document_sync_intervals sync_intervals = Enterprise::Account.captain_document_sync_intervals
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 } stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0, documents_skipped: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account| Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
break if @remaining_global_capacity <= 0 break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1 stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync') next unless account.feature_enabled?('captain_document_auto_sync')
next unless account_in_selected_plan?(account)
stats[:accounts_enabled] += 1 stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval(sync_intervals) interval = account.captain_document_sync_interval(sync_intervals)
@@ -30,6 +24,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
stats[:accounts_scheduled] += 1 stats[:accounts_scheduled] += 1
result = enqueue_due_documents(account, interval) result = enqueue_due_documents(account, interval)
stats[:documents_enqueued] += result[:enqueued] stats[:documents_enqueued] += result[:enqueued]
stats[:documents_skipped] += result[:skipped]
end end
log_scheduler_summary(stats) log_scheduler_summary(stats)
@@ -38,85 +33,92 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
private private
def enqueue_due_documents(account, interval) def enqueue_due_documents(account, interval)
per_account_limit = [@per_account_batch_limit, @remaining_global_capacity].min per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
result = { enqueued: 0 } result = { enqueued: 0, skipped: 0 }
skipped_document_ids = []
due_documents(account, interval).limit(per_account_limit).to_a.each do |document| while result[:enqueued] < per_account_limit
process_due_document(document, interval, result)
documents = due_documents(account, interval, skipped_document_ids).limit(DUE_DOCUMENT_BATCH_SIZE).to_a
break if documents.empty?
documents.each do |document|
break if result[:enqueued] >= per_account_limit
process_due_document(document, result, skipped_document_ids)
end
end end
result result
end end
def process_due_document(document, interval, result) def process_due_document(document, result, skipped_document_ids)
sync_execution_delay = sync_jitter(interval) return unless document.syncable?
Captain::Documents::PerformSyncJob.set(queue: :purgable, wait: sync_execution_delay).perform_later(document) # Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
unless reserve_sync_slot(document)
result[:skipped] += 1
skipped_document_ids << document.id
return
end
Captain::Documents::PerformSyncJob.perform_later(document)
@remaining_global_capacity -= 1 @remaining_global_capacity -= 1
result[:enqueued] += 1 result[:enqueued] += 1
end end
def due_documents(account, interval) def due_documents(account, interval, skipped_document_ids)
syncing = Captain::Document.sync_statuses[:syncing] syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced] synced = Captain::Document.sync_statuses[:synced]
failed = Captain::Document.sync_statuses[:failed] failed = Captain::Document.sync_statuses[:failed]
stale_cutoff = SYNC_STALE_TIMEOUT.ago
# The scheduler runs at predictable plan windows. Use a wider due window so
# jittered executions do not miss the next window just because they finished later.
sync_due_before = due_window(interval).ago
documents = account.captain_documents.syncable.where(status: :available).where( documents = account.captain_documents.syncable.where(status: :available).where(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \ '(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(sync_status = ? AND last_sync_attempted_at < ?)', '(sync_status = ? AND last_sync_attempted_at < ?)',
synced, sync_due_before, failed, sync_due_before, syncing, stale_cutoff synced, interval.ago, failed, interval.ago, syncing, SYNC_STALE_TIMEOUT.ago
) )
documents = documents.where.not(id: skipped_document_ids) if skipped_document_ids.present?
documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id) documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id)
end end
def configured_sync_limit(config_key, default) def reserve_sync_slot(document)
configured_value = InstallationConfig.find_by(name: config_key)&.value mark_sync_started(document)
limit = configured_value.to_s.to_i true
limit.positive? ? limit : default rescue ActiveRecord::RecordInvalid => e
log_document_skip(document, e)
false
end end
def account_in_selected_plan?(account) def log_document_skip(document, error)
return true if @plan_name.blank? payload = {
event: 'document_skipped',
document_id: document.id,
account_id: document.account_id,
assistant_id: document.assistant_id,
error_class: error.class.name,
error_message: error.message,
validation_errors: document.errors.full_messages
}
account_sync_plan(account) == @plan_name Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
def account_sync_plan(account)
plan = account.custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
plan.to_s.downcase.presence
end
def sync_jitter(interval)
jitter_window = if interval <= 1.day
DAILY_SYNC_JITTER
elsif interval <= 1.week
WEEKLY_SYNC_JITTER
else
MONTHLY_SYNC_JITTER
end
rand(0..jitter_window.to_i).seconds
end
def due_window(interval)
(interval.to_i / 2).seconds
end end
def log_scheduler_summary(stats) def log_scheduler_summary(stats)
payload = { payload = {
event: 'completed', event: 'completed',
plan_name: @plan_name,
global_cap_hit: @remaining_global_capacity <= 0, global_cap_hit: @remaining_global_capacity <= 0,
per_account_batch_limit: @per_account_batch_limit,
global_batch_limit: @global_batch_limit,
remaining_global_capacity: @remaining_global_capacity remaining_global_capacity: @remaining_global_capacity
}.merge(stats) }.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}") Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end end
def mark_sync_started(document)
document.update!(
sync_status: :syncing,
sync_step: nil,
last_sync_error_code: nil,
last_sync_attempted_at: Time.current
)
end
end end
@@ -1,19 +0,0 @@
module Enterprise::Internal::TriggerDailyScheduledItemsJob
def perform
super
Captain::Documents::ScheduleSyncsJob.perform_later('enterprise')
Captain::Documents::ScheduleSyncsJob.perform_later('business') if business_auto_sync_due?
Captain::Documents::ScheduleSyncsJob.perform_later('startups') if startup_auto_sync_due?
end
private
def business_auto_sync_due?
Time.current.utc.sunday?
end
def startup_auto_sync_due?
Time.current.utc.day == 1
end
end
@@ -0,0 +1,7 @@
module Enterprise::Internal::TriggerHourlyScheduledItemsJob
def perform
super
Captain::Documents::ScheduleSyncsJob.perform_later
end
end
@@ -31,11 +31,10 @@ module Enterprise::Webhooks::WhatsappEventsJob
# and timer/recorder kick off before the contact actually answers. # and timer/recorder kick off before the contact actually answers.
def handle_call_events(channel, params) def handle_call_events(channel, params)
value = params.dig(:entry, 0, :changes, 0, :value) || {} value = params.dig(:entry, 0, :changes, 0, :value) || {}
contacts = value[:contacts]
Array(value[:calls]).each do |call_payload| Array(value[:calls]).each do |call_payload|
with_call_lock(channel, call_payload[:id]) do with_call_lock(channel, call_payload[:id]) do
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload], contacts: contacts }).perform Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
end end
end end
@@ -16,7 +16,6 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
advanced_search_indexing advanced_search_indexing
advanced_search advanced_search
linear_integration linear_integration
channel_voice
].freeze ].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
@@ -58,7 +58,6 @@ module Enterprise::WebsiteBrandingService
socials: brand['socials'] || [], socials: brand['socials'] || [],
links: brand['links'], links: brand['links'],
email: @email, email: @email,
email_provider: detect_email_provider,
industries: brand.dig('industries', 'eic') || [], industries: brand.dig('industries', 'eic') || [],
stock: brand['stock'], stock: brand['stock'],
is_nsfw: brand['is_nsfw'] || false is_nsfw: brand['is_nsfw'] || false
@@ -1,12 +1,11 @@
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation include Integrations::LlmInstrumentation
TRANSCRIPTION_MODEL = 'gpt-4o-mini-transcribe'.freeze WHISPER_MODEL = 'whisper-1'.freeze
# OpenAI's transcription endpoint hard limit is 25 MB *decimal* (25_000_000), not # Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes
# binary (25.megabytes = 26_214_400) — using the binary form leaks the 25.0–26.2 MB # = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API
# range to the API as 413s. Long audio (~70+ min Opus) keeps the attachment but skips # as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription.
# transcription. WHISPER_BYTE_LIMIT = 25_000_000
TRANSCRIPTION_BYTE_LIMIT = 25_000_000
attr_reader :attachment, :message, :account attr_reader :attachment, :message, :account
@@ -43,7 +42,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
blob = attachment.file&.blob blob = attachment.file&.blob
return false unless blob return false unless blob
blob.byte_size > TRANSCRIPTION_BYTE_LIMIT blob.byte_size > WHISPER_BYTE_LIMIT
end end
def fetch_audio_file def fetch_audio_file
@@ -76,12 +75,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = nil transcribed_text = nil
File.open(temp_file_path, 'rb') do |file| File.open(temp_file_path, 'rb') do |file|
# temperature: 0.0 minimises hallucinations on silence / near-silent # temperature: 0.0 minimises Whisper's hallucinations on silence /
# audio; non-zero values trigger spiraling repeats — well-documented # near-silent audio; non-zero values trigger spiraling repeats like
# behaviour across OpenAI transcription models. # "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour.
response = @client.audio.transcribe( response = @client.audio.transcribe(
parameters: { parameters: {
model: TRANSCRIPTION_MODEL, model: WHISPER_MODEL,
file: file, file: file,
temperature: 0.0 temperature: 0.0
} }
@@ -98,7 +97,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
def instrumentation_params(file_path) def instrumentation_params(file_path)
{ {
span_name: 'llm.messages.audio_transcription', span_name: 'llm.messages.audio_transcription',
model: TRANSCRIPTION_MODEL, model: WHISPER_MODEL,
account_id: account&.id, account_id: account&.id,
feature_name: 'audio_transcription', feature_name: 'audio_transcription',
file_path: file_path file_path: file_path
@@ -59,17 +59,9 @@ class Voice::InboundCallBuilder
end end
def ensure_contact! def ensure_contact!
contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record| account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = contact_name.presence || from_number record.name = from_number if record.name.blank?
end end
contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number
contact
end
# WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio
# calls don't, so contact naming falls back to the phone number.
def contact_name
extra_meta['contact_name'].presence
end end
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +. # WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
@@ -73,27 +73,15 @@ class Whatsapp::IncomingCallService
def create_inbound_call(payload) def create_inbound_call(payload)
sdp_offer = payload.dig(:session, :sdp) sdp_offer = payload.dig(:session, :sdp)
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
name = caller_profile_name(payload)
extra_meta['contact_name'] = name if name.present?
call = Voice::InboundCallBuilder.perform!( call = Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id], inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp, extra_meta: extra_meta provider: :whatsapp,
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
) )
update_conversation(call) update_conversation(call)
broadcast_incoming(call, sdp_offer) broadcast_incoming(call, sdp_offer)
end end
# Match strictly on wa_id (== calls[].from): in a batched payload missing this
# call's contact entry, borrowing another caller's name would corrupt this
# contact, so fall back to the phone number (nil here) instead of contacts.first.
def caller_profile_name(payload)
contacts = Array(params[:contacts]).map(&:with_indifferent_access)
match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s }
match&.dig(:profile, :name).presence
end
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply # `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
# Meta's SDP answer so the handshake completes during ringing; the call # Meta's SDP answer so the handshake completes during ringing; the call
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on # stays in `ringing` until status=ACCEPTED arrives. Don't gate on
@@ -171,13 +159,11 @@ class Whatsapp::IncomingCallService
) )
end end
# Ring the assignee if any, else online inbox agents, else fall back to the # Ring the assignee if any, else online inbox agents, else the whole account.
# inbox's own agents and account admins. Never the whole-account stream, which
# would ring online agents from unrelated inboxes.
def broadcast_incoming(call, sdp_offer) def broadcast_incoming(call, sdp_offer)
contact = call.contact contact = call.contact
token = call.conversation.assignee&.pubsub_token token = call.conversation.assignee&.pubsub_token
streams = token ? [token] : (online_agent_streams.presence || fallback_agent_streams) streams = token ? [token] : (online_agent_streams.presence || account_streams)
broadcast(call, 'voice_call.incoming', broadcast(call, 'voice_call.incoming',
streams: streams, streams: streams,
direction: call.direction_label, inbox_id: call.inbox_id, direction: call.direction_label, inbox_id: call.inbox_id,
@@ -189,11 +175,6 @@ class Whatsapp::IncomingCallService
inbox.available_agents.pluck('users.pubsub_token').compact inbox.available_agents.pluck('users.pubsub_token').compact
end end
def fallback_agent_streams
user_ids = inbox.member_ids | inbox.account.administrators.ids
User.where(id: user_ids).pluck(:pubsub_token).compact
end
def broadcast(call, event, streams: account_streams, **extra) def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) } payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) } streams.each { |s| ActionCable.server.broadcast(s, payload) }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chatwoot/chatwoot", "name": "@chatwoot/chatwoot",
"version": "4.14.1", "version": "4.14.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"eslint": "eslint app/**/*.{js,vue}", "eslint": "eslint app/**/*.{js,vue}",
@@ -54,6 +54,23 @@ RSpec.describe 'Contacts API', type: :request do
expect(contact_inboxes_source_ids).to include(contact_inbox.source_id) expect(contact_inboxes_source_ids).to include(contact_inbox.source_id)
end end
it 'returns canonical country attributes for contact display' do
contact.update!(additional_attributes: { country: 'United States' })
get "/api/v1/accounts/#{account.id}/contacts",
headers: admin.create_new_auth_token,
as: :json
response_body = response.parsed_body
response_contact = response_body['payload'].find { |item| item['id'] == contact.id }
expect(response_contact['additional_attributes']).to include(
'country_code' => 'US',
'country' => 'United States'
)
expect(response_contact['country_code']).to eq('US')
end
it 'returns all contacts without contact inboxes' do it 'returns all contacts without contact inboxes' do
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false", get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false",
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
@@ -43,7 +43,6 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
] ]
expect(params['scope']).to eq(expected_scope) expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"]) expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account # Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present expect(params['state']).to be_present
@@ -8,12 +8,12 @@ RSpec.describe 'Google::CallbacksController', type: :request do
describe 'GET /google/callback' do describe 'GET /google/callback' do
let(:response_body_success) do let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
let(:response_body_success_without_name) do let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
@@ -8,12 +8,12 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
describe 'GET /microsoft/callback' do describe 'GET /microsoft/callback' do
let(:response_body_success) do let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
let(:response_body_success_without_name) do let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10) }
end end
@@ -51,13 +51,10 @@ RSpec.describe 'Enterprise Audit API', type: :request do
expect(json_response['audit_logs'][1]['action']).to eql('create') expect(json_response['audit_logs'][1]['action']).to eql('create')
expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name) expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name)
expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id) expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id)
expect(json_response['current_page']).to be(1)
# contains audit log for account user as well # contains audit log for account user as well
# contains audit logs for account update(enable audit logs) # contains audit logs for account update(enable audit logs)
expect(json_response.slice('current_page', 'per_page', 'total_entries')).to eql( expect(json_response['total_entries']).to be(3)
'current_page' => 1,
'per_page' => 25,
'total_entries' => 3
)
end end
end end
end end
@@ -147,7 +147,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
params: sync_params, params: sync_params,
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
as: :json as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).on_queue('low').exactly(documents.size).times end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
documents.each do |document| documents.each do |document|
expect(document.reload).to have_attributes( expect(document.reload).to have_attributes(
@@ -190,7 +190,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
it 'queues documents that already have a sync in progress' do it 'skips documents that already have a sync in progress' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available) syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago) syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
@@ -199,10 +199,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
params: sync_params.merge(ids: [syncing_document.id]), params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
as: :json as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document).on_queue('low') end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
end end
it 'queues stale syncing documents again' do it 'queues stale syncing documents again' do
@@ -243,7 +243,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
before do before do
create_list(:captain_document, 5, assistant: assistant, account: account) create_list(:captain_document, 5, assistant: assistant, account: account)
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').update!(value: captain_limits.to_json) create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
post "/api/v1/accounts/#{account.id}/captain/documents", post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes, params: valid_attributes,
headers: admin.create_new_auth_token headers: admin.create_new_auth_token
@@ -281,7 +281,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect do expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync", post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low') end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
expect(document.reload).to have_attributes( expect(document.reload).to have_attributes(
sync_status: 'syncing', sync_status: 'syncing',
@@ -292,15 +292,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(response).to have_http_status(:accepted) expect(response).to have_http_status(:accepted)
end end
it 'queues documents that already have a sync in progress' do it 'rejects documents that already have a sync in progress' do
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago) document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync", post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low') end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:accepted) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'queues stale syncing documents again' do it 'queues stale syncing documents again' do
@@ -1,60 +0,0 @@
require 'rails_helper'
RSpec.describe Captain::Documents::PerformSyncJob, type: :job do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
def stub_lock(job)
allow(job).to receive(:with_lock).and_yield
end
def stub_page_fetch(content: 'Updated content')
fetch_result = Captain::Documents::SinglePageFetcher::Result.new(
success: true,
title: 'Updated title',
content: content
)
fetcher = instance_double(Captain::Documents::SinglePageFetcher, fetch: fetch_result)
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
end
def stub_page_fetch_failure
fetcher = instance_double(Captain::Documents::SinglePageFetcher)
allow(fetcher).to receive(:fetch).and_raise(StandardError, 'boom')
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
end
it 'syncs the document content' do
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
job = described_class.new
stub_lock(job)
stub_page_fetch
job.perform(document)
expect(document.reload).to have_attributes(
sync_status: 'synced',
last_sync_attempted_at: Time.current,
last_synced_at: Time.current,
content: 'Updated content'
)
end
end
it 'marks unexpected failures as failed' do
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
job = described_class.new
stub_lock(job)
stub_page_fetch_failure
expect { job.perform(document) }.to raise_error(StandardError, 'boom')
expect(document.reload).to have_attributes(
sync_status: 'failed',
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
end
end
end
@@ -5,28 +5,11 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
before do before do
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', { business: 168, enterprise: 24, startups: 720, hacker: nil }.to_json) create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 50)
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 1000)
account.enable_features!('captain_document_auto_sync') account.enable_features!('captain_document_auto_sync')
clear_enqueued_jobs clear_enqueued_jobs
end end
def set_installation_config(name, value)
InstallationConfig.find_or_initialize_by(name: name).tap do |config|
config.value = value
config.save!
end
end
def update_sync_limit(name, value)
InstallationConfig.find_by!(name: name).update!(value: value)
end
def sync_job_for(document)
have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
context 'when the account has not enabled auto-sync' do context 'when the account has not enabled auto-sync' do
before { account.disable_features!('captain_document_auto_sync') } before { account.disable_features!('captain_document_auto_sync') }
@@ -49,25 +32,6 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
end end
end end
context 'when a plan name is passed' do
it 'queues due documents only for that plan' do
enterprise_account = create(:account, custom_attributes: { plan_name: 'Enterprise' })
enterprise_account.enable_features!('captain_document_auto_sync')
enterprise_assistant = create(:captain_assistant, account: enterprise_account)
business_document = create(:captain_document, assistant: assistant, account: account, status: :available)
enterprise_document = create(:captain_document, assistant: enterprise_assistant, account: enterprise_account, status: :available)
business_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
enterprise_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
clear_enqueued_jobs
described_class.new.perform('enterprise')
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(enterprise_document)
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(business_document)
end
end
context 'when an available document has backfilled sync metadata' do context 'when an available document has backfilled sync metadata' do
it 'leaves it alone when last synced within the plan cadence' do it 'leaves it alone when last synced within the plan cadence' do
create( create(
@@ -90,34 +54,53 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
account: account, account: account,
status: :available, status: :available,
sync_status: :synced, sync_status: :synced,
last_synced_at: 8.days.ago last_synced_at: 3.days.ago
) )
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to sync_job_for(document).on_queue('purgable') .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end end
it 'delays only the queued sync job' do it 'marks the due document as syncing before queueing' do
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
job = described_class.new
allow(job).to receive(:rand).and_return(30.minutes.to_i)
document = create( document = create(
:captain_document, :captain_document,
assistant: assistant, assistant: assistant,
account: account, account: account,
status: :available, status: :available,
sync_status: :synced, sync_status: :synced,
last_synced_at: 8.days.ago last_synced_at: 3.days.ago
) )
clear_enqueued_jobs clear_enqueued_jobs
job.perform described_class.new.perform
expect(Captain::Documents::PerformSyncJob) expect(document.reload).to have_attributes(
.to have_been_enqueued.with(document).at(30.minutes.from_now) sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end end
end end
it 'does not queue the same document again while the reserved sync is fresh' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 2.days.ago
)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end end
context 'when an available document was synced within the plan cadence' do context 'when an available document was synced within the plan cadence' do
@@ -133,59 +116,78 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
context 'when an available document was last synced before the plan cadence' do context 'when an available document was last synced before the plan cadence' do
it 'queues a sync for that document' do it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available) document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago) document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to sync_job_for(document) .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
end
context 'when jitter spreads queued sync execution' do
it 'uses a widened due window so jittered syncs do not skip the next plan run' do
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
job = described_class.new
interval = 1.week
due_window = (interval.to_i / 2).seconds
allow(job).to receive(:rand).and_return(2.hours.to_i)
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :synced, last_synced_at: (due_window - 1.minute).ago)
clear_enqueued_jobs
expect { job.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
document.update!(sync_status: :synced, last_synced_at: (due_window + 1.minute).ago)
clear_enqueued_jobs
expect { job.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob)
.with(document)
.on_queue('purgable')
.at(2.hours.from_now)
end
end end
it 'uses a random delay inside the cadence window' do it 'skips invalid legacy documents without counting them against the account cap' do
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
document = create(:captain_document, assistant: assistant, account: account, status: :available) create(
document.update!(sync_status: :synced, last_synced_at: 8.days.ago) :captain_document,
job = described_class.new assistant: assistant,
sync_execution_delay = 12_345.seconds account: account,
status: :in_progress,
content: nil,
external_link: 'https://example.com'
)
invalid_document = build(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 2.days.ago,
last_sync_attempted_at: 2.days.ago,
external_link: 'https://example.com/'
)
invalid_document.save!(validate: false)
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs
clear_enqueued_jobs expect { described_class.new.perform }.not_to raise_error
allow(job).to receive(:rand).with(0..described_class::WEEKLY_SYNC_JITTER.to_i).and_return(sync_execution_delay.to_i) expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(invalid_document)
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
end
expect { job.perform } it 'keeps paging due documents when invalid documents fill the first batch' do
.to sync_job_for(document) stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
.at(sync_execution_delay.from_now) stub_const("#{described_class}::DUE_DOCUMENT_BATCH_SIZE", 1)
end create(
:captain_document,
assistant: assistant,
account: account,
status: :in_progress,
content: nil,
external_link: 'https://example.com'
)
invalid_document = build(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 2.days.ago,
last_sync_attempted_at: 3.days.ago,
external_link: 'https://example.com/'
)
invalid_document.save!(validate: false)
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs
described_class.new.perform
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
end end
end end
context 'when more documents are due than the account cap allows' do context 'when more documents are due than the account cap allows' do
before do before do
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2) stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 2)
end end
it 'queues backfilled and oldest-attempted documents first' do it 'queues backfilled and oldest-attempted documents first' do
@@ -193,47 +195,26 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available) oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available) backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available)
newest_document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago) newest_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
oldest_document.update!(sync_status: :synced, last_synced_at: 9.days.ago, last_sync_attempted_at: 9.days.ago) oldest_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
backfilled_document.update!(sync_status: :synced, last_synced_at: 10.days.ago, last_sync_attempted_at: nil) backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil)
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to sync_job_for(backfilled_document) .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document)
.and sync_job_for(oldest_document) .and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document)
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document) expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
end end
end end
context 'when sync caps are configured' do
it 'uses installation config caps for per-account and global limits' do
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 3)
second_account = create(:account, custom_attributes: { plan_name: 'business' })
second_account.enable_features!('captain_document_auto_sync')
second_assistant = create(:captain_assistant, account: second_account)
first_account_documents = create_list(:captain_document, 3, assistant: assistant, account: account, status: :available)
second_account_documents = create_list(:captain_document, 3, assistant: second_assistant, account: second_account, status: :available)
(first_account_documents + second_account_documents).each do |document|
document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
end
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(3).times
end
end
context 'when an available document failed before the plan cadence' do context 'when an available document failed before the plan cadence' do
it 'queues a sync for that document' do it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available) document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :failed, last_sync_attempted_at: 8.days.ago) document.update!(sync_status: :failed, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to sync_job_for(document) .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end end
end end
@@ -247,7 +228,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
clear_enqueued_jobs clear_enqueued_jobs
expect { described_class.new.perform } expect { described_class.new.perform }
.to sync_job_for(document) .to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end end
end end
@@ -1,42 +0,0 @@
require 'rails_helper'
RSpec.describe Internal::TriggerDailyScheduledItemsJob do
before do
allow(ChatwootHub).to receive(:installation_identifier).and_return('test-installation-id')
allow(Captain::Documents::ScheduleSyncsJob).to receive(:perform_later)
end
it 'enqueues enterprise Captain document auto-sync every day' do
travel_to Time.zone.parse('2026-05-26 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
end
it 'enqueues business Captain document auto-sync weekly' do
travel_to Time.zone.parse('2026-05-24 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('business')
end
it 'enqueues startup Captain document auto-sync monthly' do
travel_to Time.zone.parse('2026-06-01 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('startups')
end
it 'does not enqueue business or startup Captain document auto-sync before their plan window' do
travel_to Time.zone.parse('2026-05-25 00:00:00 UTC') do
described_class.perform_now
end
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('business')
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('startups')
end
end
@@ -72,7 +72,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
content_type: 'audio/mpeg' content_type: 'audio/mpeg'
) )
allow(service).to receive(:can_transcribe?).and_return(true) allow(service).to receive(:can_transcribe?).and_return(true)
allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::TRANSCRIPTION_BYTE_LIMIT + 1) allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1)
end end
it 'returns an error without calling Whisper' do it 'returns an error without calling Whisper' do
@@ -32,9 +32,6 @@ describe Whatsapp::IncomingCallService do
describe 'inbound connect' do describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." } let(:sdp_offer) { "v=0\r\n...sdp..." }
let!(:agent) { create(:user, account: account) }
before { create(:inbox_member, inbox: inbox, user: agent) }
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
allow(ActionCable.server).to receive(:broadcast) allow(ActionCable.server).to receive(:broadcast)
@@ -47,15 +44,9 @@ describe Whatsapp::IncomingCallService do
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing', expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
provider_call_id: provider_call_id) provider_call_id: provider_call_id)
expect(call.meta['sdp_offer']).to eq(sdp_offer) expect(call.meta['sdp_offer']).to eq(sdp_offer)
# No agent is online, so the call falls back to the inbox's agents (and
# account admins) — never the whole-account stream.
expect(ActionCable.server).to have_received(:broadcast).with( expect(ActionCable.server).to have_received(:broadcast).with(
agent.pubsub_token,
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
)
expect(ActionCable.server).not_to have_received(:broadcast).with(
"account_#{account.id}", "account_#{account.id}",
hash_including(event: 'voice_call.incoming') hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
) )
end end
end end
@@ -82,7 +82,6 @@ RSpec.describe Instagram::IntegrationHelper do
context 'when client secret is not configured' do context 'when client secret is not configured' do
let(:client_secret) { nil } let(:client_secret) { nil }
let(:valid_token) { 'any-token' }
it 'returns nil' do it 'returns nil' do
expect(verify_instagram_token(valid_token)).to be_nil expect(verify_instagram_token(valid_token)).to be_nil
@@ -65,7 +65,6 @@ RSpec.describe Linear::IntegrationHelper do
context 'when client secret is not configured' do context 'when client secret is not configured' do
let(:client_secret) { nil } let(:client_secret) { nil }
let(:valid_token) { 'any-token' }
it 'returns nil' do it 'returns nil' do
expect(verify_linear_token(valid_token)).to be_nil expect(verify_linear_token(valid_token)).to be_nil
@@ -65,7 +65,6 @@ RSpec.describe Shopify::IntegrationHelper do
context 'when client secret is not configured' do context 'when client secret is not configured' do
let(:client_secret) { nil } let(:client_secret) { nil }
let(:valid_token) { 'any-token' }
it 'returns nil' do it 'returns nil' do
expect(verify_shopify_token(valid_token)).to be_nil expect(verify_shopify_token(valid_token)).to be_nil
@@ -99,6 +99,19 @@ RSpec.describe Account::ContactsExportJob do
expect(row['labels']).to eq('vip') expect(row['labels']).to eq('vip')
end end
it 'exports canonical country codes from legacy country data' do
legacy_country_contact = create(:contact, account: account, email: 'legacy-country@example.com',
additional_attributes: { country: 'United States' })
described_class.perform_now(account.id, user.id, %w[email country_code], {})
csv_content = account.contacts_export.download.force_encoding('UTF-8').delete_prefix("\xEF\xBB\xBF")
csv_data = CSV.parse(csv_content, headers: true)
row = csv_data.find { |record| record['email'] == legacy_country_contact.email }
expect(row['country_code']).to eq('US')
end
it 'bulk loads labels while exporting contacts' do it 'bulk loads labels while exporting contacts' do
create(:label, account: account, title: 'vip') create(:label, account: account, title: 'vip')
create(:label, account: account, title: 'support') create(:label, account: account, title: 'support')
+2 -2
View File
@@ -223,12 +223,12 @@ RSpec.describe Channel::Whatsapp do
expect(channel.voice_enabled?).to be true expect(channel.voice_enabled?).to be true
end end
it 'returns true for manual whatsapp_cloud channels with calling_enabled' do it 'returns false for whatsapp_cloud channels without embedded_signup source' do
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false) validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true)) channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true))
expect(channel.voice_enabled?).to be true expect(channel.voice_enabled?).to be false
end end
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
+36
View File
@@ -93,6 +93,42 @@ RSpec.describe Contact do
end end
end end
describe '#canonical_country_code' do
it 'prefers the contact country_code column when it has a valid country code' do
contact = create(:contact, additional_attributes: { country_code: 'IN', country: 'India' })
contact[:country_code] = 'US'
expect(contact.canonical_country_code).to eq('US')
expect(contact.canonical_country_name).to eq('United States')
end
it 'falls back to additional_attributes country_code' do
contact = create(:contact, additional_attributes: { country_code: 'in' })
expect(contact.canonical_country_code).to eq('IN')
expect(contact.canonical_country_name).to eq('India')
end
it 'normalizes legacy country names from additional_attributes country' do
contact = create(:contact, additional_attributes: { country: 'United States' })
expect(contact.canonical_country_code).to eq('US')
expect(contact.canonical_country_name).to eq('United States')
end
end
describe '#additional_attributes_with_canonical_country' do
it 'returns additional attributes with normalized country values without persisting them' do
contact = create(:contact, additional_attributes: { country: 'United States' })
expect(contact.additional_attributes_with_canonical_country).to include(
'country_code' => 'US',
'country' => 'United States'
)
expect(contact.reload.additional_attributes).not_to include('country_code')
end
end
context 'when a contact is created' do context 'when a contact is created' do
it 'has contact type "visitor" by default' do it 'has contact type "visitor" by default' do
contact = create(:contact) contact = create(:contact)
@@ -48,6 +48,16 @@ RSpec.describe LlmFormatter::ContactLlmFormatter do
end end
end end
context 'when contact has legacy country data' do
before do
contact.update!(additional_attributes: { country: 'United States' })
end
it 'uses the canonical country code' do
expect(formatter.format).to include('Country Code: US')
end
end
context 'when contact has custom attributes' do context 'when contact has custom attributes' do
let!(:custom_attribute) do let!(:custom_attribute) do
create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company') create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company')
+3
View File
@@ -11,6 +11,9 @@ properties:
availability_status: availability_status:
type: string type: string
description: The availability status of the contact description: The availability status of the contact
country_code:
type: string
description: The normalized ISO country code of the contact
email: email:
type: string type: string
description: The email address of the contact description: The email address of the contact
@@ -24,7 +24,7 @@ responses:
per_page: per_page:
type: integer type: integer
description: Number of items per page description: Number of items per page
example: 25 example: 15
total_entries: total_entries:
type: integer type: integer
description: Total number of audit log entries description: Total number of audit log entries
@@ -49,4 +49,4 @@ responses:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/bad_request_error' $ref: '#/components/schemas/bad_request_error'
+1 -1
View File
@@ -1649,7 +1649,7 @@
"per_page": { "per_page": {
"type": "integer", "type": "integer",
"description": "Number of items per page", "description": "Number of items per page",
"example": 25 "example": 15
}, },
"total_entries": { "total_entries": {
"type": "integer", "type": "integer",
+1 -1
View File
@@ -192,7 +192,7 @@
"per_page": { "per_page": {
"type": "integer", "type": "integer",
"description": "Number of items per page", "description": "Number of items per page",
"example": 25 "example": 15
}, },
"total_entries": { "total_entries": {
"type": "integer", "type": "integer",