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:
Pranav
2026-07-02 13:33:22 +05:30
committed by GitHub
co-authored by Muhsin Keloth Sojan Jose Sony Mathew Sony Mathew
parent 926a9d8a69
commit 49b0ab0e1f
13 changed files with 1228 additions and 100 deletions
@@ -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 || [],
});
};
@@ -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
+58 -1
View File
@@ -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]
+118 -1
View File
@@ -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