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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
module Enterprise::ConversationFinder
|
||||
def conversations_base_query
|
||||
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
|
||||
return super unless current_account.feature_enabled?('sla')
|
||||
|
||||
super.includes(:applied_sla, :sla_events, inbox: :working_hours)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,6 +44,8 @@ class AppliedSla < ApplicationRecord
|
||||
after_update_commit :push_conversation_event
|
||||
|
||||
def push_event_data
|
||||
sla_due_at_values = due_at_values
|
||||
|
||||
{
|
||||
id: id,
|
||||
sla_id: sla_policy_id,
|
||||
@@ -55,10 +57,65 @@ class AppliedSla < ApplicationRecord
|
||||
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
|
||||
sla_next_response_time_threshold: sla_policy.next_response_time_threshold,
|
||||
sla_only_during_business_hours: sla_policy.only_during_business_hours,
|
||||
sla_resolution_time_threshold: sla_policy.resolution_time_threshold
|
||||
sla_resolution_time_threshold: sla_policy.resolution_time_threshold,
|
||||
sla_frt_due_at: sla_due_at_values[:frt],
|
||||
sla_nrt_due_at: sla_due_at_values[:nrt],
|
||||
sla_rt_due_at: sla_due_at_values[:rt]
|
||||
}
|
||||
end
|
||||
|
||||
def due_at_values
|
||||
working_hours_by_day_cache = conversation.inbox.working_hours.index_by(&:day_of_week) if sla_policy.only_during_business_hours?
|
||||
|
||||
{
|
||||
frt: frt_due_at(working_hours_by_day_cache: working_hours_by_day_cache),
|
||||
nrt: nrt_due_at(working_hours_by_day_cache: working_hours_by_day_cache),
|
||||
rt: rt_due_at(working_hours_by_day_cache: working_hours_by_day_cache)
|
||||
}
|
||||
end
|
||||
|
||||
def frt_due_at(working_hours_by_day_cache: nil)
|
||||
return nil if sla_policy.first_response_time_threshold.blank?
|
||||
|
||||
calculate_due_at(
|
||||
conversation.created_at,
|
||||
sla_policy.first_response_time_threshold,
|
||||
working_hours_by_day_cache: working_hours_by_day_cache
|
||||
)
|
||||
end
|
||||
|
||||
def nrt_due_at(working_hours_by_day_cache: nil)
|
||||
return nil if sla_policy.next_response_time_threshold.blank?
|
||||
return nil if conversation.waiting_since.blank?
|
||||
|
||||
calculate_due_at(
|
||||
conversation.waiting_since,
|
||||
sla_policy.next_response_time_threshold,
|
||||
working_hours_by_day_cache: working_hours_by_day_cache
|
||||
)
|
||||
end
|
||||
|
||||
def rt_due_at(working_hours_by_day_cache: nil)
|
||||
return nil if sla_policy.resolution_time_threshold.blank?
|
||||
|
||||
calculate_due_at(
|
||||
conversation.created_at,
|
||||
sla_policy.resolution_time_threshold,
|
||||
working_hours_by_day_cache: working_hours_by_day_cache
|
||||
)
|
||||
end
|
||||
|
||||
def calculate_due_at(start_time, threshold_seconds, working_hours_by_day_cache: nil)
|
||||
return (start_time + threshold_seconds.to_i.seconds).to_i unless sla_policy.only_during_business_hours?
|
||||
|
||||
Sla::BusinessHoursService.new(
|
||||
inbox: conversation.inbox,
|
||||
start_time: start_time,
|
||||
threshold_seconds: threshold_seconds,
|
||||
working_hours_by_day_cache: working_hours_by_day_cache
|
||||
).deadline.to_i
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def push_conversation_event
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
class Sla::BusinessHoursService
|
||||
pattr_initialize [:inbox!, :start_time!, :threshold_seconds!, { working_hours_by_day_cache: nil }]
|
||||
|
||||
def deadline
|
||||
return start_time + threshold_seconds.seconds unless should_apply_business_hours?
|
||||
|
||||
calculate_deadline_with_business_hours
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_apply_business_hours?
|
||||
inbox.working_hours_enabled? && open_days?
|
||||
end
|
||||
|
||||
def open_days?
|
||||
working_hours_by_day.values.any? { |working_hour| !working_hour.closed_all_day? }
|
||||
end
|
||||
|
||||
def calculate_deadline_with_business_hours
|
||||
@remaining_seconds = threshold_seconds.to_i
|
||||
@current_time = start_time.in_time_zone(timezone)
|
||||
|
||||
process_remaining_seconds while @remaining_seconds.positive?
|
||||
|
||||
@current_time
|
||||
end
|
||||
|
||||
def process_remaining_seconds
|
||||
working_hour = working_hour_for(@current_time)
|
||||
|
||||
if closed_day?(working_hour)
|
||||
@current_time = next_business_day_start(@current_time)
|
||||
return
|
||||
end
|
||||
|
||||
# If adjust moved to next day, return early to re-fetch correct working hours
|
||||
return unless adjust_current_time_to_business_hours(working_hour)
|
||||
|
||||
consume_available_seconds(working_hour)
|
||||
end
|
||||
|
||||
def closed_day?(working_hour)
|
||||
working_hour.nil? || working_hour.closed_all_day?
|
||||
end
|
||||
|
||||
# Returns true if current_time was adjusted within the same day, false if moved to next day
|
||||
def adjust_current_time_to_business_hours(working_hour)
|
||||
day_open_time = time_on_date(@current_time, working_hour.open_hour, working_hour.open_minutes)
|
||||
day_close_time = day_close_time_for(working_hour)
|
||||
|
||||
if @current_time < day_open_time
|
||||
@current_time = day_open_time
|
||||
true
|
||||
elsif @current_time >= day_close_time
|
||||
@current_time = next_business_day_start(@current_time)
|
||||
false
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def consume_available_seconds(working_hour)
|
||||
day_close_time = day_close_time_for(working_hour)
|
||||
available_seconds = (day_close_time - @current_time).to_i
|
||||
|
||||
if @remaining_seconds <= available_seconds
|
||||
@current_time += @remaining_seconds.seconds
|
||||
@remaining_seconds = 0
|
||||
else
|
||||
@remaining_seconds -= available_seconds
|
||||
@current_time = next_business_day_start(@current_time)
|
||||
end
|
||||
end
|
||||
|
||||
def day_close_time_for(working_hour)
|
||||
return @current_time.beginning_of_day + 1.day if working_hour.open_all_day?
|
||||
|
||||
time_on_date(@current_time, working_hour.close_hour, working_hour.close_minutes)
|
||||
end
|
||||
|
||||
def working_hour_for(time)
|
||||
working_hours_by_day[time.wday]
|
||||
end
|
||||
|
||||
def working_hours_by_day
|
||||
@working_hours_by_day ||= working_hours_by_day_cache || inbox.working_hours.index_by(&:day_of_week)
|
||||
end
|
||||
|
||||
def next_business_day_start(current_time)
|
||||
next_day = (current_time + 1.day).beginning_of_day
|
||||
7.times do
|
||||
working_hour = working_hour_for(next_day)
|
||||
return time_on_date(next_day, working_hour.open_hour, working_hour.open_minutes) if working_hour && !working_hour.closed_all_day?
|
||||
|
||||
next_day += 1.day
|
||||
end
|
||||
next_day
|
||||
end
|
||||
|
||||
def time_on_date(date, hour, minutes)
|
||||
date.change(hour: hour, min: minutes, sec: 0)
|
||||
end
|
||||
|
||||
def timezone
|
||||
inbox.timezone || 'UTC'
|
||||
end
|
||||
end
|
||||
@@ -2,106 +2,101 @@ class Sla::EvaluateAppliedSlaService
|
||||
pattr_initialize [:applied_sla!]
|
||||
|
||||
def perform
|
||||
check_sla_thresholds
|
||||
check_frt
|
||||
check_nrt
|
||||
check_rt
|
||||
|
||||
# We will calculate again in the next iteration
|
||||
return unless applied_sla.conversation.resolved?
|
||||
return unless conversation.resolved?
|
||||
|
||||
# after conversation is resolved, we will check if the SLA was hit or missed
|
||||
handle_hit_sla(applied_sla)
|
||||
handle_hit_sla
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_sla_thresholds
|
||||
[:first_response_time_threshold, :next_response_time_threshold, :resolution_time_threshold].each do |threshold|
|
||||
next if applied_sla.sla_policy.send(threshold).blank?
|
||||
delegate :conversation, :sla_policy, to: :applied_sla
|
||||
|
||||
send("check_#{threshold}", applied_sla, applied_sla.conversation, applied_sla.sla_policy)
|
||||
def check_frt
|
||||
return if sla_policy.first_response_time_threshold.blank?
|
||||
return if frt_was_hit?
|
||||
return if within_threshold?(applied_sla.frt_due_at)
|
||||
|
||||
handle_missed_sla('frt')
|
||||
end
|
||||
|
||||
def check_nrt
|
||||
return if sla_policy.next_response_time_threshold.blank?
|
||||
return if conversation.first_reply_created_at.blank?
|
||||
return if conversation.waiting_since.blank?
|
||||
return if within_threshold?(applied_sla.nrt_due_at)
|
||||
|
||||
handle_missed_sla('nrt')
|
||||
end
|
||||
|
||||
def check_rt
|
||||
return if sla_policy.resolution_time_threshold.blank?
|
||||
return if conversation.resolved?
|
||||
return if within_threshold?(applied_sla.rt_due_at)
|
||||
|
||||
handle_missed_sla('rt')
|
||||
end
|
||||
|
||||
def within_threshold?(due_at)
|
||||
Time.zone.now.to_i < due_at
|
||||
end
|
||||
|
||||
def frt_was_hit?
|
||||
return false if applied_sla.frt_due_at.blank?
|
||||
return false if conversation.first_reply_created_at.blank?
|
||||
|
||||
conversation.first_reply_created_at.to_i <= applied_sla.frt_due_at
|
||||
end
|
||||
|
||||
def handle_missed_sla(type)
|
||||
meta = type == 'nrt' ? { message_id: last_incoming_message_id } : {}
|
||||
return if already_missed?(type, meta)
|
||||
|
||||
create_sla_event(type, meta)
|
||||
log_miss(type)
|
||||
applied_sla.update!(sla_status: 'active_with_misses') unless applied_sla.active_with_misses?
|
||||
end
|
||||
|
||||
def handle_hit_sla
|
||||
if applied_sla.active?
|
||||
applied_sla.update!(sla_status: 'hit')
|
||||
log_result('hit')
|
||||
else
|
||||
applied_sla.update!(sla_status: 'missed')
|
||||
log_result('missed')
|
||||
end
|
||||
end
|
||||
|
||||
def still_within_threshold?(threshold)
|
||||
Time.zone.now.to_i < threshold
|
||||
end
|
||||
|
||||
def check_first_response_time_threshold(applied_sla, conversation, sla_policy)
|
||||
threshold = conversation.created_at.to_i + sla_policy.first_response_time_threshold.to_i
|
||||
return if first_reply_was_within_threshold?(conversation, threshold)
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla, 'frt')
|
||||
end
|
||||
|
||||
def first_reply_was_within_threshold?(conversation, threshold)
|
||||
conversation.first_reply_created_at.present? && conversation.first_reply_created_at.to_i <= threshold
|
||||
end
|
||||
|
||||
def check_next_response_time_threshold(applied_sla, conversation, sla_policy)
|
||||
# still waiting for first reply, so covered under first response time threshold
|
||||
return if conversation.first_reply_created_at.blank?
|
||||
# Waiting on customer response, no need to check next response time threshold
|
||||
return if conversation.waiting_since.blank?
|
||||
|
||||
threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla, 'nrt')
|
||||
end
|
||||
|
||||
def get_last_message_id(conversation)
|
||||
# TODO: refactor the method to fetch last message without reply
|
||||
conversation.messages.where(message_type: :incoming).last&.id
|
||||
end
|
||||
|
||||
def already_missed?(applied_sla, type, meta = {})
|
||||
def already_missed?(type, meta)
|
||||
SlaEvent.exists?(applied_sla: applied_sla, event_type: type, meta: meta)
|
||||
end
|
||||
|
||||
def check_resolution_time_threshold(applied_sla, conversation, sla_policy)
|
||||
return if conversation.resolved?
|
||||
|
||||
threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla, 'rt')
|
||||
def last_incoming_message_id
|
||||
Message.where(account_id: conversation.account_id, conversation_id: conversation.id, message_type: :incoming).last&.id
|
||||
end
|
||||
|
||||
def handle_missed_sla(applied_sla, type, meta = {})
|
||||
meta = { message_id: get_last_message_id(applied_sla.conversation) } if type == 'nrt'
|
||||
return if already_missed?(applied_sla, type, meta)
|
||||
|
||||
create_sla_event(applied_sla, type, meta)
|
||||
Rails.logger.warn "SLA #{type} missed for conversation #{applied_sla.conversation.id} " \
|
||||
"in account #{applied_sla.account_id} " \
|
||||
"for sla_policy #{applied_sla.sla_policy.id}"
|
||||
|
||||
applied_sla.update!(sla_status: 'active_with_misses') if applied_sla.sla_status != 'active_with_misses'
|
||||
end
|
||||
|
||||
def handle_hit_sla(applied_sla)
|
||||
if applied_sla.active?
|
||||
applied_sla.update!(sla_status: 'hit')
|
||||
Rails.logger.info "SLA hit for conversation #{applied_sla.conversation.id} " \
|
||||
"in account #{applied_sla.account_id} " \
|
||||
"for sla_policy #{applied_sla.sla_policy.id}"
|
||||
else
|
||||
applied_sla.update!(sla_status: 'missed')
|
||||
Rails.logger.info "SLA missed for conversation #{applied_sla.conversation.id} " \
|
||||
"in account #{applied_sla.account_id} " \
|
||||
"for sla_policy #{applied_sla.sla_policy.id}"
|
||||
end
|
||||
end
|
||||
|
||||
def create_sla_event(applied_sla, event_type, meta = {})
|
||||
def create_sla_event(event_type, meta)
|
||||
SlaEvent.create!(
|
||||
applied_sla: applied_sla,
|
||||
conversation: applied_sla.conversation,
|
||||
conversation: conversation,
|
||||
event_type: event_type,
|
||||
meta: meta,
|
||||
account: applied_sla.account,
|
||||
inbox: applied_sla.conversation.inbox,
|
||||
sla_policy: applied_sla.sla_policy
|
||||
inbox: conversation.inbox,
|
||||
sla_policy: sla_policy
|
||||
)
|
||||
end
|
||||
|
||||
def log_miss(type)
|
||||
Rails.logger.warn "SLA #{type} missed for conversation #{conversation.id} " \
|
||||
"in account #{applied_sla.account_id} for sla_policy #{sla_policy.id}"
|
||||
end
|
||||
|
||||
def log_result(result)
|
||||
Rails.logger.info "SLA #{result} for conversation #{conversation.id} " \
|
||||
"in account #{applied_sla.account_id} for sla_policy #{sla_policy.id}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,3 +9,7 @@ json.sla_first_response_time_threshold resource.sla_policy.first_response_time_t
|
||||
json.sla_next_response_time_threshold resource.sla_policy.next_response_time_threshold
|
||||
json.sla_only_during_business_hours resource.sla_policy.only_during_business_hours
|
||||
json.sla_resolution_time_threshold resource.sla_policy.resolution_time_threshold
|
||||
sla_due_at_values = resource.due_at_values
|
||||
json.sla_frt_due_at sla_due_at_values[:frt]
|
||||
json.sla_nrt_due_at sla_due_at_values[:nrt]
|
||||
json.sla_rt_due_at sla_due_at_values[:rt]
|
||||
|
||||
@@ -22,10 +22,45 @@ RSpec.describe AppliedSla, type: :model do
|
||||
sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold,
|
||||
sla_next_response_time_threshold: applied_sla.sla_policy.next_response_time_threshold,
|
||||
sla_only_during_business_hours: applied_sla.sla_policy.only_during_business_hours,
|
||||
sla_resolution_time_threshold: applied_sla.sla_policy.resolution_time_threshold
|
||||
sla_resolution_time_threshold: applied_sla.sla_policy.resolution_time_threshold,
|
||||
sla_frt_due_at: applied_sla.frt_due_at,
|
||||
sla_nrt_due_at: applied_sla.nrt_due_at,
|
||||
sla_rt_due_at: applied_sla.rt_due_at
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'shares the working hours cache while serializing due times' do
|
||||
account = create(:account)
|
||||
inbox = create(:inbox, account: account, working_hours_enabled: true, timezone: 'UTC')
|
||||
sla_policy = create(
|
||||
:sla_policy,
|
||||
account: account,
|
||||
first_response_time_threshold: 1.hour,
|
||||
next_response_time_threshold: 30.minutes,
|
||||
resolution_time_threshold: 2.hours,
|
||||
only_during_business_hours: true
|
||||
)
|
||||
start_time = Time.zone.parse('2024-01-17 10:00:00')
|
||||
conversation = create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
created_at: start_time,
|
||||
waiting_since: start_time + 1.hour
|
||||
)
|
||||
conversation.update!(waiting_since: start_time + 1.hour)
|
||||
applied_sla = create(:applied_sla, account: account, conversation: conversation, sla_policy: sla_policy)
|
||||
working_hours = inbox.working_hours
|
||||
|
||||
expect(working_hours).to receive(:index_by).once.and_call_original
|
||||
|
||||
expect(applied_sla.push_event_data).to include(
|
||||
sla_frt_due_at: Time.zone.parse('2024-01-17 11:00:00').to_i,
|
||||
sla_nrt_due_at: Time.zone.parse('2024-01-17 11:30:00').to_i,
|
||||
sla_rt_due_at: Time.zone.parse('2024-01-17 12:00:00').to_i
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
@@ -34,4 +69,86 @@ RSpec.describe AppliedSla, type: :model do
|
||||
expect(applied_sla.sla_status).to eq 'active'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#frt_due_at' do
|
||||
it 'returns nil when first_response_time_threshold is blank' do
|
||||
applied_sla = create(:applied_sla)
|
||||
applied_sla.sla_policy.update!(first_response_time_threshold: nil)
|
||||
|
||||
expect(applied_sla.frt_due_at).to be_nil
|
||||
end
|
||||
|
||||
it 'returns deadline based on conversation created_at' do
|
||||
applied_sla = create(:applied_sla)
|
||||
applied_sla.sla_policy.update!(first_response_time_threshold: 3600, only_during_business_hours: false)
|
||||
|
||||
expected_deadline = applied_sla.conversation.created_at.to_i + 3600
|
||||
expect(applied_sla.frt_due_at).to eq(expected_deadline)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#nrt_due_at' do
|
||||
it 'returns nil when next_response_time_threshold is blank' do
|
||||
applied_sla = create(:applied_sla)
|
||||
applied_sla.sla_policy.update!(next_response_time_threshold: nil)
|
||||
|
||||
expect(applied_sla.nrt_due_at).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when waiting_since is blank' do
|
||||
applied_sla = create(:applied_sla)
|
||||
applied_sla.sla_policy.update!(next_response_time_threshold: 1800)
|
||||
applied_sla.conversation.update!(waiting_since: nil)
|
||||
|
||||
expect(applied_sla.nrt_due_at).to be_nil
|
||||
end
|
||||
|
||||
it 'returns deadline based on waiting_since' do
|
||||
applied_sla = create(:applied_sla)
|
||||
waiting_since = 2.hours.ago
|
||||
applied_sla.sla_policy.update!(next_response_time_threshold: 1800, only_during_business_hours: false)
|
||||
applied_sla.conversation.update!(waiting_since: waiting_since)
|
||||
|
||||
expected_deadline = waiting_since.to_i + 1800
|
||||
expect(applied_sla.nrt_due_at).to eq(expected_deadline)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#rt_due_at' do
|
||||
it 'returns nil when resolution_time_threshold is blank' do
|
||||
applied_sla = create(:applied_sla)
|
||||
applied_sla.sla_policy.update!(resolution_time_threshold: nil)
|
||||
|
||||
expect(applied_sla.rt_due_at).to be_nil
|
||||
end
|
||||
|
||||
it 'returns deadline based on conversation created_at' do
|
||||
applied_sla = create(:applied_sla)
|
||||
applied_sla.sla_policy.update!(resolution_time_threshold: 7200, only_during_business_hours: false)
|
||||
|
||||
expected_deadline = applied_sla.conversation.created_at.to_i + 7200
|
||||
expect(applied_sla.rt_due_at).to eq(expected_deadline)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_due_at' do
|
||||
it 'uses BusinessHoursService when only_during_business_hours is true' do
|
||||
account = create(:account)
|
||||
inbox = create(:inbox, account: account, working_hours_enabled: true)
|
||||
sla_policy = create(:sla_policy, account: account, first_response_time_threshold: 3600, only_during_business_hours: true)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
applied_sla = create(:applied_sla, sla_policy: sla_policy, conversation: conversation, account: account)
|
||||
|
||||
expect(Sla::BusinessHoursService).to receive(:new).and_call_original
|
||||
applied_sla.frt_due_at
|
||||
end
|
||||
|
||||
it 'does not use BusinessHoursService when only_during_business_hours is false' do
|
||||
applied_sla = create(:applied_sla)
|
||||
applied_sla.sla_policy.update!(first_response_time_threshold: 3600, only_during_business_hours: false)
|
||||
|
||||
expect(Sla::BusinessHoursService).not_to receive(:new)
|
||||
applied_sla.frt_due_at
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Sla::BusinessHoursService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, working_hours_enabled: true, timezone: 'UTC') }
|
||||
|
||||
# Default working hours: Mon-Fri 9:00-17:00 UTC, Sat-Sun closed
|
||||
describe '#deadline' do
|
||||
context 'when business hours should not apply' do
|
||||
it 'returns wall-clock deadline when working_hours_enabled is false' do
|
||||
inbox.update!(working_hours_enabled: false)
|
||||
start_time = Time.zone.parse('2024-01-19 16:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 3600)
|
||||
|
||||
expect(service.deadline.to_i).to eq((start_time + 1.hour).to_i)
|
||||
end
|
||||
|
||||
it 'returns wall-clock deadline when all days are closed' do
|
||||
inbox.working_hours.find_each { |wh| wh.update!(closed_all_day: true) }
|
||||
start_time = Time.zone.parse('2024-01-19 16:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 3600)
|
||||
|
||||
expect(service.deadline.to_i).to eq((start_time + 1.hour).to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when start time is during business hours' do
|
||||
it 'calculates deadline within the same day' do
|
||||
# Wednesday 10:00 AM + 2 hours = Wednesday 12:00 PM
|
||||
start_time = Time.zone.parse('2024-01-17 10:00:00') # Wednesday
|
||||
expected_deadline = Time.zone.parse('2024-01-17 12:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 2.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(expected_deadline.to_i)
|
||||
end
|
||||
|
||||
it 'spans to next business day when threshold exceeds remaining hours' do
|
||||
# Friday 4:00 PM + 2 hours = Monday 10:00 AM (1h Friday + 1h Monday)
|
||||
friday_4pm = Time.zone.parse('2024-01-19 16:00:00')
|
||||
monday_10am = Time.zone.parse('2024-01-22 10:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: friday_4pm, threshold_seconds: 2.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(monday_10am.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when start time is before business hours' do
|
||||
it 'starts counting from business hours open time' do
|
||||
# Wednesday 7:00 AM + 2 hours = Wednesday 11:00 AM (starts at 9 AM)
|
||||
start_time = Time.zone.parse('2024-01-17 07:00:00') # Wednesday 7 AM
|
||||
expected_deadline = Time.zone.parse('2024-01-17 11:00:00') # Wednesday 11 AM
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 2.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(expected_deadline.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when start time is after business hours' do
|
||||
it 'starts counting from next business day' do
|
||||
# Wednesday 6:00 PM + 2 hours = Thursday 11:00 AM
|
||||
start_time = Time.zone.parse('2024-01-17 18:00:00') # Wednesday 6 PM
|
||||
expected_deadline = Time.zone.parse('2024-01-18 11:00:00') # Thursday 11 AM
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: start_time, threshold_seconds: 2.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(expected_deadline.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when start time is on a closed day' do
|
||||
it 'starts counting from next business day' do
|
||||
# Saturday 10:00 AM + 2 hours = Monday 11:00 AM
|
||||
saturday = Time.zone.parse('2024-01-20 10:00:00')
|
||||
monday_11am = Time.zone.parse('2024-01-22 11:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: saturday, threshold_seconds: 2.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(monday_11am.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when threshold spans multiple days' do
|
||||
it 'calculates correctly across multiple business days' do
|
||||
# Monday 4:00 PM + 10 hours = Wednesday 10:00 AM
|
||||
# Monday: 1h (4-5 PM), Tuesday: 8h (9-5), Wednesday: 1h (9-10 AM)
|
||||
monday_4pm = Time.zone.parse('2024-01-15 16:00:00')
|
||||
wednesday_10am = Time.zone.parse('2024-01-17 10:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: monday_4pm, threshold_seconds: 10.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(wednesday_10am.to_i)
|
||||
end
|
||||
|
||||
it 'reuses loaded working hours while calculating across days' do
|
||||
monday_4pm = Time.zone.parse('2024-01-15 16:00:00')
|
||||
wednesday_10am = Time.zone.parse('2024-01-17 10:00:00')
|
||||
working_hours = inbox.working_hours
|
||||
service = described_class.new(inbox: inbox, start_time: monday_4pm, threshold_seconds: 10.hours)
|
||||
|
||||
expect(working_hours).to receive(:index_by).once.and_call_original
|
||||
expect(working_hours).not_to receive(:find_by)
|
||||
|
||||
expect(service.deadline.to_i).to eq(wednesday_10am.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different timezone' do
|
||||
it 'respects inbox timezone' do
|
||||
inbox.update!(timezone: 'America/New_York')
|
||||
# Friday 4:00 PM EST + 2 hours = Monday 10:00 AM EST
|
||||
friday_4pm_est = Time.zone.parse('2024-01-19 16:00:00 EST')
|
||||
monday_10am_est = Time.zone.parse('2024-01-22 10:00:00 EST')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: friday_4pm_est, threshold_seconds: 2.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(monday_10am_est.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when day is open all day' do
|
||||
it 'treats the day as 24 hours of business time' do
|
||||
# Set Saturday to open_all_day (0:00 - 23:59)
|
||||
inbox.working_hours.find_by(day_of_week: 6).update!(open_all_day: true, closed_all_day: false)
|
||||
|
||||
# Saturday 10:00 AM + 2 hours = Saturday 12:00 PM
|
||||
saturday_10am = Time.zone.parse('2024-01-20 10:00:00')
|
||||
saturday_12pm = Time.zone.parse('2024-01-20 12:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: saturday_10am, threshold_seconds: 2.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(saturday_12pm.to_i)
|
||||
end
|
||||
|
||||
it 'includes the final minute in the business-time window' do
|
||||
inbox.working_hours.find_by(day_of_week: 6).update!(open_all_day: true, closed_all_day: false)
|
||||
|
||||
saturday_midnight = Time.zone.parse('2024-01-20 00:00:00')
|
||||
sunday_midnight = Time.zone.parse('2024-01-21 00:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: saturday_midnight, threshold_seconds: 24.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(sunday_midnight.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when days have different business hours' do
|
||||
it 'uses the correct close time after advancing to next day' do
|
||||
# Monday (day 1) closes at 17:00, Tuesday (day 2) closes at 20:00
|
||||
inbox.working_hours.find_by(day_of_week: 1).update!(open_hour: 9, close_hour: 17)
|
||||
inbox.working_hours.find_by(day_of_week: 2).update!(open_hour: 9, close_hour: 20)
|
||||
|
||||
# Start at Monday 18:00 (after close) + 10 hours
|
||||
# Should start counting from Tuesday 9:00 AM
|
||||
# Tuesday has 11 hours available (9:00-20:00), so 10 hours = Tuesday 19:00
|
||||
monday_6pm = Time.zone.parse('2024-01-15 18:00:00') # Monday
|
||||
tuesday_7pm = Time.zone.parse('2024-01-16 19:00:00') # Tuesday
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: monday_6pm, threshold_seconds: 10.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(tuesday_7pm.to_i)
|
||||
end
|
||||
|
||||
it 'spans correctly across days with varying hours' do
|
||||
# Monday (day 1): 9:00-17:00 (8h), Tuesday (day 2): 9:00-20:00 (11h)
|
||||
inbox.working_hours.find_by(day_of_week: 1).update!(open_hour: 9, close_hour: 17)
|
||||
inbox.working_hours.find_by(day_of_week: 2).update!(open_hour: 9, close_hour: 20)
|
||||
|
||||
# Start at Monday 16:00 + 12 hours
|
||||
# Monday: 1h (16:00-17:00), Tuesday: 11h remaining (9:00-20:00)
|
||||
monday_4pm = Time.zone.parse('2024-01-15 16:00:00')
|
||||
tuesday_8pm = Time.zone.parse('2024-01-16 20:00:00')
|
||||
|
||||
service = described_class.new(inbox: inbox, start_time: monday_4pm, threshold_seconds: 12.hours)
|
||||
|
||||
expect(service.deadline.to_i).to eq(tuesday_8pm.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -140,6 +140,71 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when first response SLA is hit after non-business hours' do
|
||||
let(:created_at) { Time.zone.parse('2026-06-25 00:39:56 UTC') }
|
||||
let(:wall_clock_breach_time) { Time.zone.parse('2026-06-25 01:40:03 UTC') }
|
||||
let(:first_reply_created_at) { Time.zone.parse('2026-06-25 11:45:36 UTC') }
|
||||
let(:post_reply_eval_time) { Time.zone.parse('2026-06-25 11:46:38 UTC') }
|
||||
let(:email_inbox) { create(:inbox, :with_email, account: account, working_hours_enabled: true, timezone: 'America/New_York') }
|
||||
let(:business_hours_sla_policy) do
|
||||
create(
|
||||
:sla_policy,
|
||||
account: account,
|
||||
first_response_time_threshold: 1.hour,
|
||||
next_response_time_threshold: nil,
|
||||
resolution_time_threshold: nil,
|
||||
only_during_business_hours: true
|
||||
)
|
||||
end
|
||||
let(:business_hours_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: email_inbox,
|
||||
sla_policy: business_hours_sla_policy,
|
||||
created_at: created_at,
|
||||
last_activity_at: created_at
|
||||
)
|
||||
end
|
||||
let(:business_hours_applied_sla) { business_hours_conversation.applied_sla }
|
||||
|
||||
before do
|
||||
{
|
||||
0 => [11, 0, 20, 0],
|
||||
1 => [7, 0, 20, 0],
|
||||
2 => [7, 0, 20, 0],
|
||||
3 => [7, 0, 20, 0],
|
||||
4 => [7, 0, 16, 0],
|
||||
5 => [7, 0, 16, 0],
|
||||
6 => [11, 0, 20, 0]
|
||||
}.each do |day_of_week, (open_hour, open_minutes, close_hour, close_minutes)|
|
||||
email_inbox.working_hours.find_by(day_of_week: day_of_week).update!(
|
||||
open_hour: open_hour,
|
||||
open_minutes: open_minutes,
|
||||
close_hour: close_hour,
|
||||
close_minutes: close_minutes,
|
||||
closed_all_day: false,
|
||||
open_all_day: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not mark FRT missed while outside business hours or after an on-time business-hours reply' do
|
||||
travel_to wall_clock_breach_time do
|
||||
described_class.new(applied_sla: business_hours_applied_sla).perform
|
||||
end
|
||||
|
||||
business_hours_conversation.update!(first_reply_created_at: first_reply_created_at, last_activity_at: first_reply_created_at)
|
||||
|
||||
travel_to post_reply_eval_time do
|
||||
described_class.new(applied_sla: business_hours_applied_sla).perform
|
||||
end
|
||||
|
||||
expect(business_hours_applied_sla.reload.sla_status).to eq('active')
|
||||
expect(SlaEvent.where(applied_sla: business_hours_applied_sla, event_type: 'frt')).not_to exist
|
||||
end
|
||||
end
|
||||
|
||||
context 'when next response SLA is hit' do
|
||||
before do
|
||||
applied_sla.sla_policy.update(next_response_time_threshold: 6.hours)
|
||||
@@ -191,16 +256,16 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
# Simulate conversation timeline
|
||||
# Hit frt
|
||||
# incoming message from customer
|
||||
create(:message, conversation: conversation, created_at: 6.hours.ago, message_type: :incoming)
|
||||
create(:message, conversation: conversation, account: conversation.account, created_at: 6.hours.ago, message_type: :incoming)
|
||||
# outgoing message from agent within frt
|
||||
create(:message, conversation: conversation, created_at: 5.hours.ago, message_type: :outgoing)
|
||||
create(:message, conversation: conversation, account: conversation.account, created_at: 5.hours.ago, message_type: :outgoing)
|
||||
|
||||
# Miss nrt first time
|
||||
create(:message, conversation: conversation, created_at: 4.hours.ago, message_type: :incoming)
|
||||
create(:message, conversation: conversation, account: conversation.account, created_at: 4.hours.ago, message_type: :incoming)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
|
||||
# Miss nrt second time
|
||||
create(:message, conversation: conversation, created_at: 3.hours.ago, message_type: :incoming)
|
||||
create(:message, conversation: conversation, account: conversation.account, created_at: 3.hours.ago, message_type: :incoming)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
|
||||
# Conversation is resolved missing rt
|
||||
|
||||
Reference in New Issue
Block a user