mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
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:
@@ -5,6 +5,7 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|||||||
import { EventBusService } from '../events/event-bus.service';
|
import { EventBusService } from '../events/event-bus.service';
|
||||||
import { Topics } from '../events/event-types';
|
import { Topics } from '../events/event-types';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
@@ -16,6 +17,7 @@ export class OzonetelAgentController {
|
|||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly eventBus: EventBusService,
|
private readonly eventBus: EventBusService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Read-through accessors so admin updates take effect immediately.
|
// Read-through accessors so admin updates take effect immediately.
|
||||||
@@ -124,6 +126,9 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
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'}`);
|
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 {
|
try {
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { OzonetelAgentService } from './ozonetel-agent.service';
|
|||||||
import { KookooIvrController } from './kookoo-ivr.controller';
|
import { KookooIvrController } from './kookoo-ivr.controller';
|
||||||
import { WorklistModule } from '../worklist/worklist.module';
|
import { WorklistModule } from '../worklist/worklist.module';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
|
||||||
controllers: [OzonetelAgentController, KookooIvrController],
|
controllers: [OzonetelAgentController, KookooIvrController],
|
||||||
providers: [OzonetelAgentService],
|
providers: [OzonetelAgentService],
|
||||||
exports: [OzonetelAgentService],
|
exports: [OzonetelAgentService],
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { SupervisorController } from './supervisor.controller';
|
import { SupervisorController } from './supervisor.controller';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, OzonetelAgentModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||||
controllers: [SupervisorController],
|
controllers: [SupervisorController],
|
||||||
providers: [SupervisorService],
|
providers: [SupervisorService],
|
||||||
exports: [SupervisorService],
|
exports: [SupervisorService],
|
||||||
|
|||||||
@@ -20,11 +20,20 @@ type AgentStateEntry = {
|
|||||||
timestamp: string;
|
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()
|
@Injectable()
|
||||||
export class SupervisorService implements OnModuleInit {
|
export class SupervisorService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SupervisorService.name);
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
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 }>();
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -37,6 +46,17 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
this.logger.log('Supervisor service initialized');
|
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) {
|
handleCallEvent(event: any) {
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
const ucid = event.ucid ?? event.monitorUCID;
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
@@ -71,6 +91,46 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user