Compare commits

..
72 changed files with 287 additions and 1532 deletions
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.14.0
-12
View File
@@ -1,21 +1,9 @@
/* global axios */
import ApiClient from './ApiClient';
class CampaignsAPI extends ApiClient {
constructor() {
super('campaigns', { accountScoped: true });
}
analyticsMetrics(id) {
return axios.get(`${this.url}/${id}/analytics/metrics`);
}
analyticsContacts(id, { status, page } = {}) {
return axios.get(`${this.url}/${id}/analytics/contacts`, {
params: { status, page },
});
}
}
export default new CampaignsAPI();
@@ -42,13 +42,9 @@ const props = defineProps({
type: Number,
default: 0,
},
showAnalytics: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['edit', 'delete', 'analytics']);
const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
@@ -120,17 +116,7 @@ const inboxIcon = computed(() => {
/>
</div>
</div>
<div class="flex items-center justify-end w-28 gap-2">
<Button
v-if="showAnalytics"
v-tooltip.top="t('CAMPAIGN.WHATSAPP.CARD.ANALYTICS')"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-chart-no-axes-column"
:title="t('CAMPAIGN.WHATSAPP.CARD.ANALYTICS')"
@click="emit('analytics')"
/>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
variant="faded"
@@ -1,7 +1,5 @@
<script setup>
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useConfig } from 'dashboard/composables/useConfig';
defineProps({
campaigns: {
@@ -14,13 +12,10 @@ defineProps({
},
});
const emit = defineEmits(['edit', 'delete', 'analytics']);
const STATUS_COMPLETED = 'completed';
const { isEnterprise } = useConfig();
const emit = defineEmits(['edit', 'delete']);
const handleEdit = campaign => emit('edit', campaign);
const handleDelete = campaign => emit('delete', campaign);
const handleAnalytics = campaign => emit('analytics', campaign);
</script>
<template>
@@ -36,14 +31,8 @@ const handleAnalytics = campaign => emit('analytics', campaign);
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
:show-analytics="
isEnterprise &&
campaign.inbox?.channel_type === INBOX_TYPES.WHATSAPP &&
campaign.campaign_status === STATUS_COMPLETED
"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
@analytics="handleAnalytics(campaign)"
/>
</div>
</template>
@@ -35,14 +35,7 @@ const props = defineProps({
},
});
defineEmits([
'accept',
'reject',
'end',
'toggleMute',
'goToConversation',
'dismiss',
]);
defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']);
const { t } = useI18n();
@@ -122,19 +115,6 @@ const channelIcon = computed(() => {
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
{{ statusLabel }}
</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>
@@ -25,7 +25,6 @@ const {
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
@@ -52,15 +51,6 @@ const mainCardState = computed(() => {
: 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 = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
@@ -240,11 +230,10 @@ onBeforeUnmount(stopRingtone);
v-for="call in stackedIncomingCalls"
:key="call.callSid"
:call="call"
:state="stackedCardState(call)"
:state="VOICE_CALL_DIRECTION.INCOMING"
:call-info="getCallInfo(call)"
@accept="handleJoinCall(call)"
@reject="rejectIncomingCall(call.callSid)"
@dismiss="dismissCall(call.callSid)"
@go-to-conversation="goToConversation(call)"
/>
@@ -259,7 +248,6 @@ onBeforeUnmount(stopRingtone);
:show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@end="handleEndCall"
@toggle-mute="toggleMute"
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
@@ -25,7 +25,7 @@ const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available';
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
@@ -554,10 +554,11 @@ provideMessageContext({
<Avatar v-bind="avatarInfo" :size="24" />
</div>
<div
class="[grid-area:bubble] flex min-w-0"
class="[grid-area:bubble] flex"
:class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
>
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
<template>
<div
class="text-sm min-w-0"
class="text-sm"
:class="[
messageClass,
{
@@ -91,6 +91,10 @@ const wasDeclinedByAgent = computed(
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
@@ -120,48 +124,43 @@ const durationSeconds = computed(() => {
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(() => {
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) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an
// outbound call never falls through to the "Incoming call" label.
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
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) {
return [handledBy.value, formattedDuration.value]
.filter(Boolean)
.join(' · ');
return formattedDuration.value;
}
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) {
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
if (isOutbound.value) {
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');
}
// 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');
});
@@ -31,17 +31,6 @@ const timeStampURL = computed(() => {
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 isPlaying = ref(false);
@@ -226,18 +215,7 @@ const downloadAudio = async () => {
v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ displayedTranscript }}
<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>
{{ attachment.transcribedText }}
</div>
</div>
</template>
@@ -589,11 +589,11 @@ function isCmdPlusEnterToSendEnabled() {
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
});
@@ -105,11 +105,11 @@ export default {
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => handleReplyClick(),
allowOnFocusedInput: false,
allowOnFocusedInput: true,
},
};
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 { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
const mount = async events => {
useKeyboardEvents(events);
await keyboardEventsMock.mountedCallbacks[0]();
return keyboardEventsMock.registeredKeybindings;
};
describe('useKeyboardEvents', () => {
beforeEach(() => {
keyboardEventsMock.mountedCallbacks = [];
keyboardEventsMock.unmountedCallbacks = [];
keyboardEventsMock.registeredKeybindings = {};
});
it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined();
});
@@ -50,46 +9,18 @@ describe('useKeyboardEvents', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('registers keybindings on mount and removes the listener on unmount', async () => {
const addEventListener = vi.spyOn(document, 'addEventListener');
it('should set up listeners on mount and remove them on unmount', async () => {
const events = {
'ALT+KeyL': () => {},
};
const keybindings = await mount({ 'ALT+KeyL': () => {} });
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
const { signal } = addEventListener.mock.calls[0][2];
expect(signal.aborted).toBe(false);
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);
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
});
});
-5
View File
@@ -1,5 +1,4 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls';
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
// rendering it after the user navigates away from a conversation list that
// 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;
if (!sender) return null;
return {
@@ -145,7 +145,6 @@
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
},
"CARD": {
"ANALYTICS": "View analytics",
"STATUS": {
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
@@ -200,38 +199,6 @@
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
},
"ANALYTICS": {
"TITLE": "Campaign analytics",
"BACK": "Back to WhatsApp campaigns",
"EMPTY": "No delivery records found.",
"EMPTY_FILTER": "No {status} delivery records found.",
"SUMMARY": "{delivered} of {audience} contacts received this campaign. {skipped} skipped and {failed} failed.",
"RATE": "{value} of audience",
"PAGE_INFO": "Page {current} of {total}",
"BREADCRUMB": {
"CAMPAIGNS": "Campaigns",
"WHATSAPP": "WhatsApp"
},
"FILTERS": {
"ALL": "All"
},
"METRICS": {
"AUDIENCE": "Audience",
"SENT": "Sent",
"DELIVERED": "Delivered",
"READ": "Read",
"FAILED": "Failed",
"SKIPPED": "Skipped"
},
"TABLE": {
"CONTACT": "Contact",
"PHONE_NUMBER": "Phone number",
"STATUS": "Status",
"MESSAGE": "Message",
"MESSAGE_NOT_GENERATED": "Not generated",
"ERROR_REASON": "Reason"
}
}
},
"CONFIRM_DELETE": {
@@ -86,16 +86,13 @@
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call",
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
"CALL_BACK": "Call back"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -315,7 +312,6 @@
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call",
"END_CALL": "End call",
"MUTE": "Mute mic",
@@ -4,7 +4,6 @@ import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
import WhatsAppCampaignAnalyticsPage from './pages/WhatsAppCampaignAnalyticsPage.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = {
@@ -61,15 +60,6 @@ const campaignsRoutes = {
},
component: WhatsAppCampaignsPage,
},
{
path: 'whatsapp/:campaignId/analytics',
name: 'campaigns_whatsapp_analytics',
meta: {
...meta,
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
},
component: WhatsAppCampaignAnalyticsPage,
},
],
},
],
@@ -1,386 +0,0 @@
<script setup>
import { computed, onMounted, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import CampaignsAPI from 'dashboard/api/campaigns';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const getters = useStoreGetters();
const state = reactive({
metrics: null,
contacts: [],
meta: {},
status: 'all',
page: 1,
isFetchingMetrics: false,
isFetchingContacts: false,
});
const campaignId = computed(() => Number(route.params.campaignId));
const campaign = computed(() =>
getters['campaigns/getAllCampaigns'].value.find(
record => Number(record.id) === campaignId.value
)
);
const campaignTitle = computed(
() => campaign.value?.title || `#${campaignId.value}`
);
const breadcrumbItems = computed(() => [
{ label: t('CAMPAIGN.WHATSAPP.ANALYTICS.BREADCRUMB.CAMPAIGNS') },
{ label: t('CAMPAIGN.WHATSAPP.ANALYTICS.BREADCRUMB.WHATSAPP') },
{ label: campaignTitle.value },
]);
const statusOptions = computed(() => [
{ key: 'all', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.FILTERS.ALL') },
{ key: 'sent', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SENT') },
{
key: 'delivered',
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.DELIVERED'),
},
{ key: 'read', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.READ') },
{ key: 'failed', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.FAILED') },
{ key: 'skipped', label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SKIPPED') },
]);
const metricItems = computed(() => [
{
key: 'audience',
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.AUDIENCE'),
showRate: false,
},
{
key: 'sent',
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SENT'),
showRate: true,
},
{
key: 'delivered',
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.DELIVERED'),
showRate: true,
},
{
key: 'read',
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.READ'),
showRate: true,
},
{
key: 'failed',
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.FAILED'),
showRate: true,
},
{
key: 'skipped',
label: t('CAMPAIGN.WHATSAPP.ANALYTICS.METRICS.SKIPPED'),
showRate: true,
},
]);
const hasNextPage = computed(() => state.page < Number(state.meta.total_pages));
const hasPreviousPage = computed(() => state.page > 1);
const audienceCount = computed(() => Number(state.metrics?.audience || 0));
const insightSummary = computed(() =>
t('CAMPAIGN.WHATSAPP.ANALYTICS.SUMMARY', {
delivered: state.metrics?.delivered || 0,
audience: audienceCount.value,
skipped: state.metrics?.skipped || 0,
failed: state.metrics?.failed || 0,
})
);
const filterLabel = option => {
const count =
option.key === 'all'
? audienceCount.value
: Number(state.metrics?.[option.key] || 0);
return `${option.label} ${count}`;
};
const metricRate = key => {
if (!audienceCount.value) return '0%';
return `${Math.round((Number(state.metrics?.[key] || 0) / audienceCount.value) * 100)}%`;
};
const fetchMetrics = async () => {
state.isFetchingMetrics = true;
try {
const response = await CampaignsAPI.analyticsMetrics(campaignId.value);
state.metrics = response.data;
} finally {
state.isFetchingMetrics = false;
}
};
const fetchContacts = async () => {
state.isFetchingContacts = true;
try {
const response = await CampaignsAPI.analyticsContacts(campaignId.value, {
status: state.status === 'all' ? undefined : state.status,
page: state.page,
});
state.contacts = response.data.payload;
state.meta = response.data.meta;
} finally {
state.isFetchingContacts = false;
}
};
const setStatus = status => {
state.status = status;
state.page = 1;
};
const goToPreviousPage = () => {
if (!hasPreviousPage.value) return;
state.page -= 1;
};
const goToNextPage = () => {
if (!hasNextPage.value) return;
state.page += 1;
};
const statusLabel = status =>
statusOptions.value.find(option => option.key === status)?.label || status;
const errorReason = delivery =>
delivery.error_message || delivery.error_title || delivery.error_code || '-';
const messageContent = delivery => {
if (delivery.message_content) return delivery.message_content;
if (delivery.status === 'skipped') {
return t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.MESSAGE_NOT_GENERATED');
}
return '-';
};
const emptyStateMessage = computed(() => {
if (state.status === 'all') return t('CAMPAIGN.WHATSAPP.ANALYTICS.EMPTY');
return t('CAMPAIGN.WHATSAPP.ANALYTICS.EMPTY_FILTER', {
status: statusLabel(state.status).toLowerCase(),
});
});
const statusBadgeClass = status =>
({
sent: 'bg-n-blue-3 text-n-blue-11',
delivered: 'bg-n-teal-3 text-n-teal-11',
read: 'bg-n-iris-3 text-n-iris-11',
failed: 'bg-n-ruby-3 text-n-ruby-11',
skipped: 'bg-n-amber-3 text-n-amber-11',
})[status] || 'bg-n-slate-3 text-n-slate-11';
const goBack = () => {
router.push({ name: 'campaigns_whatsapp_index' });
};
const handleBreadcrumbClick = () => {
goBack();
};
onMounted(() => {
store.dispatch('campaigns/get');
fetchMetrics();
fetchContacts();
});
watch(
() => [state.status, state.page],
() => fetchContacts()
);
</script>
<template>
<section class="flex h-full flex-col overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="mx-auto w-full max-w-7xl">
<div class="flex w-full items-center py-7">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</div>
</header>
<main class="flex-1 overflow-y-auto px-6">
<div class="mx-auto w-full max-w-7xl py-4">
<div class="mb-6 min-w-0">
<h1 class="text-heading-1 text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TITLE') }}
</h1>
<p class="mt-1 truncate text-sm text-n-slate-11">
{{ campaignTitle }}
<span
v-if="campaign?.inbox?.name"
class="before:mx-1 before:text-n-slate-10 before:content-['·']"
>
{{ campaign.inbox.name }}
</span>
</p>
</div>
<div
v-if="state.isFetchingMetrics"
class="flex h-24 items-center justify-center text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="grid grid-cols-2 gap-3 md:grid-cols-6">
<div
v-for="item in metricItems"
:key="item.key"
class="rounded-lg border border-n-weak bg-n-alpha-2 p-4"
>
<div class="text-sm font-medium text-n-slate-11">
{{ item.label }}
</div>
<div class="mt-3 text-2xl font-semibold text-n-slate-12">
{{ state.metrics?.[item.key] || 0 }}
</div>
<div
v-if="item.showRate"
class="mt-1 text-xs font-medium text-n-slate-10"
>
{{
t('CAMPAIGN.WHATSAPP.ANALYTICS.RATE', {
value: metricRate(item.key),
})
}}
</div>
</div>
</div>
<div
v-if="state.metrics"
class="mt-4 rounded-lg border border-n-weak bg-n-alpha-1 px-4 py-3 text-sm text-n-slate-11"
>
{{ insightSummary }}
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Button
v-for="option in statusOptions"
:key="option.key"
size="sm"
:variant="state.status === option.key ? 'solid' : 'faded'"
:color="state.status === option.key ? 'blue' : 'slate'"
:label="filterLabel(option)"
@click="setStatus(option.key)"
/>
</div>
<div class="mt-4 overflow-hidden rounded-lg border border-n-weak">
<table class="w-full table-fixed text-left text-sm">
<thead class="bg-n-alpha-2 text-xs font-medium text-n-slate-11">
<tr>
<th class="w-[15%] px-4 py-3">
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.CONTACT') }}
</th>
<th class="w-[14%] px-4 py-3">
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.PHONE_NUMBER') }}
</th>
<th class="w-[10%] px-4 py-3">
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.STATUS') }}
</th>
<th class="w-[43%] px-4 py-3">
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.MESSAGE') }}
</th>
<th class="w-[18%] px-4 py-3">
{{ t('CAMPAIGN.WHATSAPP.ANALYTICS.TABLE.ERROR_REASON') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-12">
<tr v-if="state.isFetchingContacts">
<td colspan="5" class="h-24 text-center text-n-slate-11">
<Spinner />
</td>
</tr>
<tr v-else-if="state.contacts.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-n-slate-11">
{{ emptyStateMessage }}
</td>
</tr>
<template v-else>
<tr
v-for="delivery in state.contacts"
:key="delivery.contact.id"
>
<td class="break-words px-4 py-4 align-top">
{{ delivery.contact.name || '-' }}
</td>
<td class="break-words px-4 py-4 align-top">
{{ delivery.contact.phone_number || '-' }}
</td>
<td class="px-4 py-4 align-top">
<span
class="inline-flex h-6 items-center rounded-md px-2 text-xs font-medium capitalize"
:class="statusBadgeClass(delivery.status)"
>
{{ statusLabel(delivery.status) }}
</span>
</td>
<td
class="whitespace-pre-wrap break-words px-4 py-4 align-top text-n-slate-11"
:class="{
'italic text-n-slate-10': !delivery.message_content,
}"
>
{{ messageContent(delivery) }}
</td>
<td
class="whitespace-pre-wrap break-words px-4 py-4 align-top text-n-slate-11"
>
{{ errorReason(delivery) }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between gap-3">
<span class="text-sm text-n-slate-11">
{{
t('CAMPAIGN.WHATSAPP.ANALYTICS.PAGE_INFO', {
current: state.meta.current_page || 1,
total: state.meta.total_pages || 1,
})
}}
</span>
<div class="flex gap-2">
<Button
size="sm"
color="slate"
variant="faded"
icon="i-lucide-chevron-left"
:disabled="!hasPreviousPage"
@click="goToPreviousPage"
/>
<Button
size="sm"
color="slate"
variant="faded"
icon="i-lucide-chevron-right"
:disabled="!hasNextPage"
@click="goToNextPage"
/>
</div>
</div>
</div>
</main>
</section>
</template>
@@ -10,11 +10,9 @@ import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
import { useRouter } from 'vue-router';
const { t } = useI18n();
const getters = useStoreGetters();
const router = useRouter();
const selectedCampaign = ref(null);
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
@@ -36,13 +34,6 @@ const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
const handleAnalytics = campaign => {
router.push({
name: 'campaigns_whatsapp_analytics',
params: { campaignId: campaign.id },
});
};
</script>
<template>
@@ -68,7 +59,6 @@ const handleAnalytics = campaign => {
v-else-if="!hasNoWhatsAppCampaigns"
:campaigns="WhatsAppCampaigns"
@delete="handleDelete"
@analytics="handleAnalytics"
/>
<WhatsAppCampaignEmptyState
v-else
@@ -253,6 +253,7 @@ export default {
if (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
@@ -7,7 +7,7 @@ const state = {
records: [],
meta: {
currentPage: 1,
perPage: 25,
perPage: 15,
totalEntries: 0,
},
uiFlags: {
+1 -3
View File
@@ -54,9 +54,7 @@ export const useCallsStore = defineStore('calls', {
return;
}
// Prepend so the newest call surfaces as the primary card (incomingCalls[0])
// and older calls drop into the stack below it.
this.calls.unshift({
this.calls.push({
...callData,
isActive: false,
});
@@ -23,5 +23,3 @@ class Internal::TriggerDailyScheduledItemsJob < ApplicationJob
@designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440
end
end
Internal::TriggerDailyScheduledItemsJob.prepend_mod_with('Internal::TriggerDailyScheduledItemsJob')
-1
View File
@@ -129,4 +129,3 @@ class Campaign < ApplicationRecord
"NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);"
end
end
Campaign.include_mod_with('Campaign')
+8 -10
View File
@@ -41,19 +41,19 @@ class Channel::Whatsapp < ApplicationRecord
end
# 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);
# only 360dialog (default provider) can't reach the call APIs.
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled?
voice_calling_supported? &&
provider_config['calling_enabled'].present? &&
account.feature_enabled?('channel_voice')
end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
# on even though calling_enabled would persist.
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only
# reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud
# and 360dialog inboxes can't be toggled on even though calling_enabled would persist.
def voice_calling_supported?
provider == 'whatsapp_cloud'
provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup'
end
def provider_service
@@ -69,7 +69,7 @@ class Channel::Whatsapp < ApplicationRecord
# Saved with validate: false to skip validate_provider_config's remote credential
# re-check, which could spuriously fail and desync the flag from Meta.
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')
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
# trap admins). Leaves Meta's WABA calling.status untouched.
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)
save!(validate: false)
@@ -140,5 +140,3 @@ class Channel::Whatsapp < ApplicationRecord
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
end
end
Channel::Whatsapp.prepend_mod_with('Channel::Whatsapp')
+1 -35
View File
@@ -1,5 +1,3 @@
require 'resolv'
class WebsiteBrandingService
include SocialLinkParser
@@ -26,8 +24,7 @@ class WebsiteBrandingService
colors: extract_colors(doc),
logos: extract_logos(doc),
socials: build_socials(links),
email: @email,
email_provider: detect_email_provider
email: @email
})
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] #{e.message}"
@@ -107,37 +104,6 @@ class WebsiteBrandingService
rescue URI::InvalidURIError
nil
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
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
@@ -215,5 +215,3 @@ class Whatsapp::IncomingMessageBaseService
@contact.name == phone_number || @contact.name == formatted_phone_number
end
end
Whatsapp::IncomingMessageBaseService.prepend_mod_with('Whatsapp::IncomingMessageBaseService')
@@ -109,5 +109,3 @@ class Whatsapp::OneoffCampaignService
nil
end
end
Whatsapp::OneoffCampaignService.prepend_mod_with('Whatsapp::OneoffCampaignService')
@@ -104,5 +104,3 @@ class Whatsapp::Providers::BaseService
create_payload('list', message.outgoing_content, JSON.generate(json_hash))
end
end
Whatsapp::Providers::BaseService.prepend_mod_with('Whatsapp::Providers::BaseService')
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.14.1'
version: '4.14.0'
development:
<<: *shared
-12
View File
@@ -215,18 +215,6 @@
value:
locked: false
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
# ------- Context.dev Config ------- #
+1 -6
View File
@@ -124,12 +124,7 @@ Rails.application.routes.draw do
resources :inbox_limits, only: [:create, :update, :destroy]
end
end
resources :campaigns, only: [:index, :create, :show, :update, :destroy] do
if ChatwootApp.enterprise?
get 'analytics/metrics', to: 'campaigns/analytics#metrics'
get 'analytics/contacts', to: 'campaigns/analytics#contacts'
end
end
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
namespace :channels do
resource :twilio_channel, only: [:create]
@@ -1,36 +0,0 @@
class CreateCampaignDeliveries < ActiveRecord::Migration[7.1]
def change
create_campaign_deliveries
add_campaign_delivery_indexes
end
private
def create_campaign_deliveries
create_table :campaign_deliveries do |t|
t.references :account, null: false, foreign_key: true
t.references :campaign, null: false, foreign_key: true
t.references :contact, null: false, foreign_key: true
t.references :inbox, null: false, foreign_key: true
t.string :source_id
t.integer :status, null: false, default: 0
t.string :error_code
t.string :error_title
t.text :error_message
t.text :message_content
t.datetime :sent_at
t.datetime :delivered_at
t.datetime :read_at
t.datetime :failed_at
t.timestamps
end
end
def add_campaign_delivery_indexes
add_index :campaign_deliveries, [:account_id, :campaign_id]
add_index :campaign_deliveries, [:campaign_id, :status]
add_index :campaign_deliveries, [:campaign_id, :contact_id], unique: true
add_index :campaign_deliveries, :source_id, unique: true, where: 'source_id IS NOT NULL'
end
end
+1 -32
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_30_090000) do
ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -285,33 +285,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_30_090000) do
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
end
create_table "campaign_deliveries", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "campaign_id", null: false
t.bigint "contact_id", null: false
t.bigint "inbox_id", null: false
t.string "source_id"
t.integer "status", default: 0, null: false
t.string "error_code"
t.string "error_title"
t.text "error_message"
t.text "message_content"
t.datetime "sent_at"
t.datetime "delivered_at"
t.datetime "read_at"
t.datetime "failed_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "campaign_id"], name: "index_campaign_deliveries_on_account_id_and_campaign_id"
t.index ["account_id"], name: "index_campaign_deliveries_on_account_id"
t.index ["campaign_id", "contact_id"], name: "index_campaign_deliveries_on_campaign_id_and_contact_id", unique: true
t.index ["campaign_id", "status"], name: "index_campaign_deliveries_on_campaign_id_and_status"
t.index ["campaign_id"], name: "index_campaign_deliveries_on_campaign_id"
t.index ["contact_id"], name: "index_campaign_deliveries_on_contact_id"
t.index ["inbox_id"], name: "index_campaign_deliveries_on_inbox_id"
t.index ["source_id"], name: "index_campaign_deliveries_on_source_id", unique: true, where: "(source_id IS NOT NULL)"
end
create_table "campaigns", force: :cascade do |t|
t.integer "display_id", null: false
t.string "title", null: false
@@ -1350,10 +1323,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_30_090000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "campaign_deliveries", "accounts"
add_foreign_key "campaign_deliveries", "campaigns"
add_foreign_key "campaign_deliveries", "contacts"
add_foreign_key "campaign_deliveries", "inboxes"
add_foreign_key "inboxes", "portals"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").
@@ -2,7 +2,7 @@ class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAcco
before_action :check_admin_authorization?
before_action :fetch_audit
RESULTS_PER_PAGE = 25
RESULTS_PER_PAGE = 15
def show
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
@@ -1,83 +0,0 @@
class Api::V1::Accounts::Campaigns::AnalyticsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 25
before_action :campaign
before_action :authorize_campaign
before_action :ensure_whatsapp_campaign_analytics_enabled!
def metrics
render json: delivery_metrics
end
def contacts
deliveries = filtered_deliveries.includes(:contact).page(current_page).per(RESULTS_PER_PAGE)
render json: {
payload: deliveries.map { |delivery| delivery_payload(delivery) },
meta: {
current_page: deliveries.current_page,
total_pages: deliveries.total_pages,
total_count: deliveries.total_count
}
}
end
private
def campaign
@campaign ||= Current.account.campaigns.find_by!(display_id: params[:campaign_id])
end
def ensure_whatsapp_campaign_analytics_enabled!
return if @campaign.one_off? && @campaign.inbox.inbox_type == 'Whatsapp' && Current.account.feature_enabled?(:whatsapp_campaign)
raise Pundit::NotAuthorizedError
end
def authorize_campaign
authorize @campaign, :show?
end
def delivery_metrics
metric_deliveries = @campaign.campaign_deliveries
counts = metric_deliveries.group(:status).count
{
audience: metric_deliveries.count,
sent: metric_deliveries.where.not(source_id: nil).count,
delivered: counts['delivered'].to_i + counts['read'].to_i,
read: counts['read'].to_i,
failed: counts['failed'].to_i,
skipped: counts['skipped'].to_i
}
end
def filtered_deliveries
return deliveries unless CampaignDelivery.statuses.key?(params[:status])
deliveries.where(status: params[:status])
end
def deliveries
@deliveries ||= @campaign.campaign_deliveries.order(created_at: :desc)
end
def delivery_payload(delivery)
{
contact: {
id: delivery.contact.id,
name: delivery.contact.name,
phone_number: delivery.contact.phone_number
},
status: delivery.status,
message_content: delivery.message_content,
error_code: delivery.error_code,
error_title: delivery.error_title,
error_message: delivery.error_message
}
end
def current_page
params[:page].presence || 1
end
end
@@ -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|
next unless document.syncable?
next unless document.available?
next if document.sync_in_progress?
document.update!(
sync_status: :syncing,
@@ -37,6 +37,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
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_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!(
sync_status: :syncing,
@@ -35,14 +35,8 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def initiate
@call = create_outbound_call
# Link the call to its message in one transaction so the message.created
# broadcast (an after_create_commit hook) fires only once call.message_id is
# 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
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
private
@@ -48,7 +48,9 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
return if document.pdf_document?
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
rescue LockAcquisitionError
log_sync_outcome(document, result: :already_syncing)
@@ -62,12 +64,6 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
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)
payload = {
document_id: document.id,
@@ -1,27 +1,21 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs
DEFAULT_PER_ACCOUNT_BATCH_LIMIT = 50
DEFAULT_GLOBAL_BATCH_LIMIT = 1000
PER_ACCOUNT_HOURLY_CAP = 50
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
DAILY_SYNC_JITTER = 4.hours
WEEKLY_SYNC_JITTER = 1.day
MONTHLY_SYNC_JITTER = 4.days
def perform(plan_name = nil)
@per_account_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', DEFAULT_PER_ACCOUNT_BATCH_LIMIT)
@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
def perform
@remaining_global_capacity = GLOBAL_HOURLY_CAP
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|
break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync')
next unless account_in_selected_plan?(account)
stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval(sync_intervals)
@@ -30,6 +24,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
stats[:accounts_scheduled] += 1
result = enqueue_due_documents(account, interval)
stats[:documents_enqueued] += result[:enqueued]
stats[:documents_skipped] += result[:skipped]
end
log_scheduler_summary(stats)
@@ -38,85 +33,92 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
private
def enqueue_due_documents(account, interval)
per_account_limit = [@per_account_batch_limit, @remaining_global_capacity].min
result = { enqueued: 0 }
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
result = { enqueued: 0, skipped: 0 }
skipped_document_ids = []
due_documents(account, interval).limit(per_account_limit).to_a.each do |document|
process_due_document(document, interval, result)
while result[:enqueued] < per_account_limit
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
result
end
def process_due_document(document, interval, result)
sync_execution_delay = sync_jitter(interval)
def process_due_document(document, result, skipped_document_ids)
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
result[:enqueued] += 1
end
def due_documents(account, interval)
def due_documents(account, interval, skipped_document_ids)
syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced]
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(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(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)
end
def configured_sync_limit(config_key, default)
configured_value = InstallationConfig.find_by(name: config_key)&.value
limit = configured_value.to_s.to_i
limit.positive? ? limit : default
def reserve_sync_slot(document)
mark_sync_started(document)
true
rescue ActiveRecord::RecordInvalid => e
log_document_skip(document, e)
false
end
def account_in_selected_plan?(account)
return true if @plan_name.blank?
def log_document_skip(document, error)
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
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
Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
def log_scheduler_summary(stats)
payload = {
event: 'completed',
plan_name: @plan_name,
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
}.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
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
@@ -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.
def handle_call_events(channel, params)
value = params.dig(:entry, 0, :changes, 0, :value) || {}
contacts = value[:contacts]
Array(value[:calls]).each do |call_payload|
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
@@ -1,80 +0,0 @@
class CampaignDelivery < ApplicationRecord
belongs_to :account
belongs_to :campaign
belongs_to :contact
belongs_to :inbox
enum status: {
queued: 0,
skipped: 1,
sent: 2,
delivered: 3,
read: 4,
failed: 5
}
validates :contact_id, uniqueness: { scope: :campaign_id }
validates :source_id, uniqueness: true, allow_blank: true
def mark_sent!(source_id)
update!(
source_id: source_id,
status: :sent,
sent_at: Time.current,
error_code: nil,
error_title: nil,
error_message: nil
)
end
def mark_skipped!(message)
update!(status: :skipped, error_message: message)
end
def mark_failed!(error = {})
update!(
status: :failed,
failed_at: event_time(error[:timestamp]),
error_code: error[:code],
error_title: error[:title],
error_message: error[:message]
)
end
def update_from_whatsapp_status!(status)
normalized_status = status[:status].to_s
return unless %w[delivered read failed].include?(normalized_status)
return if status_downgrade?(normalized_status)
return mark_failed!(whatsapp_error(status)) if normalized_status == 'failed'
update!(
status: normalized_status,
"#{normalized_status}_at": event_time(status[:timestamp])
)
end
private
def status_downgrade?(new_status)
return false if new_status == 'failed'
self.class.statuses[new_status] < self.class.statuses[status]
end
def whatsapp_error(status)
error = status[:errors]&.first || {}
{
code: error[:code],
title: error[:title],
message: error[:message] || error[:error_data]&.dig(:details),
timestamp: status[:timestamp]
}
end
def event_time(timestamp)
return Time.current if timestamp.blank?
Time.zone.at(timestamp.to_i)
end
end
@@ -1,7 +0,0 @@
module Enterprise::Campaign
extend ActiveSupport::Concern
included do
has_many :campaign_deliveries, dependent: :destroy
end
end
@@ -1,10 +0,0 @@
module Enterprise::Channel::Whatsapp
attr_reader :last_provider_error
def send_template(...)
provider = provider_service
response = provider.send_template(...)
@last_provider_error = provider.last_error
response
end
end
@@ -2,7 +2,6 @@ module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true, counter_cache: true
has_many :campaign_deliveries, dependent: :destroy_async
after_commit :associate_company_from_email,
on: [:create, :update],
@@ -16,7 +16,6 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
advanced_search_indexing
advanced_search
linear_integration
channel_voice
].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'] || [],
links: brand['links'],
email: @email,
email_provider: detect_email_provider,
industries: brand.dig('industries', 'eic') || [],
stock: brand['stock'],
is_nsfw: brand['is_nsfw'] || false
@@ -1,10 +0,0 @@
module Enterprise::Whatsapp::IncomingMessageBaseService
private
def process_statuses
status = @processed_params[:statuses].first
CampaignDelivery.find_by(source_id: status[:id])&.update_from_whatsapp_status!(status)
super
end
end
@@ -1,102 +0,0 @@
module Enterprise::Whatsapp::OneoffCampaignService
def perform
validate_campaign!
deliveries = create_deliveries(extract_audience_labels)
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
process_deliveries(deliveries)
end
private
def process_delivery(delivery)
contact = delivery.contact
Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})"
if contact.phone_number.blank?
Rails.logger.info "Skipping contact #{contact.name} - no phone number"
delivery.mark_skipped!('Phone number is missing')
return
end
if campaign.template_params.blank?
Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
delivery.mark_skipped!('Template parameters are missing')
return
end
processed_template_params = process_liquid_template_params(contact)
if processed_template_params.nil?
delivery.mark_skipped!('Template parameters could not be resolved')
return
end
delivery.update!(message_content: rendered_message_content(contact))
send_whatsapp_template_message(delivery: delivery, to: contact.phone_number, template_params: processed_template_params)
end
def create_deliveries(audience_labels)
contacts = campaign.account.contacts.tagged_with(audience_labels, any: true)
Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}"
contacts.find_each.map do |contact|
campaign.campaign_deliveries.find_or_create_by!(contact: contact) do |delivery|
delivery.account = campaign.account
delivery.inbox = campaign.inbox
end
end
end
def process_deliveries(deliveries)
deliveries.each { |delivery| process_delivery(delivery) }
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def rendered_message_content(contact)
Liquid::CampaignTemplateService.new(campaign: campaign, contact: contact).call(campaign.message)
end
def send_whatsapp_template_message(delivery:, to:, template_params:)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: template_params
)
name, namespace, lang_code, processed_parameters = processor.call
if name.blank?
delivery.mark_skipped!('Template name could not be resolved')
return
end
source_id = channel.send_template(to, template_info(name, namespace, lang_code, processed_parameters), nil)
update_delivery_from_provider_response(delivery, source_id)
rescue StandardError => e
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
delivery.mark_failed!(message: e.message)
# continue processing remaining contacts
nil
end
def template_info(name, namespace, lang_code, processed_parameters)
{
name: name,
namespace: namespace,
lang_code: lang_code,
parameters: processed_parameters
}
end
def update_delivery_from_provider_response(delivery, source_id)
if source_id.present?
delivery.mark_sent!(source_id)
else
delivery.mark_failed!(channel.last_provider_error || { message: 'WhatsApp provider did not return a message id' })
end
end
end
@@ -1,37 +0,0 @@
module Enterprise::Whatsapp::Providers::BaseService
attr_reader :last_error
def process_response(response, message)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
@last_error = nil
parsed_response['messages'].first['id']
else
handle_error(response, message)
nil
end
end
def handle_error(response, message)
Rails.logger.error response.body
@last_error = parsed_error(response)
return if message.blank?
error_message = @last_error[:message]
return if error_message.blank?
message.external_error = error_message
message.status = :failed
message.save!
end
def parsed_error(response)
parsed_response = response.parsed_response
error = parsed_response.is_a?(Hash) ? parsed_response['error'] || {} : {}
{
code: error['code'],
title: error['title'],
message: error['error_user_msg'] || error['message']
}
end
end
@@ -1,12 +1,11 @@
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
TRANSCRIPTION_MODEL = 'gpt-4o-mini-transcribe'.freeze
# OpenAI's transcription endpoint hard limit is 25 MB *decimal* (25_000_000), not
# binary (25.megabytes = 26_214_400) — using the binary form leaks the 25.026.2 MB
# range to the API as 413s. Long audio (~70+ min Opus) keeps the attachment but skips
# transcription.
TRANSCRIPTION_BYTE_LIMIT = 25_000_000
WHISPER_MODEL = 'whisper-1'.freeze
# Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes
# = 26_214_400) — using the binary form leaks the 25.026.2 MB range to the API
# as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription.
WHISPER_BYTE_LIMIT = 25_000_000
attr_reader :attachment, :message, :account
@@ -43,7 +42,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
blob = attachment.file&.blob
return false unless blob
blob.byte_size > TRANSCRIPTION_BYTE_LIMIT
blob.byte_size > WHISPER_BYTE_LIMIT
end
def fetch_audio_file
@@ -76,12 +75,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
# temperature: 0.0 minimises hallucinations on silence / near-silent
# audio; non-zero values trigger spiraling repeats — well-documented
# behaviour across OpenAI transcription models.
# temperature: 0.0 minimises Whisper's hallucinations on silence /
# near-silent audio; non-zero values trigger spiraling repeats like
# "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour.
response = @client.audio.transcribe(
parameters: {
model: TRANSCRIPTION_MODEL,
model: WHISPER_MODEL,
file: file,
temperature: 0.0
}
@@ -98,7 +97,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
def instrumentation_params(file_path)
{
span_name: 'llm.messages.audio_transcription',
model: TRANSCRIPTION_MODEL,
model: WHISPER_MODEL,
account_id: account&.id,
feature_name: 'audio_transcription',
file_path: file_path
@@ -59,17 +59,9 @@ class Voice::InboundCallBuilder
end
def ensure_contact!
contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = contact_name.presence || from_number
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = from_number if record.name.blank?
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
# 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)
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!(
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)
broadcast_incoming(call, sdp_offer)
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
# Meta's SDP answer so the handshake completes during ringing; the call
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on
@@ -171,13 +159,11 @@ class Whatsapp::IncomingCallService
)
end
# Ring the assignee if any, else online inbox agents, else fall back to the
# inbox's own agents and account admins. Never the whole-account stream, which
# would ring online agents from unrelated inboxes.
# Ring the assignee if any, else online inbox agents, else the whole account.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
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',
streams: streams,
direction: call.direction_label, inbox_id: call.inbox_id,
@@ -189,11 +175,6 @@ class Whatsapp::IncomingCallService
inbox.available_agents.pluck('users.pubsub_token').compact
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)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }
@@ -9,6 +9,7 @@ SAML_SETUP_PROC = proc do |env|
account_id = request.params['account_id'] ||
env['omniauth.params']&.dig('account_id')
relay_state = request.params['RelayState'] || ''
for_mobile = relay_state.casecmp('mobile').zero?
if account_id
# Keep SAML request context in OmniAuth env so the callback can be processed
@@ -21,9 +22,17 @@ SAML_SETUP_PROC = proc do |env|
settings = AccountSamlSettings.find_by(account_id: account_id)
if settings
# Carry the relay state in the ACS URL (like account_id) only for mobile,
# so the mobile signal survives the cross-site callback POST without
# relying on the session cookie or the IdP echoing RelayState back. Web
# logins keep the registered ACS URL untouched so strict IdPs that
# validate it exactly are unaffected.
acs_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
acs_url += "&RelayState=#{ERB::Util.url_encode(relay_state)}" if for_mobile
# Configure the strategy options dynamically
env['omniauth.strategy'].options[:idp_sso_service_url_runtime_params] = { RelayState: :RelayState }
env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
env['omniauth.strategy'].options[:assertion_consumer_service_url] = acs_url
env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.14.1",
"version": "4.14.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -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]['audited_changes']['name']).to eql(inbox.name)
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 logs for account update(enable audit logs)
expect(json_response.slice('current_page', 'per_page', 'total_entries')).to eql(
'current_page' => 1,
'per_page' => 25,
'total_entries' => 3
)
expect(json_response['total_entries']).to be(3)
end
end
end
@@ -147,7 +147,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
params: sync_params,
headers: admin.create_new_auth_token,
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|
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)
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.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]),
headers: admin.create_new_auth_token,
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(json_response).to eq({ ids: [syncing_document.id], count: 1 })
end
it 'queues stale syncing documents again' do
@@ -243,7 +243,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
before do
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",
params: valid_attributes,
headers: admin.create_new_auth_token
@@ -281,7 +281,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
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(
sync_status: 'syncing',
@@ -292,15 +292,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(response).to have_http_status(:accepted)
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)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
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
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) }
before do
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', { business: 168, enterprise: 24, startups: 720, 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)
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
account.enable_features!('captain_document_auto_sync')
clear_enqueued_jobs
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
before { account.disable_features!('captain_document_auto_sync') }
@@ -49,25 +32,6 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
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
it 'leaves it alone when last synced within the plan cadence' do
create(
@@ -90,34 +54,53 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 8.days.ago
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
expect { described_class.new.perform }
.to sync_job_for(document).on_queue('purgable')
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
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
job = described_class.new
allow(job).to receive(:rand).and_return(30.minutes.to_i)
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 8.days.ago
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
job.perform
described_class.new.perform
expect(Captain::Documents::PerformSyncJob)
.to have_been_enqueued.with(document).at(30.minutes.from_now)
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
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
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
it 'queues a sync for that document' do
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
expect { described_class.new.perform }
.to sync_job_for(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
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
it 'uses a random delay inside the cadence window' do
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :synced, last_synced_at: 8.days.ago)
job = described_class.new
sync_execution_delay = 12_345.seconds
it 'skips invalid legacy documents without counting them against the account cap' do
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
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: 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
allow(job).to receive(:rand).with(0..described_class::WEEKLY_SYNC_JITTER.to_i).and_return(sync_execution_delay.to_i)
expect { described_class.new.perform }.not_to raise_error
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 }
.to sync_job_for(document)
.at(sync_execution_delay.from_now)
end
it 'keeps paging due documents when invalid documents fill the first batch' do
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
stub_const("#{described_class}::DUE_DOCUMENT_BATCH_SIZE", 1)
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
context 'when more documents are due than the account cap allows' 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
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)
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)
oldest_document.update!(sync_status: :synced, last_synced_at: 9.days.ago, last_sync_attempted_at: 9.days.ago)
backfilled_document.update!(sync_status: :synced, last_synced_at: 10.days.ago, last_sync_attempted_at: nil)
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: 3.days.ago, last_sync_attempted_at: 3.days.ago)
backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil)
clear_enqueued_jobs
expect { described_class.new.perform }
.to sync_job_for(backfilled_document)
.and sync_job_for(oldest_document)
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document)
.and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document)
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
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
it 'queues a sync for that document' do
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
expect { described_class.new.perform }
.to sync_job_for(document)
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
end
@@ -247,7 +228,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
clear_enqueued_jobs
expect { described_class.new.perform }
.to sync_job_for(document)
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
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'
)
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
it 'returns an error without calling Whisper' do
@@ -32,9 +32,6 @@ describe Whatsapp::IncomingCallService do
describe 'inbound connect' do
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
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',
provider_call_id: provider_call_id)
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(
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}",
hash_including(event: 'voice_call.incoming')
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
)
end
end
+2 -2
View File
@@ -223,12 +223,12 @@ RSpec.describe Channel::Whatsapp do
expect(channel.voice_enabled?).to be true
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',
validate_provider_config: false, sync_templates: false)
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
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
@@ -24,7 +24,7 @@ responses:
per_page:
type: integer
description: Number of items per page
example: 25
example: 15
total_entries:
type: integer
description: Total number of audit log entries
@@ -49,4 +49,4 @@ responses:
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
$ref: '#/components/schemas/bad_request_error'
+1 -1
View File
@@ -1649,7 +1649,7 @@
"per_page": {
"type": "integer",
"description": "Number of items per page",
"example": 25
"example": 15
},
"total_entries": {
"type": "integer",
+1 -1
View File
@@ -192,7 +192,7 @@
"per_page": {
"type": "integer",
"description": "Number of items per page",
"example": 25
"example": 15
},
"total_entries": {
"type": "integer",