Files
moreminimore-chat/app/javascript/dashboard/composables/useSlaStatus.js
Sony Mathew 502c45f73b fix: freeze SLA misses after resolution (#15024)
## Description

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

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

### Closes


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

## Type of change

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

## How to reproduce

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

## What changed

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

Account-scoped production rollout starts with:

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

## How Has This Been Tested?

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

## Checklist:

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

---------

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

86 lines
2.1 KiB
JavaScript

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,
};
};