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

@@ -7,6 +7,8 @@ import { SupervisorService } from '../supervisor/supervisor.service';
import { AgentHistoryService, AgentEventType } from '../supervisor/agent-history.service';
import { CallerResolutionService } from '../caller/caller-resolution.service';
import { TelephonyConfigService } from '../config/telephony-config.service';
import { AgentLookupService } from '../platform/agent-lookup.service';
import { CdrEnrichmentService } from '../ozonetel/cdr-enrichment.service';
@Controller('api/maint')
@UseGuards(MaintGuard)
@@ -21,6 +23,8 @@ export class MaintController {
private readonly supervisor: SupervisorService,
private readonly callerResolution: CallerResolutionService,
private readonly history: AgentHistoryService,
private readonly agentLookup: AgentLookupService,
private readonly cdrEnrichment: CdrEnrichmentService,
) {}
@Post('force-ready')
@@ -554,4 +558,118 @@ export class MaintController {
}
return events;
}
// Historical enrichment: runs the same CDR-enrichment loop the cron runs,
// but kicks it off immediately and (optionally) widens the date window
// beyond "today + yesterday" up to the CDR API's 15-day limit.
//
// POST /api/maint/enrich-call-agents
// Headers: x-maint-otp: <OTP>
// Body: { days?: number } — default 2 (matches the cron); max 15
@Post('enrich-call-agents')
async enrichCallAgents(@Body() body: { days?: number }) {
const requestedDays = Math.max(1, Math.min(15, body?.days ?? 2));
this.logger.log(`[MAINT] Enrich call agents — days=${requestedDays}`);
// Call the enrichment service once per date, respecting the 2-req/min
// CDR rate limit. Each tick fetches one date's CDR (1 req) so we can
// iterate up to 2 dates per minute — enforce a 35s gap between dates.
const dates = this.recentDatesIst(requestedDays);
let totalScanned = 0;
let totalEnriched = 0;
let totalSkipped = 0;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
try {
const result = await this.enrichSingleDate(date);
totalScanned += result.scanned;
totalEnriched += result.enriched;
totalSkipped += result.skipped;
this.logger.log(`[MAINT] ${date} — scanned=${result.scanned} enriched=${result.enriched} skipped=${result.skipped}`);
} catch (err: any) {
this.logger.warn(`[MAINT] Enrich failed for ${date}: ${err?.message ?? err}`);
}
// Rate limiting: 35s between dates to stay under 2 req/min on CDR.
if (i < dates.length - 1) await new Promise((r) => setTimeout(r, 35_000));
}
this.logger.log(`[MAINT] Enrichment complete: scanned=${totalScanned} enriched=${totalEnriched} skipped=${totalSkipped} across ${dates.length} dates`);
return { status: 'ok', scanned: totalScanned, enriched: totalEnriched, skipped: totalSkipped, dates };
}
private async enrichSingleDate(date: string): Promise<{ scanned: number; enriched: number; skipped: number }> {
// Reuse the cdr-enrichment path via its runOnce method, but scoped.
// For simplicity we reimplement the single-date logic here so we can
// parameterize the date without leaking CDR-enrichment internals.
const cdrRows = await this.ozonetel.fetchCDR({ date });
if (cdrRows.length === 0) return { scanned: 0, enriched: 0, skipped: 0 };
const byUcid = new Map<string, any>();
for (const row of cdrRows) {
const ucid = String(row.UCID ?? '').trim();
if (ucid) byUcid.set(ucid, row);
}
// Fetch calls missing agent link on this date
const gte = `${date}T00:00:00+05:30`;
const lte = `${date}T23:59:59+05:30`;
const calls: Array<any> = [];
let after: string | null = null;
for (let page = 0; page < 30; 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) calls.push(e.node);
const pageInfo = data?.calls?.pageInfo ?? {};
if (!pageInfo.hasNextPage) break;
after = pageInfo.endCursor ?? null;
}
let enriched = 0;
let skipped = 0;
for (const call of calls) {
const cdrRow = byUcid.get(String(call.ucid).trim());
if (!cdrRow) { skipped++; continue; }
const patch: Record<string, any> = {};
if (cdrRow.AgentID && !call.agentId) {
const uuid = await this.agentLookup.resolveByOzonetelId(cdrRow.AgentID);
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, 80));
} catch (err) {
skipped++;
}
}
return { scanned: calls.length, enriched, skipped };
}
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;
}
}