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

@@ -12,6 +12,9 @@ export default () => ({
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com', subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '', webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
}, },
missedQueue: {
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
},
ai: { ai: {
provider: process.env.AI_PROVIDER ?? 'openai', provider: process.env.AI_PROVIDER ?? 'openai',
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '', anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',

View File

@@ -1,6 +1,8 @@
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common'; import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { OzonetelAgentService } from './ozonetel-agent.service'; import { OzonetelAgentService } from './ozonetel-agent.service';
import { MissedQueueService } from '../worklist/missed-queue.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
@Controller('api/ozonetel') @Controller('api/ozonetel')
export class OzonetelAgentController { export class OzonetelAgentController {
@@ -13,6 +15,8 @@ export class OzonetelAgentController {
constructor( constructor(
private readonly ozonetelAgent: OzonetelAgentService, private readonly ozonetelAgent: OzonetelAgentService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly missedQueue: MissedQueueService,
private readonly platform: PlatformGraphqlService,
) { ) {
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3'; this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? ''; 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'; const message = error.response?.data?.message ?? error.message ?? 'State change failed';
return { status: 'error', message }; 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') @Post('agent-ready')
@@ -109,6 +125,7 @@ export class OzonetelAgentController {
durationSec?: number; durationSec?: number;
leadId?: string; leadId?: string;
notes?: string; notes?: string;
missedCallId?: string;
}, },
) { ) {
if (!body.ucid || !body.disposition) { if (!body.ucid || !body.disposition) {
@@ -125,13 +142,40 @@ export class OzonetelAgentController {
ucid: body.ucid, ucid: body.ucid,
disposition: ozonetelDisposition, disposition: ozonetelDisposition,
}); });
return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed'; const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
this.logger.error(`Dispose failed: ${message}`); 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') @Post('dial')

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { OzonetelAgentController } from './ozonetel-agent.controller'; import { OzonetelAgentController } from './ozonetel-agent.controller';
import { OzonetelAgentService } from './ozonetel-agent.service'; 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 { PlatformModule } from '../platform/platform.module';
@Module({ @Module({
imports: [PlatformModule, forwardRef(() => WorklistModule)],
controllers: [OzonetelAgentController, KookooIvrController], controllers: [OzonetelAgentController, KookooIvrController],
providers: [OzonetelAgentService], providers: [OzonetelAgentService],
exports: [OzonetelAgentService], exports: [OzonetelAgentService],

View File

@@ -14,7 +14,7 @@ export class PlatformGraphqlService {
} }
// Server-to-server query using API key // Server-to-server query using API key
private async query<T>(query: string, variables?: Record<string, any>): Promise<T> { async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`); return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
} }

View File

@@ -0,0 +1,225 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
// Normalize phone to +91XXXXXXXXXX format
export function normalizePhone(raw: string): string {
let digits = raw.replace(/[^0-9]/g, '');
// Strip leading country code variations: 0091, 91, 0
if (digits.startsWith('0091')) digits = digits.slice(4);
else if (digits.startsWith('91') && digits.length > 10) digits = digits.slice(2);
else if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(1);
return `+91${digits.slice(-10)}`;
}
@Injectable()
export class MissedQueueService implements OnModuleInit {
private readonly logger = new Logger(MissedQueueService.name);
private readonly pollIntervalMs: number;
private readonly processedUcids = new Set<string>();
private assignmentMutex = false;
constructor(
private readonly config: ConfigService,
private readonly platform: PlatformGraphqlService,
private readonly ozonetel: OzonetelAgentService,
) {
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
}
onModuleInit() {
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
}
async ingest(): Promise<{ created: number; updated: number }> {
let created = 0;
let updated = 0;
const now = new Date();
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
const format = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19);
let abandonCalls: any[];
try {
abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: format(fiveMinAgo), toTime: format(now) });
} catch (err) {
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
return { created: 0, updated: 0 };
}
if (!abandonCalls?.length) return { created: 0, updated: 0 };
for (const call of abandonCalls) {
const ucid = call.monitorUCID;
if (!ucid || this.processedUcids.has(ucid)) continue;
this.processedUcids.add(ucid);
const phone = normalizePhone(call.callerID || '');
if (!phone || phone.length < 13) continue;
const did = call.did || '';
const callTime = call.callTime || new Date().toISOString();
try {
const existing = await this.platform.query<any>(
`{ calls(first: 1, filter: {
callbackstatus: { eq: PENDING_CALLBACK },
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
}) { edges { node { id missedcallcount } } } }`,
);
const existingNode = existing?.calls?.edges?.[0]?.node;
if (existingNode) {
const newCount = (existingNode.missedcallcount || 1) + 1;
await this.platform.query<any>(
`mutation { updateCall(id: "${existingNode.id}", data: {
missedcallcount: ${newCount},
startedAt: "${callTime}",
callsourcenumber: "${did}"
}) { id } }`,
);
updated++;
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
} else {
await this.platform.query<any>(
`mutation { createCall(data: {
callStatus: MISSED,
direction: INBOUND,
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
callsourcenumber: "${did}",
callbackstatus: PENDING_CALLBACK,
missedcallcount: 1,
startedAt: "${callTime}"
}) { id } }`,
);
created++;
this.logger.log(`Created missed call record for ${phone}`);
}
} catch (err) {
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
}
}
// Trim processedUcids to prevent unbounded growth
if (this.processedUcids.size > 500) {
const arr = Array.from(this.processedUcids);
this.processedUcids.clear();
arr.slice(-200).forEach(u => this.processedUcids.add(u));
}
if (created || updated) this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
return { created, updated };
}
async assignNext(agentName: string): Promise<any | null> {
if (this.assignmentMutex) return null;
this.assignmentMutex = true;
try {
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
let result = await this.platform.query<any>(
`{ calls(first: 1, filter: {
callbackstatus: { eq: PENDING_CALLBACK },
agentName: { eq: "" }
}, orderBy: [{ startedAt: AscNullsLast }]) {
edges { node {
id callerNumber { primaryPhoneNumber }
startedAt callsourcenumber missedcallcount
} }
} }`,
);
let call = result?.calls?.edges?.[0]?.node;
// Also check for null agentName
if (!call) {
result = await this.platform.query<any>(
`{ calls(first: 1, filter: {
callbackstatus: { eq: PENDING_CALLBACK },
agentName: { is: NULL }
}, orderBy: [{ startedAt: AscNullsLast }]) {
edges { node {
id callerNumber { primaryPhoneNumber }
startedAt callsourcenumber missedcallcount
} }
} }`,
);
call = result?.calls?.edges?.[0]?.node;
}
if (!call) return null;
await this.platform.query<any>(
`mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`,
);
this.logger.log(`Assigned missed call ${call.id} to ${agentName}`);
return call;
} catch (err) {
this.logger.warn(`Assignment failed: ${err}`);
return null;
} finally {
this.assignmentMutex = false;
}
}
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER'];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
}
const dataParts: string[] = [`callbackstatus: ${status}`];
if (status === 'CALLBACK_ATTEMPTED') {
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
}
return this.platform.queryWithAuth<any>(
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
undefined,
authHeader,
);
}
async getMissedQueue(agentName: string, authHeader: string): Promise<{
pending: any[];
attempted: any[];
completed: any[];
invalid: any[];
}> {
const fields = `id name createdAt direction callStatus agentName
callerNumber { primaryPhoneNumber }
startedAt endedAt durationSec disposition leadId
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
agentName: { eq: "${agentName}" },
callStatus: { eq: MISSED },
callbackstatus: { eq: ${status} }
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
try {
const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([
this.platform.queryWithAuth<any>(buildQuery('PENDING_CALLBACK'), undefined, authHeader),
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_ATTEMPTED'), undefined, authHeader),
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_COMPLETED'), undefined, authHeader),
this.platform.queryWithAuth<any>(buildQuery('INVALID'), undefined, authHeader),
this.platform.queryWithAuth<any>(buildQuery('WRONG_NUMBER'), undefined, authHeader),
]);
const extract = (r: any) => r?.calls?.edges?.map((e: any) => e.node) ?? [];
return {
pending: extract(pending),
attempted: extract(attempted),
completed: [...extract(completed), ...extract(wrongNumber)],
invalid: extract(invalid),
};
} catch (err) {
this.logger.warn(`Failed to fetch missed queue: ${err}`);
return { pending: [], attempted: [], completed: [], invalid: [] };
}
}
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Headers, HttpException, Logger } from '@nestjs/common'; import { Controller, Get, Patch, Headers, Param, Body, HttpException, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { WorklistService } from './worklist.service'; import { WorklistService } from './worklist.service';
import { MissedQueueService } from './missed-queue.service';
@Controller('api/worklist') @Controller('api/worklist')
export class WorklistController { export class WorklistController {
@@ -8,6 +9,7 @@ export class WorklistController {
constructor( constructor(
private readonly worklist: WorklistService, private readonly worklist: WorklistService,
private readonly missedQueue: MissedQueueService,
private readonly platform: PlatformGraphqlService, private readonly platform: PlatformGraphqlService,
) {} ) {}
@@ -23,6 +25,24 @@ export class WorklistController {
return this.worklist.getWorklist(agentName, authHeader); return this.worklist.getWorklist(agentName, authHeader);
} }
@Get('missed-queue')
async getMissedQueue(@Headers('authorization') authHeader: string) {
if (!authHeader) throw new HttpException('Authorization header required', 401);
const agentName = await this.resolveAgentName(authHeader);
return this.missedQueue.getMissedQueue(agentName, authHeader);
}
@Patch('missed-queue/:id/status')
async updateMissedCallStatus(
@Param('id') id: string,
@Headers('authorization') authHeader: string,
@Body() body: { status: string },
) {
if (!authHeader) throw new HttpException('Authorization header required', 401);
if (!body.status) throw new HttpException('status is required', 400);
return this.missedQueue.updateStatus(id, body.status, authHeader);
}
private async resolveAgentName(authHeader: string): Promise<string> { private async resolveAgentName(authHeader: string): Promise<string> {
try { try {
const data = await this.platform.queryWithAuth<any>( const data = await this.platform.queryWithAuth<any>(

View File

@@ -1,13 +1,16 @@
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 { WorklistController } from './worklist.controller'; import { WorklistController } from './worklist.controller';
import { WorklistService } from './worklist.service'; import { WorklistService } from './worklist.service';
import { MissedQueueService } from './missed-queue.service';
import { MissedCallWebhookController } from './missed-call-webhook.controller'; import { MissedCallWebhookController } from './missed-call-webhook.controller';
import { KookooCallbackController } from './kookoo-callback.controller'; import { KookooCallbackController } from './kookoo-callback.controller';
@Module({ @Module({
imports: [PlatformModule], imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController], controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
providers: [WorklistService], providers: [WorklistService, MissedQueueService],
exports: [MissedQueueService],
}) })
export class WorklistModule {} export class WorklistModule {}

View File

@@ -76,13 +76,15 @@ export class WorklistService {
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> { private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
try { try {
// FIFO ordering (AscNullsLast) — oldest first. Filter to active callback statuses only.
const data = await this.platform.queryWithAuth<any>( const data = await this.platform.queryWithAuth<any>(
`{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, callStatus: { eq: MISSED } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { `{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
id name createdAt id name createdAt
direction callStatus agentName direction callStatus agentName
callerNumber { primaryPhoneNumber } callerNumber { primaryPhoneNumber }
startedAt endedAt durationSec startedAt endedAt durationSec
disposition leadId disposition leadId
callbackstatus callsourcenumber missedcallcount callbackattemptedat
} } } }`, } } } }`,
undefined, undefined,
authHeader, authHeader,