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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user