mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
#536: Performance endpoint now accepts agentId query param and filters CDR to that agent only. Previously returned all agents' calls as one agent's total. Fixed 'Unanswered' → 'NotAnswered' status filter. #538: Team performance now includes per-agent call metrics (total, inbound, outbound, answered, missed) from CDR data + teamTotals aggregate. Previously only returned Ozonetel time breakdown without any call counts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
|
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 {
|
|
private readonly logger = new Logger(OzonetelAgentController.name);
|
|
|
|
constructor(
|
|
private readonly ozonetelAgent: OzonetelAgentService,
|
|
private readonly telephony: TelephonyConfigService,
|
|
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.
|
|
private get defaultAgentId(): string {
|
|
return this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
|
}
|
|
private get defaultAgentPassword(): string {
|
|
return this.telephony.getConfig().ozonetel.agentPassword;
|
|
}
|
|
private get defaultSipId(): string {
|
|
return this.telephony.getConfig().ozonetel.sipId || '521814';
|
|
}
|
|
|
|
@Post('agent-login')
|
|
async agentLogin(
|
|
@Body() body: { agentId: string; password: string; phoneNumber: string; mode?: string },
|
|
) {
|
|
this.logger.log(`Agent login request for ${body.agentId}`);
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.loginAgent(body);
|
|
return result;
|
|
} catch (error: any) {
|
|
throw new HttpException(
|
|
error.response?.data?.message ?? 'Agent login failed',
|
|
error.response?.status ?? 500,
|
|
);
|
|
}
|
|
}
|
|
|
|
@Post('agent-logout')
|
|
async agentLogout(
|
|
@Body() body: { agentId: string; password: string },
|
|
) {
|
|
this.logger.log(`Agent logout request for ${body.agentId}`);
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.logoutAgent(body);
|
|
return result;
|
|
} catch (error: any) {
|
|
throw new HttpException(
|
|
error.response?.data?.message ?? 'Agent logout failed',
|
|
error.response?.status ?? 500,
|
|
);
|
|
}
|
|
}
|
|
|
|
@Post('agent-state')
|
|
async agentState(
|
|
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
|
) {
|
|
if (!body.state) {
|
|
throw new HttpException('state required', 400);
|
|
}
|
|
|
|
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.changeAgentState({
|
|
agentId: this.defaultAgentId,
|
|
state: body.state,
|
|
pauseReason: body.pauseReason,
|
|
});
|
|
this.logger.log(`[AGENT-STATE] Ozonetel response: ${JSON.stringify(result)}`);
|
|
|
|
// Auto-assign missed call when agent goes Ready
|
|
if (body.state === 'Ready') {
|
|
try {
|
|
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
|
if (assigned) {
|
|
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
|
return { ...result, assignedCall: assigned };
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`[AGENT-STATE] Auto-assignment on Ready failed: ${err}`);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
|
this.logger.error(`[AGENT-STATE] FAILED: ${message} ${responseData}`);
|
|
return { status: 'error', message };
|
|
}
|
|
}
|
|
|
|
// force-ready moved to /api/maint/force-ready
|
|
|
|
@Post('dispose')
|
|
async dispose(
|
|
@Body() body: {
|
|
ucid: string;
|
|
disposition: string;
|
|
agentId?: string;
|
|
callerPhone?: string;
|
|
direction?: string;
|
|
durationSec?: number;
|
|
leadId?: string;
|
|
notes?: string;
|
|
missedCallId?: string;
|
|
},
|
|
) {
|
|
if (!body.ucid || !body.disposition) {
|
|
throw new HttpException('ucid and disposition required', 400);
|
|
}
|
|
|
|
const agentId = body.agentId ?? this.defaultAgentId;
|
|
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
|
|
|
// Cancel the ACW auto-dispose timer — the frontend submitted disposition
|
|
this.supervisor.cancelAcwTimer(agentId);
|
|
|
|
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${agentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.setDisposition({
|
|
agentId,
|
|
ucid: body.ucid,
|
|
disposition: ozonetelDisposition,
|
|
});
|
|
this.logger.log(`[DISPOSE] Ozonetel response: ${JSON.stringify(result)}`);
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
|
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
|
}
|
|
|
|
// Create call record for outbound calls. Inbound calls are
|
|
// created by the webhook — but we skip outbound in the webhook
|
|
// (they're not "missed calls"). So the dispose endpoint is the
|
|
// only place that creates the call record for outbound dials.
|
|
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
|
try {
|
|
const callData: Record<string, any> = {
|
|
name: `Outbound — ${body.callerPhone}`,
|
|
direction: 'OUTBOUND',
|
|
callStatus: 'COMPLETED',
|
|
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
|
agentName: agentId,
|
|
durationSec: body.durationSec ?? 0,
|
|
disposition: body.disposition,
|
|
};
|
|
if (body.leadId) callData.leadId = body.leadId;
|
|
|
|
const apiKey = process.env.PLATFORM_API_KEY;
|
|
if (apiKey) {
|
|
const result = await this.platform.queryWithAuth<any>(
|
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
|
{ data: callData },
|
|
`Bearer ${apiKey}`,
|
|
);
|
|
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
|
}
|
|
} catch (err: any) {
|
|
this.logger.warn(`[DISPOSE] Failed to create outbound call record: ${err.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}`);
|
|
}
|
|
|
|
// Emit event for downstream processing (AI insights, metrics, etc.)
|
|
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
|
callId: null,
|
|
ucid: body.ucid,
|
|
agentId: this.defaultAgentId,
|
|
callerPhone: body.callerPhone ?? '',
|
|
direction: body.direction ?? 'INBOUND',
|
|
durationSec: body.durationSec ?? 0,
|
|
disposition: body.disposition,
|
|
leadId: body.leadId ?? null,
|
|
notes: body.notes ?? null,
|
|
timestamp: new Date().toISOString(),
|
|
}).catch(() => {});
|
|
|
|
return { status: 'ok' };
|
|
}
|
|
|
|
@Post('dial')
|
|
async dial(
|
|
@Body() body: { phoneNumber: string; agentId?: string; campaignName?: string; leadId?: string },
|
|
) {
|
|
if (!body.phoneNumber) {
|
|
throw new HttpException('phoneNumber required', 400);
|
|
}
|
|
|
|
const agentId = body.agentId ?? this.defaultAgentId;
|
|
const did = this.telephony.getConfig().ozonetel.did;
|
|
const campaignName = body.campaignName
|
|
|| this.telephony.getConfig().ozonetel.campaignName
|
|
|| (did ? `Inbound_${did}` : '');
|
|
|
|
if (!campaignName) {
|
|
throw new HttpException('Campaign name not configured — set in Telephony settings or pass campaignName', 400);
|
|
}
|
|
|
|
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${agentId} lead=${body.leadId ?? 'none'}`);
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.manualDial({
|
|
agentId,
|
|
campaignName,
|
|
customerNumber: body.phoneNumber,
|
|
});
|
|
return result;
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message ?? error.message ?? 'Dial failed';
|
|
throw new HttpException(message, error.response?.status ?? 502);
|
|
}
|
|
}
|
|
|
|
@Post('call-control')
|
|
async callControl(
|
|
@Body() body: {
|
|
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
|
|
ucid: string;
|
|
conferenceNumber?: string;
|
|
},
|
|
) {
|
|
if (!body.action || !body.ucid) {
|
|
throw new HttpException('action and ucid required', 400);
|
|
}
|
|
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
|
|
throw new HttpException('conferenceNumber required for CONFERENCE action', 400);
|
|
}
|
|
|
|
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.callControl(body);
|
|
return result;
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
|
throw new HttpException(message, error.response?.status ?? 502);
|
|
}
|
|
}
|
|
|
|
@Post('recording')
|
|
async recording(
|
|
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
|
|
) {
|
|
if (!body.ucid || !body.action) {
|
|
throw new HttpException('ucid and action required', 400);
|
|
}
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.pauseRecording(body);
|
|
return result;
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
|
|
throw new HttpException(message, error.response?.status ?? 502);
|
|
}
|
|
}
|
|
|
|
@Get('missed-calls')
|
|
async missedCalls() {
|
|
const result = await this.ozonetelAgent.getAbandonCalls();
|
|
return result;
|
|
}
|
|
|
|
@Get('call-history')
|
|
async callHistory(
|
|
@Query('date') date?: string,
|
|
@Query('status') status?: string,
|
|
@Query('callType') callType?: string,
|
|
) {
|
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
|
this.logger.log(`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`);
|
|
|
|
const result = await this.ozonetelAgent.fetchCDR({
|
|
date: targetDate,
|
|
status,
|
|
callType,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get('performance')
|
|
async performance(@Query('date') date?: string, @Query('agentId') agentId?: string) {
|
|
const agent = agentId ?? this.defaultAgentId;
|
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
|
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
|
|
|
const [cdr, summary, aht] = await Promise.all([
|
|
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
|
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
|
this.ozonetelAgent.getAHT(agent),
|
|
]);
|
|
|
|
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
|
const agentCdr = cdr.filter((c: any) => c.AgentID === agent || c.AgentName === agent);
|
|
|
|
const totalCalls = agentCdr.length;
|
|
const inbound = agentCdr.filter((c: any) => c.Type === 'InBound').length;
|
|
const outbound = agentCdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
|
const answered = agentCdr.filter((c: any) => c.Status === 'Answered').length;
|
|
const missed = agentCdr.filter((c: any) => c.Status === 'NotAnswered').length;
|
|
|
|
const talkTimes = agentCdr
|
|
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
|
.map((c: any) => {
|
|
const parts = c.TalkTime.split(':').map(Number);
|
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
});
|
|
const avgTalkTimeSec = talkTimes.length > 0
|
|
? Math.round(talkTimes.reduce((a: number, b: number) => a + b, 0) / talkTimes.length)
|
|
: 0;
|
|
|
|
const dispositions: Record<string, number> = {};
|
|
for (const c of agentCdr) {
|
|
const d = (c as any).Disposition || 'No Disposition';
|
|
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
|
}
|
|
|
|
const appointmentsBooked = agentCdr.filter((c: any) =>
|
|
c.Disposition?.toLowerCase().includes('appointment'),
|
|
).length;
|
|
|
|
return {
|
|
date: targetDate,
|
|
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
|
avgTalkTimeSec,
|
|
avgHandlingTime: aht,
|
|
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
|
appointmentsBooked,
|
|
timeUtilization: summary,
|
|
dispositions,
|
|
};
|
|
}
|
|
|
|
private mapToOzonetelDisposition(disposition: string): string {
|
|
// Campaign only has 'General Enquiry' configured currently
|
|
const map: Record<string, string> = {
|
|
'APPOINTMENT_BOOKED': 'General Enquiry',
|
|
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
|
'INFO_PROVIDED': 'General Enquiry',
|
|
'NO_ANSWER': 'General Enquiry',
|
|
'WRONG_NUMBER': 'General Enquiry',
|
|
'CALLBACK_REQUESTED': 'General Enquiry',
|
|
};
|
|
return map[disposition] ?? 'General Enquiry';
|
|
}
|
|
}
|