Files
helix-engage-server/src/supervisor/supervisor.service.ts
saridsa2 5969441868 fix: map Ozonetel 'pause' webhook action to break state
Ozonetel sends action: 'pause' via webhook when agent is paused, but
mapOzonetelAction only handled 'AUX'. The 'pause' action fell through
to default (null), so the break SSE event was never emitted. The agent
UI stayed on 'Ready' while Ozonetel had the agent PAUSED.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:52:52 +05:30

286 lines
13 KiB
TypeScript

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<string, ActiveCall>();
private readonly agentStates = new Map<string, AgentStateEntry>();
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
// Barge session tracking — key is agentId
private readonly bargeSessions = new Map<string, {
supervisorId: string;
agentId: string;
sipNumber: string;
mode: 'listen' | 'whisper' | 'barge';
startedAt: string;
}>();
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<any> {
// 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<any>(
`{ 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}`);
}
}