Files
helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts
saridsa2 3f22166ac0
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: dispose creates inbound Call record, webhook enriches — eliminates UCID mismatch + timing race
Root cause: CDR webhook fires 5s after dispose, stores monitorUCID.
Frontend has agent-side UCID from SIP. They never matched → disposition
not persisted, agent call history empty.

Fix:
- Dispose endpoint now creates Call records for ALL answered calls
  (inbound + outbound), not just outbound. Record gets agent-side UCID
  + correct disposition immediately.
- Webhook checks if Call record already exists (by agent UCID via
  monitorUCID→agentUCID mapping). If found, enriches with recording
  URL, agent chain name, CDR timing. If not found, creates as fallback.
- SupervisorService stores UCID mapping from real-time events (which
  carry both UCIDs). Auto-expires after 10 minutes.

Verified: UCID-MAP logged, dispose creates record, webhook enriches
without duplicating. DB shows correct agent UCID + disposition + recording.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 08:31:07 +05:30

563 lines
26 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 { 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<string, any> = {
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<any>(
`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<string, any> = {};
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<any>(
`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<string, string> = {
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<any>(
`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<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,
dispositions,
};
}
private mapToOzonetelDisposition(disposition: string): string {
// Campaign only has 'General Enquiry' configured currently
const map: Record<string, string> = {
'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<any>(
`{ 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;
}
}
}