feat(calls): consolidate agent identity via Ozonetel CDR

Ozonetel's webhook AgentName is a transfer-chain display string — same
display can collide (two agents both named "GlobalHealthX" with distinct
agent IDs), and chained like "RamaiahAdmin -> Ganesh Bandi -> GlobalHealthX".
Team Performance was bucketing every unique raw string as a separate
"agent", producing 7 rows for 3 real agents.

Fix — authoritative agent link via CDR AgentID (unique):

- New AgentLookupService (platform module): case-insensitive
  ozonetelAgentId → Agent UUID cache, shared across webhook / dispose /
  enrichment / backfill paths
- Webhook + outbound-dispose now persist UCID on Call so CDR can join
- Outbound dispose resolves agent relation at create time and overwrites
  from CDR AgentID post-hoc (catches dial transfers)
- New CdrEnrichmentService: every 30 min fetches today + yesterday CDR,
  patches Calls missing agentId / transferredTo / transferType by UCID
  join. Well under Ozonetel's 2 req/min cap.
- Historical backfill maint endpoint: /api/maint/enrich-call-agents
  with configurable day window (default 2, max 15). Rate-limited at 35s
  between dates.

Call schema additions (synced on Global + Ramaiah): agent relation,
ucid, transferredTo, transferType. agentName remains for legacy/display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 07:43:28 +05:30
parent 9472f83cd8
commit 846c5f4c9b
7 changed files with 349 additions and 4 deletions

View File

