mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
fix: three-layer ACW protection — prevent agent stuck in wrapping-up
Root cause: when an agent refreshes the page during or after a call, the React state (UCID, callState, disposition modal) is wiped. The SIP BYE event fires but no component exists to trigger the disposition modal → no POST to /api/ozonetel/dispose → agent stuck in ACW. Layer 1 (beforeunload warning): Shows browser's native "Leave page?" dialog during active calls. Agent can cancel and stay. Layer 2 (sendBeacon auto-dispose): UCID persisted to localStorage when call activates. On page unload, navigator.sendBeacon fires /api/ozonetel/dispose with CALLBACK_REQUESTED. Guaranteed delivery even during page death. Cleared from localStorage when disposition modal submits normally. Layer 3 lives in helix-engage-server (separate commit). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Sync UCID to localStorage so sendBeacon can fire auto-dispose on page refresh.
|
||||||
|
// Cleared on disposition submit (handleDisposition below) or when call resets to idle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (callUcid) {
|
||||||
|
localStorage.setItem('helix_active_ucid', callUcid);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// Don't clear on unmount if disposition hasn't fired — the
|
||||||
|
// beforeunload handler in SipProvider needs it
|
||||||
|
};
|
||||||
|
}, [callUcid]);
|
||||||
|
|
||||||
// Detect caller disconnect: call was active and ended without agent pressing End
|
// Detect caller disconnect: call was active and ended without agent pressing End
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
||||||
@@ -115,6 +127,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
|
||||||
|
localStorage.removeItem('helix_active_ucid');
|
||||||
|
|
||||||
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
||||||
handleReset();
|
handleReset();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,15 +89,43 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
|
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
|
||||||
// and resets to idle via the "Back to Worklist" button
|
// and resets to idle via the "Back to Worklist" button
|
||||||
|
|
||||||
// Cleanup on unmount + page unload
|
// Layer 1: Warn on page refresh/close during active call
|
||||||
|
// Layer 2: Fire sendBeacon to auto-dispose if user confirms leave
|
||||||
|
// These two layers protect against the "agent refreshes mid-call → stuck in ACW" bug.
|
||||||
|
// Layer 3 (server-side ACW timeout) lives in supervisor.service.ts.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
const ucid = localStorage.getItem('helix_active_ucid');
|
||||||
|
|
||||||
|
// Layer 1: Show browser "Leave page?" dialog during active calls
|
||||||
|
if (callState === 'active' || callState === 'ringing-in' || callState === 'ringing-out') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Fire disposition beacon if there's an active UCID
|
||||||
|
// sendBeacon is guaranteed to fire even during page unload
|
||||||
|
if (ucid) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
ucid,
|
||||||
|
disposition: 'CALLBACK_REQUESTED',
|
||||||
|
autoDisposed: true,
|
||||||
|
});
|
||||||
|
navigator.sendBeacon('/api/ozonetel/dispose', new Blob([payload], { type: 'application/json' }));
|
||||||
|
localStorage.removeItem('helix_active_ucid');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUnload = () => disconnectSip();
|
const handleUnload = () => disconnectSip();
|
||||||
window.addEventListener('beforeunload', handleUnload);
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
window.addEventListener('unload', handleUnload);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeunload', handleUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
window.removeEventListener('unload', handleUnload);
|
||||||
disconnectSip();
|
disconnectSip();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [callState]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user