Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470a73632d | ||
|
|
9fc2f25d43 | ||
|
|
565b747658 | ||
|
|
3a1beab3de | ||
|
|
64260b1f7f | ||
|
|
92d5d5ecff |
@@ -80,7 +80,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def toggle_status
|
||||
# FIXME: move this logic into a service object
|
||||
if bot_handoff?
|
||||
if pending_to_open_by_bot?
|
||||
@conversation.bot_handoff!
|
||||
elsif params[:status].present?
|
||||
set_conversation_status
|
||||
@@ -88,15 +88,19 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
else
|
||||
@status = @conversation.toggle_status
|
||||
end
|
||||
handle_human_open if @conversation.open? && Current.user.is_a?(User)
|
||||
assign_conversation if should_assign_conversation?
|
||||
end
|
||||
|
||||
def bot_handoff?
|
||||
def pending_to_open_by_bot?
|
||||
return false unless Current.user.is_a?(AgentBot)
|
||||
|
||||
@conversation.status == 'pending' && params[:status] == 'open'
|
||||
end
|
||||
|
||||
def should_assign_conversation?
|
||||
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
|
||||
end
|
||||
|
||||
def toggle_priority
|
||||
@conversation.toggle_priority(params[:priority])
|
||||
head :ok
|
||||
@@ -179,9 +183,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||
end
|
||||
|
||||
def handle_human_open
|
||||
@conversation.assignee_agent_bot = nil
|
||||
@conversation.assignee = Current.user if Current.user.agent?
|
||||
def assign_conversation
|
||||
@conversation.assignee = current_user
|
||||
@conversation.save!
|
||||
end
|
||||
|
||||
|
||||
@@ -26,20 +26,13 @@ class CaptainAssistant extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ assistantId, range, signal }) {
|
||||
getStats({ assistantId, range, signal }) {
|
||||
const requestConfig = {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||
}
|
||||
|
||||
getFaqStats({ assistantId, signal }) {
|
||||
const requestConfig = {};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range, stats }) {
|
||||
|
||||
+36
-7
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
evaluateSLAStatus,
|
||||
shouldRefreshSLAStatus,
|
||||
} from 'dashboard/helper/slaHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -12,6 +16,7 @@ const props = defineProps({
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const { t } = useI18n();
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
@@ -24,12 +29,15 @@ const slaEvents = computed(() => props.conversation?.slaEvents);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return slaStatus.value?.threshold && appliedSLA.value?.id;
|
||||
return slaStatus.value?.type && appliedSLA.value?.id;
|
||||
});
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
return slaStatus.value?.type?.toUpperCase();
|
||||
});
|
||||
const slaValueText = computed(
|
||||
() => slaStatus.value?.threshold || t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
|
||||
);
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
@@ -39,7 +47,24 @@ const updateSlaStatus = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
clearTimer();
|
||||
if (
|
||||
!shouldRefreshSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.conversation,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
@@ -52,12 +77,16 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
clearTimer();
|
||||
});
|
||||
|
||||
watch(() => props.conversation, updateSlaStatus);
|
||||
watch(
|
||||
() => props.conversation,
|
||||
() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}
|
||||
);
|
||||
|
||||
// This expose is to provide context to the parent component, so that it can decided weather
|
||||
// a new row has to be added to the conversation card or not
|
||||
@@ -96,7 +125,7 @@ defineExpose({
|
||||
class="text-sm truncate"
|
||||
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
|
||||
{{ `${slaStatusText}: ${slaValueText}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
evaluateSLAStatus,
|
||||
shouldRefreshSLAStatus,
|
||||
} from 'dashboard/helper/slaHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
@@ -15,6 +19,7 @@ const props = defineProps({
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const { t } = useI18n();
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
@@ -28,8 +33,18 @@ defineOptions({
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.type);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
const slaLabel = computed(() => {
|
||||
if (slaStatus.value?.threshold) return slaStatus.value.threshold;
|
||||
|
||||
const status = t('CONVERSATION.HEADER.SLA_STATUS.MISSED');
|
||||
return {
|
||||
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
|
||||
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
|
||||
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
|
||||
}[slaStatus.value.type];
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
@@ -39,7 +54,24 @@ const updateSlaStatus = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
clearTimer();
|
||||
if (
|
||||
!shouldRefreshSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
@@ -52,12 +84,16 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
clearTimer();
|
||||
});
|
||||
|
||||
watch(() => props.chat, updateSlaStatus);
|
||||
watch(
|
||||
() => props.chat,
|
||||
() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
@@ -70,11 +106,7 @@ defineExpose({
|
||||
v-bind="$attrs"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
>
|
||||
<Label
|
||||
:label="slaStatus.threshold"
|
||||
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||
compact
|
||||
>
|
||||
<Label :label="slaLabel" :color="isSlaMissed ? 'ruby' : 'amber'" compact>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||
</template>
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
toggleMark,
|
||||
wrapInList,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
+43
-11
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import {
|
||||
evaluateSLAStatus,
|
||||
shouldRefreshSLAStatus,
|
||||
} from 'dashboard/helper/slaHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -32,7 +35,7 @@ const slaStatus = ref({
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.type);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
const slaTextStyles = computed(() =>
|
||||
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
|
||||
@@ -40,12 +43,24 @@ const slaTextStyles = computed(() =>
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
|
||||
const status = isSlaMissed.value
|
||||
? t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
|
||||
: t('CONVERSATION.HEADER.SLA_STATUS.DUE');
|
||||
|
||||
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||
});
|
||||
return {
|
||||
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
|
||||
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
|
||||
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
|
||||
}[upperCaseType];
|
||||
});
|
||||
const showFullStatusText = computed(
|
||||
() => props.showExtendedInfo && props.parentWidth > 650
|
||||
);
|
||||
const slaValueText = computed(
|
||||
() =>
|
||||
slaStatus.value?.threshold ||
|
||||
(showFullStatusText.value ? '' : slaStatusText.value)
|
||||
);
|
||||
|
||||
const showSlaPopoverCard = computed(
|
||||
() => props.showExtendedInfo && slaEvents.value?.length > 0
|
||||
@@ -65,7 +80,24 @@ const updateSlaStatus = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
clearTimer();
|
||||
if (
|
||||
!shouldRefreshSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
@@ -76,6 +108,7 @@ watch(
|
||||
() => props.chat,
|
||||
() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -91,9 +124,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
clearTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -118,7 +149,7 @@ onUnmounted(() => {
|
||||
:class="slaTextStyles"
|
||||
/>
|
||||
<span
|
||||
v-if="showExtendedInfo && parentWidth > 650"
|
||||
v-if="showFullStatusText"
|
||||
class="text-xs font-medium"
|
||||
:class="slaTextStyles"
|
||||
>
|
||||
@@ -126,10 +157,11 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="slaValueText"
|
||||
class="text-xs font-medium"
|
||||
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
|
||||
>
|
||||
{{ slaStatus.threshold }}
|
||||
{{ slaValueText }}
|
||||
</span>
|
||||
</div>
|
||||
<SLAPopoverCard
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
@@ -47,6 +47,22 @@ const toUnixTimestamp = value => {
|
||||
: Math.floor(parsedTimestamp / 1000);
|
||||
};
|
||||
|
||||
const isSLACompleted = (sla, conversation) => {
|
||||
const terminalStatuses = ['hit', 'missed'];
|
||||
|
||||
return Boolean(
|
||||
sla.slaCompletedAt ||
|
||||
terminalStatuses.includes(sla.slaStatus) ||
|
||||
conversation.status === 'resolved'
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldRefreshSLAStatus = ({ appliedSla, chat }) => {
|
||||
if (!appliedSla || !chat) return false;
|
||||
|
||||
return !isSLACompleted(useCamelCase(appliedSla), useCamelCase(chat));
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates SLA status using backend-computed due times
|
||||
* @param {Object} params - Parameters object
|
||||
@@ -66,6 +82,9 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
const conversation = useCamelCase(chat);
|
||||
const events = useCamelCase(slaEvents || []);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const completionTime = toUnixTimestamp(sla.slaCompletedAt);
|
||||
const isCompleted = isSLACompleted(sla, conversation);
|
||||
const evaluationTime = completionTime || (isCompleted ? null : currentTime);
|
||||
const slaStatuses = [];
|
||||
|
||||
const dueAtByType = {
|
||||
@@ -84,47 +103,51 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
|
||||
slaStatuses.push({
|
||||
type,
|
||||
threshold: missedAt - currentTime,
|
||||
threshold: evaluationTime ? missedAt - evaluationTime : null,
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
|
||||
const shouldCheckFirstResponse =
|
||||
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
|
||||
if (!isCompleted) {
|
||||
const firstReplyCreatedAt = toUnixTimestamp(
|
||||
conversation.firstReplyCreatedAt
|
||||
);
|
||||
const shouldCheckFirstResponse =
|
||||
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
|
||||
|
||||
// Check FRT - until first reply is made on time
|
||||
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
|
||||
const threshold = sla.slaFrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'FRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
// Check FRT - until first reply is made on time
|
||||
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
|
||||
const threshold = sla.slaFrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'FRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check NRT - only if first reply made and waiting for response
|
||||
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
|
||||
const threshold = sla.slaNrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'NRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
// Check NRT - only if first reply made and waiting for response
|
||||
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
|
||||
const threshold = sla.slaNrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'NRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check RT - only if conversation is unresolved
|
||||
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
|
||||
const threshold = sla.slaRtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'RT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
// Check RT - only if conversation is unresolved
|
||||
if (sla.slaRtDueAt) {
|
||||
const threshold = sla.slaRtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'RT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (slaStatuses.length === 0) {
|
||||
@@ -137,13 +160,19 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
return a.isSlaMissed ? -1 : 1;
|
||||
}
|
||||
|
||||
if (a.threshold === null || b.threshold === null) {
|
||||
if (a.threshold === b.threshold) return 0;
|
||||
return a.threshold === null ? -1 : 1;
|
||||
}
|
||||
|
||||
return Math.abs(a.threshold) - Math.abs(b.threshold);
|
||||
});
|
||||
const mostUrgent = slaStatuses[0];
|
||||
|
||||
return {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(mostUrgent.threshold),
|
||||
threshold:
|
||||
mostUrgent.threshold === null ? '' : formatSLATime(mostUrgent.threshold),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateSLAStatus } from '../slaHelper';
|
||||
import { evaluateSLAStatus, shouldRefreshSLAStatus } from '../slaHelper';
|
||||
|
||||
describe('#SLA Helpers', () => {
|
||||
const currentTimestamp = 1700000000; // Fixed timestamp for testing
|
||||
@@ -378,6 +378,109 @@ describe('#SLA Helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed SLA misses', () => {
|
||||
it('freezes a recorded FRT miss at the SLA completion time', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_completed_at: currentTimestamp - 3600,
|
||||
sla_frt_due_at: currentTimestamp - 7200,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'frt', created_at: currentTimestamp - 7000 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'FRT',
|
||||
threshold: '1h',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes a recorded NRT miss at the SLA completion time', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_completed_at: currentTimestamp - 3600,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 5400 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'NRT',
|
||||
threshold: '30m',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes a recorded RT miss at the SLA completion time', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_completed_at: currentTimestamp - 3600,
|
||||
sla_rt_due_at: currentTimestamp - 7200,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'RT',
|
||||
threshold: '1h',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a static miss for a legacy completed SLA without a timestamp', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_rt_due_at: currentTimestamp - 7200,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'RT',
|
||||
threshold: '',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh scheduling', () => {
|
||||
it('refreshes only active unresolved SLAs', () => {
|
||||
expect(
|
||||
shouldRefreshSLAStatus({
|
||||
appliedSla: { sla_status: 'active' },
|
||||
chat: { status: 'open' },
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRefreshSLAStatus({
|
||||
appliedSla: { sla_status: 'active' },
|
||||
chat: { status: 'resolved' },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRefreshSLAStatus({
|
||||
appliedSla: { sla_status: 'missed' },
|
||||
chat: { status: 'open' },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time formatting', () => {
|
||||
it('formats time in days and hours', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
|
||||
|
||||
@@ -26,28 +26,25 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const metricStats = ref(null);
|
||||
const faqStats = ref(null);
|
||||
const isFetchingMetrics = ref(false);
|
||||
const stats = ref(null);
|
||||
const isFetching = ref(false);
|
||||
|
||||
// Increments on every fetch so a response (or retry) from a superseded
|
||||
// range/assistant can't clobber the latest request's state.
|
||||
let metricsFetchToken = 0;
|
||||
let faqStatsFetchToken = 0;
|
||||
let metricsAbortController = null;
|
||||
let faqStatsAbortController = null;
|
||||
let fetchToken = 0;
|
||||
let abortController = null;
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
metricsFetchToken += 1;
|
||||
const token = metricsFetchToken;
|
||||
metricsAbortController?.abort();
|
||||
metricsAbortController = new AbortController();
|
||||
const { signal } = metricsAbortController;
|
||||
metricStats.value = null;
|
||||
isFetchingMetrics.value = true;
|
||||
const fetchStats = async () => {
|
||||
fetchToken += 1;
|
||||
const token = fetchToken;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
stats.value = null;
|
||||
isFetching.value = true;
|
||||
|
||||
const requestMetrics = () =>
|
||||
CaptainAssistant.getMetrics({
|
||||
const requestStats = () =>
|
||||
CaptainAssistant.getStats({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
signal,
|
||||
@@ -55,54 +52,25 @@ const fetchMetrics = async () => {
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
({ data } = await requestMetrics());
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
// One silent retry before giving up, unless the request was aborted.
|
||||
try {
|
||||
if (token === metricsFetchToken && !signal.aborted)
|
||||
({ data } = await requestMetrics());
|
||||
if (token === fetchToken && !signal.aborted)
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== metricsFetchToken || signal.aborted) return;
|
||||
metricStats.value = data;
|
||||
isFetchingMetrics.value = false;
|
||||
if (token !== fetchToken || signal.aborted) return;
|
||||
stats.value = data;
|
||||
isFetching.value = false;
|
||||
};
|
||||
|
||||
const fetchFaqStats = async () => {
|
||||
faqStatsFetchToken += 1;
|
||||
const token = faqStatsFetchToken;
|
||||
faqStatsAbortController?.abort();
|
||||
faqStatsAbortController = new AbortController();
|
||||
const { signal } = faqStatsAbortController;
|
||||
faqStats.value = null;
|
||||
onUnmounted(() => abortController?.abort());
|
||||
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getFaqStats({
|
||||
assistantId: assistantId.value,
|
||||
signal,
|
||||
});
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
||||
} catch {
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
if (!metricStats.value || !faqStats.value) return null;
|
||||
|
||||
return { ...metricStats.value, knowledge: faqStats.value };
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
metricsAbortController?.abort();
|
||||
faqStatsAbortController?.abort();
|
||||
});
|
||||
|
||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
||||
watch(assistantId, fetchFaqStats, { immediate: true });
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
@@ -122,7 +90,7 @@ const formatDuration = hours =>
|
||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = metricStats.value?.[statKey];
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
@@ -216,9 +184,9 @@ const closeDrilldown = () => {
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
||||
<WelcomeCard :range="selectedRange" :stats="stats" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
@@ -231,15 +199,13 @@ const closeDrilldown = () => {
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:loading="isFetchingMetrics"
|
||||
:clickable="
|
||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
||||
"
|
||||
:loading="isFetching"
|
||||
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
@@ -173,7 +173,6 @@ class Conversation < ApplicationRecord
|
||||
|
||||
def bot_handoff!
|
||||
update(waiting_since: Time.current) if waiting_since.blank?
|
||||
self.assignee_agent_bot = nil
|
||||
open!
|
||||
dispatcher_dispatch(CONVERSATION_BOT_HANDOFF)
|
||||
end
|
||||
@@ -292,19 +291,13 @@ class Conversation < ApplicationRecord
|
||||
|
||||
return handle_campaign_status if campaign.present?
|
||||
|
||||
set_active_bot_conversation if inbox.active_bot?
|
||||
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
|
||||
self.status = :pending if inbox.active_bot?
|
||||
end
|
||||
|
||||
def handle_campaign_status
|
||||
set_active_bot_conversation if campaign.sender_id.nil? && inbox.active_bot?
|
||||
end
|
||||
|
||||
def set_active_bot_conversation
|
||||
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
|
||||
self.status = :pending
|
||||
return unless inbox.agent_bot_inbox&.active? && assignee_id.blank?
|
||||
|
||||
self.assignee_agent_bot = inbox.agent_bot
|
||||
# If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it
|
||||
self.status = :pending if campaign.sender_id.nil? && inbox.active_bot?
|
||||
end
|
||||
|
||||
def notify_conversation_creation
|
||||
|
||||
+1
-2
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
get :metrics
|
||||
get :faq_stats
|
||||
get :stats
|
||||
get :summary
|
||||
get :drilldown
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddCompletedAtToAppliedSlas < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :applied_slas, :completed_at, :datetime
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -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_07_13_184351) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_15_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -178,6 +178,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "sla_status", default: 0
|
||||
t.datetime "completed_at"
|
||||
t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true
|
||||
t.index ["account_id"], name: "index_applied_slas_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
|
||||
|
||||
@@ -37,23 +37,6 @@ class Captain::AssistantStatsBuilder
|
||||
build_metrics(current, previous)
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def faq_stats
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :window
|
||||
@@ -73,7 +56,8 @@ class Captain::AssistantStatsBuilder
|
||||
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
|
||||
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
|
||||
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
|
||||
knowledge: knowledge
|
||||
}
|
||||
end
|
||||
|
||||
@@ -89,7 +73,7 @@ class Captain::AssistantStatsBuilder
|
||||
auto_resolution: rate(resolution[:resolved], handled),
|
||||
handoff: rate(resolution[:handoff], handled),
|
||||
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
|
||||
reopen: reopen_rate(range, resolution[:resolved]),
|
||||
reopen: reopen_rate(range),
|
||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
||||
}
|
||||
end
|
||||
@@ -174,9 +158,7 @@ class Captain::AssistantStatsBuilder
|
||||
# derived from the assistant's handled conversations (not current inbox membership) so a later
|
||||
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
|
||||
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
|
||||
def reopen_rate(range, resolved_count)
|
||||
return 0 if resolved_count.zero?
|
||||
|
||||
def reopen_rate(range)
|
||||
resolved_scope = account.reporting_events
|
||||
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
@@ -196,7 +178,24 @@ class Captain::AssistantStatsBuilder
|
||||
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
||||
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
||||
.distinct.count('reporting_events.conversation_id')
|
||||
rate(reopened, resolved_count)
|
||||
rate(reopened, resolved_scope.distinct.count(:conversation_id))
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def knowledge
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -42,14 +42,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
def metrics
|
||||
def stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||
end
|
||||
|
||||
def faq_stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
|
||||
end
|
||||
|
||||
def summary
|
||||
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
||||
result = cached_or_generated_summary(window, summary_stats)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# sla_status :integer default("active")
|
||||
# completed_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
@@ -53,6 +54,7 @@ class AppliedSla < ApplicationRecord
|
||||
sla_status: sla_status,
|
||||
created_at: created_at.to_i,
|
||||
updated_at: updated_at.to_i,
|
||||
sla_completed_at: completed_at&.to_i,
|
||||
sla_description: sla_policy.description,
|
||||
sla_name: sla_policy.name,
|
||||
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
|
||||
|
||||
@@ -33,6 +33,18 @@ module Enterprise::Conversation
|
||||
|
||||
private
|
||||
|
||||
def handle_resolved_status_change
|
||||
super
|
||||
update_applied_sla_completion
|
||||
end
|
||||
|
||||
def update_applied_sla_completion
|
||||
return unless saved_change_to_status?
|
||||
return if applied_sla.blank? || applied_sla.hit? || applied_sla.missed?
|
||||
|
||||
applied_sla.update!(completed_at: resolved? ? Time.current : nil)
|
||||
end
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
|
||||
@@ -7,11 +7,7 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def metrics?
|
||||
true
|
||||
end
|
||||
|
||||
def faq_stats?
|
||||
def stats?
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
class Sla::BackfillAppliedSlaCompletedAtService
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
|
||||
def initialize(**options)
|
||||
options.assert_valid_keys(:account_id, :all_accounts, :apply, :batch_size, :after_id, :output)
|
||||
|
||||
@account_id = options[:account_id]
|
||||
@all_accounts = options.fetch(:all_accounts, false)
|
||||
@apply = options.fetch(:apply, false)
|
||||
@batch_size = options.fetch(:batch_size, DEFAULT_BATCH_SIZE)
|
||||
@after_id = options.fetch(:after_id, 0)
|
||||
@output = options.fetch(:output, $stdout)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_options!
|
||||
|
||||
scope = candidate_scope
|
||||
eligible_count = scope.count
|
||||
counters = { processed: 0, matched: 0, updated: 0, skipped: 0, last_id: @after_id }
|
||||
|
||||
print_preflight(eligible_count)
|
||||
|
||||
scope.find_in_batches(batch_size: @batch_size, start: @after_id + 1) { |batch| process_batch(batch, counters) }
|
||||
|
||||
result = counters.merge(eligible: eligible_count, dry_run: !@apply)
|
||||
@output.puts "Completed: #{result.inspect}"
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_batch(batch, counters)
|
||||
resolution_times = resolution_times_for(batch)
|
||||
updated_count = @apply ? bulk_update(resolution_times) : 0
|
||||
|
||||
counters[:processed] += batch.size
|
||||
counters[:matched] += resolution_times.size
|
||||
counters[:updated] += updated_count
|
||||
counters[:skipped] += batch.size - resolution_times.size
|
||||
counters[:last_id] = batch.last.id
|
||||
|
||||
@output.puts "Processed through applied_sla_id=#{counters[:last_id]} " \
|
||||
"(matched=#{counters[:matched]}, updated=#{counters[:updated]}, skipped=#{counters[:skipped]})"
|
||||
end
|
||||
|
||||
def validate_options!
|
||||
account_scope = @account_id.present?
|
||||
raise ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true' if account_scope == @all_accounts
|
||||
raise ArgumentError, 'BATCH_SIZE must be greater than zero' unless @batch_size.positive?
|
||||
raise ArgumentError, 'AFTER_ID must be zero or greater' if @after_id.negative?
|
||||
|
||||
Account.find(@account_id) if account_scope
|
||||
end
|
||||
|
||||
def candidate_scope
|
||||
scope = AppliedSla.where(sla_status: :missed, completed_at: nil).where('applied_slas.id > ?', @after_id)
|
||||
scope = scope.where(account_id: @account_id) if @account_id.present?
|
||||
scope
|
||||
end
|
||||
|
||||
def resolution_times_for(batch)
|
||||
events_by_conversation = ReportingEvent
|
||||
.where(
|
||||
account_id: batch.map(&:account_id).uniq,
|
||||
conversation_id: batch.map(&:conversation_id),
|
||||
name: 'conversation_resolved'
|
||||
)
|
||||
.where.not(event_end_time: nil)
|
||||
.order(:conversation_id, event_end_time: :desc)
|
||||
.group_by(&:conversation_id)
|
||||
|
||||
batch.each_with_object({}) do |applied_sla, resolution_times|
|
||||
event = events_by_conversation.fetch(applied_sla.conversation_id, []).find do |reporting_event|
|
||||
reporting_event.event_end_time.between?(applied_sla.created_at, applied_sla.updated_at)
|
||||
end
|
||||
resolution_times[applied_sla.id] = event.event_end_time if event
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_update(resolution_times)
|
||||
return 0 if resolution_times.empty?
|
||||
|
||||
connection = AppliedSla.connection
|
||||
values = resolution_times.map do |id, completed_at|
|
||||
"(#{connection.quote(id)}, #{connection.quote(completed_at)}::timestamp)"
|
||||
end.join(', ')
|
||||
|
||||
statement = <<~SQL.squish
|
||||
UPDATE #{connection.quote_table_name(AppliedSla.table_name)} AS applied_slas
|
||||
SET completed_at = backfill.completed_at
|
||||
FROM (VALUES #{values}) AS backfill(id, completed_at)
|
||||
WHERE applied_slas.id = backfill.id
|
||||
AND applied_slas.completed_at IS NULL
|
||||
SQL
|
||||
|
||||
connection.exec_update(statement, 'Backfill applied SLA completed_at')
|
||||
end
|
||||
|
||||
def print_preflight(eligible_count)
|
||||
scope = @account_id.present? ? "account_id=#{@account_id}" : 'all accounts'
|
||||
mode = @apply ? 'APPLY' : 'DRY RUN'
|
||||
@output.puts "Applied SLA completed_at backfill: mode=#{mode}, scope=#{scope}, batch_size=#{@batch_size}, after_id=#{@after_id}"
|
||||
@output.puts "Eligible missed applied SLAs: #{eligible_count}"
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ json.sla_id resource.sla_policy_id
|
||||
json.sla_status resource.sla_status
|
||||
json.created_at resource.created_at.to_i
|
||||
json.updated_at resource.updated_at.to_i
|
||||
json.sla_completed_at resource.completed_at&.to_i
|
||||
json.sla_description resource.sla_policy.description
|
||||
json.sla_name resource.sla_policy.name
|
||||
json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold
|
||||
|
||||
+4
-1
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.23",
|
||||
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||
"@chatwoot/utils": "^0.0.56",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
@@ -86,6 +86,9 @@
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^3.0.4",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
|
||||
Generated
+22
-6
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.23
|
||||
version: 1.3.23
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.56
|
||||
version: 0.0.56
|
||||
@@ -180,6 +180,15 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||
prosemirror-commands:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
prosemirror-inputrules:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
prosemirror-schema-list:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -452,8 +461,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||
|
||||
'@chatwoot/utils@0.0.56':
|
||||
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
||||
@@ -3992,6 +4001,9 @@ packages:
|
||||
prosemirror-tables@1.5.0:
|
||||
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
|
||||
@@ -5124,7 +5136,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
@@ -9023,7 +9035,7 @@ snapshots:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-transform: 1.10.0
|
||||
|
||||
prosemirror-state@1.4.3:
|
||||
dependencies:
|
||||
@@ -9039,6 +9051,10 @@ snapshots:
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Backfill applied_slas.completed_at from conversation resolution reporting events.
|
||||
#
|
||||
# Account-scoped dry run:
|
||||
# ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb
|
||||
#
|
||||
# Account-scoped apply:
|
||||
# ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb
|
||||
#
|
||||
# Explicit global apply with resume controls:
|
||||
# ALL_ACCOUNTS=true APPLY=true BATCH_SIZE=500 AFTER_ID=0 \
|
||||
# bundle exec rails runner script/backfill_applied_sla_completed_at.rb
|
||||
|
||||
begin
|
||||
account_id = Integer(ENV.fetch('ACCOUNT_ID'), 10) if ENV['ACCOUNT_ID'].present?
|
||||
all_accounts = ENV['ALL_ACCOUNTS'] == 'true'
|
||||
apply = ENV['APPLY'] == 'true'
|
||||
batch_size = Integer(ENV.fetch('BATCH_SIZE', Sla::BackfillAppliedSlaCompletedAtService::DEFAULT_BATCH_SIZE.to_s), 10)
|
||||
after_id = Integer(ENV.fetch('AFTER_ID', '0'), 10)
|
||||
|
||||
Sla::BackfillAppliedSlaCompletedAtService.new(
|
||||
account_id: account_id,
|
||||
all_accounts: all_accounts,
|
||||
apply: apply,
|
||||
batch_size: batch_size,
|
||||
after_id: after_id
|
||||
).perform
|
||||
rescue ArgumentError, ActiveRecord::RecordNotFound => e
|
||||
warn "Backfill aborted: #{e.message}"
|
||||
exit 1
|
||||
end
|
||||
@@ -565,17 +565,15 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(conversation.reload.assignee_id).to eq(agent.id)
|
||||
end
|
||||
|
||||
it 'does not self assign and clears the agent bot owner if admin changes the conversation status to open' do
|
||||
conversation.update!(status: 'pending', assignee: nil, assignee_agent_bot: agent_bot)
|
||||
|
||||
it 'disbale self assign if admin changes the conversation status to open' do
|
||||
conversation.update!(status: 'pending')
|
||||
conversation.update!(assignee_id: nil)
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
expect(conversation.reload.assignee_id).not_to eq(administrator.id)
|
||||
expect(conversation.reload.assignee_agent_bot).to be_nil
|
||||
end
|
||||
|
||||
it 'toggles the conversation status to specific status when parameter is passed' do
|
||||
|
||||
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
|
||||
expect(metrics.keys).to contain_exactly(
|
||||
:conversations_handled, :auto_resolution_rate, :handoff_rate,
|
||||
:hours_saved, :reopen_rate, :conversation_depth
|
||||
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
|
||||
)
|
||||
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
|
||||
end
|
||||
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#faq_stats' do
|
||||
describe '#metrics knowledge' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
|
||||
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
|
||||
it 'returns approved, pending, document counts and coverage' do
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
||||
end
|
||||
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
it 'reports zero coverage when there are no responses' do
|
||||
Captain::AssistantResponse.where(assistant: assistant).delete_all
|
||||
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge[:coverage]).to eq(0)
|
||||
end
|
||||
|
||||
@@ -8,13 +8,14 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
it 'returns SLA data for the conversation if the feature is enabled' do
|
||||
account.enable_features!('sla')
|
||||
conversation = create(:conversation, account: account)
|
||||
applied_sla = create(:applied_sla, conversation: conversation)
|
||||
applied_sla = create(:applied_sla, conversation: conversation, completed_at: 1.hour.ago)
|
||||
sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id)
|
||||
expect(response.parsed_body['applied_sla']['sla_completed_at']).to eq(applied_sla.completed_at.to_i)
|
||||
expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id)
|
||||
end
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ RSpec.describe AppliedSla, type: :model do
|
||||
sla_status: applied_sla.sla_status,
|
||||
created_at: applied_sla.created_at.to_i,
|
||||
updated_at: applied_sla.updated_at.to_i,
|
||||
sla_completed_at: nil,
|
||||
sla_description: applied_sla.sla_policy.description,
|
||||
sla_name: applied_sla.sla_policy.name,
|
||||
sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold,
|
||||
|
||||
@@ -41,6 +41,37 @@ RSpec.describe Conversation, type: :model do
|
||||
# end
|
||||
end
|
||||
|
||||
describe 'SLA completion' do
|
||||
let(:applied_sla) { create(:applied_sla) }
|
||||
let(:conversation) { applied_sla.conversation }
|
||||
|
||||
it 'records the completion time when the conversation is resolved' do
|
||||
completion_time = Time.zone.parse('2026-07-15 10:00:00')
|
||||
|
||||
travel_to(completion_time) { conversation.update!(status: :resolved) }
|
||||
|
||||
expect(applied_sla.reload.completed_at).to eq(completion_time)
|
||||
end
|
||||
|
||||
it 'clears the completion time when a nonterminal SLA is reopened' do
|
||||
conversation.update!(status: :resolved)
|
||||
|
||||
conversation.update!(status: :open)
|
||||
|
||||
expect(applied_sla.reload.completed_at).to be_nil
|
||||
end
|
||||
|
||||
it 'preserves the completion time when a terminal SLA is reopened' do
|
||||
conversation.update!(status: :resolved)
|
||||
completed_at = applied_sla.reload.completed_at
|
||||
applied_sla.update!(sla_status: :missed)
|
||||
|
||||
conversation.update!(status: :open)
|
||||
|
||||
expect(applied_sla.reload.completed_at).to eq(completed_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_policy' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
|
||||
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
||||
|
||||
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
|
||||
permissions :index?, :show?, :playground? do
|
||||
context 'when administrator' do
|
||||
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Sla::BackfillAppliedSlaCompletedAtService do
|
||||
let(:output) { StringIO.new }
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:applied_sla) do
|
||||
create(
|
||||
:applied_sla,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
sla_status: :missed,
|
||||
created_at: 3.days.ago,
|
||||
updated_at: 1.day.ago
|
||||
)
|
||||
end
|
||||
let!(:resolution_event) do
|
||||
create(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
name: 'conversation_resolved',
|
||||
event_start_time: applied_sla.created_at,
|
||||
event_end_time: 2.days.ago
|
||||
)
|
||||
end
|
||||
|
||||
it 'defaults to a dry run' do
|
||||
result = described_class.new(account_id: account.id, output: output).perform
|
||||
|
||||
expect(result).to include(dry_run: true, eligible: 1, matched: 1, updated: 0, skipped: 0)
|
||||
expect(applied_sla.reload.completed_at).to be_nil
|
||||
end
|
||||
|
||||
it 'backfills the latest reliable resolution without changing updated_at' do
|
||||
latest_resolution = create(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
name: 'conversation_resolved',
|
||||
event_start_time: applied_sla.created_at,
|
||||
event_end_time: 36.hours.ago
|
||||
)
|
||||
original_updated_at = applied_sla.updated_at
|
||||
|
||||
result = described_class.new(account_id: account.id, apply: true, output: output).perform
|
||||
|
||||
expect(result).to include(dry_run: false, eligible: 1, matched: 1, updated: 1, skipped: 0)
|
||||
expect(applied_sla.reload.completed_at).to eq(latest_resolution.reload.event_end_time)
|
||||
expect(applied_sla.updated_at).to eq(original_updated_at)
|
||||
end
|
||||
|
||||
it 'skips records without a reliable resolution event' do
|
||||
resolution_event.destroy!
|
||||
|
||||
result = described_class.new(account_id: account.id, apply: true, output: output).perform
|
||||
|
||||
expect(result).to include(eligible: 1, matched: 0, updated: 0, skipped: 1)
|
||||
expect(applied_sla.reload.completed_at).to be_nil
|
||||
end
|
||||
|
||||
it 'is idempotent' do
|
||||
service = described_class.new(account_id: account.id, apply: true, output: output)
|
||||
|
||||
service.perform
|
||||
result = service.perform
|
||||
|
||||
expect(result).to include(eligible: 0, matched: 0, updated: 0, skipped: 0)
|
||||
expect(applied_sla.reload.completed_at).to eq(resolution_event.reload.event_end_time)
|
||||
end
|
||||
|
||||
it 'requires exactly one account scope' do
|
||||
expect { described_class.new(output: output).perform }
|
||||
.to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true')
|
||||
expect { described_class.new(account_id: account.id, all_accounts: true, output: output).perform }
|
||||
.to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true')
|
||||
end
|
||||
|
||||
it 'limits account runs and requires explicit global scope for other accounts' do
|
||||
other_account = create(:account)
|
||||
other_conversation = create(:conversation, account: other_account)
|
||||
other_applied_sla = create(
|
||||
:applied_sla,
|
||||
account: other_account,
|
||||
conversation: other_conversation,
|
||||
sla_status: :missed,
|
||||
created_at: 3.days.ago,
|
||||
updated_at: 1.day.ago
|
||||
)
|
||||
other_resolution_event = create(
|
||||
:reporting_event,
|
||||
account: other_account,
|
||||
inbox: other_conversation.inbox,
|
||||
conversation: other_conversation,
|
||||
name: 'conversation_resolved',
|
||||
event_start_time: other_applied_sla.created_at,
|
||||
event_end_time: 2.days.ago
|
||||
)
|
||||
|
||||
described_class.new(account_id: account.id, apply: true, output: output).perform
|
||||
|
||||
expect(applied_sla.reload.completed_at).to eq(resolution_event.reload.event_end_time)
|
||||
expect(other_applied_sla.reload.completed_at).to be_nil
|
||||
|
||||
described_class.new(all_accounts: true, apply: true, output: output).perform
|
||||
|
||||
expect(other_applied_sla.reload.completed_at).to eq(other_resolution_event.reload.event_end_time)
|
||||
end
|
||||
|
||||
it 'resumes after the supplied applied SLA id' do
|
||||
result = described_class.new(account_id: account.id, after_id: applied_sla.id, output: output).perform
|
||||
|
||||
expect(result).to include(eligible: 0, processed: 0, last_id: applied_sla.id)
|
||||
end
|
||||
end
|
||||
@@ -408,14 +408,6 @@ RSpec.describe Conversation do
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'clears agent bot ownership' do
|
||||
conversation.update!(assignee_agent_bot: create(:agent_bot, account: conversation.account))
|
||||
|
||||
conversation.bot_handoff!
|
||||
|
||||
expect(conversation.reload.assignee_agent_bot).to be_nil
|
||||
end
|
||||
|
||||
it 'dispatches CONVERSATION_BOT_HANDOFF event' do
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
.with(described_class::CONVERSATION_BOT_HANDOFF, anything, hash_including(conversation: conversation))
|
||||
@@ -727,19 +719,6 @@ RSpec.describe Conversation do
|
||||
expect(conversation.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'sets connected agent bot as the conversation owner' do
|
||||
expect(conversation.assignee_agent_bot).to eq(bot_inbox.agent_bot)
|
||||
expect(conversation.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'preserves explicit human assignee' do
|
||||
agent = create(:user, account: bot_inbox.inbox.account)
|
||||
conversation = create(:conversation, inbox: bot_inbox.inbox, assignee: agent)
|
||||
|
||||
expect(conversation.assignee).to eq(agent)
|
||||
expect(conversation.assignee_agent_bot).to be_nil
|
||||
end
|
||||
|
||||
context 'with campaigns' do
|
||||
let(:user) { create(:user, account: bot_inbox.inbox.account) }
|
||||
|
||||
@@ -747,14 +726,12 @@ RSpec.describe Conversation do
|
||||
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user)
|
||||
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
|
||||
expect(conversation.status).to eq('open')
|
||||
expect(conversation.assignee_agent_bot).to be_nil
|
||||
end
|
||||
|
||||
it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do
|
||||
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil)
|
||||
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
|
||||
expect(conversation.status).to eq('pending')
|
||||
expect(conversation.assignee_agent_bot).to eq(bot_inbox.agent_bot)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -785,10 +762,6 @@ RSpec.describe Conversation do
|
||||
it 'returns conversation status as pending' do
|
||||
expect(conversation.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'does not set agent bot ownership' do
|
||||
expect(conversation.assignee_agent_bot).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#delete conversation' do
|
||||
|
||||
Reference in New Issue
Block a user