## 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>
170 lines
6.6 KiB
Ruby
170 lines
6.6 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe AppliedSla, type: :model do
|
|
describe 'associations' do
|
|
it { is_expected.to belong_to(:sla_policy) }
|
|
it { is_expected.to belong_to(:account) }
|
|
it { is_expected.to belong_to(:conversation) }
|
|
end
|
|
|
|
describe 'push_event_data' do
|
|
it 'returns the correct hash' do
|
|
applied_sla = create(:applied_sla)
|
|
expect(applied_sla.push_event_data).to eq(
|
|
{
|
|
id: applied_sla.id,
|
|
sla_id: applied_sla.sla_policy_id,
|
|
sla_status: applied_sla.sla_status,
|
|
created_at: applied_sla.created_at.to_i,
|
|
updated_at: applied_sla.updated_at.to_i,
|
|
sla_completed_at: nil,
|
|
sla_description: applied_sla.sla_policy.description,
|
|
sla_name: applied_sla.sla_policy.name,
|
|
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_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
|
|
it 'creates valid applied sla policy object' do
|
|
applied_sla = create(:applied_sla)
|
|
expect(applied_sla.sla_status).to eq 'active'
|
|
end
|
|
end
|
|
|
|
describe '.with_sla_applicable_conversation' do
|
|
it 'excludes blocked contacts and keeps conversations with missing contacts' do
|
|
applied_sla = create(:applied_sla)
|
|
blocked_applied_sla = create(:applied_sla)
|
|
missing_contact_applied_sla = create(:applied_sla)
|
|
|
|
blocked_applied_sla.conversation.contact.update!(blocked: true)
|
|
missing_contact_applied_sla.conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
|
|
|
|
expect(described_class.with_sla_applicable_conversation).to include(applied_sla, missing_contact_applied_sla)
|
|
expect(described_class.with_sla_applicable_conversation).not_to include(blocked_applied_sla)
|
|
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
|