@@ -0,0 +1,146 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { OzonetelAgentService } from './ozonetel-agent.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { AgentLookupService } from '../platform/agent-lookup.service';
/**
* Periodically pulls Ozonetel CDR (per-row, includes unique AgentID) and
* enriches Call records that were created from the missed-call webhook
* or outbound dispose without the authoritative agent relation.
*
* Runs every 30 minutes — well under Ozonetel's 2-req/min cap on the CDR
* endpoints (one fetch per workspace per tick = 2/hour).
*
* Pairs Call rows to CDR rows by `ucid`. Only patches Calls that are
* missing `agentId` / `transferredTo` / `transferType` — idempotent.
*/
const ENRICHMENT_INTERVAL_MS = 30 * 60 * 1000;
const ENRICHMENT_DATE_WINDOW_DAYS = 2; // today + yesterday in case late-arriving calls straddle IST midnight
@Injectable()
export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CdrEnrichmentService.name);
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly ozonetel: OzonetelAgentService,
private readonly platform: PlatformGraphqlService,
private readonly agentLookup: AgentLookupService,
) {}
async onModuleInit() {
// Kick off after 60s so the sidecar isn't hammering platform during boot,
// then settle into the 30-min cadence.
setTimeout(() => {
this.runOnce().catch((err) => {
this.logger.warn(`[CDR-ENRICH] First run failed: ${err?.message ?? err}`);
});
}, 60_000);
this.timer = setInterval(() => {
this.runOnce().catch((err) => {
this.logger.warn(`[CDR-ENRICH] Tick failed: ${err?.message ?? err}`);
});
}, ENRICHMENT_INTERVAL_MS);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async runOnce(): Promise<{ scanned: number; enriched: number; skipped: number }> {
let scanned = 0;
let enriched = 0;
let skipped = 0;
// Walk the IST-date window. For each date, pull CDR + patch Calls.
const dates = this.recentDatesIst(ENRICHMENT_DATE_WINDOW_DAYS);
for (const date of dates) {
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
if (cdrRows.length === 0) continue;
// Build UCID → cdr-row map so we can O(1) join per Call.
const byUcid = new Map<string, any>();
for (const row of cdrRows) {
const ucid = String(row.UCID ?? '').trim();
if (ucid) byUcid.set(ucid, row);
}
if (byUcid.size === 0) continue;
// Pull Calls in the same date window that are missing agent linkage
// (i.e. ucid set, agentId null). Patch each.
const calls = await this.fetchCallsMissingAgent(date);
scanned += calls.length;
for (const call of calls) {
const cdrRow = byUcid.get(String(call.ucid).trim());
if (!cdrRow) { skipped++; continue; }
const patch: Record<string, any> = {};
const cdrAgentId = cdrRow.AgentID;
if (cdrAgentId && !call.agentId) {
const uuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
if (uuid) patch.agentId = uuid;
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
}
if (cdrRow.TransferredTo && !call.transferredTo) patch.transferredTo = cdrRow.TransferredTo;
if (cdrRow.TransferType && !call.transferType) patch.transferType = cdrRow.TransferType;
if (Object.keys(patch).length === 0) { skipped++; continue; }
try {
await this.platform.query<any>(
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
{ id: call.id, data: patch },
);
enriched++;
await new Promise((r) => setTimeout(r, 50));
} catch (err: any) {
this.logger.warn(`[CDR-ENRICH] Patch failed for ${call.id}: ${err?.message ?? err}`);
skipped++;
}
}
}
if (scanned > 0 || enriched > 0) {
this.logger.log(`[CDR-ENRICH] Pass complete — dates=[${dates.join(',')}] scanned=${scanned} enriched=${enriched} skipped=${skipped}`);
}
return { scanned, enriched, skipped };
}
private async fetchCallsMissingAgent(date: string): Promise<Array<{ id: string; ucid: string | null; agentId: string | null; transferredTo: string | null; transferType: string | null }>> {
// Bound by IST day. CDR window is 15 days; we only ever need recent.
const gte = `${date}T00:00:00+05:30`;
const lte = `${date}T23:59:59+05:30`;
const results: Array<any> = [];
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<any>(
`{ calls(first: 200${cursorArg}, filter: {
startedAt: { gte: "${gte}", lte: "${lte}" },
ucid: { is: NOT_NULL },
agentId: { is: NULL }
}) {
edges { node { id ucid agentId transferredTo transferType } }
pageInfo { hasNextPage endCursor }
} }`,
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
const edges = data?.calls?.edges ?? [];
for (const e of edges) results.push(e.node);
const pageInfo = data?.calls?.pageInfo ?? {};
if (!pageInfo.hasNextPage) break;
after = pageInfo.endCursor ?? null;
}
return results;
}
private recentDatesIst(n: number): string[] {
const dates: string[] = [];
for (let i = 0; i < n; i++) {
const d = new Date(Date.now() + 5.5 * 60 * 60 * 1000 - i * 24 * 60 * 60 * 1000);
dates.push(d.toISOString().slice(0, 10));
}
return dates;
}
}

View File

@@ -2,6 +2,7 @@ import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nest
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';
@@ -28,6 +29,7 @@ export class OzonetelAgentController {
private readonly platform: PlatformGraphqlService,
private readonly eventBus: EventBusService,
private readonly supervisor: SupervisorService,
private readonly agentLookup: AgentLookupService,
) {}
private requireAgentId(agentId: string | undefined | null): string {
@@ -174,6 +176,14 @@ export class OzonetelAgentController {
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;
@@ -210,6 +220,17 @@ export class OzonetelAgentController {
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 } }`,

View File

@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { OzonetelAgentController } from './ozonetel-agent.controller';
import { OzonetelAgentService } from './ozonetel-agent.service';
import { KookooIvrController } from './kookoo-ivr.controller';
import { CdrEnrichmentService } from './cdr-enrichment.service';
import { WorklistModule } from '../worklist/worklist.module';
import { PlatformModule } from '../platform/platform.module';
import { SupervisorModule } from '../supervisor/supervisor.module';
@@ -9,7 +10,7 @@ import { SupervisorModule } from '../supervisor/supervisor.module';
@Module({
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
controllers: [OzonetelAgentController, KookooIvrController],
providers: [OzonetelAgentService],
exports: [OzonetelAgentService],
providers: [OzonetelAgentService, CdrEnrichmentService],
exports: [OzonetelAgentService, CdrEnrichmentService],
})
export class OzonetelAgentModule {}