From 1a5a7c4bf5fe30afe5a95c873bd0f40c0f322768 Mon Sep 17 00:00:00 2001 From: coygg Date: Tue, 7 Apr 2026 15:29:33 -0400 Subject: [PATCH 1/6] fix: harden telnyx dialer reconnect lifecycle --- src/__tests__/telnyx-lifecycle.test.ts | 42 ++++++++ src/hooks/use-telnyx.ts | 131 +++++++++++++++++++------ src/lib/telnyx-lifecycle.ts | 50 ++++++++++ 3 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/telnyx-lifecycle.test.ts create mode 100644 src/lib/telnyx-lifecycle.ts diff --git a/src/__tests__/telnyx-lifecycle.test.ts b/src/__tests__/telnyx-lifecycle.test.ts new file mode 100644 index 00000000..4dc94720 --- /dev/null +++ b/src/__tests__/telnyx-lifecycle.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + getLifecycleRecoveryDelay, + getRegistrationHealthCheckIntervalMs, + shouldRunInteractionRecovery, +} from '@/lib/telnyx-lifecycle' + +describe('telnyx lifecycle helpers', () => { + it('returns the expected recovery delay for each lifecycle trigger', () => { + expect(getLifecycleRecoveryDelay('pageshow')).toBe(500) + expect(getLifecycleRecoveryDelay('online')).toBe(1000) + expect(getLifecycleRecoveryDelay('sdk-error')).toBe(1000) + expect(getLifecycleRecoveryDelay('socket-close-active')).toBe(1000) + expect(getLifecycleRecoveryDelay('socket-close-idle')).toBe(1000) + expect(getLifecycleRecoveryDelay('focus')).toBe(1000) + expect(getLifecycleRecoveryDelay('interaction')).toBe(1000) + expect(getLifecycleRecoveryDelay('makeCall')).toBe(1000) + expect(getLifecycleRecoveryDelay('visibilitychange')).toBe(1500) + expect(getLifecycleRecoveryDelay('post-call')).toBe(8000) + }) + + it('uses the hidden interval when the tab is backgrounded', () => { + expect(getRegistrationHealthCheckIntervalMs('hidden', 60_000, 15_000)).toBe(15_000) + }) + + it('uses the visible interval when the tab is visible or unknown', () => { + expect(getRegistrationHealthCheckIntervalMs('visible', 60_000, 15_000)).toBe(60_000) + expect(getRegistrationHealthCheckIntervalMs(undefined, 60_000, 15_000)).toBe(60_000) + }) + + it('rate limits interaction-driven recovery checks', () => { + expect(shouldRunInteractionRecovery(10_000, 0)).toBe(true) + expect(shouldRunInteractionRecovery(4_999, 0)).toBe(false) + expect(shouldRunInteractionRecovery(6_000, 2_000)).toBe(false) + expect(shouldRunInteractionRecovery(7_001, 2_000)).toBe(true) + }) + + it('accepts a custom cooldown for edge-case throttling', () => { + expect(shouldRunInteractionRecovery(2_000, 0, 2_500)).toBe(false) + expect(shouldRunInteractionRecovery(2_500, 0, 2_500)).toBe(true) + }) +}) diff --git a/src/hooks/use-telnyx.ts b/src/hooks/use-telnyx.ts index 7d6050aa..4b561647 100644 --- a/src/hooks/use-telnyx.ts +++ b/src/hooks/use-telnyx.ts @@ -5,6 +5,11 @@ import { createClient } from '@/lib/supabase/client' // Realtime uses the SSR client directly import type { CallNotification, ScreenPopData } from '@/types' import { showPresenceToast } from '@/hooks/usePresence' +import { + getLifecycleRecoveryDelay, + getRegistrationHealthCheckIntervalMs, + shouldRunInteractionRecovery, +} from '@/lib/telnyx-lifecycle' // --------------------------------------------------------------------------- // Types @@ -68,6 +73,7 @@ export interface UseTelnyxReturn { const MIN_REINIT_INTERVAL_MS = 5_000 // never reinit more than once per 5s const SOCKET_RECOVERY_GRACE_MS = 20_000 const REGISTRATION_CHECK_INTERVAL_MS = 60_000 +const HIDDEN_REGISTRATION_CHECK_INTERVAL_MS = 15_000 // Keep manual reconnect cooldown shorter than the registration check interval // so one failed attempt doesn't suppress the next periodic health check. const MANUAL_RECONNECT_COOLDOWN_MS = REGISTRATION_CHECK_INTERVAL_MS / 2 @@ -138,6 +144,9 @@ export function useTelnyx(): UseTelnyxReturn { const fallbackLastCheckedRef = useRef(0) const channelHealthyRef = useRef(false) const notificationUserIdRef = useRef(null) + const ensureRegisteredRef = useRef<((reason: string) => Promise) | null>(null) + const runLifecycleRecoveryRef = useRef<((reason: string, delayMs?: number) => void) | null>(null) + const lastInteractionRecoveryAtRef = useRef(0) // Keep ref in sync with state for use inside closures useEffect(() => { @@ -350,6 +359,7 @@ export function useTelnyx(): UseTelnyxReturn { if (!destroyed) { setConnectionStatus('error') setRegistrationState(false) + runLifecycleRecovery('sdk-error', getLifecycleRecoveryDelay('sdk-error')) } }) @@ -369,6 +379,10 @@ export function useTelnyx(): UseTelnyxReturn { }) ) + // Start reconnecting immediately in parallel so the call can survive + // if the browser was only background-throttled for a moment. + runLifecycleRecovery('socket-close-active', getLifecycleRecoveryDelay('socket-close-active')) + // After grace period, if still disconnected, THEN end the call if (graceTimerRef.current) clearTimeout(graceTimerRef.current) graceTimerRef.current = setTimeout(async () => { @@ -397,7 +411,10 @@ export function useTelnyx(): UseTelnyxReturn { await ensureRegistered('socket-close-grace') }, SOCKET_RECOVERY_GRACE_MS) } else { - // No active call - just try to reconnect + // No active call - reconnect right away, then keep the grace follow-up + // as a second chance if the browser was suspended. + runLifecycleRecovery('socket-close-idle', getLifecycleRecoveryDelay('socket-close-idle')) + if (graceTimerRef.current) clearTimeout(graceTimerRef.current) graceTimerRef.current = setTimeout(async () => { graceTimerRef.current = null @@ -854,17 +871,20 @@ export function useTelnyx(): UseTelnyxReturn { }, delayMs) } + ensureRegisteredRef.current = ensureRegistered + runLifecycleRecoveryRef.current = runLifecycleRecovery + // Recover when tab becomes visible again function handleVisibilityChange() { if (document.visibilityState === 'visible') { console.log('[useTelnyx] Tab visible, checking registration...') - runLifecycleRecovery('visibilitychange', 1500) + runLifecycleRecovery('visibilitychange', getLifecycleRecoveryDelay('visibilitychange')) } } function handleOnline() { console.log('[useTelnyx] Network online, checking registration...') - runLifecycleRecovery('online', 1000) + runLifecycleRecovery('online', getLifecycleRecoveryDelay('online')) } function handleOffline() { @@ -876,30 +896,70 @@ export function useTelnyx(): UseTelnyxReturn { } function handlePageShow(event: PageTransitionEvent) { - if (event.persisted) { - console.log('[useTelnyx] Page restored from bfcache, checking registration...') - runLifecycleRecovery('pageshow', 500) - } + if (!event.persisted && connectionStatusRef.current === 'connected') return + console.log('[useTelnyx] Page show, checking registration...') + runLifecycleRecovery('pageshow', getLifecycleRecoveryDelay('pageshow')) + } + + function handleWindowFocus() { + console.log('[useTelnyx] Window focused, checking registration...') + runLifecycleRecovery('focus', getLifecycleRecoveryDelay('focus')) + } + + function handleUserInteraction() { + const now = Date.now() + if (!shouldRunInteractionRecovery(now, lastInteractionRecoveryAtRef.current)) return + lastInteractionRecoveryAtRef.current = now + runLifecycleRecovery('interaction', getLifecycleRecoveryDelay('interaction')) } document.addEventListener('visibilitychange', handleVisibilityChange) window.addEventListener('online', handleOnline) window.addEventListener('offline', handleOffline) window.addEventListener('pageshow', handlePageShow) - - // Periodic registration health check (SDK reconnect remains primary) - const registrationCheckInterval = setInterval(() => { + window.addEventListener('focus', handleWindowFocus) + window.addEventListener('pointerdown', handleUserInteraction, { passive: true }) + window.addEventListener('keydown', handleUserInteraction) + + // Periodic registration health check (SDK reconnect remains primary). + // Hidden tabs use a shorter cadence because browsers may silently suspend + // the WebRTC websocket/SIP registration after a period of inactivity. + let registrationCheckTimer: ReturnType | null = null + const scheduleRegistrationHealthCheck = () => { if (destroyed) return - if (connectionStatusRef.current === 'config_error') return - console.log('[useTelnyx] Periodic registration check...') - void ensureRegistered('periodic') - }, REGISTRATION_CHECK_INTERVAL_MS) + const visibilityState = typeof document !== 'undefined' ? document.visibilityState : 'visible' + const delay = getRegistrationHealthCheckIntervalMs( + visibilityState, + REGISTRATION_CHECK_INTERVAL_MS, + HIDDEN_REGISTRATION_CHECK_INTERVAL_MS + ) + + registrationCheckTimer = setTimeout(() => { + registrationCheckTimer = null + if (destroyed || connectionStatusRef.current === 'config_error') { + scheduleRegistrationHealthCheck() + return + } + + console.log('[useTelnyx] Periodic registration check...', { visibilityState }) + void ensureRegistered('periodic').finally(() => { + scheduleRegistrationHealthCheck() + }) + }, delay) + } + + scheduleRegistrationHealthCheck() return () => { destroyed = true + ensureRegisteredRef.current = null + runLifecycleRecoveryRef.current = null ensureRegisteredScheduledRef.current = false stopDurationTimer() - clearInterval(registrationCheckInterval) + if (registrationCheckTimer) { + clearTimeout(registrationCheckTimer) + registrationCheckTimer = null + } if (graceTimerRef.current) { clearTimeout(graceTimerRef.current) graceTimerRef.current = null @@ -918,6 +978,9 @@ export function useTelnyx(): UseTelnyxReturn { window.removeEventListener('online', handleOnline) window.removeEventListener('offline', handleOffline) window.removeEventListener('pageshow', handlePageShow) + window.removeEventListener('focus', handleWindowFocus) + window.removeEventListener('pointerdown', handleUserInteraction) + window.removeEventListener('keydown', handleUserInteraction) keepaliveLockReleaseRef.current?.() cleanupRemoteAudio() if (client) { @@ -1069,21 +1132,29 @@ export function useTelnyx(): UseTelnyxReturn { // ---- Call controls ---- const makeCall = useCallback((destinationNumber: string) => { - if (!clientRef.current) { - console.warn('[useTelnyx] Client not connected') - return - } - try { - const call = clientRef.current.newCall({ - destinationNumber, - callerNumber: process.env.NEXT_PUBLIC_TELNYX_PHONE_NUMBER || '', - callerName: 'PolicyJar', - }) - callRef.current = call - } catch (err) { - console.error('[useTelnyx] makeCall error:', err) - } - }, []) + void (async () => { + if (!clientRef.current || connectionStatusRef.current !== 'connected' || !isRegistered) { + console.warn('[useTelnyx] Dial requested while not ready, attempting recovery first') + runLifecycleRecoveryRef.current?.('makeCall', getLifecycleRecoveryDelay('makeCall')) + const recovered = await ensureRegisteredRef.current?.('makeCall') + if (!recovered || !clientRef.current) { + showPresenceToast('Phone connection is recovering. Try again in a moment.', 'error') + return + } + } + + try { + const call = clientRef.current.newCall({ + destinationNumber, + callerNumber: process.env.NEXT_PUBLIC_TELNYX_PHONE_NUMBER || '', + callerName: 'PolicyJar', + }) + callRef.current = call + } catch (err) { + console.error('[useTelnyx] makeCall error:', err) + } + })() + }, [isRegistered]) const answer = useCallback(() => { try { diff --git a/src/lib/telnyx-lifecycle.ts b/src/lib/telnyx-lifecycle.ts new file mode 100644 index 00000000..806ee6cb --- /dev/null +++ b/src/lib/telnyx-lifecycle.ts @@ -0,0 +1,50 @@ +export type TelnyxRecoveryReason = + | 'sdk-error' + | 'socket-close-active' + | 'socket-close-idle' + | 'visibilitychange' + | 'online' + | 'pageshow' + | 'focus' + | 'interaction' + | 'post-call' + | 'makeCall' + +const INTERACTION_RECOVERY_COOLDOWN_MS = 5_000 + +export function getLifecycleRecoveryDelay(reason: TelnyxRecoveryReason): number { + switch (reason) { + case 'pageshow': + return 500 + case 'online': + case 'sdk-error': + case 'socket-close-active': + case 'socket-close-idle': + case 'focus': + case 'interaction': + case 'makeCall': + return 1_000 + case 'visibilitychange': + return 1_500 + case 'post-call': + return 8_000 + default: + return 1_000 + } +} + +export function getRegistrationHealthCheckIntervalMs( + visibilityState: string | undefined, + visibleIntervalMs: number, + hiddenIntervalMs: number +): number { + return visibilityState === 'hidden' ? hiddenIntervalMs : visibleIntervalMs +} + +export function shouldRunInteractionRecovery( + now: number, + lastInteractionRecoveryAt: number, + cooldownMs = INTERACTION_RECOVERY_COOLDOWN_MS +): boolean { + return now - lastInteractionRecoveryAt >= cooldownMs +} From 8cdd746c4833450c5f687905822ba27f6b46cbad Mon Sep 17 00:00:00 2001 From: coygg Date: Tue, 7 Apr 2026 15:35:22 -0400 Subject: [PATCH 2/6] fix: export functional src middleware shim for Next build --- src/middleware.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index 7131edbe..b0c30bea 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,4 +1,12 @@ -// This file is intentionally empty. -// Next.js 16 only reads the root middleware.ts file. -// CSP nonce logic has been merged into /middleware.ts. -// See: https://nextjs.org/docs/app/building-your-application/routing/middleware +import type { NextRequest } from 'next/server' +import { middleware as rootMiddleware } from '../middleware' + +export function middleware(request: NextRequest) { + return rootMiddleware(request) +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +} From 9f4708f2b8ef4fac87bf31a3e94c06fc6c263b02 Mon Sep 17 00:00:00 2001 From: coygg Date: Tue, 7 Apr 2026 16:33:51 -0400 Subject: [PATCH 3/6] fix: restrict presence heartbeats to leader tab --- src/__tests__/use-presence-heartbeat.test.ts | 13 +++++++++++++ src/hooks/usePresence.ts | 10 ++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/__tests__/use-presence-heartbeat.test.ts diff --git a/src/__tests__/use-presence-heartbeat.test.ts b/src/__tests__/use-presence-heartbeat.test.ts new file mode 100644 index 00000000..1e930995 --- /dev/null +++ b/src/__tests__/use-presence-heartbeat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { shouldSendPresenceHeartbeat } from '@/hooks/usePresence' + +describe('shouldSendPresenceHeartbeat', () => { + it('allows heartbeats for the leader tab', () => { + expect(shouldSendPresenceHeartbeat(true)).toBe(true) + }) + + it('blocks heartbeats for non-leader tabs', () => { + expect(shouldSendPresenceHeartbeat(false)).toBe(false) + }) +}) diff --git a/src/hooks/usePresence.ts b/src/hooks/usePresence.ts index a3c33983..be0ab3b5 100644 --- a/src/hooks/usePresence.ts +++ b/src/hooks/usePresence.ts @@ -15,6 +15,10 @@ const PRESENCE_LEADER_FRESH_MS = 5_000 const BC_LEADER_HEARTBEAT_INTERVAL_MS = 3_000 // Leader broadcasts heartbeat every 3s const HEARTBEAT_TS_EVENT = 'presence:heartbeat-ts' // Notifies listeners of a successful API heartbeat +export function shouldSendPresenceHeartbeat(isLeader: boolean) { + return isLeader +} + let sharedWrapUpEndsAt: number | null = null let presenceSingletonRefCount = 0 let sharedHeartbeatInterval: ReturnType | null = null @@ -205,6 +209,10 @@ export function usePresence() { }, []) const sendHeartbeat = useCallback(async () => { + if (!shouldSendPresenceHeartbeat(presenceLeaderRef.current)) { + return + } + try { // In background tabs, browsers throttle fetch/XHR but not sendBeacon. // Use sendBeacon when hidden to ensure heartbeats still land on time. @@ -400,6 +408,8 @@ export function usePresence() { // Became leader: start broadcasting heartbeats for non-leader watchdogs. startBcLeaderHeartbeat() stopBcNonLeaderWatchdog() + staleCheckNeededRef.current = true + void sendHeartbeat() } else { // Lost election: watch for leader heartbeats to detect future crashes. stopBcLeaderHeartbeat() From 0286ce5dc6d9bbe7ae3000bec6df70d7d15071ec Mon Sep 17 00:00:00 2001 From: coygg Date: Tue, 7 Apr 2026 17:47:44 -0400 Subject: [PATCH 4/6] fix: pass CSP nonce to theme provider --- src/app/layout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 543da5e4..71002fb7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic' import type { Metadata } from "next"; +import { headers } from 'next/headers' import { Plus_Jakarta_Sans, Geist_Mono } from "next/font/google"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ThemeProvider } from "@/components/theme-provider"; @@ -24,11 +25,13 @@ export const metadata: Metadata = { "The inbound-first dialer + CRM that insurance agencies actually need. IVR, ACD, screen pop, and power dialer -- built for Final Expense and Life Insurance.", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const nonce = (await headers()).get('x-nonce') ?? undefined + return ( {children} From 02c8c8e5bc429dc7c02bd7533f0680102db26b1b Mon Sep 17 00:00:00 2001 From: coygg Date: Tue, 7 Apr 2026 19:54:13 -0400 Subject: [PATCH 5/6] fix: address dialer reconnect review feedback --- src/app/(app)/dialer/page.tsx | 11 ++- src/components/leads/lead-table.tsx | 4 +- src/components/phone/softphone.tsx | 8 ++- src/hooks/use-telnyx.ts | 100 +++++++++++++++------------- src/hooks/usePresence.ts | 1 + src/middleware.ts | 2 +- 6 files changed, 73 insertions(+), 53 deletions(-) diff --git a/src/app/(app)/dialer/page.tsx b/src/app/(app)/dialer/page.tsx index b7e424a1..6325330b 100644 --- a/src/app/(app)/dialer/page.tsx +++ b/src/app/(app)/dialer/page.tsx @@ -309,7 +309,16 @@ export default function DialerPage() { }) // Make the call via Telnyx WebRTC - makeCall(currentLead.phone) + const didStartCall = await makeCall(currentLead.phone) + if (!didStartCall) { + setDialing(false) + await fetch('/api/dialer/attempt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: session.id, lead_id: currentLead.id, status: 'failed' }), + }) + return + } // Don't clear dialing here — keep button locked until SDK emits callActive } catch (err) { console.error('Dial failed:', err) diff --git a/src/components/leads/lead-table.tsx b/src/components/leads/lead-table.tsx index a9d3016a..2aef3dcb 100644 --- a/src/components/leads/lead-table.tsx +++ b/src/components/leads/lead-table.tsx @@ -246,9 +246,9 @@ export function LeadTable() { { + onClick={async (e) => { e.stopPropagation() - if (lead.phone) makeCall(lead.phone) + if (lead.phone) await makeCall(lead.phone) }} disabled={connectionStatus !== 'connected' || !lead.phone} > diff --git a/src/components/phone/softphone.tsx b/src/components/phone/softphone.tsx index 901a0c98..1edf143a 100644 --- a/src/components/phone/softphone.tsx +++ b/src/components/phone/softphone.tsx @@ -121,10 +121,12 @@ export function Softphone() { const handleBackspace = () => setDialInput((prev) => prev.slice(0, -1)) - const handleDial = () => { + const handleDial = async () => { const number = dialInput.trim() - if (number) { - makeCall(number) + if (!number) return + + const didStartCall = await makeCall(number) + if (didStartCall) { setDialInput('') } } diff --git a/src/hooks/use-telnyx.ts b/src/hooks/use-telnyx.ts index 4b561647..3a5b8f59 100644 --- a/src/hooks/use-telnyx.ts +++ b/src/hooks/use-telnyx.ts @@ -46,7 +46,7 @@ export interface UseTelnyxReturn { isRegistered: boolean hasInitialized: boolean currentCall: TelnyxCallInfo | null - makeCall: (destinationNumber: string) => void + makeCall: (destinationNumber: string) => Promise answer: () => void reject: () => void hangup: () => void @@ -874,8 +874,39 @@ export function useTelnyx(): UseTelnyxReturn { ensureRegisteredRef.current = ensureRegistered runLifecycleRecoveryRef.current = runLifecycleRecovery + let registrationCheckTimer: ReturnType | null = null + const scheduleRegistrationHealthCheck = () => { + if (destroyed) return + const visibilityState = typeof document !== 'undefined' ? document.visibilityState : 'visible' + const delay = getRegistrationHealthCheckIntervalMs( + visibilityState, + REGISTRATION_CHECK_INTERVAL_MS, + HIDDEN_REGISTRATION_CHECK_INTERVAL_MS + ) + + registrationCheckTimer = setTimeout(() => { + registrationCheckTimer = null + if (destroyed || connectionStatusRef.current === 'config_error') { + scheduleRegistrationHealthCheck() + return + } + + console.log('[useTelnyx] Periodic registration check...', { visibilityState }) + void ensureRegistered('periodic').finally(() => { + scheduleRegistrationHealthCheck() + }) + }, delay) + } + // Recover when tab becomes visible again function handleVisibilityChange() { + if (registrationCheckTimer) { + clearTimeout(registrationCheckTimer) + registrationCheckTimer = null + } + + scheduleRegistrationHealthCheck() + if (document.visibilityState === 'visible') { console.log('[useTelnyx] Tab visible, checking registration...') runLifecycleRecovery('visibilitychange', getLifecycleRecoveryDelay('visibilitychange')) @@ -924,30 +955,6 @@ export function useTelnyx(): UseTelnyxReturn { // Periodic registration health check (SDK reconnect remains primary). // Hidden tabs use a shorter cadence because browsers may silently suspend // the WebRTC websocket/SIP registration after a period of inactivity. - let registrationCheckTimer: ReturnType | null = null - const scheduleRegistrationHealthCheck = () => { - if (destroyed) return - const visibilityState = typeof document !== 'undefined' ? document.visibilityState : 'visible' - const delay = getRegistrationHealthCheckIntervalMs( - visibilityState, - REGISTRATION_CHECK_INTERVAL_MS, - HIDDEN_REGISTRATION_CHECK_INTERVAL_MS - ) - - registrationCheckTimer = setTimeout(() => { - registrationCheckTimer = null - if (destroyed || connectionStatusRef.current === 'config_error') { - scheduleRegistrationHealthCheck() - return - } - - console.log('[useTelnyx] Periodic registration check...', { visibilityState }) - void ensureRegistered('periodic').finally(() => { - scheduleRegistrationHealthCheck() - }) - }, delay) - } - scheduleRegistrationHealthCheck() return () => { @@ -1131,29 +1138,30 @@ export function useTelnyx(): UseTelnyxReturn { // ---- Call controls ---- - const makeCall = useCallback((destinationNumber: string) => { - void (async () => { - if (!clientRef.current || connectionStatusRef.current !== 'connected' || !isRegistered) { - console.warn('[useTelnyx] Dial requested while not ready, attempting recovery first') - runLifecycleRecoveryRef.current?.('makeCall', getLifecycleRecoveryDelay('makeCall')) - const recovered = await ensureRegisteredRef.current?.('makeCall') - if (!recovered || !clientRef.current) { - showPresenceToast('Phone connection is recovering. Try again in a moment.', 'error') - return - } + const makeCall = useCallback(async (destinationNumber: string): Promise => { + if (!clientRef.current || connectionStatusRef.current !== 'connected' || !isRegistered) { + console.warn('[useTelnyx] Dial requested while not ready, attempting recovery first') + runLifecycleRecoveryRef.current?.('makeCall', getLifecycleRecoveryDelay('makeCall')) + const recovered = await ensureRegisteredRef.current?.('makeCall') + if (!recovered || !clientRef.current) { + showPresenceToast('Phone connection is recovering. Try again in a moment.', 'error') + return false } + } - try { - const call = clientRef.current.newCall({ - destinationNumber, - callerNumber: process.env.NEXT_PUBLIC_TELNYX_PHONE_NUMBER || '', - callerName: 'PolicyJar', - }) - callRef.current = call - } catch (err) { - console.error('[useTelnyx] makeCall error:', err) - } - })() + try { + const call = clientRef.current.newCall({ + destinationNumber, + callerNumber: process.env.NEXT_PUBLIC_TELNYX_PHONE_NUMBER || '', + callerName: 'PolicyJar', + }) + callRef.current = call + return true + } catch (err) { + console.error('[useTelnyx] makeCall error:', err) + showPresenceToast('Unable to start the call. Please try again.', 'error') + return false + } }, [isRegistered]) const answer = useCallback(() => { diff --git a/src/hooks/usePresence.ts b/src/hooks/usePresence.ts index be0ab3b5..e4ca993f 100644 --- a/src/hooks/usePresence.ts +++ b/src/hooks/usePresence.ts @@ -15,6 +15,7 @@ const PRESENCE_LEADER_FRESH_MS = 5_000 const BC_LEADER_HEARTBEAT_INTERVAL_MS = 3_000 // Leader broadcasts heartbeat every 3s const HEARTBEAT_TS_EVENT = 'presence:heartbeat-ts' // Notifies listeners of a successful API heartbeat +// Exported for focused unit tests around leader-only heartbeats. export function shouldSendPresenceHeartbeat(isLeader: boolean) { return isLeader } diff --git a/src/middleware.ts b/src/middleware.ts index b0c30bea..4fabd7be 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -7,6 +7,6 @@ export function middleware(request: NextRequest) { export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } From 210c48c798c8c8c40c0e9e1bd43660b632ba0608 Mon Sep 17 00:00:00 2001 From: coygg Date: Tue, 7 Apr 2026 21:58:52 -0400 Subject: [PATCH 6/6] fix: sync local leader heartbeat kick --- src/hooks/usePresence.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/hooks/usePresence.ts b/src/hooks/usePresence.ts index e4ca993f..0bf9cf5b 100644 --- a/src/hooks/usePresence.ts +++ b/src/hooks/usePresence.ts @@ -365,6 +365,11 @@ export function usePresence() { } } + const handleBecameLeader = () => { + staleCheckNeededRef.current = true + void sendHeartbeat() + } + const electLeader = () => { const channel = leadershipChannelRef.current if (!channel) { @@ -409,8 +414,7 @@ export function usePresence() { // Became leader: start broadcasting heartbeats for non-leader watchdogs. startBcLeaderHeartbeat() stopBcNonLeaderWatchdog() - staleCheckNeededRef.current = true - void sendHeartbeat() + handleBecameLeader() } else { // Lost election: watch for leader heartbeats to detect future crashes. stopBcLeaderHeartbeat() @@ -471,8 +475,12 @@ export function usePresence() { const leaderFresh = Boolean(leader?.timestamp && now - Number(leader.timestamp) < PRESENCE_LEADER_FRESH_MS) const sameTab = leader?.tabId === tabIdRef.current const canClaim = !leaderFresh || sameTab + const becameLeader = canClaim && !presenceLeaderRef.current setLeader(canClaim) if (canClaim) { + if (becameLeader) { + handleBecameLeader() + } try { window.localStorage.setItem( PRESENCE_LEADER_KEY,