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 { AgentLookupService } from '../platform/agent-lookup.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'; import { AgentHistoryService } from '../supervisor/agent-history.service'; // Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds. // Returns null when input is missing or all-zero. function parseHmsToSec(raw: any): number | null { if (!raw || typeof raw !== 'string') return null; if (raw === '00:00:00') return null; const parts = raw.split(':').map((p) => parseInt(p, 10)); if (parts.length !== 3 || parts.some((n) => isNaN(n))) return null; return parts[0] * 3600 + parts[1] * 60 + parts[2]; } @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, private readonly agentLookup: AgentLookupService, private readonly agentHistory: AgentHistoryService, ) {} private requireAgentId(agentId: string | undefined | null): string { if (!agentId) throw new HttpException('agentId required', 400); return agentId; } @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: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string }, ) { if (!body.state) { throw new HttpException('state required', 400); } const agentId = this.requireAgentId(body.agentId); this.logger.log(`[AGENT-STATE] ${agentId} → ${body.state} (${body.pauseReason ?? 'none'})`); try { const result = await this.ozonetelAgent.changeAgentState({ agentId, 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(agentId); 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; leadName?: string; notes?: string; missedCallId?: string; }, ) { if (!body.ucid || !body.disposition) { throw new HttpException('ucid and disposition required', 400); } const agentId = this.requireAgentId(body.agentId); 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 at dispose time for ALL answered calls // (inbound + outbound). The dispose endpoint fires BEFORE the // CDR webhook, so creating here gives us the correct agent-side // UCID and the agent's chosen disposition immediately. The webhook // arrives ~5s later and enriches with recording URL + chain name. if (body.callerPhone) { const isInbound = body.direction !== 'OUTBOUND'; try { const durationSec = body.durationSec ?? 0; const endedAt = new Date().toISOString(); const startedAt = durationSec > 0 ? new Date(Date.now() - durationSec * 1000).toISOString() : endedAt; const callData: Record = { name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`, direction: isInbound ? 'INBOUND' : 'OUTBOUND', callStatus: 'COMPLETED', callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` }, agentName: agentId, durationSec, disposition: body.disposition, startedAt, endedAt, }; // Persist UCID so the CDR enrichment cron and backfill can // resolve the authoritative agent relation even if the initial // lookup misses. if (body.ucid) callData.ucid = body.ucid; // Resolve the agent relation from the logged-in agentId. For // outbound, the dispatching agent IS the handler — no transfer. const agentUuid = await this.agentLookup.resolveByOzonetelId(agentId); if (agentUuid) callData.agentId = agentUuid; if (body.leadId) callData.leadId = body.leadId; if (body.leadName) callData.leadName = body.leadName; 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 ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`); // Fetch recording URL from CDR after a delay (Ozonetel needs time to process) const callId = result.createCall.id; const ucid = body.ucid; const dateStr = new Date().toISOString().split('T')[0]; setTimeout(async () => { try { // fetchCdrByUCID is the targeted lookup — Ozonetel resolves // leg-pair UCIDs server-side, so the agent-facing UCID we // hold reliably returns the call row and its CallAudio. const record = await this.ozonetelAgent.fetchCdrByUCID({ date: dateStr, ucid }); const audioUrl = record?.CallAudio || record?.AudioFile; // Compose a single update with recording + SLA timing // fields. CDR exposes HandlingTime, WrapupDuration, // HoldDuration as HH:MM:SS strings. const updateData: Record = {}; if (audioUrl) { updateData.recording = { primaryLinkUrl: audioUrl, primaryLinkLabel: 'Recording' }; } const handlingSec = parseHmsToSec(record?.HandlingTime); const wrapupSec = parseHmsToSec(record?.WrapupDuration); const holdSec = parseHmsToSec(record?.HoldDuration); if (handlingSec !== null) updateData.handlingTimeS = handlingSec; if (wrapupSec !== null) updateData.acwDurationS = wrapupSec; if (holdSec !== null) updateData.holdDurationS = holdSec; // Overwrite agent relation with CDR's AgentID (the // actual final handler; may differ from the caller // agentId if Ozonetel transferred the dial). const cdrAgentId = record?.AgentID; if (cdrAgentId) { const cdrAgentUuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId); if (cdrAgentUuid) updateData.agentId = cdrAgentUuid; if (record.AgentName) updateData.agentName = record.AgentName; } if (record?.TransferredTo) updateData.transferredTo = record.TransferredTo; if (record?.TransferType) updateData.transferType = record.TransferType; if (Object.keys(updateData).length > 0) { await this.platform.queryWithAuth( `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, { id: callId, data: updateData }, `Bearer ${apiKey}`, ); this.logger.log(`[DISPOSE] Updated outbound call ${callId} ${audioUrl ? 'with recording + ' : ''}timing (handling=${handlingSec ?? 'na'}s wrap=${wrapupSec ?? 'na'}s hold=${holdSec ?? 'na'}s)`); } else { this.logger.warn(`[DISPOSE] No CallAudio or timing for ucid=${ucid} — record=${JSON.stringify(record ?? null)}`); } } catch (err: any) { this.logger.warn(`[DISPOSE] Failed to fetch recording for outbound call: ${err.message}`); } }, 30_000); } } 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', APPOINTMENT_RESCHEDULED: 'CALLBACK_COMPLETED', APPOINTMENT_CANCELLED: 'CALLBACK_COMPLETED', INFO_PROVIDED: 'CALLBACK_COMPLETED', FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED', CALLBACK_REQUESTED: 'CALLBACK_COMPLETED', NOT_INTERESTED: 'CALLBACK_COMPLETED', WRONG_NUMBER: 'WRONG_NUMBER', NO_ANSWER: 'CALLBACK_ATTEMPTED', }; const newStatus = statusMap[body.disposition]; if (newStatus) { try { await this.platform.query( `mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus}, disposition: ${body.disposition} }) { id } }`, ); } catch (err) { this.logger.warn(`Failed to update missed call status: ${err}`); } } } // Inbound disposition is now handled by the call record creation // above — the dispose endpoint creates the record with the correct // disposition. No separate update-by-UCID needed. // Auto-assign next missed call to this agent try { await this.missedQueue.assignNext(agentId); } 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, 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 = this.requireAgentId(body.agentId); 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); if (body.action === 'HOLD') { this.supervisor.updateCallStatus(body.ucid, 'on-hold'); } else if (body.action === 'UNHOLD') { this.supervisor.updateCallStatus(body.ucid, 'active'); } 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 = this.requireAgentId(agentId); const targetDate = date ?? new Date().toISOString().split('T')[0]; this.logger.log(`Performance: date=${targetDate} agent=${agent}`); // Trigger an on-demand rollup for the requested date so the // AgentSession row reflects the current open session (caps at now) // instead of waiting up to 15 min for the background tick. Fire-and- // forget with a short await so we don't block the whole response on // cache-refresh tail but still hand the read a fresh row when Redpanda // is quiet. Safe to error — AgentSession just stays stale. await this.agentHistory.rollupSessions(targetDate).catch(() => {}); const [cdr, summary, aht, agentSessionBreakdown] = await Promise.all([ this.ozonetelAgent.fetchCDR({ date: targetDate }), this.ozonetelAgent.getAgentSummary(agent, targetDate), this.ozonetelAgent.getAHT(agent), this.fetchAgentSessionTimeBreakdown(agent, targetDate), ]); // Prefer our AgentSession rollup when present — it correctly counts // the current OPEN session (caps at now), while Ozonetel's summaryReport // only tallies CLOSED login→logout pairs. Fall back to Ozonetel if // our rollup hasn't captured this agent yet (e.g., brand-new agent, // workspace without AgentEvent entity synced). const timeUtilization = agentSessionBreakdown ?? summary; // Filter CDR to this agent only — fetchCDR returns all agents' calls // Use case-insensitive matching — Ozonetel field casing varies const agentLower = agent.toLowerCase(); const agentCdr = cdr.filter((c: any) => (c.AgentID ?? '').toLowerCase() === agentLower || (c.AgentName ?? '').toLowerCase() === agentLower, ); this.logger.log(`[PERFORMANCE] CDR total=${cdr.length} agentFiltered=${agentCdr.length} agent="${agent}"`); if (cdr.length > 0 && agentCdr.length === 0) { const sampleIds = cdr.slice(0, 3).map((c: any) => `AgentID="${c.AgentID}" AgentName="${c.AgentName}"`); this.logger.warn(`[PERFORMANCE] No CDR match for agent "${agent}". Sample CDR agents: ${sampleIds.join(', ')}`); } const totalCalls = agentCdr.length; const inbound = agentCdr.filter((c: any) => (c.Type ?? '').toLowerCase() === 'inbound').length; const outbound = agentCdr.filter((c: any) => { const type = (c.Type ?? '').toLowerCase(); return type === 'manual' || type === 'progressive' || type === 'outbound'; }).length; const answered = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === 'answered').length; const missed = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === '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, dispositions, }; } private mapToOzonetelDisposition(disposition: string): string { // Campaign only has 'General Enquiry' configured currently const map: Record = { 'APPOINTMENT_BOOKED': 'General Enquiry', 'APPOINTMENT_RESCHEDULED': 'General Enquiry', 'APPOINTMENT_CANCELLED': 'General Enquiry', 'FOLLOW_UP_SCHEDULED': 'General Enquiry', 'INFO_PROVIDED': 'General Enquiry', 'NO_ANSWER': 'General Enquiry', 'WRONG_NUMBER': 'General Enquiry', 'NOT_INTERESTED': 'General Enquiry', 'CALLBACK_REQUESTED': 'General Enquiry', }; return map[disposition] ?? 'General Enquiry'; } // Convert our AgentSession rollup (seconds per category) into the HH:MM:SS // shape the frontend expects — so My Performance gets LOGIN TIME with the // current open session included, not just closed sessions from Ozonetel. private async fetchAgentSessionTimeBreakdown(ozonetelAgentId: string, date: string): Promise<{ totalLoginDuration: string; totalBusyTime: string; totalIdleTime: string; totalPauseTime: string; totalWrapupTime: string; totalDialTime: string; } | null> { try { const agentUuid = await this.agentLookup.resolveByOzonetelId(ozonetelAgentId); if (!agentUuid) return null; const data = await this.platform.query( `{ agentSessions(first: 1, filter: { agentId: { eq: "${agentUuid}" }, date: { eq: "${date}" } }) { edges { node { loginDurationS busyTimeS idleTimeS pauseTimeS wrapupTimeS dialTimeS } } } }`, ); const node = data?.agentSessions?.edges?.[0]?.node; if (!node) return null; const hms = (sec: number | null | undefined): string => { const s = Math.max(0, Math.round(sec ?? 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60; return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${r.toString().padStart(2, '0')}`; }; // If the entire rollup is zero, treat as "no data yet" — fall back // to Ozonetel's summaryReport so the KPI isn't all zeroes. const total = (node.loginDurationS ?? 0) + (node.busyTimeS ?? 0) + (node.idleTimeS ?? 0) + (node.pauseTimeS ?? 0) + (node.wrapupTimeS ?? 0); if (total === 0) return null; return { totalLoginDuration: hms(node.loginDurationS), totalBusyTime: hms(node.busyTimeS), totalIdleTime: hms(node.idleTimeS), totalPauseTime: hms(node.pauseTimeS), totalWrapupTime: hms(node.wrapupTimeS), totalDialTime: hms(node.dialTimeS), }; } catch { return null; } } }