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:
2026-03-24 22:04:31 +05:30
parent d3331e56c0
commit eb4000961f
10 changed files with 388 additions and 62 deletions

View File

@@ -1,4 +1,5 @@
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
import { Observable, filter, map } from 'rxjs';
import { SupervisorService } from './supervisor.service';
@Controller('api/supervisor')
@@ -22,7 +23,7 @@ export class SupervisorController {
@Post('call-event')
handleCallEvent(@Body() body: any) {
const event = body.data ?? body;
this.logger.log(`Call event: ${event.action} ucid=${event.ucid ?? event.monitorUCID} agent=${event.agent_id ?? event.agentID}`);
this.logger.log(`[CALL-EVENT] ${JSON.stringify(event)}`);
this.supervisor.handleCallEvent(event);
return { received: true };
}
@@ -30,8 +31,25 @@ export class SupervisorController {
@Post('agent-event')
handleAgentEvent(@Body() body: any) {
const event = body.data ?? body;
this.logger.log(`Agent event: ${event.action} agent=${event.agentId ?? event.agent_id}`);
this.logger.log(`[AGENT-EVENT] ${JSON.stringify(event)}`);
this.supervisor.handleAgentEvent(event);
return { received: true };
}
@Get('agent-state')
getAgentState(@Query('agentId') agentId: string) {
const state = this.supervisor.getAgentState(agentId);
return state ?? { state: 'offline', timestamp: null };
}
@Sse('agent-state/stream')
streamAgentState(@Query('agentId') agentId: string): Observable<MessageEvent> {
this.logger.log(`[SSE] Agent state stream opened for ${agentId}`);
return this.supervisor.agentStateSubject.pipe(
filter(event => event.agentId === agentId),
map(event => ({
data: JSON.stringify({ state: event.state, timestamp: event.timestamp }),
} as MessageEvent)),
);
}
}

View File

@@ -8,5 +8,6 @@ import { SupervisorService } from './supervisor.service';
imports: [PlatformModule, OzonetelAgentModule],
controllers: [SupervisorController],
providers: [SupervisorService],
exports: [SupervisorService],
})
export class SupervisorModule {}

View File

@@ -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[] {