Compare commits

...
Author SHA1 Message Date
Muhsin 473dc978e0 feat: sla business hours 2025-12-16 13:01:10 +05:30
6 changed files with 804 additions and 3 deletions
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
const props = defineProps({
conversation: {
@@ -40,9 +41,17 @@ const slaStatusText = computed(() => {
});
const updateSlaStatus = () => {
const businessHoursConfig = getBusinessHoursConfig(
appliedSLA.value?.slaPolicy,
props.conversation?.inbox
);
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
chat: props.conversation,
options: businessHoursConfig
? { businessHours: businessHoursConfig }
: undefined,
});
};
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -58,9 +59,17 @@ const groupClass = computed(() => {
});
const updateSlaStatus = () => {
const businessHoursConfig = getBusinessHoursConfig(
appliedSLA.value?.sla_policy,
props.chat?.inbox
);
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
options: businessHoursConfig
? { businessHours: businessHoursConfig }
: undefined,
});
};
@@ -0,0 +1,66 @@
/**
* Helper functions for SLA business hours configuration
*/
/**
* Extracts business hours configuration from SLA policy and inbox data
* Supports both camelCase and snake_case property naming conventions
*
* @param {Object} slaPolicy - The SLA policy object
* @param {Object} inbox - The inbox object with working hours configuration
* @returns {Object|null} Business hours configuration for utils package, or null if not applicable
*/
export const getBusinessHoursConfig = (slaPolicy, inbox) => {
// Handle both camelCase and snake_case property names
const onlyDuringBusinessHours =
slaPolicy?.only_during_business_hours ?? slaPolicy?.onlyDuringBusinessHours;
const workingHoursEnabled =
inbox?.working_hours_enabled ?? inbox?.workingHoursEnabled;
if (!onlyDuringBusinessHours || !workingHoursEnabled) {
return null;
}
// Convert working hours format to match utils expectation
const workingHours = {};
const dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
// Handle both camelCase and snake_case working hours arrays
const inboxWorkingHours = inbox.working_hours ?? inbox.workingHours;
if (inboxWorkingHours) {
inboxWorkingHours.forEach(dayConfig => {
// Handle both property naming conventions
const dayOfWeek = dayConfig.day_of_week ?? dayConfig.dayOfWeek;
const closedAllDay = dayConfig.closed_all_day ?? dayConfig.closedAllDay;
const openHour = dayConfig.open_hour ?? dayConfig.openHour;
const openMinutes = dayConfig.open_minutes ?? dayConfig.openMinutes;
const closeHour = dayConfig.close_hour ?? dayConfig.closeHour;
const closeMinutes = dayConfig.close_minutes ?? dayConfig.closeMinutes;
const dayName = dayMap[dayOfWeek];
if (closedAllDay) {
workingHours[dayName] = null;
} else {
// Convert to HH:MM format
const startHour = String(openHour || 0).padStart(2, '0');
const startMin = String(openMinutes || 0).padStart(2, '0');
const endHour = String(closeHour || 0).padStart(2, '0');
const endMin = String(closeMinutes || 0).padStart(2, '0');
workingHours[dayName] = {
start: `${startHour}:${startMin}`,
finish: `${endHour}:${endMin}`,
};
}
});
}
return {
working_hours_enabled: workingHoursEnabled,
timezone: inbox.timezone || 'UTC',
working_hours: workingHours,
only_during_business_hours: onlyDuringBusinessHours,
};
};
@@ -0,0 +1,386 @@
import { getBusinessHoursConfig } from '../slaHelper';
describe('slaHelper', () => {
describe('getBusinessHoursConfig', () => {
const mockWorkingHours = [
{
day_of_week: 1, // Monday
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 30,
closed_all_day: false,
},
{
day_of_week: 2, // Tuesday
open_hour: 8,
open_minutes: 30,
close_hour: 18,
close_minutes: 0,
closed_all_day: false,
},
{
day_of_week: 0, // Sunday
closed_all_day: true,
},
];
const mockWorkingHoursCamelCase = [
{
dayOfWeek: 1, // Monday
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 30,
closedAllDay: false,
},
{
dayOfWeek: 2, // Tuesday
openHour: 8,
openMinutes: 30,
closeHour: 18,
closeMinutes: 0,
closedAllDay: false,
},
{
dayOfWeek: 0, // Sunday
closedAllDay: true,
},
];
describe('when business hours are not required', () => {
it('returns null when SLA policy does not require business hours', () => {
const slaPolicy = { only_during_business_hours: false };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when inbox does not have working hours enabled', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: false,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when both conditions are false', () => {
const slaPolicy = { only_during_business_hours: false };
const inbox = { working_hours_enabled: false };
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when slaPolicy is null', () => {
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(null, inbox);
expect(result).toBeNull();
});
it('returns null when inbox is null', () => {
const slaPolicy = { only_during_business_hours: true };
const result = getBusinessHoursConfig(slaPolicy, null);
expect(result).toBeNull();
});
});
describe('when business hours are required - snake_case properties', () => {
it('converts working hours correctly with snake_case properties', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
timezone: 'America/New_York',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toEqual({
working_hours_enabled: true,
timezone: 'America/New_York',
working_hours: {
sun: null, // Closed all day
mon: {
start: '09:00',
finish: '17:30',
},
tue: {
start: '08:30',
finish: '18:00',
},
},
only_during_business_hours: true,
});
});
it('handles missing timezone by defaulting to UTC', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours.slice(0, 1), // Just Monday
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.timezone).toBe('UTC');
});
it('handles zero hours and minutes correctly', () => {
const workingHoursWithZeros = [
{
day_of_week: 1,
open_hour: 0,
open_minutes: 0,
close_hour: 0,
close_minutes: 0,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursWithZeros,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.mon).toEqual({
start: '00:00',
finish: '00:00',
});
});
});
describe('when business hours are required - camelCase properties', () => {
it('converts working hours correctly with camelCase properties', () => {
const slaPolicy = { onlyDuringBusinessHours: true };
const inbox = {
workingHoursEnabled: true,
workingHours: mockWorkingHoursCamelCase,
timezone: 'America/Los_Angeles',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toEqual({
working_hours_enabled: true,
timezone: 'America/Los_Angeles',
working_hours: {
sun: null, // Closed all day
mon: {
start: '09:00',
finish: '17:30',
},
tue: {
start: '08:30',
finish: '18:00',
},
},
only_during_business_hours: true,
});
});
});
describe('mixed property naming conventions', () => {
it('handles mixed snake_case and camelCase properties', () => {
const slaPolicy = {
only_during_business_hours: true, // snake_case
onlyDuringBusinessHours: false, // This should be ignored due to nullish coalescing
};
const inbox = {
working_hours_enabled: true, // snake_case
workingHours: mockWorkingHoursCamelCase, // camelCase
timezone: 'UTC',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).not.toBeNull();
expect(result.only_during_business_hours).toBe(true);
expect(result.working_hours_enabled).toBe(true);
});
it('prioritizes snake_case over camelCase when both exist', () => {
const slaPolicy = {
only_during_business_hours: true,
onlyDuringBusinessHours: false,
};
const inbox = {
working_hours_enabled: true,
workingHoursEnabled: false,
working_hours: mockWorkingHours,
workingHours: mockWorkingHoursCamelCase,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.only_during_business_hours).toBe(true);
expect(result.working_hours_enabled).toBe(true);
// Should use snake_case working_hours
expect(result.working_hours.mon.start).toBe('09:00');
});
});
describe('edge cases', () => {
it('handles empty working hours array', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: [],
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({});
});
it('handles missing working hours property', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
// No working_hours property
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({});
});
it('handles undefined values in working hours', () => {
const workingHoursWithUndefined = [
{
day_of_week: 1,
open_hour: undefined,
open_minutes: undefined,
close_hour: undefined,
close_minutes: undefined,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursWithUndefined,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.mon).toEqual({
start: '00:00',
finish: '00:00',
});
});
it('pads single digit hours and minutes correctly', () => {
const workingHoursSingleDigit = [
{
day_of_week: 3, // Wednesday
open_hour: 9,
open_minutes: 5,
close_hour: 5,
close_minutes: 0,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursSingleDigit,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.wed).toEqual({
start: '09:05',
finish: '05:00',
});
});
it('handles all days of the week correctly', () => {
const fullWeekWorkingHours = [
{ day_of_week: 0, closed_all_day: true }, // Sunday
{
day_of_week: 1,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Monday
{
day_of_week: 2,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Tuesday
{
day_of_week: 3,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Wednesday
{
day_of_week: 4,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Thursday
{
day_of_week: 5,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Friday
{ day_of_week: 6, closed_all_day: true }, // Saturday
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: fullWeekWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({
sun: null,
mon: { start: '09:00', finish: '17:00' },
tue: { start: '09:00', finish: '17:00' },
wed: { start: '09:00', finish: '17:00' },
thu: { start: '09:00', finish: '17:00' },
fri: { start: '09:00', finish: '17:00' },
sat: null,
});
});
});
});
});
@@ -1,5 +1,6 @@
class Sla::EvaluateAppliedSlaService
pattr_initialize [:applied_sla!]
include ReportingEventHelper
def perform
check_sla_thresholds
@@ -21,12 +22,58 @@ class Sla::EvaluateAppliedSlaService
end
end
def should_use_business_hours?(sla_policy, inbox)
sla_policy.only_during_business_hours? && inbox.working_hours_enabled?
end
# Calculates the SLA threshold deadline considering business hours if enabled.
#
# This method determines when an SLA will be breached by adding the threshold duration
# to the start time. If business hours are enabled, it only counts time during working hours,
# automatically skipping weekends and after-hours periods.
def calculate_threshold_deadline(start_time, threshold_seconds, inbox, sla_policy)
# Fall back to simple calendar time calculation if business hours not enabled
return start_time.to_i + threshold_seconds unless should_use_business_hours?(sla_policy, inbox)
# Configure the working_hours gem with inbox-specific schedule and timezone
configure_working_hours_for_calculation(inbox)
# Convert start time to inbox timezone for accurate business hours calculation
start_time_in_timezone = start_time.in_time_zone(inbox.timezone).to_time
# Determine effective start time: if outside business hours, advance to next working time
# Example: Saturday 6 PM conversation would start counting Monday 9 AM
effective_start_time = if start_time_in_timezone.in_working_hours?
start_time_in_timezone
else
WorkingHours.next_working_time(start_time_in_timezone)
end
# Add working time duration
# This automatically skips non-working hours, weekends, and holidays
deadline = effective_start_time + threshold_seconds.working.seconds
deadline.to_i
end
def configure_working_hours_for_calculation(inbox)
inbox_working_hours = configure_working_hours(inbox.working_hours)
return if inbox_working_hours.blank?
WorkingHours::Config.working_hours = inbox_working_hours
WorkingHours::Config.time_zone = inbox.timezone
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
threshold = calculate_threshold_deadline(
conversation.created_at,
sla_policy.first_response_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if first_reply_was_within_threshold?(conversation, threshold)
return if still_within_threshold?(threshold)
@@ -43,7 +90,12 @@ class Sla::EvaluateAppliedSlaService
# 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
threshold = calculate_threshold_deadline(
conversation.waiting_since,
sla_policy.next_response_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'nrt')
@@ -61,7 +113,12 @@ class Sla::EvaluateAppliedSlaService
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
threshold = calculate_threshold_deadline(
conversation.created_at,
sla_policy.resolution_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'rt')
@@ -222,4 +222,278 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1)
end
end
describe 'Business Hours SLA evaluation' do
let!(:inbox) { create(:inbox, account: account, working_hours_enabled: true, timezone: 'UTC') }
let!(:conversation) do
create(:conversation,
created_at: '15.05.2024 09:00'.to_datetime, assignee: user_1,
account: sla_policy.account,
sla_policy: sla_policy,
inbox: inbox)
end
let!(:applied_sla) { conversation.applied_sla }
before do
# Configure business hours: Mon-Fri 9 AM - 5 PM UTC
(1..5).each do |day|
create(:working_hour,
inbox: inbox,
day_of_week: day,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0)
end
# Weekend closed
[0, 6].each do |day|
create(:working_hour,
inbox: inbox,
day_of_week: day,
closed_all_day: true)
end
Time.zone = 'UTC'
travel_to '15.05.2024 10:00'.to_datetime
applied_sla.sla_policy.update(only_during_business_hours: true)
end
context 'when business hours are enabled and SLA policy requires it' do
context 'when evaluating first response time with business hours' do
before { applied_sla.sla_policy.update(first_response_time_threshold: 2.hours) }
it 'does not miss FRT when within business hours deadline' do
# Conversation created at 9 AM
# 2 hour FRT would be due at 1 PM (within business hours)
# Current time is 10 AM
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'misses FRT when business hours deadline is exceeded' do
travel_to '15.05.2024 13:01'.to_datetime
# Conversation created at 9 AM
# 2 hour business hours FRT would be due at 1 PM
# Current time is 1:01 PM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA frt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
it 'extends deadline over weekend correctly' do
# Create conversation on Friday at 4 PM
conversation.update(created_at: '10.05.2024 16:00'.to_datetime)
travel_to '13.05.2024 09:59'.to_datetime # Monday 9:59 AM
# 2 hour FRT from Friday 4 PM should be due Monday 10 AM
# (1 hour Friday + 1 hour Monday)
# Current time Monday 9:59 AM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when evaluating next response time with business hours' do
before do
applied_sla.sla_policy.update(next_response_time_threshold: 1.hour)
conversation.update(
first_reply_created_at: '15.05.2024 09:30'.to_datetime,
waiting_since: '15.05.2024 10:00'.to_datetime
)
end
it 'calculates NRT deadline correctly in business hours' do
travel_to '15.05.2024 10:30'.to_datetime
# Customer replied at 10 AM, 1 hour NRT should be due at 11 AM
# Current time 10:30 AM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'misses NRT when business hours deadline is exceeded' do
travel_to '15.05.2024 11:01'.to_datetime
# Customer replied at 10 AM, 1 hour NRT should be due at 11 AM
# Current time 11:01 AM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when evaluating resolution time with business hours' do
before { applied_sla.sla_policy.update(resolution_time_threshold: 4.hours) }
it 'calculates RT deadline correctly in business hours' do
travel_to '15.05.2024 12:00'.to_datetime
# Conversation created at 9 AM, 4 hour RT should be due at 3 PM
# Current time 12 PM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'misses RT when business hours deadline is exceeded' do
travel_to '15.05.2024 15:01'.to_datetime
# Conversation created at 9 AM, 4 hour RT should be due at 3 PM
# Current time 3:01 PM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when SLA spans across multiple business days' do
before { applied_sla.sla_policy.update(resolution_time_threshold: 10.hours) }
it 'correctly calculates deadline across multiple business days' do
# Conversation created Wednesday 4 PM
conversation.update(created_at: '15.05.2024 16:00'.to_datetime)
travel_to '16.05.2024 11:00'.to_datetime # Thursday 11 AM
# 10 hour RT: Wed 4-5 PM (1h) + Thu 9-11 AM (2h) = 3h elapsed, 7h remaining
# Deadline should be Friday at 2 PM (Thu 9-5 = 8h total = 7h remaining)
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when SLA starts near end of business day' do
before do
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
conversation.update(created_at: '15.05.2024 16:30'.to_datetime) # 4:30 PM
end
it 'extends deadline to next business day' do
travel_to '16.05.2024 10:29'.to_datetime # Next day 10:29 AM
# Started 4:30 PM (30 min before close) + 2 hours = 1.5 hours into next day
# Deadline should be 10:30 AM next day
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
end
context 'when business hours are disabled on inbox' do
before do
inbox.update(working_hours_enabled: false)
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
end
it 'falls back to calendar time calculation' do
travel_to '15.05.2024 11:01'.to_datetime
# Conversation created at 9 AM, 2 hour FRT would be due at 11 AM (calendar time)
# Current time 11:01 AM - should miss with calendar time
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when SLA policy has business hours disabled' do
before do
applied_sla.sla_policy.update(
only_during_business_hours: false,
first_response_time_threshold: 2.hours
)
end
it 'falls back to calendar time calculation' do
travel_to '15.05.2024 11:01'.to_datetime
# Conversation created at 9 AM, 2 hour FRT would be due at 11 AM (calendar time)
# Current time 11:01 AM - should miss with calendar time
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when conversation starts outside business hours' do
before do
conversation.update(created_at: '18.05.2024 18:00'.to_datetime)
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
travel_to '20.05.2024 10:00'.to_datetime # Monday 10 AM
# Saturday 6 PM
end
it 'starts counting from next business hours' do
# Conversation created Saturday 6 PM (outside business hours)
# 2 hour FRT should start counting from Monday 9 AM
# Deadline would be Monday 11 AM
# Current time Monday 10 AM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when handling different timezones' do
before do
inbox.update(timezone: 'America/New_York')
# Update working hours for EST timezone
inbox.working_hours.destroy_all
(1..5).each do |day|
create(:working_hour,
inbox: inbox,
day_of_week: day,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0)
end
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
end
it 'correctly handles timezone conversions' do
# Conversation created at 9 AM EST (2 PM UTC)
conversation.update(created_at: Time.zone.parse('15.05.2024 09:00 EST'))
# Test at 10:59 AM EST (3:59 PM UTC) - should not miss 2 hour SLA yet
travel_to Time.zone.parse('15.05.2024 10:59 EST')
# Should calculate business hours in the inbox timezone
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when no working hours are configured' do
before do
inbox.working_hours.destroy_all
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
end
it 'falls back to calendar time calculation' do
travel_to '15.05.2024 11:01'.to_datetime
# No working hours configured, should fall back to calendar time
# Conversation at 9 AM + 2 hours = 11 AM deadline
# Current time 11:01 AM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
end
end