From 77175366221ed2bc47842ebe74fb2d4ae5ad6c83 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 10 Apr 2026 12:29:41 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20server-side=20ACW=20auto-dispose=20(Laye?= =?UTF-8?q?r=203)=20=E2=80=94=2030s=20timeout=20safety=20net?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Ozonetel sends an ACW event, starts a 30-second timer. If no /api/ozonetel/dispose call arrives within that window (frontend crashed, tab closed, page refreshed), auto-disposes with "General Enquiry" + autoRelease:true. Agent exits ACW automatically. Timer is cancelled when: - Frontend submits disposition normally (cancelAcwTimer in controller) - Agent transitions to Ready or Offline - Agent logs out Wiring: OzonetelAgentModule now imports SupervisorModule (forwardRef for circular dep), controller injects SupervisorService to cancel the timer on successful dispose. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ozonetel/ozonetel-agent.controller.ts | 5 ++ src/ozonetel/ozonetel-agent.module.ts | 3 +- src/supervisor/supervisor.module.ts | 4 +- src/supervisor/supervisor.service.ts | 60 +++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index 8a885be..64f5230 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -5,6 +5,7 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { EventBusService } from '../events/event-bus.service'; import { Topics } from '../events/event-types'; import { TelephonyConfigService } from '../config/telephony-config.service'; +import { SupervisorService } from '../supervisor/supervisor.service'; @Controller('api/ozonetel') export class OzonetelAgentController { @@ -16,6 +17,7 @@ export class OzonetelAgentController { private readonly missedQueue: MissedQueueService, private readonly platform: PlatformGraphqlService, private readonly eventBus: EventBusService, + private readonly supervisor: SupervisorService, ) {} // Read-through accessors so admin updates take effect immediately. @@ -124,6 +126,9 @@ export class OzonetelAgentController { const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition); + // Cancel the ACW auto-dispose timer — the frontend submitted disposition + this.supervisor.cancelAcwTimer(this.defaultAgentId); + this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${this.defaultAgentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`); try { diff --git a/src/ozonetel/ozonetel-agent.module.ts b/src/ozonetel/ozonetel-agent.module.ts index e6a8857..ad0a2b2 100644 --- a/src/ozonetel/ozonetel-agent.module.ts +++ b/src/ozonetel/ozonetel-agent.module.ts @@ -4,9 +4,10 @@ import { OzonetelAgentService } from './ozonetel-agent.service'; import { KookooIvrController } from './kookoo-ivr.controller'; import { WorklistModule } from '../worklist/worklist.module'; import { PlatformModule } from '../platform/platform.module'; +import { SupervisorModule } from '../supervisor/supervisor.module'; @Module({ - imports: [PlatformModule, forwardRef(() => WorklistModule)], + imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)], controllers: [OzonetelAgentController, KookooIvrController], providers: [OzonetelAgentService], exports: [OzonetelAgentService], diff --git a/src/supervisor/supervisor.module.ts b/src/supervisor/supervisor.module.ts index d3f4bbf..e5c1cbc 100644 --- a/src/supervisor/supervisor.module.ts +++ b/src/supervisor/supervisor.module.ts @@ -1,11 +1,11 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PlatformModule } from '../platform/platform.module'; import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; import { SupervisorController } from './supervisor.controller'; import { SupervisorService } from './supervisor.service'; @Module({ - imports: [PlatformModule, OzonetelAgentModule], + imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)], controllers: [SupervisorController], providers: [SupervisorService], exports: [SupervisorService], diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index a187aa8..a3b72c4 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -20,11 +20,20 @@ type AgentStateEntry = { 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; timestamp: string }>(); constructor( @@ -37,6 +46,17 @@ export class SupervisorService implements 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; @@ -71,6 +91,46 @@ export class SupervisorService implements OnModuleInit { 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); + } } }