Files
moreminimore-chat/enterprise/app/models/applied_sla.rb
Sony Mathew 502c45f73b fix: freeze SLA misses after resolution (#15024)
## Description

Resolved conversations now preserve historical SLA misses without
allowing their displayed duration to keep growing. Applied SLAs record a
stable completion timestamp that is shared through REST and realtime
payloads, and the dashboard freezes FRT, NRT, and RT misses at that
point.

Legacy completed SLAs without a reliable timestamp remain visible as a
static missed state. Terminal SLAs remain frozen when a conversation is
reopened; a reopen before finalization continues the same SLA without
resetting its deadlines.

### Closes


[CW-7597](https://linear.app/chatwoot/issue/CW-7597/freeze-sla-miss-durations-after-conversation-resolution)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How to reproduce

1. Apply an SLA with a resolution-time threshold to a conversation.
2. Let the threshold breach, then resolve the conversation.
3. Observe that the recorded miss duration continues increasing every
minute even though the conversation is resolved.

## What changed

- Added nullable `applied_slas.completed_at` and exposed it as
`sla_completed_at` in conversation, report, and websocket payloads.
- Captured completion before broadcasting resolution and preserved it
for terminal applied SLAs.
- Frozen recorded FRT, NRT, and RT durations in classic and
next-generation conversation labels, including a static fallback for
legacy rows.
- Added a dry-run-first, resumable Rails runner for account-scoped or
explicitly global historical repair without enqueuing jobs or touching
`updated_at`.

Account-scoped production rollout starts with:

```sh
ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb
ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb
```

## How Has This Been Tested?

- Verified resolution stamping, nonterminal reopen clearing, and
terminal reopen preservation.
- Verified dry-run, apply, account/global scope, resume, skip,
idempotency, and timestamp-preserving backfill behavior.
- Verified all three miss types freeze and existing conversation-card
behavior remains intact.
- 71 focused RSpec examples and 37 focused Vitest examples pass.
- RuboCop, ESLint, and diff checks pass.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-29 18:21:26 +05:30

138 lines
5.2 KiB
Ruby

# == Schema Information
#
# Table name: applied_slas
#
# id :bigint not null, primary key
# sla_status :integer default("active")
# completed_at :datetime
# 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]) }
scope :with_sla_applicable_conversation, -> { where(conversation_id: Conversation.with_sla_applicable_contact.select(:id)) }
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_completed_at: completed_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