import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Subject } from 'rxjs'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; type ActiveCall = { ucid: string; agentId: string; callerNumber: string; callType: string; startTime: string; status: 'active' | 'on-hold'; }; export type AgentOzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline'; type AgentStateEntry = { state: AgentOzonetelState; timestamp: string; }; // ACW auto-dispose: if an agent has been in ACW for longer than this // without the frontend calling /api/ozonetel/dispose, the server // auto-disposes with a default disposition + autoRelease. This is the // Layer 3 safety net — covers browser crash, tab close, page refresh // where sendBeacon didn't fire, or any other frontend failure. const ACW_TIMEOUT_MS = 30_000; // 30 seconds const ACW_DEFAULT_DISPOSITION = 'General Enquiry'; @Injectable() export class SupervisorService implements OnModuleInit { private readonly logger = new Logger(SupervisorService.name); private readonly activeCalls = new Map(); private readonly agentStates = new Map(); private readonly acwTimers = new Map(); readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>(); // Barge session tracking — key is agentId private readonly bargeSessions = new Map(); constructor( private platform: PlatformGraphqlService, private ozonetel: OzonetelAgentService, private config: ConfigService, ) {} async onModuleInit() { this.logger.log('Supervisor service initialized'); } // Called by the dispose endpoint to cancel the ACW timer // (agent submitted disposition before the timeout) cancelAcwTimer(agentId: string) { const timer = this.acwTimers.get(agentId); if (timer) { clearTimeout(timer); this.acwTimers.delete(agentId); this.logger.log(`[ACW-TIMER] Cancelled for ${agentId} (disposition received)`); } } handleCallEvent(event: any) { const action = event.action; const ucid = event.ucid ?? event.monitorUCID; const agentId = event.agent_id ?? event.agentID; const callerNumber = event.caller_id ?? event.callerID; const callType = event.call_type ?? event.Type; const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString(); if (!ucid) return; if (action === 'Answered' || action === 'Calling') { // Don't show calls for offline agents (ghost calls) const agentState = this.agentStates.get(agentId); if (agentState?.state === 'offline') { this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`); return; } this.activeCalls.set(ucid, { ucid, agentId, callerNumber, callType, startTime: eventTime, status: 'active', }); this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`); } else if (action === 'Disconnect') { this.activeCalls.delete(ucid); this.logger.log(`Call ended: ${ucid}`); } } handleAgentEvent(event: any) { const agentId = event.agentId ?? event.agent_id ?? 'unknown'; const action = event.action ?? 'unknown'; const eventData = event.eventData ?? ''; const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString(); this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`); const mapped = this.mapOzonetelAction(action, eventData); if (mapped) { this.agentStates.set(agentId, { state: mapped, timestamp: eventTime }); this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime }); this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`); // Layer 3: ACW auto-dispose safety net if (mapped === 'acw') { // Find the most recent UCID for this agent const lastCall = Array.from(this.activeCalls.values()) .filter(c => c.agentId === agentId) .pop(); const ucid = lastCall?.ucid; this.cancelAcwTimer(agentId); // clear any existing timer const timer = setTimeout(async () => { // Check if agent is STILL in ACW (they might have disposed by now) const current = this.agentStates.get(agentId); if (current?.state !== 'acw') { this.logger.log(`[ACW-TIMER] ${agentId} no longer in ACW — skipping auto-dispose`); return; } this.logger.warn(`[ACW-TIMER] ${agentId} stuck in ACW for ${ACW_TIMEOUT_MS / 1000}s — auto-disposing${ucid ? ` (UCID ${ucid})` : ''}`); try { if (ucid) { await this.ozonetel.setDisposition({ agentId, ucid, disposition: ACW_DEFAULT_DISPOSITION }); } else { await this.ozonetel.changeAgentState({ agentId, state: 'Ready' }); } this.logger.log(`[ACW-TIMER] Auto-dispose successful for ${agentId}`); } catch (err: any) { this.logger.error(`[ACW-TIMER] Auto-dispose failed for ${agentId}: ${err.message}`); // Last resort: try force-ready try { await this.ozonetel.changeAgentState({ agentId, state: 'Ready' }); } catch {} } this.acwTimers.delete(agentId); }, ACW_TIMEOUT_MS); this.acwTimers.set(agentId, timer); this.logger.log(`[ACW-TIMER] Started ${ACW_TIMEOUT_MS / 1000}s timer for ${agentId}`); } else if (mapped === 'ready' || mapped === 'offline') { // Agent left ACW normally — cancel the timer this.cancelAcwTimer(agentId); } } } private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null { switch (action) { case 'release': return 'ready'; case 'IDLE': return 'ready'; // agent available after unanswered/canceled call case 'calling': return 'calling'; case 'incall': return 'in-call'; case 'ACW': return 'acw'; case 'logout': return 'offline'; case 'pause': // Ozonetel sends 'pause' via webhook when agent is paused case 'AUX': // "changeMode" is the brief AUX during login — not a real pause if (eventData === 'changeMode') return null; if (eventData?.toLowerCase().includes('training')) return 'training'; return 'break'; case 'login': return null; // wait for release default: return null; } } getAgentState(agentId: string): AgentStateEntry | null { return this.agentStates.get(agentId) ?? null; } emitForceLogout(agentId: string) { this.logger.log(`[AGENT-STATE] Emitting force-logout for ${agentId}`); this.agentStates.set(agentId, { state: 'offline', timestamp: new Date().toISOString() }); // Use a special state so frontend can distinguish admin force-logout from normal Ozonetel logout this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() }); } getActiveCalls(): ActiveCall[] { return Array.from(this.activeCalls.values()); } async getTeamPerformance(date: string): Promise { // Get all agents from platform. Field names are label-derived // camelCase on the current platform schema — see // agent-config.service.ts for the canonical explanation of the // legacy lowercase names that used to exist on staging. const agentData = await this.platform.query( `{ agents(first: 20) { edges { node { id name ozonetelAgentId npsScore maxIdleMinutes minNpsThreshold minConversion } } } }`, ); const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? []; // Fetch CDR for the entire account for this date (one call, not per-agent) let allCdr: any[] = []; try { allCdr = await this.ozonetel.fetchCDR({ date }); } catch (err) { this.logger.warn(`Failed to fetch CDR for ${date}: ${err}`); } // Fetch Ozonetel time summary per agent + compute call metrics from CDR const summaries = await Promise.all( agents.map(async (agent: any) => { if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null, calls: null }; try { const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date); // Filter CDR to this agent const agentCdr = allCdr.filter( (c: any) => c.AgentID === agent.ozonetelAgentId || c.AgentName === agent.ozonetelAgentId, ); const totalCalls = agentCdr.length; const inbound = agentCdr.filter((c: any) => c.Type === 'InBound').length; const outbound = agentCdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length; const answered = agentCdr.filter((c: any) => c.Status === 'Answered').length; const missed = agentCdr.filter((c: any) => c.Status === 'NotAnswered').length; return { ...agent, timeBreakdown: summary, calls: { total: totalCalls, inbound, outbound, answered, missed }, }; } catch (err) { this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`); return { ...agent, timeBreakdown: null, calls: null }; } }), ); // Aggregate team totals const teamTotals = { totalCalls: summaries.reduce((sum, a) => sum + (a.calls?.total ?? 0), 0), inbound: summaries.reduce((sum, a) => sum + (a.calls?.inbound ?? 0), 0), outbound: summaries.reduce((sum, a) => sum + (a.calls?.outbound ?? 0), 0), answered: summaries.reduce((sum, a) => sum + (a.calls?.answered ?? 0), 0), missed: summaries.reduce((sum, a) => sum + (a.calls?.missed ?? 0), 0), }; return { date, agents: summaries, teamTotals }; } // --- Barge session management --- getBargeSession(agentId: string) { return this.bargeSessions.get(agentId) ?? null; } startBargeSession(session: { supervisorId: string; agentId: string; sipNumber: string; mode: 'listen' | 'whisper' | 'barge'; startedAt: string }) { this.bargeSessions.set(session.agentId, session); this.logger.log(`[BARGE] Started: ${session.supervisorId} → ${session.agentId} (${session.mode})`); } updateBargeMode(agentId: string, mode: 'listen' | 'whisper' | 'barge') { const session = this.bargeSessions.get(agentId); if (!session) return; const previousMode = session.mode; session.mode = mode; // Emit SSE to agent — whisper/barge show indicator, listen is silent if (mode === 'whisper' || mode === 'barge') { this.agentStateSubject.next({ agentId, state: `supervisor-${mode}`, timestamp: new Date().toISOString() }); } else if (previousMode !== 'listen') { // Switching back to listen from whisper/barge this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() }); } this.logger.log(`[BARGE] Mode: ${agentId} → ${mode}`); } endBargeSession(agentId: string) { const session = this.bargeSessions.get(agentId); if (!session) return; this.bargeSessions.delete(agentId); this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() }); this.logger.log(`[BARGE] Ended: ${session.supervisorId} → ${agentId}`); } }