fix: Consider business hours when computing SLA breaches (#13392)
- Fixes SLA breach computation to respect the "Only during business hours" setting - Backend now pre-computes SLA deadlines, simplifying frontend logic ## How it works Before: SLA deadlines were calculated using wall-clock time, ignoring business hours. After: When an SLA policy has "Only during business hours" enabled and the inbox has working hours configured, the deadline is calculated by adding threshold time only during business hours. **How you check if a conversation has a SLA hit or miss?** <img width="474" height="510" alt="Screenshot 2026-01-28 at 7 06 53 PM" src="https://github.com/user-attachments/assets/54ec8581-18b8-45c6-a356-de8c778ea78d" /> **Example:** - Conversation created: Friday 4:30 PM - FRT threshold: 1 hour - Business hours: Mon-Fri 9 AM - 5 PM | | Breach time | |--|--| | Before | Friday 5:30 PM | | After | Monday 9:30 AM | ## Test plan - [x] Create an SLA policy with "Only during business hours" enabled - [x] Configure inbox with business hours (e.g., Mon-Fri 9-5) - [x] Conversation created during business hours - Create a conversation on Wednesday 10:00 AM UTC - Expected: FRT deadline shows Wednesday 12:00 PM UTC (2 business hours later) - [x] Conversation created before business hours - Create a conversation on Wednesday 7:00 AM UTC - Expected: FRT deadline shows Wednesday 11:00 AM UTC (counting starts at 9 AM) - [x] Conversation created after business hours - Create a conversation on Wednesday 6:00 PM UTC - Expected: FRT deadline shows Thursday 11:00 AM UTC (counting starts next day 9 AM) - [x] Conversation created on weekend - Create a conversation on Saturday 10:00 AM UTC - Expected: FRT deadline shows Monday 11:00 AM UTC (skips weekend) - [x] Threshold spans weekend - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Monday 10:00 AM UTC (1h Friday + 1h Monday) - [x] SLA without business hours - Create an SLA policy with only_during_business_hours: false - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Friday 6:00 PM UTC (wall-clock time) - [x] All Day marked as closed_all_day - Create a conversation on Tuesday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Thursday 10:00 AM UTC - [x] All Day marked as open_all_day - Create a conversation on Saturday 10:00 AM UTC with 2-hour FRT - Expected: FRT deadline shows Saturday 12:00 PM UTC - [x] UI displays correct countdown - Verify conversation card shows correct SLA timer - Verify timer shows flame icon when breached - Verify timer shows alarm icon when within threshold - Time updates automatically when time passes - [x] Verify the breach with a different timezone than your local timezone --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
co-authored by
Muhsin Keloth
Sojan Jose
Sony Mathew
Sony Mathew
parent
926a9d8a69
commit
49b0ab0e1f
+4
-11
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -19,16 +19,8 @@ const slaStatus = ref({
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const slaEvents = computed(() => props.conversation?.slaEvents);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
@@ -41,8 +33,9 @@ const slaStatusText = computed(() => {
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.conversation,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
@@ -27,6 +27,7 @@ defineOptions({
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
@@ -34,6 +35,7 @@ const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.chat,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -61,6 +61,7 @@ const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
/**
|
||||
* Formats seconds into a human-readable time string
|
||||
* @param {number} seconds - The time in seconds (can be negative for overdue)
|
||||
* @returns {string} Formatted time string like "2h 30m" or "1d 4h"
|
||||
*/
|
||||
const formatSLATime = seconds => {
|
||||
const absSeconds = Math.abs(seconds);
|
||||
|
||||
const units = {
|
||||
y: 31536000,
|
||||
mo: 2592000,
|
||||
d: 86400,
|
||||
h: 3600,
|
||||
m: 60,
|
||||
};
|
||||
|
||||
if (absSeconds < 60) {
|
||||
return '1m';
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
let remaining = absSeconds;
|
||||
|
||||
Object.entries(units).forEach(([unit, value]) => {
|
||||
if (parts.length >= 2) return;
|
||||
const count = Math.floor(remaining / value);
|
||||
if (count > 0) {
|
||||
parts.push(`${count}${unit}`);
|
||||
remaining -= count * value;
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const toUnixTimestamp = value => {
|
||||
if (!value || typeof value === 'number') return value;
|
||||
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isNaN(numericValue)) return numericValue;
|
||||
|
||||
const parsedTimestamp = Date.parse(value);
|
||||
return Number.isNaN(parsedTimestamp)
|
||||
? value
|
||||
: Math.floor(parsedTimestamp / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates SLA status using backend-computed due times
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {Object} params.appliedSla - The applied SLA with due_at timestamps
|
||||
* @param {Object} params.chat - The conversation object
|
||||
* @param {Array} params.slaEvents - Recorded SLA miss events for this conversation
|
||||
* @returns {Object} SLA status with type, threshold, icon, and isSlaMissed
|
||||
*/
|
||||
export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
const emptyStatus = { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
|
||||
if (!appliedSla || !chat) {
|
||||
return emptyStatus;
|
||||
}
|
||||
|
||||
const sla = useCamelCase(appliedSla);
|
||||
const conversation = useCamelCase(chat);
|
||||
const events = useCamelCase(slaEvents || []);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const slaStatuses = [];
|
||||
|
||||
const dueAtByType = {
|
||||
FRT: sla.slaFrtDueAt,
|
||||
RT: sla.slaRtDueAt,
|
||||
};
|
||||
const slaTypes = ['FRT', 'NRT', 'RT'];
|
||||
|
||||
events.forEach(event => {
|
||||
const type = event.eventType?.toUpperCase();
|
||||
if (!slaTypes.includes(type)) return;
|
||||
|
||||
const missedAt =
|
||||
type === 'NRT' ? event.createdAt : dueAtByType[type] || event.createdAt;
|
||||
if (!missedAt) return;
|
||||
|
||||
slaStatuses.push({
|
||||
type,
|
||||
threshold: missedAt - currentTime,
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
|
||||
if (slaStatuses.length === 0) {
|
||||
return emptyStatus;
|
||||
}
|
||||
|
||||
// Show existing breaches before upcoming deadlines, then pick the closest timer.
|
||||
slaStatuses.sort((a, b) => {
|
||||
if (a.isSlaMissed !== b.isSlaMissed) {
|
||||
return a.isSlaMissed ? -1 : 1;
|
||||
}
|
||||
|
||||
return Math.abs(a.threshold) - Math.abs(b.threshold);
|
||||
});
|
||||
const mostUrgent = slaStatuses[0];
|
||||
|
||||
return {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(mostUrgent.threshold),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,450 @@
|
||||
import { evaluateSLAStatus } from '../slaHelper';
|
||||
|
||||
describe('#SLA Helpers', () => {
|
||||
const currentTimestamp = 1700000000; // Fixed timestamp for testing
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(currentTimestamp * 1000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('evaluateSLAStatus', () => {
|
||||
describe('when inputs are invalid', () => {
|
||||
it('returns empty status when appliedSla is null', () => {
|
||||
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty status when chat is null', () => {
|
||||
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FRT (First Response Time)', () => {
|
||||
it('returns FRT status when first reply not made and within threshold', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns missed FRT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return FRT when first reply was made before due time', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 1000,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('FRT');
|
||||
});
|
||||
|
||||
it('does not return FRT when first reply was made at due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
});
|
||||
|
||||
it('returns missed FRT when first reply was made after due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 300,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('10m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 600,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: new Date(
|
||||
(currentTimestamp - 300) * 1000
|
||||
).toISOString(),
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('10m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the due time for a missed FRT event created after the deadline', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NRT (Next Response Time)', () => {
|
||||
it('returns NRT status when waiting for response and within threshold', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns missed NRT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 2700,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('15m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return NRT when not waiting for response', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('NRT');
|
||||
});
|
||||
|
||||
it('returns missed NRT when a recorded NRT miss exists after response', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: null,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('15m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
|
||||
const appliedSla = {
|
||||
sla_nrt_due_at: currentTimestamp + 300,
|
||||
sla_rt_due_at: currentTimestamp + 1800,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 60,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.threshold).toBe('30m');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return NRT when first reply not made', () => {
|
||||
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).not.toBe('NRT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RT (Resolution Time)', () => {
|
||||
it('returns RT status when conversation is open and within threshold', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['pending', 'snoozed'])(
|
||||
'returns RT status when conversation is %s and within threshold',
|
||||
status => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status,
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.icon).toBe('alarm');
|
||||
expect(result.isSlaMissed).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns missed RT status when threshold is exceeded', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the due time for a missed RT event created after the deadline', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
status: 'open',
|
||||
};
|
||||
const slaEvents = [
|
||||
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
expect(result.icon).toBe('flame');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not return RT when conversation is resolved', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600,
|
||||
status: 'resolved',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority selection', () => {
|
||||
it('returns most urgent SLA when multiple are active', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
|
||||
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
|
||||
sla_rt_due_at: currentTimestamp + 3600, // 1h
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
waiting_since: currentTimestamp - 600,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
// FRT is selected because first_reply_created_at is null
|
||||
// NRT is not checked when first_reply_created_at is null
|
||||
expect(result.type).toBe('RT');
|
||||
expect(result.threshold).toBe('1h');
|
||||
});
|
||||
|
||||
it('returns most urgent missed SLA over upcoming SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
|
||||
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 7200,
|
||||
waiting_since: currentTimestamp - 2100,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('NRT');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an existing missed SLA over a closer upcoming SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
|
||||
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: null,
|
||||
status: 'open',
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.type).toBe('FRT');
|
||||
expect(result.threshold).toBe('2h');
|
||||
expect(result.isSlaMissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time formatting', () => {
|
||||
it('formats time in days and hours', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1d 1h');
|
||||
});
|
||||
|
||||
it('formats time less than a minute as 1m', () => {
|
||||
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
|
||||
const chat = { first_reply_created_at: null, status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1m');
|
||||
});
|
||||
|
||||
it('formats months correctly', () => {
|
||||
const appliedSla = {
|
||||
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
|
||||
}; // 1 month + 1 day
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result.threshold).toBe('1mo 1d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty status scenarios', () => {
|
||||
it('returns empty when no SLA thresholds are set', () => {
|
||||
const appliedSla = {};
|
||||
const chat = { status: 'open' };
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty when all conditions are met', () => {
|
||||
const appliedSla = {
|
||||
sla_frt_due_at: currentTimestamp + 3600,
|
||||
sla_nrt_due_at: currentTimestamp + 1800,
|
||||
sla_rt_due_at: currentTimestamp + 7200,
|
||||
};
|
||||
const chat = {
|
||||
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
|
||||
waiting_since: null, // Not waiting, so NRT not applicable
|
||||
status: 'resolved', // RT not applicable
|
||||
};
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||
|
||||
expect(result).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user