mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
fix: clean outbound call gating — confirmedAnswered state with 3s debounce (#568)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace layered customerAnswered/wasAnsweredRef with clean two-concern design: - customerAnswered: live derived value (is customer on line right now?) - confirmedAnswered: latched state (did real conversation happen?) Inbound: immediate. Outbound: 3s debounce filters voicemail. Never resets until handleReset — survives acw→ended timing gap. Buttons use confirmedAnswered for outbound (no flash during voicemail), customerAnswered for inbound (immediate). Disposition routing uses confirmedAnswered for both directions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -109,33 +109,40 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
const isOutbound = callDirectionRef.current === 'OUTBOUND';
|
const isOutbound = callDirectionRef.current === 'OUTBOUND';
|
||||||
|
|
||||||
// For outbound: Ozonetel sends 'in-call' even for voicemail (~4s before ACW).
|
// customerAnswered — live signal (is customer on the line RIGHT NOW?)
|
||||||
// Debounce: only treat as answered if 'in-call' holds for 5+ seconds.
|
const customerAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
|
||||||
// For inbound: customer is already there — active means answered, no delay.
|
|
||||||
const rawAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
|
// confirmedAnswered — latched state (did a real conversation happen?)
|
||||||
const [customerAnswered, setCustomerAnswered] = useState(false);
|
// Inbound: set true on active (immediate). Outbound: set true after
|
||||||
|
// in-call holds 5+ seconds (filters voicemail). Never resets — survives
|
||||||
|
// the acw→ended timing gap. Used for disposition routing AND outbound
|
||||||
|
// button gating.
|
||||||
|
const [confirmedAnswered, setConfirmedAnswered] = useState(false);
|
||||||
|
const unansweredDisposeFired = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOutbound) {
|
if (!isOutbound && callState === 'active') {
|
||||||
setCustomerAnswered(rawAnswered);
|
setConfirmedAnswered(true);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (rawAnswered) {
|
}, [callState, isOutbound]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOutbound && customerAnswered && !confirmedAnswered) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
|
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
|
||||||
setCustomerAnswered(true);
|
setConfirmedAnswered(true);
|
||||||
}, 5000);
|
}, 3000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
setCustomerAnswered(false);
|
}, [customerAnswered, isOutbound, confirmedAnswered]);
|
||||||
}, [rawAnswered, isOutbound]);
|
|
||||||
|
|
||||||
const wasAnsweredRef = useRef(callState === 'active');
|
// Button gating: inbound uses live signal, outbound uses debounced latch
|
||||||
const unansweredDisposeFired = useRef(false);
|
const buttonsEnabled = isOutbound ? confirmedAnswered : customerAnswered;
|
||||||
|
|
||||||
// ── DEBUG: trace every state change ──
|
// ── DEBUG: trace every state change ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} rawAnswered=${rawAnswered} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current}`);
|
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`);
|
||||||
}, [callState, ozonetelState, isOutbound, rawAnswered, customerAnswered]);
|
}, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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}`);
|
||||||
@@ -153,19 +160,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
};
|
};
|
||||||
}, [callUcid]);
|
}, [callUcid]);
|
||||||
|
|
||||||
// Detect caller disconnect: call was active and ended without agent pressing End.
|
// Detect caller disconnect: call ended without agent pressing End.
|
||||||
// For outbound: use live customerAnswered (not the latch) — Ozonetel may briefly
|
// Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered.
|
||||||
// fire 'in-call' then 'acw' even when no one answered (voicemail, timeout).
|
|
||||||
// If customerAnswered is false at call end, route to Back to Worklist.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
|
if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
|
||||||
const trulyAnswered = isOutbound ? customerAnswered : wasAnsweredRef.current;
|
console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`);
|
||||||
console.log(`[CALL-DBG] ▶ CALL ENDED: trulyAnswered=${trulyAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current} callState=${callState}`);
|
if (confirmedAnswered) {
|
||||||
if (trulyAnswered) {
|
|
||||||
setCallerDisconnected(true);
|
setCallerDisconnected(true);
|
||||||
setDispositionOpen(true);
|
setDispositionOpen(true);
|
||||||
}
|
}
|
||||||
}, [callState, dispositionOpen, isOutbound, customerAnswered]);
|
}, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? '';
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
@@ -235,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setDispositionOpen(false);
|
setDispositionOpen(false);
|
||||||
setCallerDisconnected(false);
|
setCallerDisconnected(false);
|
||||||
|
setConfirmedAnswered(false);
|
||||||
setActionsTaken([]);
|
setActionsTaken([]);
|
||||||
setCallState('idle');
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
@@ -243,12 +248,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onCallComplete?.();
|
onCallComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unanswered call — for outbound use live flag, for inbound use latch
|
|
||||||
const trulyAnsweredAtEnd = isOutbound ? customerAnswered : wasAnsweredRef.current;
|
|
||||||
|
|
||||||
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
|
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!trulyAnsweredAtEnd && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
|
if (!confirmedAnswered && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
|
||||||
unansweredDisposeFired.current = true;
|
unansweredDisposeFired.current = true;
|
||||||
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
||||||
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
|
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
|
||||||
@@ -264,7 +266,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
notes: 'Auto-disposed — customer did not answer',
|
notes: 'Auto-disposed — customer did not answer',
|
||||||
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
|
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
|
||||||
}
|
}
|
||||||
}, [trulyAnsweredAtEnd, isOutbound, callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Outbound ringing
|
// Outbound ringing
|
||||||
if (callState === 'ringing-out') {
|
if (callState === 'ringing-out') {
|
||||||
@@ -315,8 +317,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!trulyAnsweredAtEnd && (callState === 'ended' || callState === 'failed')) {
|
if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
|
||||||
console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: trulyAnswered=${trulyAnsweredAtEnd} isOutbound=${isOutbound} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current}`);
|
console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
@@ -331,10 +333,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === 'active' || dispositionOpen) {
|
if (callState === 'active' || dispositionOpen) {
|
||||||
if (customerAnswered && !wasAnsweredRef.current) {
|
|
||||||
console.log(`[CALL-DBG] ▶ wasAnsweredRef SET TRUE: ozonetel=${ozonetelState} callState=${callState}`);
|
|
||||||
}
|
|
||||||
if (customerAnswered) wasAnsweredRef.current = true;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
||||||
@@ -416,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
isDisabled={!customerAnswered}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||||
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||||
isDisabled={!customerAnswered}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
isDisabled={!customerAnswered}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||||
|
|
||||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||||
@@ -608,12 +606,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
isOpen={dispositionOpen}
|
isOpen={dispositionOpen}
|
||||||
callerName={fullName || phoneDisplay}
|
callerName={fullName || phoneDisplay}
|
||||||
callerDisconnected={callerDisconnected}
|
callerDisconnected={callerDisconnected}
|
||||||
// wasAnsweredRef only flips true once callState reaches
|
callAnswered={confirmedAnswered}
|
||||||
// 'active'. Outbound callbacks that never connect keep
|
|
||||||
// this false, which narrows the disposition options to
|
|
||||||
// no-answer outcomes and prevents SLA-gaming dispositions
|
|
||||||
// like Info Provided on a call the customer never took.
|
|
||||||
callAnswered={wasAnsweredRef.current}
|
|
||||||
actionsTaken={actionsTaken}
|
actionsTaken={actionsTaken}
|
||||||
onSubmit={handleDisposition}
|
onSubmit={handleDisposition}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user