mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
563 lines
26 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|