mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: SSE agent state, maint module, timestamp fix, missed call lead lookup
- SSE agent state stream: supervisor maintains state map from Ozonetel webhooks, streams via /api/supervisor/agent-state/stream - Force-logout via SSE: distinct force-logout event type avoids conflict with normal login cycle - Maint module (/api/maint): OTP-guarded endpoints for force-ready, unlock-agent, backfill-missed-calls, fix-timestamps - Fix Ozonetel IST→UTC timestamp conversion: istToUtc() in webhook controller and missed-queue service - Missed call lead lookup: ingestion queries leads by phone, stores leadId + leadName on Call entity - Timestamp backfill endpoint: throttled at 700ms/mutation, idempotent (skips already-fixed records) - Structured logging: full JSON payloads for agent/call webhooks, [DISPOSE] trace with agentId - Fix dead code: agent-state endpoint auto-assign was after return statement - Export SupervisorService for cross-module injection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -12,10 +13,19 @@ type ActiveCall = {
|
||||
status: 'active' | 'on-hold';
|
||||
};
|
||||
|
||||
export type AgentOzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||
|
||||
type AgentStateEntry = {
|
||||
state: AgentOzonetelState;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
@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>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
@@ -50,7 +60,46 @@ export class SupervisorService implements OnModuleInit {
|
||||
}
|
||||
|
||||
handleAgentEvent(event: any) {
|
||||
this.logger.log(`Agent event: ${event.agentId ?? event.agent_id} → ${event.action}`);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null {
|
||||
switch (action) {
|
||||
case 'release': return 'ready';
|
||||
case 'calling': return 'calling';
|
||||
case 'incall': return 'in-call';
|
||||
case 'ACW': return 'acw';
|
||||
case 'logout': return 'offline';
|
||||
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[] {
|
||||
|
||||
Reference in New Issue
Block a user