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 = { 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( `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 = { 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( `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 = {}; 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 = { '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'; } }