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,54 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PlatformGraphqlService } from './platform-graphql.service';
/**
* Maps Ozonetel agent identifiers (unique — e.g. "ramaiahadmin",
* "globalhealthx", "global") to the platform Agent entity UUID. Used by
* ingest paths (webhook, dispose, CDR enrichment, backfill) so every Call
* ends up with the correct `agent` relation regardless of how Ozonetel
* formats the display name (AgentName collisions, transfer chains like
* "A -> B -> C", etc.).
*
* The cache is case-insensitive because Ozonetel occasionally mixes
* casing ("global" vs "Global" vs "GLOBAL") across webhook/CDR responses.
*/
@Injectable()
export class AgentLookupService implements OnModuleInit {
private readonly logger = new Logger(AgentLookupService.name);
private readonly uuidByOzonetelId = new Map<string, string>();
constructor(private readonly platform: PlatformGraphqlService) {}
async onModuleInit() {
await this.refresh();
}
async refresh(): Promise<void> {
try {
const data = await this.platform.query<any>(
`{ agents(first: 100) { edges { node { id ozonetelAgentId } } } }`,
);
const edges = data?.agents?.edges ?? [];
this.uuidByOzonetelId.clear();
for (const edge of edges) {
const n = edge.node;
if (n.ozonetelAgentId) {
this.uuidByOzonetelId.set(n.ozonetelAgentId.toLowerCase(), n.id);
}
}
this.logger.log(`[AGENT-LOOKUP] Loaded ${this.uuidByOzonetelId.size} agents`);
} catch (err) {
this.logger.warn(`[AGENT-LOOKUP] Refresh failed: ${err}`);
}
}
async resolveByOzonetelId(ozonetelId: string | null | undefined): Promise<string | null> {
if (!ozonetelId) return null;
const key = ozonetelId.toLowerCase();
const cached = this.uuidByOzonetelId.get(key);
if (cached) return cached;
// Cache miss — refresh once (handles late-provisioned agents)
await this.refresh();
return this.uuidByOzonetelId.get(key) ?? null;
}
}