From 92d5d5ecffbf3b22b346655267f7f050e751b24b Mon Sep 17 00:00:00 2001 From: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:00:51 +0530 Subject: [PATCH] fix(sla): freeze misses after resolution --- .../ConversationCard/SLACardLabel.vue | 43 +++++-- .../Conversation/Sla/SLACardLabel.vue | 54 ++++++-- .../conversation/components/SLACardLabel.vue | 54 ++++++-- app/javascript/dashboard/helper/slaHelper.js | 97 ++++++++++----- .../dashboard/helper/specs/slaHelper.spec.js | 105 +++++++++++++++- ...000000_add_completed_at_to_applied_slas.rb | 5 + db/schema.rb | 3 +- enterprise/app/models/applied_sla.rb | 2 + .../app/models/enterprise/conversation.rb | 12 ++ ...ckfill_applied_sla_completed_at_service.rb | 106 ++++++++++++++++ .../api/v1/models/_applied_sla.json.jbuilder | 1 + script/backfill_applied_sla_completed_at.rb | 30 +++++ .../accounts/conversations_controller_spec.rb | 3 +- spec/enterprise/models/applied_sla_spec.rb | 1 + spec/enterprise/models/conversation_spec.rb | 31 +++++ ...l_applied_sla_completed_at_service_spec.rb | 117 ++++++++++++++++++ 16 files changed, 598 insertions(+), 66 deletions(-) create mode 100644 db/migrate/20260715000000_add_completed_at_to_applied_slas.rb create mode 100644 enterprise/app/services/sla/backfill_applied_sla_completed_at_service.rb create mode 100644 script/backfill_applied_sla_completed_at.rb create mode 100644 spec/enterprise/services/sla/backfill_applied_sla_completed_at_service_spec.rb diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue index 608bd84bd..c49f8e93a 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue @@ -1,6 +1,10 @@ @@ -118,7 +149,7 @@ onUnmounted(() => { :class="slaTextStyles" /> @@ -126,10 +157,11 @@ onUnmounted(() => { - {{ slaStatus.threshold }} + {{ slaValueText }} { : Math.floor(parsedTimestamp / 1000); }; +const isSLACompleted = (sla, conversation) => { + const terminalStatuses = ['hit', 'missed']; + + return Boolean( + sla.slaCompletedAt || + terminalStatuses.includes(sla.slaStatus) || + conversation.status === 'resolved' + ); +}; + +export const shouldRefreshSLAStatus = ({ appliedSla, chat }) => { + if (!appliedSla || !chat) return false; + + return !isSLACompleted(useCamelCase(appliedSla), useCamelCase(chat)); +}; + /** * Evaluates SLA status using backend-computed due times * @param {Object} params - Parameters object @@ -66,6 +82,9 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => { const conversation = useCamelCase(chat); const events = useCamelCase(slaEvents || []); const currentTime = Math.floor(Date.now() / 1000); + const completionTime = toUnixTimestamp(sla.slaCompletedAt); + const isCompleted = isSLACompleted(sla, conversation); + const evaluationTime = completionTime || (isCompleted ? null : currentTime); const slaStatuses = []; const dueAtByType = { @@ -84,47 +103,51 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => { slaStatuses.push({ type, - threshold: missedAt - currentTime, + threshold: evaluationTime ? missedAt - evaluationTime : null, icon: 'flame', isSlaMissed: true, }); }); - const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt); - const shouldCheckFirstResponse = - !firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt; + if (!isCompleted) { + 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 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 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, - }); + // Check RT - only if conversation is unresolved + if (sla.slaRtDueAt) { + const threshold = sla.slaRtDueAt - currentTime; + slaStatuses.push({ + type: 'RT', + threshold, + icon: threshold <= 0 ? 'flame' : 'alarm', + isSlaMissed: threshold <= 0, + }); + } } if (slaStatuses.length === 0) { @@ -137,13 +160,19 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => { return a.isSlaMissed ? -1 : 1; } + if (a.threshold === null || b.threshold === null) { + if (a.threshold === b.threshold) return 0; + return a.threshold === null ? -1 : 1; + } + return Math.abs(a.threshold) - Math.abs(b.threshold); }); const mostUrgent = slaStatuses[0]; return { type: mostUrgent.type, - threshold: formatSLATime(mostUrgent.threshold), + threshold: + mostUrgent.threshold === null ? '' : formatSLATime(mostUrgent.threshold), icon: mostUrgent.icon, isSlaMissed: mostUrgent.isSlaMissed, }; diff --git a/app/javascript/dashboard/helper/specs/slaHelper.spec.js b/app/javascript/dashboard/helper/specs/slaHelper.spec.js index 466112f7d..b5156ce08 100644 --- a/app/javascript/dashboard/helper/specs/slaHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/slaHelper.spec.js @@ -1,4 +1,4 @@ -import { evaluateSLAStatus } from '../slaHelper'; +import { evaluateSLAStatus, shouldRefreshSLAStatus } from '../slaHelper'; describe('#SLA Helpers', () => { const currentTimestamp = 1700000000; // Fixed timestamp for testing @@ -378,6 +378,109 @@ describe('#SLA Helpers', () => { }); }); + describe('completed SLA misses', () => { + it('freezes a recorded FRT miss at the SLA completion time', () => { + const appliedSla = { + sla_status: 'missed', + sla_completed_at: currentTimestamp - 3600, + sla_frt_due_at: currentTimestamp - 7200, + }; + const chat = { status: 'resolved' }; + const slaEvents = [ + { event_type: 'frt', created_at: currentTimestamp - 7000 }, + ]; + + const result = evaluateSLAStatus({ appliedSla, chat, slaEvents }); + + expect(result).toMatchObject({ + type: 'FRT', + threshold: '1h', + isSlaMissed: true, + }); + }); + + it('freezes a recorded NRT miss at the SLA completion time', () => { + const appliedSla = { + sla_status: 'missed', + sla_completed_at: currentTimestamp - 3600, + }; + const chat = { status: 'resolved' }; + const slaEvents = [ + { event_type: 'nrt', created_at: currentTimestamp - 5400 }, + ]; + + const result = evaluateSLAStatus({ appliedSla, chat, slaEvents }); + + expect(result).toMatchObject({ + type: 'NRT', + threshold: '30m', + isSlaMissed: true, + }); + }); + + it('freezes a recorded RT miss at the SLA completion time', () => { + const appliedSla = { + sla_status: 'missed', + sla_completed_at: currentTimestamp - 3600, + sla_rt_due_at: currentTimestamp - 7200, + }; + const chat = { status: 'resolved' }; + const slaEvents = [ + { event_type: 'rt', created_at: currentTimestamp - 7000 }, + ]; + + const result = evaluateSLAStatus({ appliedSla, chat, slaEvents }); + + expect(result).toMatchObject({ + type: 'RT', + threshold: '1h', + isSlaMissed: true, + }); + }); + + it('returns a static miss for a legacy completed SLA without a timestamp', () => { + const appliedSla = { + sla_status: 'missed', + sla_rt_due_at: currentTimestamp - 7200, + }; + const chat = { status: 'resolved' }; + const slaEvents = [ + { event_type: 'rt', created_at: currentTimestamp - 7000 }, + ]; + + const result = evaluateSLAStatus({ appliedSla, chat, slaEvents }); + + expect(result).toMatchObject({ + type: 'RT', + threshold: '', + isSlaMissed: true, + }); + }); + }); + + describe('refresh scheduling', () => { + it('refreshes only active unresolved SLAs', () => { + expect( + shouldRefreshSLAStatus({ + appliedSla: { sla_status: 'active' }, + chat: { status: 'open' }, + }) + ).toBe(true); + expect( + shouldRefreshSLAStatus({ + appliedSla: { sla_status: 'active' }, + chat: { status: 'resolved' }, + }) + ).toBe(false); + expect( + shouldRefreshSLAStatus({ + appliedSla: { sla_status: 'missed' }, + chat: { status: 'open' }, + }) + ).toBe(false); + }); + }); + describe('time formatting', () => { it('formats time in days and hours', () => { const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours diff --git a/db/migrate/20260715000000_add_completed_at_to_applied_slas.rb b/db/migrate/20260715000000_add_completed_at_to_applied_slas.rb new file mode 100644 index 000000000..46c1e1c6f --- /dev/null +++ b/db/migrate/20260715000000_add_completed_at_to_applied_slas.rb @@ -0,0 +1,5 @@ +class AddCompletedAtToAppliedSlas < ActiveRecord::Migration[7.1] + def change + add_column :applied_slas, :completed_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 43e7135b9..6179e4806 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do +ActiveRecord::Schema[7.1].define(version: 2026_07_15_000000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -178,6 +178,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "sla_status", default: 0 + t.datetime "completed_at" t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true t.index ["account_id"], name: "index_applied_slas_on_account_id" t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id" diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index cab812b36..25bd0f8a4 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -4,6 +4,7 @@ # # 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 @@ -53,6 +54,7 @@ class AppliedSla < ApplicationRecord 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, diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb index 077e0e932..96e0ff63d 100644 --- a/enterprise/app/models/enterprise/conversation.rb +++ b/enterprise/app/models/enterprise/conversation.rb @@ -33,6 +33,18 @@ module Enterprise::Conversation private + def handle_resolved_status_change + super + update_applied_sla_completion + end + + def update_applied_sla_completion + return unless saved_change_to_status? + return if applied_sla.blank? || applied_sla.hit? || applied_sla.missed? + + applied_sla.update!(completed_at: resolved? ? Time.current : nil) + end + def dispatch_captain_inference_event(event_name) dispatcher_dispatch(event_name) end diff --git a/enterprise/app/services/sla/backfill_applied_sla_completed_at_service.rb b/enterprise/app/services/sla/backfill_applied_sla_completed_at_service.rb new file mode 100644 index 000000000..d9445796f --- /dev/null +++ b/enterprise/app/services/sla/backfill_applied_sla_completed_at_service.rb @@ -0,0 +1,106 @@ +class Sla::BackfillAppliedSlaCompletedAtService + DEFAULT_BATCH_SIZE = 500 + + def initialize(**options) + options.assert_valid_keys(:account_id, :all_accounts, :apply, :batch_size, :after_id, :output) + + @account_id = options[:account_id] + @all_accounts = options.fetch(:all_accounts, false) + @apply = options.fetch(:apply, false) + @batch_size = options.fetch(:batch_size, DEFAULT_BATCH_SIZE) + @after_id = options.fetch(:after_id, 0) + @output = options.fetch(:output, $stdout) + end + + def perform + validate_options! + + scope = candidate_scope + eligible_count = scope.count + counters = { processed: 0, matched: 0, updated: 0, skipped: 0, last_id: @after_id } + + print_preflight(eligible_count) + + scope.find_in_batches(batch_size: @batch_size, start: @after_id + 1) { |batch| process_batch(batch, counters) } + + result = counters.merge(eligible: eligible_count, dry_run: !@apply) + @output.puts "Completed: #{result.inspect}" + result + end + + private + + def process_batch(batch, counters) + resolution_times = resolution_times_for(batch) + updated_count = @apply ? bulk_update(resolution_times) : 0 + + counters[:processed] += batch.size + counters[:matched] += resolution_times.size + counters[:updated] += updated_count + counters[:skipped] += batch.size - resolution_times.size + counters[:last_id] = batch.last.id + + @output.puts "Processed through applied_sla_id=#{counters[:last_id]} " \ + "(matched=#{counters[:matched]}, updated=#{counters[:updated]}, skipped=#{counters[:skipped]})" + end + + def validate_options! + account_scope = @account_id.present? + raise ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true' if account_scope == @all_accounts + raise ArgumentError, 'BATCH_SIZE must be greater than zero' unless @batch_size.positive? + raise ArgumentError, 'AFTER_ID must be zero or greater' if @after_id.negative? + + Account.find(@account_id) if account_scope + end + + def candidate_scope + scope = AppliedSla.where(sla_status: :missed, completed_at: nil).where('applied_slas.id > ?', @after_id) + scope = scope.where(account_id: @account_id) if @account_id.present? + scope + end + + def resolution_times_for(batch) + events_by_conversation = ReportingEvent + .where( + account_id: batch.map(&:account_id).uniq, + conversation_id: batch.map(&:conversation_id), + name: 'conversation_resolved' + ) + .where.not(event_end_time: nil) + .order(:conversation_id, event_end_time: :desc) + .group_by(&:conversation_id) + + batch.each_with_object({}) do |applied_sla, resolution_times| + event = events_by_conversation.fetch(applied_sla.conversation_id, []).find do |reporting_event| + reporting_event.event_end_time.between?(applied_sla.created_at, applied_sla.updated_at) + end + resolution_times[applied_sla.id] = event.event_end_time if event + end + end + + def bulk_update(resolution_times) + return 0 if resolution_times.empty? + + connection = AppliedSla.connection + values = resolution_times.map do |id, completed_at| + "(#{connection.quote(id)}, #{connection.quote(completed_at)}::timestamp)" + end.join(', ') + + statement = <<~SQL.squish + UPDATE #{connection.quote_table_name(AppliedSla.table_name)} AS applied_slas + SET completed_at = backfill.completed_at + FROM (VALUES #{values}) AS backfill(id, completed_at) + WHERE applied_slas.id = backfill.id + AND applied_slas.completed_at IS NULL + SQL + + connection.exec_update(statement, 'Backfill applied SLA completed_at') + end + + def print_preflight(eligible_count) + scope = @account_id.present? ? "account_id=#{@account_id}" : 'all accounts' + mode = @apply ? 'APPLY' : 'DRY RUN' + @output.puts "Applied SLA completed_at backfill: mode=#{mode}, scope=#{scope}, batch_size=#{@batch_size}, after_id=#{@after_id}" + @output.puts "Eligible missed applied SLAs: #{eligible_count}" + end +end diff --git a/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder b/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder index c00782622..dae48d74f 100644 --- a/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder +++ b/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder @@ -3,6 +3,7 @@ json.sla_id resource.sla_policy_id json.sla_status resource.sla_status json.created_at resource.created_at.to_i json.updated_at resource.updated_at.to_i +json.sla_completed_at resource.completed_at&.to_i json.sla_description resource.sla_policy.description json.sla_name resource.sla_policy.name json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold diff --git a/script/backfill_applied_sla_completed_at.rb b/script/backfill_applied_sla_completed_at.rb new file mode 100644 index 000000000..bc97b041e --- /dev/null +++ b/script/backfill_applied_sla_completed_at.rb @@ -0,0 +1,30 @@ +# Backfill applied_slas.completed_at from conversation resolution reporting events. +# +# Account-scoped dry run: +# ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb +# +# Account-scoped apply: +# ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb +# +# Explicit global apply with resume controls: +# ALL_ACCOUNTS=true APPLY=true BATCH_SIZE=500 AFTER_ID=0 \ +# bundle exec rails runner script/backfill_applied_sla_completed_at.rb + +begin + account_id = Integer(ENV.fetch('ACCOUNT_ID'), 10) if ENV['ACCOUNT_ID'].present? + all_accounts = ENV['ALL_ACCOUNTS'] == 'true' + apply = ENV['APPLY'] == 'true' + batch_size = Integer(ENV.fetch('BATCH_SIZE', Sla::BackfillAppliedSlaCompletedAtService::DEFAULT_BATCH_SIZE.to_s), 10) + after_id = Integer(ENV.fetch('AFTER_ID', '0'), 10) + + Sla::BackfillAppliedSlaCompletedAtService.new( + account_id: account_id, + all_accounts: all_accounts, + apply: apply, + batch_size: batch_size, + after_id: after_id + ).perform +rescue ArgumentError, ActiveRecord::RecordNotFound => e + warn "Backfill aborted: #{e.message}" + exit 1 +end diff --git a/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb index 7d053eccf..dd1adf6e9 100644 --- a/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -8,13 +8,14 @@ RSpec.describe 'Conversations API', type: :request do it 'returns SLA data for the conversation if the feature is enabled' do account.enable_features!('sla') conversation = create(:conversation, account: account) - applied_sla = create(:applied_sla, conversation: conversation) + applied_sla = create(:applied_sla, conversation: conversation, completed_at: 1.hour.ago) sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla) get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token expect(response).to have_http_status(:ok) expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id) + expect(response.parsed_body['applied_sla']['sla_completed_at']).to eq(applied_sla.completed_at.to_i) expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id) end diff --git a/spec/enterprise/models/applied_sla_spec.rb b/spec/enterprise/models/applied_sla_spec.rb index df685444c..8b45029ec 100644 --- a/spec/enterprise/models/applied_sla_spec.rb +++ b/spec/enterprise/models/applied_sla_spec.rb @@ -17,6 +17,7 @@ RSpec.describe AppliedSla, type: :model do 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, diff --git a/spec/enterprise/models/conversation_spec.rb b/spec/enterprise/models/conversation_spec.rb index 7138c468c..ca4c365b8 100644 --- a/spec/enterprise/models/conversation_spec.rb +++ b/spec/enterprise/models/conversation_spec.rb @@ -41,6 +41,37 @@ RSpec.describe Conversation, type: :model do # end end + describe 'SLA completion' do + let(:applied_sla) { create(:applied_sla) } + let(:conversation) { applied_sla.conversation } + + it 'records the completion time when the conversation is resolved' do + completion_time = Time.zone.parse('2026-07-15 10:00:00') + + travel_to(completion_time) { conversation.update!(status: :resolved) } + + expect(applied_sla.reload.completed_at).to eq(completion_time) + end + + it 'clears the completion time when a nonterminal SLA is reopened' do + conversation.update!(status: :resolved) + + conversation.update!(status: :open) + + expect(applied_sla.reload.completed_at).to be_nil + end + + it 'preserves the completion time when a terminal SLA is reopened' do + conversation.update!(status: :resolved) + completed_at = applied_sla.reload.completed_at + applied_sla.update!(sla_status: :missed) + + conversation.update!(status: :open) + + expect(applied_sla.reload.completed_at).to eq(completed_at) + end + end + describe 'sla_policy' do let(:account) { create(:account) } let(:conversation) { create(:conversation, account: account) } diff --git a/spec/enterprise/services/sla/backfill_applied_sla_completed_at_service_spec.rb b/spec/enterprise/services/sla/backfill_applied_sla_completed_at_service_spec.rb new file mode 100644 index 000000000..3bf772132 --- /dev/null +++ b/spec/enterprise/services/sla/backfill_applied_sla_completed_at_service_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +RSpec.describe Sla::BackfillAppliedSlaCompletedAtService do + let(:output) { StringIO.new } + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:applied_sla) do + create( + :applied_sla, + account: account, + conversation: conversation, + sla_status: :missed, + created_at: 3.days.ago, + updated_at: 1.day.ago + ) + end + let!(:resolution_event) do + create( + :reporting_event, + account: account, + inbox: conversation.inbox, + conversation: conversation, + name: 'conversation_resolved', + event_start_time: applied_sla.created_at, + event_end_time: 2.days.ago + ) + end + + it 'defaults to a dry run' do + result = described_class.new(account_id: account.id, output: output).perform + + expect(result).to include(dry_run: true, eligible: 1, matched: 1, updated: 0, skipped: 0) + expect(applied_sla.reload.completed_at).to be_nil + end + + it 'backfills the latest reliable resolution without changing updated_at' do + latest_resolution = create( + :reporting_event, + account: account, + inbox: conversation.inbox, + conversation: conversation, + name: 'conversation_resolved', + event_start_time: applied_sla.created_at, + event_end_time: 36.hours.ago + ) + original_updated_at = applied_sla.updated_at + + result = described_class.new(account_id: account.id, apply: true, output: output).perform + + expect(result).to include(dry_run: false, eligible: 1, matched: 1, updated: 1, skipped: 0) + expect(applied_sla.reload.completed_at).to eq(latest_resolution.event_end_time) + expect(applied_sla.updated_at).to eq(original_updated_at) + end + + it 'skips records without a reliable resolution event' do + resolution_event.destroy! + + result = described_class.new(account_id: account.id, apply: true, output: output).perform + + expect(result).to include(eligible: 1, matched: 0, updated: 0, skipped: 1) + expect(applied_sla.reload.completed_at).to be_nil + end + + it 'is idempotent' do + service = described_class.new(account_id: account.id, apply: true, output: output) + + service.perform + result = service.perform + + expect(result).to include(eligible: 0, matched: 0, updated: 0, skipped: 0) + expect(applied_sla.reload.completed_at).to eq(resolution_event.event_end_time) + end + + it 'requires exactly one account scope' do + expect { described_class.new(output: output).perform } + .to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true') + expect { described_class.new(account_id: account.id, all_accounts: true, output: output).perform } + .to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true') + end + + it 'limits account runs and requires explicit global scope for other accounts' do + other_account = create(:account) + other_conversation = create(:conversation, account: other_account) + other_applied_sla = create( + :applied_sla, + account: other_account, + conversation: other_conversation, + sla_status: :missed, + created_at: 3.days.ago, + updated_at: 1.day.ago + ) + other_resolution_event = create( + :reporting_event, + account: other_account, + inbox: other_conversation.inbox, + conversation: other_conversation, + name: 'conversation_resolved', + event_start_time: other_applied_sla.created_at, + event_end_time: 2.days.ago + ) + + described_class.new(account_id: account.id, apply: true, output: output).perform + + expect(applied_sla.reload.completed_at).to eq(resolution_event.event_end_time) + expect(other_applied_sla.reload.completed_at).to be_nil + + described_class.new(all_accounts: true, apply: true, output: output).perform + + expect(other_applied_sla.reload.completed_at).to eq(other_resolution_event.event_end_time) + end + + it 'resumes after the supplied applied SLA id' do + result = described_class.new(account_id: account.id, after_id: applied_sla.id, output: output).perform + + expect(result).to include(eligible: 0, processed: 0, last_id: applied_sla.id) + end +end