fix: server-side ACW auto-dispose (Layer 3) — 30s timeout safety net

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 12:29:41 +05:30
parent 33dc8b5669
commit 7717536622
4 changed files with 69 additions and 3 deletions

View File

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

View File

@@ -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],

View File

@@ -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],

View File

@@ -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<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; 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);
}
}
}