mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat(maint): backfill-call-agents-by-name for historical Calls
Historical Calls pre-date UCID persistence, so the CDR-join enrichment can't reach them. Fallback: parse agentName (may be "A -> B -> C" transfer chain), take the final hop, resolve to Agent by ozonetelAgentId (case-insensitive) or by Full Name. Preserve the full chain string in transferredTo when it was actually chained. No rate limit — pure platform queries, no CDR. POST /api/maint/backfill-call-agents-by-name Header: x-maint-otp: <OTP> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -598,6 +598,104 @@ export class MaintController {
|
|||||||
return { status: 'ok', scanned: totalScanned, enriched: totalEnriched, skipped: totalSkipped, dates };
|
return { status: 'ok', scanned: totalScanned, enriched: totalEnriched, skipped: totalSkipped, dates };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback backfill for historical Calls that pre-date UCID persistence.
|
||||||
|
// Can't join to CDR without UCID, so parse the agentName string (which
|
||||||
|
// may be a transfer chain "A -> B -> C"), take the final segment, and
|
||||||
|
// resolve to an Agent entity by name or ozonetelAgentId (case-insensitive).
|
||||||
|
//
|
||||||
|
// POST /api/maint/backfill-call-agents-by-name
|
||||||
|
// Headers: x-maint-otp: <OTP>
|
||||||
|
// Body: {}
|
||||||
|
@Post('backfill-call-agents-by-name')
|
||||||
|
async backfillCallAgentsByName() {
|
||||||
|
this.logger.log('[MAINT] Backfill call agents by name — matching agentName last-segment to Agent entity');
|
||||||
|
|
||||||
|
// Pull all active agents — cheap, cached at service level but we
|
||||||
|
// also need a name→UUID map for this pass.
|
||||||
|
const agentData = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name ozonetelAgentId } } } }`,
|
||||||
|
);
|
||||||
|
const agentUuidByName = new Map<string, string>(); // lowercase name → UUID
|
||||||
|
const agentUuidByOzonetelId = new Map<string, string>(); // lowercase ozonetelAgentId → UUID
|
||||||
|
for (const edge of agentData?.agents?.edges ?? []) {
|
||||||
|
const a = edge.node;
|
||||||
|
if (a.name) agentUuidByName.set(a.name.toLowerCase().trim(), a.id);
|
||||||
|
if (a.ozonetelAgentId) agentUuidByOzonetelId.set(a.ozonetelAgentId.toLowerCase().trim(), a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scanned = 0;
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let unmatched = 0;
|
||||||
|
const unmatchedSamples = new Set<string>();
|
||||||
|
|
||||||
|
// Paginate through all Calls with agentId=null and agentName set.
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 50; page++) {
|
||||||
|
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200${cursorArg}, filter: {
|
||||||
|
agentId: { is: NULL },
|
||||||
|
agentName: { is: NOT_NULL }
|
||||||
|
}) {
|
||||||
|
edges { node { id agentName } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
|
||||||
|
const edges = data?.calls?.edges ?? [];
|
||||||
|
scanned += edges.length;
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const call = edge.node;
|
||||||
|
if (!call.agentName || call.agentName.trim() === '') { skipped++; continue; }
|
||||||
|
|
||||||
|
// Take the final hop of the transfer chain, trimmed.
|
||||||
|
const segments = call.agentName.split('->').map((s: string) => s.trim()).filter(Boolean);
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (!last) { skipped++; continue; }
|
||||||
|
|
||||||
|
// Prefer ozonetelAgentId match; fall back to display name.
|
||||||
|
const key = last.toLowerCase();
|
||||||
|
const uuid = agentUuidByOzonetelId.get(key) ?? agentUuidByName.get(key);
|
||||||
|
if (!uuid) {
|
||||||
|
unmatched++;
|
||||||
|
if (unmatchedSamples.size < 10) unmatchedSamples.add(last);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the raw chain on transferredTo if it was actually chained,
|
||||||
|
// so the audit trail is preserved even without CDR data.
|
||||||
|
const patchData: Record<string, any> = { agentId: uuid };
|
||||||
|
if (segments.length > 1) patchData.transferredTo = call.agentName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: call.id, data: patchData },
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
} catch (err) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = data?.calls?.pageInfo ?? {};
|
||||||
|
if (!pageInfo.hasNextPage) break;
|
||||||
|
after = pageInfo.endCursor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill by name complete: scanned=${scanned} patched=${patched} unmatched=${unmatched} skipped=${skipped}`);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
scanned,
|
||||||
|
patched,
|
||||||
|
unmatched,
|
||||||
|
skipped,
|
||||||
|
unmatchedSamples: Array.from(unmatchedSamples),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async enrichSingleDate(date: string): Promise<{ scanned: number; enriched: number; skipped: number }> {
|
private async enrichSingleDate(date: string): Promise<{ scanned: number; enriched: number; skipped: number }> {
|
||||||
// Reuse the cdr-enrichment path via its runOnce method, but scoped.
|
// Reuse the cdr-enrichment path via its runOnce method, but scoped.
|
||||||
// For simplicity we reimplement the single-date logic here so we can
|
// For simplicity we reimplement the single-date logic here so we can
|
||||||
|
|||||||
Reference in New Issue
Block a user