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