- 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>
135 lines
5.0 KiB
Ruby
135 lines
5.0 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: applied_slas
|
|
#
|
|
# id :bigint not null, primary key
|
|
# sla_status :integer default("active")
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :bigint not null
|
|
# conversation_id :bigint not null
|
|
# sla_policy_id :bigint not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_applied_slas_on_account_id (account_id)
|
|
# index_applied_slas_on_account_sla_policy_conversation (account_id,sla_policy_id,conversation_id) UNIQUE
|
|
# index_applied_slas_on_conversation_id (conversation_id)
|
|
# index_applied_slas_on_sla_policy_id (sla_policy_id)
|
|
#
|
|
class AppliedSla < ApplicationRecord
|
|
belongs_to :account
|
|
belongs_to :sla_policy
|
|
belongs_to :conversation
|
|
|
|
has_many :sla_events, dependent: :destroy_async
|
|
|
|
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
|
|
before_validation :ensure_account_id
|
|
|
|
enum sla_status: { active: 0, hit: 1, missed: 2, active_with_misses: 3 }
|
|
|
|
scope :filter_by_date_range, ->(range) { where(created_at: range) if range.present? }
|
|
scope :filter_by_inbox_id, ->(inbox_id) { joins(:conversation).where(conversations: { inbox_id: inbox_id }) if inbox_id.present? }
|
|
scope :filter_by_team_id, ->(team_id) { joins(:conversation).where(conversations: { team_id: team_id }) if team_id.present? }
|
|
scope :filter_by_sla_policy_id, ->(sla_policy_id) { where(sla_policy_id: sla_policy_id) if sla_policy_id.present? }
|
|
scope :filter_by_label_list, lambda { |label_list|
|
|
joins(:conversation).where('conversations.cached_label_list LIKE ?', "%#{label_list}%") if label_list.present?
|
|
}
|
|
scope :filter_by_assigned_agent_id, lambda { |assigned_agent_id|
|
|
joins(:conversation).where(conversations: { assignee_id: assigned_agent_id }) if assigned_agent_id.present?
|
|
}
|
|
scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
|
|
|
|
after_update_commit :push_conversation_event
|
|
|
|
def push_event_data
|
|
sla_due_at_values = due_at_values
|
|
|
|
{
|
|
id: id,
|
|
sla_id: sla_policy_id,
|
|
sla_status: sla_status,
|
|
created_at: created_at.to_i,
|
|
updated_at: updated_at.to_i,
|
|
sla_description: sla_policy.description,
|
|
sla_name: sla_policy.name,
|
|
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_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
|
|
# right now we simply use `CONVERSATION_UPDATED` event to notify the frontend
|
|
# we can eventually start using `CONVERSATION_SLA_UPDATED` event as required later
|
|
# for now the updated event should suffice
|
|
|
|
return unless saved_change_to_sla_status?
|
|
|
|
conversation.dispatch_conversation_updated_event
|
|
end
|
|
|
|
def ensure_account_id
|
|
self.account_id ||= sla_policy&.account_id
|
|
end
|
|
end
|