Files
helix-engage/src/providers/sip-provider.tsx
saridsa2 5632f15031 fix: P1 call-desk defects batch
- Mute persists across calls: sip-manager's "ended/failed" branch now
  resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only
  the SIP track was unmuted, leaving the UI icon + toggle logic in a
  muted state that the next call inherited).
- Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button
  was missing an isRegistered check in its disabled prop, so it stayed
  clickable when SIP was down. Button now shows "Telephony unavailable"
  and is disabled.
- Past dates in Follow-up: enquiry-form's follow-up date input had no
  min constraint. Switched to a raw <input type="date"> with min set
  to today's ISO date.
- Returning-patient AI summary during call: ai-chat-panel now auto-fires
  a "give me a quick summary of <caller>" request whenever the caller's
  leadId changes (new incoming call). Clears prior chat state so each
  caller starts fresh.
- Remove Type column in Patients page (Badge import also pruned).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:38:35 +05:30

269 lines
11 KiB
TypeScript

import { useEffect, useCallback, useRef, type PropsWithChildren } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import {
sipConnectionStatusAtom,
sipCallStateAtom,
sipCallerNumberAtom,
sipIsMutedAtom,
sipIsOnHoldAtom,
sipCallDurationAtom,
sipCallStartTimeAtom,
sipCallUcidAtom,
} from '@/state/sip-state';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { SIPConfig } from '@/types/sip';
// SIP config comes exclusively from the Agent entity (stored on login).
// No env var fallback — users without an Agent entity don't connect SIP.
const getSipConfig = (): SIPConfig | null => {
try {
const stored = localStorage.getItem('helix_agent_config');
if (stored) {
const config = JSON.parse(stored);
if (config.sipUri && config.sipWsServer) {
return {
displayName: 'Helix Agent',
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: 'stun:stun.l.google.com:19302',
};
}
}
} catch {}
return null;
};
export const SipProvider = ({ children }: PropsWithChildren) => {
const [, setConnectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState, setCallState] = useAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const setCallDuration = useSetAtom(sipCallDurationAtom);
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
const setIsMutedGlobal = useSetAtom(sipIsMutedAtom);
const setIsOnHoldGlobal = useSetAtom(sipIsOnHoldAtom);
// Register Jotai setters so the singleton SIP manager can update atoms
useEffect(() => {
registerSipStateUpdater({
setConnectionStatus,
setCallState,
setCallerNumber,
setCallUcid,
setIsMuted: setIsMutedGlobal,
setIsOnHold: setIsOnHoldGlobal,
});
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid, setIsMutedGlobal, setIsOnHoldGlobal]);
// Auto-connect SIP on mount — only if Agent entity has SIP config
useEffect(() => {
const config = getSipConfig();
if (config) {
connectSip(config);
} else {
console.log('[SIP] No agent SIP config — skipping connection');
}
}, []);
// Call duration timer
useEffect(() => {
if (callState === 'active') {
const start = new Date();
setCallStartTime(start);
const interval = window.setInterval(() => {
setCallDuration(Math.floor((Date.now() - start.getTime()) / 1000));
}, 1000);
return () => clearInterval(interval);
}
setCallDuration(0);
setCallStartTime(null);
}, [callState, setCallDuration, setCallStartTime]);
// Ringtone on incoming call
useEffect(() => {
if (callState === 'ringing-in') {
import('@/lib/ringtone').then(({ startRingtone }) => startRingtone());
} else {
import('@/lib/ringtone').then(({ stopRingtone }) => stopRingtone());
}
}, [callState]);
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
// and resets to idle via the "Back to Worklist" button
// 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.
//
// IMPORTANT: beforeunload reads callState via a ref (not the dep array)
// because adding callState to deps causes the cleanup to fire on every
// state transition → disconnectSip() → kills the call mid-flight.
const callStateRef = useRef(callState);
callStateRef.current = callState;
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const ucid = localStorage.getItem('helix_active_ucid');
const state = callStateRef.current;
// Layer 1: Show browser "Leave page?" dialog during active calls
if (state === 'active' || state === 'ringing-in' || state === 'ringing-out') {
e.preventDefault();
e.returnValue = '';
}
// Layer 2: Fire disposition beacon if there's an active UCID
if (ucid) {
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const payload = JSON.stringify({
ucid,
disposition: 'CALLBACK_REQUESTED',
agentId: agentCfg.ozonetelAgentId,
autoDisposed: true,
});
navigator.sendBeacon('/api/ozonetel/dispose', new Blob([payload], { type: 'application/json' }));
localStorage.removeItem('helix_active_ucid');
}
};
const handleUnload = () => disconnectSip(true, 'page-unload');
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('unload', handleUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('unload', handleUnload);
disconnectSip(true, 'sip-provider-unmount'); // force — component is unmounting
};
}, []); // empty deps — runs once on mount, cleanup only on unmount
return <>{children}</>;
};
// Hook for components to access SIP actions + state
export const useSip = () => {
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState, setCallState] = useAtom(sipCallStateAtom);
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom);
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
const [callDuration] = useAtom(sipCallDurationAtom);
const makeCall = useCallback((phoneNumber: string) => {
getSipClient()?.call(phoneNumber);
setCallerNumber(phoneNumber);
}, [setCallerNumber]);
// Ozonetel outbound dial — single path for all outbound calls
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
// Hard guard — no dial is valid when SIP isn't registered, because
// the audio leg can't be established. Every entry point (worklist
// row, click-to-call, phone-action-cell, patient 360, etc.) funnels
// through this callback, so gating here is the single source of
// truth for "can this agent place a call right now?"
if (connectionStatus !== 'registered') {
notify.error('Telephony unavailable', 'Cannot place call — SIP is not registered. Check your connection.');
console.warn(`[DIAL] Blocked — SIP not registered (status=${connectionStatus})`);
return;
}
// Block outbound calls when agent is on Break or Training
const agentCfg = localStorage.getItem('helix_agent_config');
if (agentCfg) {
const { useAgentState: _ } = await import('@/hooks/use-agent-state');
// Read state from the SSE endpoint directly (can't use hook here)
const agentId = JSON.parse(agentCfg).ozonetelAgentId;
try {
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
const stateData = await stateRes.json();
if (stateData.state === 'break' || stateData.state === 'training') {
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
return;
}
} catch {}
}
console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`);
setCallState('ringing-out');
setCallerNumber(phoneNumber);
setOutboundPending(true);
const safetyTimeout = setTimeout(() => {
console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending');
setOutboundPending(false);
}, 30000);
try {
// Send agent config so the sidecar dials with the correct agent ID + campaign
const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', {
phoneNumber,
agentId: agentConfig.ozonetelAgentId,
campaignName: agentConfig.campaignName,
});
console.log('[DIAL] Dial API response:', result);
clearTimeout(safetyTimeout);
// Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound
if (result?.ucid) {
console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`);
setCallUcid(result.ucid);
}
} catch (err) {
console.error('[DIAL] Dial API failed:', err);
clearTimeout(safetyTimeout);
setOutboundPending(false);
setCallState('idle');
setCallerNumber(null);
throw new Error('Dial failed');
}
}, [setCallState, setCallerNumber, setCallUcid, connectionStatus]);
const answer = useCallback(() => getSipClient()?.answer(), []);
const reject = useCallback(() => getSipClient()?.reject(), []);
const hangup = useCallback(() => getSipClient()?.hangup(), []);
const toggleMute = useCallback(() => {
if (isMuted) {
getSipClient()?.unmute();
} else {
getSipClient()?.mute();
}
setIsMuted(!isMuted);
}, [isMuted, setIsMuted]);
const toggleHold = useCallback(() => {
if (isOnHold) {
getSipClient()?.unhold();
} else {
getSipClient()?.hold();
}
setIsOnHold(!isOnHold);
}, [isOnHold, setIsOnHold]);
return {
connectionStatus,
callState,
callerNumber,
callUcid,
isMuted,
isOnHold,
callDuration,
isRegistered: connectionStatus === 'registered',
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
ozonetelStatus: 'logged-in' as const,
ozonetelError: null as string | null,
connect: () => { const c = getSipConfig(); if (c) connectSip(c); },
disconnect: disconnectSip,
makeCall,
dialOutbound,
answer,
reject,
hangup,
toggleMute,
toggleHold,
};
};