feat: Phase 2 — missed call queue ingestion, auto-assignment, endpoints

- MissedQueueService: polls Ozonetel abandonCalls every 30s, dedup by phone
- Auto-assigns oldest PENDING_CALLBACK call on agent Ready (dispose + state change)
- GET /api/worklist/missed-queue, PATCH /api/worklist/missed-queue/:id/status
- Worklist query updated with callback fields and FIFO ordering
- PlatformGraphqlService.query() made public for server-to-server ops
- forwardRef circular dependency resolution between WorklistModule and OzonetelAgentModule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 09:17:33 +05:30
parent 4963a698d9
commit cec2526d37
8 changed files with 310 additions and 10 deletions

View File

@@ -1,6 +1,8 @@
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OzonetelAgentService } from './ozonetel-agent.service';
import { MissedQueueService } from '../worklist/missed-queue.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
@Controller('api/ozonetel')
export class OzonetelAgentController {
@@ -13,6 +15,8 @@ export class OzonetelAgentController {
constructor(
private readonly ozonetelAgent: OzonetelAgentService,
private readonly config: ConfigService,
private readonly missedQueue: MissedQueueService,
private readonly platform: PlatformGraphqlService,
) {
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
@@ -74,6 +78,18 @@ export class OzonetelAgentController {
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
return { status: 'error', message };
}
// Auto-assign missed call when agent goes Ready
if (body.state === 'Ready') {
try {
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
if (assigned) {
return { status: 'ok', message: `State changed to Ready. Assigned missed call ${assigned.id}`, assignedCall: assigned };
}
} catch (err) {
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
}
}
}
@Post('agent-ready')
@@ -109,6 +125,7 @@ export class OzonetelAgentController {
durationSec?: number;
leadId?: string;
notes?: string;
missedCallId?: string;
},
) {
if (!body.ucid || !body.disposition) {
@@ -125,13 +142,40 @@ export class OzonetelAgentController {
ucid: body.ucid,
disposition: ozonetelDisposition,
});
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
this.logger.error(`Dispose failed: ${message}`);
// Don't throw — disposition failure shouldn't block the UI
return { status: 'error', message };
}
// Handle missed call callback status update
if (body.missedCallId) {
const statusMap: Record<string, string> = {
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
INFO_PROVIDED: 'CALLBACK_COMPLETED',
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
WRONG_NUMBER: 'WRONG_NUMBER',
};
const newStatus = statusMap[body.disposition];
if (newStatus) {
try {
await this.platform.query<any>(
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
);
} catch (err) {
this.logger.warn(`Failed to update missed call status: ${err}`);
}
}
}
// Auto-assign next missed call to this agent
try {
await this.missedQueue.assignNext(this.defaultAgentId);
} catch (err) {
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
}
return { status: 'ok' };
}
@Post('dial')