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>
This commit is contained in:
Sony Mathew
2026-07-29 18:21:26 +05:30
committed by GitHub
parent 4ac2432b77
commit 502c45f73b
18 changed files with 823 additions and 178 deletions

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSlaStatus } from 'dashboard/composables/useSlaStatus';
const props = defineProps({
conversation: {
@@ -9,67 +10,31 @@ const props = defineProps({
},
});
const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
const conversation = computed(() => props.conversation);
const appliedSLA = computed(() => conversation.value?.appliedSla);
const slaEvents = computed(() => conversation.value?.slaEvents);
const { slaStatus } = useSlaStatus({
appliedSla: appliedSLA,
chat: conversation,
slaEvents,
});
const appliedSLA = computed(() => props.conversation?.appliedSla);
const slaEvents = computed(() => props.conversation?.slaEvents);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
return slaStatus.value?.threshold && appliedSLA.value?.id;
return slaStatus.value?.type && appliedSLA.value?.id;
});
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const slaValueText = computed(
() => slaStatus.value?.threshold || t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.conversation,
slaEvents: slaEvents.value || [],
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.conversation, updateSlaStatus);
// This expose is to provide context to the parent component, so that it can decided weather
// a new row has to be added to the conversation card or not
// Expose whether the parent conversation card needs an SLA row.
// SLACardLabel > CardMessagePreviewWithMeta > ConversationCard
//
// We need to do this becuase each SLA card has it's own SLA timer
// and it's just convenient to have this logic in the SLACardLabel component
// However this is a bit hacky, and we should change this in the future
//
// TODO: A better implementation would be to have the timer as a shared composable, just like the provider pattern
// we use across the next components. Have the calculation be done on the top ConversationCard component
// and then the value be injected to the SLACardLabel component
defineExpose({
hasSlaThreshold,
});
@@ -96,7 +61,7 @@ defineExpose({
class="text-sm truncate"
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
{{ `${slaStatusText}: ${slaValueText}` }}
</span>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSlaStatus } from 'dashboard/composables/useSlaStatus';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
@@ -12,53 +13,33 @@ const props = defineProps({
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
const { t } = useI18n();
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const chat = computed(() => props.chat);
const appliedSLA = computed(() => chat.value?.applied_sla);
const slaEvents = computed(() => chat.value?.sla_events);
const { slaStatus } = useSlaStatus({
appliedSla: appliedSLA,
chat,
slaEvents,
});
const hasSlaThreshold = computed(() => slaStatus.value?.type);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaLabel = computed(() => {
if (slaStatus.value?.threshold) return slaStatus.value.threshold;
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
const status = t('CONVERSATION.HEADER.SLA_STATUS.MISSED');
return {
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
}[slaStatus.value.type];
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
@@ -70,11 +51,7 @@ defineExpose({
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<Label :label="slaLabel" :color="isSlaMissed ? 'ruby' : 'amber'" compact>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { useSlaStatus } from 'dashboard/composables/useSlaStatus';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -19,20 +19,17 @@ const props = defineProps({
},
});
const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
const chat = computed(() => props.chat);
const appliedSLA = computed(() => chat.value?.applied_sla);
const slaEvents = computed(() => chat.value?.sla_events);
const { slaStatus } = useSlaStatus({
appliedSla: appliedSLA,
chat,
slaEvents,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const hasSlaThreshold = computed(() => slaStatus.value?.type);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
@@ -40,12 +37,24 @@ const slaTextStyles = computed(() =>
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
const status = isSlaMissed.value
? t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
: t('CONVERSATION.HEADER.SLA_STATUS.DUE');
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
return {
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
}[upperCaseType];
});
const showFullStatusText = computed(
() => props.showExtendedInfo && props.parentWidth > 650
);
const slaValueText = computed(
() =>
slaStatus.value?.threshold ||
(showFullStatusText.value ? '' : slaStatusText.value)
);
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
@@ -57,44 +66,11 @@ const groupClass = computed(() => {
: 'rounded h-5 border border-n-strong';
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -118,7 +94,7 @@ onUnmounted(() => {
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo && parentWidth > 650"
v-if="showFullStatusText"
class="text-xs font-medium"
:class="slaTextStyles"
>
@@ -126,10 +102,11 @@ onUnmounted(() => {
</span>
</div>
<span
v-if="slaValueText"
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
{{ slaValueText }}
</span>
</div>
<SLAPopoverCard

View File

@@ -0,0 +1,112 @@
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useSlaStatus } from '../useSlaStatus';
const currentTimestamp = 1750000000;
const mountComposable = ({ appliedSla, chat, slaEvents = ref([]) }) => {
let composable;
const wrapper = mount(
defineComponent({
setup() {
composable = useSlaStatus({ appliedSla, chat, slaEvents });
return () => h('div');
},
})
);
return { wrapper, ...composable };
};
describe('useSlaStatus', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(currentTimestamp * 1000));
});
afterEach(() => {
vi.useRealTimers();
});
it('evaluates immediately and refreshes active SLAs every minute', () => {
const appliedSla = ref({
sla_frt_due_at: currentTimestamp + 120,
});
const chat = ref({
first_reply_created_at: null,
status: 'open',
});
const { slaStatus, wrapper } = mountComposable({ appliedSla, chat });
expect(slaStatus.value.threshold).toBe('2m');
vi.advanceTimersByTime(60000);
expect(slaStatus.value.threshold).toBe('1m');
wrapper.unmount();
});
it('refreshes and stops the timer when the chat object is replaced', async () => {
const appliedSla = ref({
sla_frt_due_at: currentTimestamp + 120,
});
const chat = ref({
first_reply_created_at: null,
status: 'open',
});
const { slaStatus, wrapper } = mountComposable({ appliedSla, chat });
chat.value = { status: 'resolved' };
await nextTick();
expect(slaStatus.value.type).toBe('');
expect(vi.getTimerCount()).toBe(0);
wrapper.unmount();
});
it('restarts the timer when a resolved chat is reopened in place', async () => {
const appliedSla = ref({
sla_frt_due_at: currentTimestamp + 120,
});
const chat = ref({
first_reply_created_at: null,
status: 'open',
});
const { slaStatus, wrapper } = mountComposable({ appliedSla, chat });
chat.value.status = 'resolved';
appliedSla.value.sla_completed_at = currentTimestamp;
await nextTick();
expect(slaStatus.value.type).toBe('');
expect(vi.getTimerCount()).toBe(0);
chat.value.status = 'open';
await nextTick();
expect(slaStatus.value.type).toBe('FRT');
expect(vi.getTimerCount()).toBe(1);
wrapper.unmount();
});
it('clears the timer when its component unmounts', () => {
const appliedSla = ref({
sla_frt_due_at: currentTimestamp + 120,
});
const chat = ref({
first_reply_created_at: null,
status: 'open',
});
const { wrapper } = mountComposable({ appliedSla, chat });
expect(vi.getTimerCount()).toBe(1);
wrapper.unmount();
expect(vi.getTimerCount()).toBe(0);
});
});

View File

@@ -0,0 +1,85 @@
import { onMounted, onUnmounted, ref, unref, watch } from 'vue';
import {
evaluateSLAStatus,
shouldRefreshSLAStatus,
} from 'dashboard/helper/slaHelper';
const REFRESH_INTERVAL = 60000;
export const useSlaStatus = ({ appliedSla, chat, slaEvents }) => {
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: unref(appliedSla),
chat: unref(chat),
slaEvents: unref(slaEvents) || [],
});
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
const createTimer = () => {
clearTimer();
if (
!shouldRefreshSLAStatus({
appliedSla: unref(appliedSla),
chat: unref(chat),
})
) {
return;
}
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
const refreshSlaStatus = () => {
updateSlaStatus();
createTimer();
};
const getRefreshDependencies = () => {
const currentChat = unref(chat) || {};
const currentAppliedSla = unref(appliedSla) || {};
const currentSlaEvents = unref(slaEvents) || [];
return [
currentChat.status,
currentChat.firstReplyCreatedAt ?? currentChat.first_reply_created_at,
currentChat.waitingSince ?? currentChat.waiting_since,
currentAppliedSla.slaStatus ?? currentAppliedSla.sla_status,
currentAppliedSla.slaCompletedAt ?? currentAppliedSla.sla_completed_at,
currentAppliedSla.slaFrtDueAt ?? currentAppliedSla.sla_frt_due_at,
currentAppliedSla.slaNrtDueAt ?? currentAppliedSla.sla_nrt_due_at,
currentAppliedSla.slaRtDueAt ?? currentAppliedSla.sla_rt_due_at,
currentSlaEvents
.map(
event =>
`${event.eventType ?? event.event_type}:${event.createdAt ?? event.created_at}`
)
.join('|'),
];
};
onMounted(refreshSlaStatus);
onUnmounted(clearTimer);
watch(getRefreshDependencies, refreshSlaStatus);
return {
slaStatus,
};
};

View File

@@ -47,6 +47,20 @@ const toUnixTimestamp = value => {
: Math.floor(parsedTimestamp / 1000);
};
const isTerminalSLAStatus = status => ['hit', 'missed'].includes(status);
const isSLACompleted = (sla, conversation) => {
return (
isTerminalSLAStatus(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 +80,11 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
const conversation = useCamelCase(chat);
const events = useCamelCase(slaEvents || []);
const currentTime = Math.floor(Date.now() / 1000);
const isCompleted = isSLACompleted(sla, conversation);
const completionTime = isCompleted
? toUnixTimestamp(sla.slaCompletedAt)
: null;
const evaluationTime = completionTime || (isCompleted ? null : currentTime);
const slaStatuses = [];
const dueAtByType = {
@@ -73,6 +92,9 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
RT: sla.slaRtDueAt,
};
const slaTypes = ['FRT', 'NRT', 'RT'];
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
events.forEach(event => {
const type = event.eventType?.toUpperCase();
@@ -84,19 +106,34 @@ 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;
const hasRecordedFirstResponseMiss = events.some(
event => event.eventType?.toUpperCase() === 'FRT'
);
const completedFirstResponseEvaluationTime =
completionTime &&
!isTerminalSLAStatus(sla.slaStatus) &&
!hasRecordedFirstResponseMiss &&
sla.slaFrtDueAt <= completionTime
? completionTime
: null;
const firstResponseEvaluationTime = isCompleted
? completedFirstResponseEvaluationTime
: currentTime;
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
// Check FRT until the first reply is made on time. When resolution reaches
// the client before SLA processing, use completion time to preserve the miss.
if (
sla.slaFrtDueAt &&
shouldCheckFirstResponse &&
firstResponseEvaluationTime
) {
const threshold = sla.slaFrtDueAt - firstResponseEvaluationTime;
slaStatuses.push({
type: 'FRT',
threshold,
@@ -105,6 +142,7 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
});
}
if (!isCompleted) {
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
@@ -117,7 +155,7 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
}
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
if (sla.slaRtDueAt) {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
@@ -126,6 +164,7 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
isSlaMissed: threshold <= 0,
});
}
}
if (slaStatuses.length === 0) {
return emptyStatus;
@@ -137,13 +176,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,
};

View File

@@ -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,166 @@ 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 an overdue FRT while SLA event processing is pending', () => {
const appliedSla = {
sla_status: 'active',
sla_completed_at: currentTimestamp - 3600,
sla_frt_due_at: currentTimestamp - 7200,
};
const chat = {
status: 'resolved',
first_reply_created_at: null,
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result).toMatchObject({
type: 'FRT',
threshold: '1h',
isSlaMissed: true,
});
});
it('does not mark FRT missed when completion precedes its due time', () => {
const appliedSla = {
sla_status: 'active',
sla_completed_at: currentTimestamp - 7200,
sla_frt_due_at: currentTimestamp - 3600,
};
const chat = {
status: 'resolved',
first_reply_created_at: null,
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('');
});
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);
});
it('ignores a stale completion time on a reopened nonterminal SLA', () => {
const appliedSla = {
sla_status: 'active_with_misses',
sla_completed_at: currentTimestamp - 3600,
sla_frt_due_at: currentTimestamp - 7200,
};
const chat = { status: 'open' };
const slaEvents = [
{ event_type: 'frt', created_at: currentTimestamp - 7000 },
];
expect(shouldRefreshSLAStatus({ appliedSla, chat })).toBe(true);
expect(
evaluateSLAStatus({ appliedSla, chat, slaEvents })
).toMatchObject({
type: 'FRT',
threshold: '2h',
isSlaMissed: true,
});
});
});
describe('time formatting', () => {
it('formats time in days and hours', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours

View File

@@ -0,0 +1,5 @@
class AddCompletedAtToAppliedSlas < ActiveRecord::Migration[7.1]
def change
add_column :applied_slas, :completed_at, :datetime
end
end

View File

@@ -178,6 +178,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_28_000001) 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"

View File

@@ -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,

View File

@@ -33,6 +33,23 @@ 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?
current_applied_sla = applied_sla
return if current_applied_sla.blank?
terminal_sla = current_applied_sla.sla_status.in?(%w[hit missed])
return if terminal_sla && (!resolved? || current_applied_sla.completed_at.present?)
current_applied_sla.update!(completed_at: resolved? ? Time.current : nil)
end
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -41,6 +41,49 @@ 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 'records the completion time when SLA evaluation finishes during resolution' do
completion_time = Time.zone.parse('2026-07-15 10:00:00')
allow(conversation).to receive(:update_applied_sla_completion).and_wrap_original do |method|
applied_sla.update!(sla_status: :missed)
method.call
end
travel_to(completion_time) { conversation.update!(status: :resolved) }
expect(applied_sla.reload).to have_attributes(sla_status: 'missed', completed_at: 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) }

View File

@@ -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.reload.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.reload.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.reload.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.reload.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