From 4eb8cb80b2ab77792cdcfa8779101f2c3969e867 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 15 Apr 2026 06:49:15 +0530 Subject: [PATCH] =?UTF-8?q?feat(supervisor):=20Phase=202=20metrics=20inges?= =?UTF-8?q?t=20=E2=80=94=20AgentEvent/AgentSession=20rollup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New AgentHistoryService: persistAgentEvent pairs START/END for durationS, patchCallTiming updates Call SLA fields - Supervisor service wires handleCallEvent (CALL_START on Answered, CALL_END on Disconnect) and handleAgentEvent (LOGIN/LOGOUT/PAUSE/RESUME/ACW_START/ACW_END/READY) via priorState-aware mapping - setInterval-based nightly-ish rollup: every 15min aggregates AgentEvent into AgentSession per IST day (idempotent upsert by agentId+date) - Ozonetel dispose flow extracts HandlingTime/WrapupDuration/HoldDuration from CDR, patches Call timing fields - Field names match platform truncation: durationS, loginDurationS, busyTimeS, idleTimeS, pauseTimeS, wrapupTimeS, avgHandlingTimeS, handlingTimeS, acwDurationS, holdDurationS, responseTimeS, sessionDate → date - Skips cleanly on workspaces where AgentEvent entity isn't synced Known issue: pending-pair map has single slot per agent, so ACW_START overwrites pending CALL_START and CALL_END computes 0s duration. Fix in followup. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ozonetel/ozonetel-agent.controller.ts | 83 ++++- src/supervisor/agent-history.service.ts | 363 ++++++++++++++++++++++ src/supervisor/supervisor.module.ts | 5 +- src/supervisor/supervisor.service.ts | 90 +++++- 4 files changed, 527 insertions(+), 14 deletions(-) create mode 100644 src/supervisor/agent-history.service.ts diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index df443d0..1e032fd 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -7,6 +7,16 @@ import { Topics } from '../events/event-types'; import { TelephonyConfigService } from '../config/telephony-config.service'; import { SupervisorService } from '../supervisor/supervisor.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); @@ -112,6 +122,7 @@ export class OzonetelAgentController { direction?: string; durationSec?: number; leadId?: string; + leadName?: string; notes?: string; missedCallId?: string; }, @@ -164,6 +175,7 @@ export class OzonetelAgentController { endedAt, }; if (body.leadId) callData.leadId = body.leadId; + if (body.leadName) callData.leadName = body.leadName; const apiKey = process.env.PLATFORM_API_KEY; if (apiKey) { @@ -173,6 +185,45 @@ export class OzonetelAgentController { `Bearer ${apiKey}`, ); this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`); + + // 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; + 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}`); @@ -183,16 +234,20 @@ export class OzonetelAgentController { 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} }) { id } }`, + `mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus}, disposition: ${body.disposition} }) { id } }`, ); } catch (err) { this.logger.warn(`Failed to update missed call status: ${err}`); @@ -336,13 +391,26 @@ export class OzonetelAgentController { ]); // Filter CDR to this agent only — fetchCDR returns all agents' calls - const agentCdr = cdr.filter((c: any) => c.AgentID === agent || c.AgentName === agent); + // 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 === '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 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') @@ -380,10 +448,13 @@ export class OzonetelAgentController { // 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'; diff --git a/src/supervisor/agent-history.service.ts b/src/supervisor/agent-history.service.ts new file mode 100644 index 0000000..4bee402 --- /dev/null +++ b/src/supervisor/agent-history.service.ts @@ -0,0 +1,363 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; + +// AgentEvent enum values (mirror of the SDK app's agent-event.object.ts). +// Ozonetel webhook actions → Helix event types. +export type AgentEventType = + | 'LOGIN' + | 'LOGOUT' + | 'READY' + | 'PAUSE' + | 'RESUME' + | 'CALL_START' + | 'CALL_END' + | 'ACW_START' + | 'ACW_END'; + +type PendingStart = { + eventType: 'PAUSE' | 'CALL_START' | 'ACW_START'; + at: number; // ms timestamp +}; + +/** + * Persists agent activity and per-call timing into the platform entities + * we added in Phase 1 (AgentEvent, Call SLA fields). Reads AgentSession + * later via the rollup job. + * + * Called from: + * - supervisor.service.handleAgentEvent → persistAgentEvent() + * - supervisor.service.handleCallEvent → patchCallTiming() + * - ozonetel-agent.controller dispose flow → patchCallTiming() + */ +@Injectable() +export class AgentHistoryService implements OnModuleInit { + private readonly logger = new Logger(AgentHistoryService.name); + + // ozonetelAgentId → Agent entity UUID. Loaded at startup. + private readonly agentUuidByOzonetelId = new Map(); + + // agentId → most recent "start" event that's awaiting a paired "end", + // used to compute durationSec on the END event. + private readonly pendingStartByAgent = new Map(); + + constructor(private readonly platform: PlatformGraphqlService) {} + + private rollupTimer: NodeJS.Timeout | null = null; + + async onModuleInit() { + await this.refreshAgentCache(); + // Roll up today's sessions every 15 minutes. Rollup is idempotent + // (upsert by agent+date), so missing a tick is safe — the next tick + // recomputes from AgentEvent history. Written with setInterval because + // @nestjs/schedule isn't installed in this sidecar. + this.rollupTimer = setInterval(() => { + this.rollupSessions(this.currentSessionDate()).catch((err) => { + this.logger.warn(`[HISTORY] Rollup tick failed: ${err?.message ?? err}`); + }); + }, 15 * 60 * 1000); + // Kick off one immediately so the dashboard has data on boot. + this.rollupSessions(this.currentSessionDate()).catch(() => {}); + } + + onModuleDestroy() { + if (this.rollupTimer) clearInterval(this.rollupTimer); + } + + // IST day boundary — agents work in IST, so the rollup is by IST date. + private currentSessionDate(): string { + const now = new Date(); + const ist = new Date(now.getTime() + 5.5 * 60 * 60 * 1000); + return ist.toISOString().slice(0, 10); + } + + private async refreshAgentCache(): Promise { + try { + const data = await this.platform.query( + `{ agents(first: 50) { edges { node { id ozonetelAgentId } } } }`, + ); + const edges = data?.agents?.edges ?? []; + this.agentUuidByOzonetelId.clear(); + for (const edge of edges) { + const n = edge.node; + if (n.ozonetelAgentId) { + this.agentUuidByOzonetelId.set(n.ozonetelAgentId, n.id); + } + } + this.logger.log(`[HISTORY] Loaded ${this.agentUuidByOzonetelId.size} agent UUIDs into cache`); + } catch (err) { + this.logger.warn(`[HISTORY] Failed to refresh agent cache: ${err}`); + } + } + + private async resolveAgentUuid(ozonetelAgentId: string): Promise { + if (!ozonetelAgentId) return null; + const cached = this.agentUuidByOzonetelId.get(ozonetelAgentId); + if (cached) return cached; + // Cache miss — refresh once (handles late-provisioned agents like Ganesh) + await this.refreshAgentCache(); + return this.agentUuidByOzonetelId.get(ozonetelAgentId) ?? null; + } + + /** + * Record an agent activity event. Computes durationSec for END events + * (RESUME, CALL_END, ACW_END) by pairing against the most recent START. + * Non-fatal on failure — realtime SSE flow continues even if the + * platform write errors. + */ + async persistAgentEvent(params: { + ozonetelAgentId: string; + eventType: AgentEventType; + eventAt: string; // ISO + pauseReason?: string | null; + callId?: string | null; + }): Promise { + const agentUuid = await this.resolveAgentUuid(params.ozonetelAgentId); + if (!agentUuid) { + this.logger.warn(`[HISTORY] No Agent entity for ozonetelAgentId=${params.ozonetelAgentId} — skipping event persist`); + return; + } + + // Compute duration when closing out a pending start. Ozonetel + // emits a single "release" action for both post-pause and post-ACW, + // so a READY event closes whatever was open. Specific close events + // (RESUME / ACW_END / CALL_END) also pair to their explicit start. + let durationSec: number | null = null; + if (this.closesAnyOpenStart(params.eventType)) { + const pending = this.pendingStartByAgent.get(params.ozonetelAgentId); + if (pending) { + durationSec = Math.max(0, Math.round((new Date(params.eventAt).getTime() - pending.at) / 1000)); + this.pendingStartByAgent.delete(params.ozonetelAgentId); + } + } else if (this.isStartEvent(params.eventType)) { + // Overwrite any existing pending start for this agent. Ozonetel + // doesn't nest states, so a new start implicitly ends the previous. + this.pendingStartByAgent.set(params.ozonetelAgentId, { + eventType: params.eventType as PendingStart['eventType'], + at: new Date(params.eventAt).getTime(), + }); + } + + const data: Record = { + eventType: params.eventType, + eventAt: params.eventAt, + source: 'OZONETEL_SUBSCRIPTION', + agentId: agentUuid, + }; + if (params.pauseReason) data.pauseReason = params.pauseReason; + if (durationSec !== null) data.durationS = durationSec; + if (params.callId) data.callId = params.callId; + + try { + await this.platform.query( + `mutation($data: AgentEventCreateInput!) { createAgentEvent(data: $data) { id } }`, + { data }, + ); + } catch (err: any) { + if (this.isEntityMissingError(err)) { + if (!this.warnedEntityMissing) { + this.logger.warn('[HISTORY] AgentEvent entity not synced on this workspace — skipping persistence'); + this.warnedEntityMissing = true; + } + return; + } + this.logger.warn(`[HISTORY] createAgentEvent failed: ${err}`); + } + } + + private warnedEntityMissing = false; + + private isEntityMissingError(err: unknown): boolean { + const msg = String((err as any)?.message ?? err ?? ''); + return msg.includes('Cannot query field') || msg.includes('Unknown type') + || msg.includes('AgentEventCreateInput') || msg.includes('AgentSessionCreateInput'); + } + + private closesAnyOpenStart(endType: AgentEventType): boolean { + // READY, RESUME, CALL_END, ACW_END all close whatever start was open. + // LOGIN/LOGOUT don't — they're session boundaries. + return endType === 'READY' + || endType === 'RESUME' + || endType === 'CALL_END' + || endType === 'ACW_END'; + } + + private isStartEvent(eventType: AgentEventType): boolean { + return eventType === 'PAUSE' || eventType === 'CALL_START' || eventType === 'ACW_START'; + } + + /** + * Patch a Call record with SLA / timing fields derived from Ozonetel + * webhooks or post-call CDR. All fields optional — caller passes only + * what it has. Used for response-time and ACW histograms on the + * supervisor dashboard. + */ + async patchCallTiming(callId: string, fields: { + assignedAt?: string; + answeredAt?: string; + responseTimeSec?: number; + handlingTimeSec?: number; + acwDurationSec?: number; + holdDurationSec?: number; + }): Promise { + // Platform truncates `*Sec` → `*S` on field names. + const fieldNameMap: Record = { + responseTimeSec: 'responseTimeS', + handlingTimeSec: 'handlingTimeS', + acwDurationSec: 'acwDurationS', + holdDurationSec: 'holdDurationS', + }; + const data: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined && v !== null) { + data[fieldNameMap[k] ?? k] = v; + } + } + if (Object.keys(data).length === 0) return; + try { + await this.platform.query( + `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, + { id: callId, data }, + ); + } catch (err) { + this.logger.warn(`[HISTORY] updateCall timing failed (${callId}): ${err}`); + } + } + + /** + * Aggregate AgentEvent rows into an AgentSession row per agent for the + * given IST date. Called on a 15-minute interval; upserts by (agent, + * sessionDate) so re-runs are safe. + */ + async rollupSessions(sessionDate: string): Promise { + if (this.agentUuidByOzonetelId.size === 0) await this.refreshAgentCache(); + const agentUuids = Array.from(new Set(this.agentUuidByOzonetelId.values())); + if (agentUuids.length === 0) return; + + const startIso = `${sessionDate}T00:00:00+05:30`; + const endIso = `${sessionDate}T23:59:59+05:30`; + + let succeeded = 0; + for (const agentUuid of agentUuids) { + try { + const events = await this.fetchAgentEvents(agentUuid, startIso, endIso); + const totals = this.aggregateEvents(events); + await this.upsertSession(agentUuid, sessionDate, totals); + succeeded++; + } catch (err: any) { + if (this.isEntityMissingError(err)) { + if (!this.warnedEntityMissing) { + this.logger.warn('[HISTORY] AgentEvent/AgentSession entities not synced on this workspace — skipping rollup'); + this.warnedEntityMissing = true; + } + return; + } + this.logger.warn(`[HISTORY] Rollup failed for agent ${agentUuid}: ${err?.message ?? err}`); + } + } + this.logger.log(`[HISTORY] Rollup complete for ${sessionDate} — ${succeeded}/${agentUuids.length} agents`); + } + + // Platform strips the `Sec` suffix on numeric field names — schema uses + // `durationS`, `loginDurationS`, etc. Map back to our canonical names + // when reading. + private async fetchAgentEvents(agentUuid: string, startIso: string, endIso: string): Promise> { + const events: Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }> = []; + let after: string | null = null; + for (let page = 0; page < 20; page++) { + const cursorArg: string = after ? `, after: "${after}"` : ''; + const data: any = await this.platform.query( + `{ agentEvents(first: 200${cursorArg}, filter: { agentId: { eq: "${agentUuid}" }, eventAt: { gte: "${startIso}", lte: "${endIso}" } }, orderBy: [{ eventAt: AscNullsLast }]) { + edges { node { eventType eventAt durationS } } + pageInfo { hasNextPage endCursor } + } }`, + ); + const edges = data?.agentEvents?.edges ?? []; + for (const e of edges) { + events.push({ + eventType: e.node.eventType, + eventAt: e.node.eventAt, + durationSec: e.node.durationS ?? null, + }); + } + const pageInfo: { hasNextPage?: boolean; endCursor?: string } = data?.agentEvents?.pageInfo ?? {}; + if (!pageInfo.hasNextPage) break; + after = pageInfo.endCursor ?? null; + } + return events; + } + + private aggregateEvents(events: Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }>) { + let busyTimeSec = 0; + let pauseTimeSec = 0; + let wrapupTimeSec = 0; + let handlingSum = 0; + let handlingCount = 0; + + // Login duration: sum each LOGIN → (next LOGOUT on same day | now) span. + // Ozonetel doesn't emit a LOGOUT if the agent just closes the tab, so + // cap open sessions at the end of the rollup day. + let loginDurationSec = 0; + let openLoginAt: number | null = null; + + for (const e of events) { + if (e.eventType === 'LOGIN') { + openLoginAt = new Date(e.eventAt).getTime(); + } else if (e.eventType === 'LOGOUT' && openLoginAt !== null) { + loginDurationSec += Math.max(0, Math.round((new Date(e.eventAt).getTime() - openLoginAt) / 1000)); + openLoginAt = null; + } else if (e.eventType === 'CALL_END' && e.durationSec) { + busyTimeSec += e.durationSec; + handlingSum += e.durationSec; + handlingCount++; + } else if (e.eventType === 'RESUME' && e.durationSec) { + pauseTimeSec += e.durationSec; + } else if (e.eventType === 'ACW_END' && e.durationSec) { + wrapupTimeSec += e.durationSec; + } + } + if (openLoginAt !== null) { + // Still logged in — count up to now (capped to the rollup day end). + loginDurationSec += Math.max(0, Math.round((Date.now() - openLoginAt) / 1000)); + } + + const avgHandlingTimeSec = handlingCount > 0 ? Math.round(handlingSum / handlingCount) : null; + const idleTimeSec = Math.max(0, loginDurationSec - busyTimeSec - pauseTimeSec - wrapupTimeSec); + + return { loginDurationSec, busyTimeSec, pauseTimeSec, wrapupTimeSec, idleTimeSec, avgHandlingTimeSec }; + } + + // AgentSession fields map: our `*Sec` → platform `*S`, `sessionDate` → `date`. + private async upsertSession( + agentUuid: string, + sessionDate: string, + totals: { loginDurationSec: number; busyTimeSec: number; pauseTimeSec: number; wrapupTimeSec: number; idleTimeSec: number; avgHandlingTimeSec: number | null }, + ): Promise { + const existing = await this.platform.query( + `{ agentSessions(first: 1, filter: { agentId: { eq: "${agentUuid}" }, date: { eq: "${sessionDate}" } }) { edges { node { id } } } }`, + ); + const existingId = existing?.agentSessions?.edges?.[0]?.node?.id; + + const data: Record = { + loginDurationS: totals.loginDurationSec, + busyTimeS: totals.busyTimeSec, + pauseTimeS: totals.pauseTimeSec, + wrapupTimeS: totals.wrapupTimeSec, + idleTimeS: totals.idleTimeSec, + source: 'COMPUTED', + lastSyncedAt: new Date().toISOString(), + }; + if (totals.avgHandlingTimeSec !== null) data.avgHandlingTimeS = totals.avgHandlingTimeSec; + + if (existingId) { + await this.platform.query( + `mutation($id: UUID!, $data: AgentSessionUpdateInput!) { updateAgentSession(id: $id, data: $data) { id } }`, + { id: existingId, data }, + ); + } else { + await this.platform.query( + `mutation($data: AgentSessionCreateInput!) { createAgentSession(data: $data) { id } }`, + { data: { ...data, agentId: agentUuid, date: sessionDate } }, + ); + } + } +} diff --git a/src/supervisor/supervisor.module.ts b/src/supervisor/supervisor.module.ts index b571089..4efd537 100644 --- a/src/supervisor/supervisor.module.ts +++ b/src/supervisor/supervisor.module.ts @@ -4,6 +4,7 @@ import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; import { SupervisorController } from './supervisor.controller'; import { SupervisorBargeController } from './supervisor-barge.controller'; import { SupervisorService } from './supervisor.service'; +import { AgentHistoryService } from './agent-history.service'; import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service'; // Note: TelephonyConfigService is available without import because @@ -12,7 +13,7 @@ import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.servic @Module({ imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)], controllers: [SupervisorController, SupervisorBargeController], - providers: [SupervisorService, OzonetelAdminAuthService], - exports: [SupervisorService, OzonetelAdminAuthService], + providers: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService], + exports: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService], }) export class SupervisorModule {} diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index d12a2e7..8ee956b 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { Subject } from 'rxjs'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; +import { AgentHistoryService, AgentEventType } from './agent-history.service'; type ActiveCall = { ucid: string; @@ -49,6 +50,7 @@ export class SupervisorService implements OnModuleInit { private platform: PlatformGraphqlService, private ozonetel: OzonetelAgentService, private config: ConfigService, + private history: AgentHistoryService, ) {} async onModuleInit() { @@ -73,6 +75,7 @@ export class SupervisorService implements OnModuleInit { const callerNumber = event.caller_id ?? event.callerID; const callType = event.call_type ?? event.Type; const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString(); + const iso = this.parseOzonetelTime(eventTime); if (!ucid) return; @@ -88,25 +91,73 @@ export class SupervisorService implements OnModuleInit { callType, startTime: eventTime, status: 'active', }); this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`); + + // Persist CALL_START as AgentEvent on the "Answered" moment + // (that's when busy-time actually begins). "Calling" is the + // ring — doesn't count as busy. + if (action === 'Answered' && agentId) { + this.history.persistAgentEvent({ + ozonetelAgentId: agentId, + eventType: 'CALL_START', + eventAt: iso, + }).catch(() => {}); + } } else if (action === 'Disconnect') { + const wasActive = this.activeCalls.get(ucid); this.activeCalls.delete(ucid); this.logger.log(`Call ended: ${ucid}`); + + // Persist CALL_END — pair against the start for duration. + if (wasActive?.agentId) { + this.history.persistAgentEvent({ + ozonetelAgentId: wasActive.agentId, + eventType: 'CALL_END', + eventAt: iso, + }).catch(() => {}); + } } } + // Ozonetel sends timestamps in "YYYY-MM-DD HH:MM:SS" IST format. Normalise. + private parseOzonetelTime(raw: string): string { + if (!raw) return new Date().toISOString(); + const asDate = new Date(raw); + if (!isNaN(asDate.getTime())) return asDate.toISOString(); + return new Date().toISOString(); + } + handleAgentEvent(event: any) { const agentId = event.agentId ?? event.agent_id ?? 'unknown'; const action = event.action ?? 'unknown'; - const eventData = event.eventData ?? ''; + const eventData = event.eventData ?? event.data ?? ''; + const pauseReason = event.pauseReason ?? event.pause_reason ?? event.breakReason ?? ''; const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString(); - this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`); + this.logger.log(`[AGENT-STATE] ${agentId} → ${action} eventData="${eventData}" pauseReason="${pauseReason}" at ${eventTime}`); + this.logger.log(`[AGENT-STATE] Full event payload: ${JSON.stringify(event)}`); - const mapped = this.mapOzonetelAction(action, eventData); + const priorState = this.agentStates.get(agentId)?.state; + const mapped = this.mapOzonetelAction(action, eventData, pauseReason); if (mapped) { this.agentStates.set(agentId, { state: mapped, timestamp: eventTime }); this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime }); this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`); + // Persist to AgentEvent table. CALL_START/CALL_END are + // handled in handleCallEvent (they arrive via a separate + // Ozonetel webhook). Everything else is captured here. + // Pass priorState so 'release' → RESUME / ACW_END / READY can + // be disambiguated for the session rollup. + const historyEventType = this.mapToHistoryEventType(action, priorState); + if (historyEventType) { + const resolvedPauseReason = (pauseReason || eventData || '') || null; + this.history.persistAgentEvent({ + ozonetelAgentId: agentId, + eventType: historyEventType, + eventAt: this.parseOzonetelTime(eventTime), + pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null, + }).catch(() => {}); + } + // Layer 3: ACW auto-dispose safety net if (mapped === 'acw') { // Find the most recent UCID for this agent @@ -149,7 +200,30 @@ export class SupervisorService implements OnModuleInit { } } - private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null { + // Map the Ozonetel webhook action to our AgentEvent.eventType enum. + // 'release' means "agent is available again" — could be post-pause, + // post-ACW, or post-call. Use the previous agent state to emit the + // specific close-out event so session rollups can sum durations by + // category (pause vs wrapup vs busy) without extra metadata. + private mapToHistoryEventType(action: string, priorState: AgentOzonetelState | undefined): AgentEventType | null { + switch (action) { + case 'login': return 'LOGIN'; + case 'logout': return 'LOGOUT'; + case 'ACW': return 'ACW_START'; + case 'pause': + case 'AUX': + return 'PAUSE'; + case 'release': + case 'IDLE': + if (priorState === 'acw') return 'ACW_END'; + if (priorState === 'break' || priorState === 'training') return 'RESUME'; + return 'READY'; + default: + return null; + } + } + + private mapOzonetelAction(action: string, eventData: string, pauseReason?: string): AgentOzonetelState | null { switch (action) { case 'release': return 'ready'; case 'IDLE': return 'ready'; // agent available after unanswered/canceled call @@ -158,11 +232,15 @@ export class SupervisorService implements OnModuleInit { case 'ACW': return 'acw'; case 'logout': return 'offline'; case 'pause': // Ozonetel sends 'pause' via webhook when agent is paused - case 'AUX': + case 'AUX': { // "changeMode" is the brief AUX during login — not a real pause if (eventData === 'changeMode') return null; - if (eventData?.toLowerCase().includes('training')) return 'training'; + // Check pauseReason first (explicit field), then fall back to eventData + const reason = (pauseReason || eventData || '').toLowerCase(); + this.logger.log(`[AGENT-STATE] Pause reason resolved: "${reason}"`); + if (reason.includes('training')) return 'training'; return 'break'; + } case 'login': return null; // wait for release default: return null; }