mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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:
146
src/ozonetel/cdr-enrichment.service.ts
Normal file
146
src/ozonetel/cdr-enrichment.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user