diff --git a/src/__tests__/health-checks-lib.test.ts b/src/__tests__/health-checks-lib.test.ts index 0be3d207..2dc13da0 100644 --- a/src/__tests__/health-checks-lib.test.ts +++ b/src/__tests__/health-checks-lib.test.ts @@ -150,7 +150,6 @@ describe('health-checks lib', () => { const result = await checkTelnyxCredentialConnection() expect(result.ok).toBe(ok) if (detail) expect(result.detail).toContain(detail) - if (!response) expect(mockFetch).not.toHaveBeenCalled() }) it.each([ diff --git a/src/app/api/agents/heartbeat/fallback/route.ts b/src/app/api/agents/heartbeat/fallback/route.ts index 958f3143..b4ca8666 100644 --- a/src/app/api/agents/heartbeat/fallback/route.ts +++ b/src/app/api/agents/heartbeat/fallback/route.ts @@ -28,7 +28,7 @@ export async function POST(request?: NextRequest) { .update({ last_heartbeat: now, updated_at: now }) .eq('user_id', auth.session.user.id) .eq('tenant_id', auth.tenant_id) - if (touchError) return NextResponse.json({ error: 'Internal error' }, { status: 400 }) + if (touchError) return NextResponse.json({ error: 'Internal error' }, { status: 500 }) const { data: row } = await admin .from('agent_presence') diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 1dfbd8e2..416ac350 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useRouter } from 'next/navigation' import { + Circle, LogOut, User, ChevronDown, @@ -32,12 +33,12 @@ import { AgentDiagnostic } from '@/components/phone/agent-diagnostic' const MANUAL_STATUSES: AgentStatus[] = ['available', 'busy', 'away', 'offline'] const statusConfig: Record = { - available: { label: 'Available', color: 'text-success', dot: 'bg-success' }, - busy: { label: 'Busy', color: 'text-destructive', dot: 'bg-destructive' }, - away: { label: 'Away', color: 'text-warning', dot: 'bg-warning' }, - on_call: { label: 'On Call', color: 'text-primary', dot: 'bg-primary' }, - wrap_up: { label: 'Wrap Up', color: 'text-warning', dot: 'bg-amber-500' }, - offline: { label: 'Offline', color: 'text-muted-foreground', dot: 'bg-muted-foreground' }, + available: { label: 'Available', color: 'text-success', dot: 'bg-success' }, + busy: { label: 'Busy', color: 'text-destructive', dot: 'bg-destructive' }, + away: { label: 'Away', color: 'text-warning', dot: 'bg-warning' }, + on_call: { label: 'On Call', color: 'text-primary', dot: 'bg-primary' }, + wrap_up: { label: 'Wrap Up', color: 'text-warning', dot: 'bg-amber-500' }, + offline: { label: 'Offline', color: 'text-muted-foreground', dot: 'bg-muted-foreground' }, } const KNOWN_STATUSES: AgentStatus[] = ['available', 'busy', 'away', 'offline', 'on_call', 'wrap_up'] @@ -75,52 +76,38 @@ export function Header() { return () => window.removeEventListener('agent-status-optimistic', handleOptimistic) }, []) - const handleStatusChange = useCallback( - async (newStatus: AgentStatus): Promise => { - if (newStatus === 'available' && !isRegistered) { - showPresenceToast('Cannot go available — phone not connected. Please refresh.', 'error') - return false - } - - const previousStatus = status - setStatus(newStatus) // optimistic UI - window.dispatchEvent( - new CustomEvent('agent-status-optimistic', { - detail: { from: previousStatus, to: newStatus }, - }) - ) + const handleStatusChange = useCallback(async (newStatus: AgentStatus): Promise => { + if (newStatus === 'available' && !isRegistered) { + showPresenceToast('Cannot go available — phone not connected. Please refresh.', 'error') + return false + } - try { - // Use the presence API so both agent_presence AND users.agent_status - // are updated atomically — no more competing Supabase direct writes. - const res = await fetch('/api/agents/presence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: newStatus }), - }) - const payload = (await res.json().catch(() => null)) as - | { noop?: boolean; reason?: string } - | null + const previousStatus = status + setStatus(newStatus) // optimistic UI + window.dispatchEvent( + new CustomEvent('agent-status-optimistic', { + detail: { from: previousStatus, to: newStatus }, + }) + ) - if (!res.ok) throw new Error(`Presence update failed: ${res.status}`) + try { + // Use the presence API so both agent_presence AND users.agent_status + // are updated atomically — no more competing Supabase direct writes. + const res = await fetch('/api/agents/presence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }) + const payload = (await res.json().catch(() => null)) as + | { noop?: boolean; reason?: string } + | null - if ( - payload?.noop === true && - (payload.reason === 'restricted_status' || payload.reason === 'concurrent_modification') - ) { - setStatus(previousStatus) - window.dispatchEvent( - new CustomEvent('agent-status-optimistic', { - detail: { from: newStatus, to: previousStatus }, - }) - ) - return false - } + if (!res.ok) throw new Error(`Presence update failed: ${res.status}`) - return true - } catch (err) { - console.error('[Header] Failed to persist agent status:', err) - // Revert optimistic update + if ( + payload?.noop === true && + (payload.reason === 'restricted_status' || payload.reason === 'concurrent_modification') + ) { setStatus(previousStatus) window.dispatchEvent( new CustomEvent('agent-status-optimistic', { @@ -129,9 +116,20 @@ export function Header() { ) return false } - }, - [isRegistered, status] - ) + + return true + } catch (err) { + console.error('[Header] Failed to persist agent status:', err) + // Revert optimistic update + setStatus(previousStatus) + window.dispatchEvent( + new CustomEvent('agent-status-optimistic', { + detail: { from: newStatus, to: previousStatus }, + }) + ) + return false + } + }, [isRegistered, status]) useEffect(() => { if (isRegistered) { @@ -140,9 +138,7 @@ export function Header() { }, [isRegistered]) useEffect(() => { - const activeCall = - currentCall?.state && - ['ringing_inbound', 'ringing_outbound', 'answering', 'active', 'held'].includes(currentCall.state) + const activeCall = currentCall?.state && ['ringing_inbound', 'ringing_outbound', 'answering', 'active', 'held'].includes(currentCall.state) if (status === 'available' && hasInitialized && !isRegistered && !activeCall) { if (autoOfflineAttemptedRef.current) return autoOfflineAttemptedRef.current = true @@ -167,26 +163,16 @@ export function Header() { router.refresh() } - const initials = - [user.first_name?.[0], user.last_name?.[0]].filter(Boolean).join('').toUpperCase() || - user.email[0].toUpperCase() + const initials = [user.first_name?.[0], user.last_name?.[0]] + .filter(Boolean) + .join('') + .toUpperCase() || user.email[0].toUpperCase() - const displayName = [user.first_name, user.last_name].filter(Boolean).join(' ') || user.email + const displayName = + [user.first_name, user.last_name].filter(Boolean).join(' ') || user.email const currentConfig = statusConfig[status] ?? statusConfig['offline'] - const presenceBadgeState: 'online' | 'offline' | 'degraded' | 'on_call' = - status === 'on_call' || status === 'wrap_up' - ? 'on_call' - : status === 'available' && connectionState !== 'offline' - ? connectionState === 'degraded' - ? 'degraded' - : 'online' - : 'offline' - - const showOfflineBanner = status === 'offline' || connectionState === 'offline' - const showDegradedBanner = !showOfflineBanner && connectionState === 'degraded' - return ( <>
@@ -195,22 +181,17 @@ export function Header() { {user.role} - {presenceBadgeState === 'online' - ? '🟢 ONLINE' - : presenceBadgeState === 'degraded' - ? '🟠 DEGRADED' - : presenceBadgeState === 'on_call' - ? '🟡 ON CALL' - : '🔴 OFFLINE'} + {connectionState === 'online' ? '🟢 ONLINE' : connectionState === 'degraded' ? '🟠 DEGRADED' : '🔴 OFFLINE'} @@ -218,40 +199,36 @@ export function Header() { - {status === 'on_call' || status === 'wrap_up' ? ( + {(status === 'on_call' || status === 'wrap_up') ? (
- - {status === 'wrap_up' && wrapUpSecondsRemaining > 0 - ? `Wrap Up (${wrapUpSecondsRemaining}s)` - : currentConfig.label} - + {status === 'wrap_up' && wrapUpSecondsRemaining > 0 ? `Wrap Up (${wrapUpSecondsRemaining}s)` : currentConfig.label}
) : ( - - - - - - {MANUAL_STATUSES.map((key) => { - const config = statusConfig[key] - return ( - handleStatusChange(key)} - className="gap-2" - > - - {status === 'offline' && key === 'available' ? 'Go Available' : config.label} - - ) - })} - - + + + + + + {MANUAL_STATUSES.map((key) => { + const config = statusConfig[key] + return ( + handleStatusChange(key)} + className="gap-2" + > + + {status === 'offline' && key === 'available' ? 'Go Available' : config.label} + + ) + })} + + )} @@ -283,10 +260,7 @@ export function Header() { Settings - + Sign Out @@ -295,21 +269,19 @@ export function Header() {
- {(showOfflineBanner || showDegradedBanner) && ( + {connectionState !== 'online' && (
- {status === 'offline' - ? 'You are currently offline. Calls will not be routed to you.' - : connectionState === 'offline' - ? 'Connection lost. Attempting to reconnect...' - : 'Connection is degraded. Calls should still route, but reconnecting is recommended.'} + {connectionState === 'degraded' + ? 'Connection is degraded. Calls should still route, but reconnecting is recommended.' + : 'You are currently offline. Calls will not be routed to you.'}