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:
2026-04-15 07:58:06 +05:30
parent 6fd17acf78
commit fb616d47ee

View File

@@ -598,6 +598,104 @@ export class MaintController {
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 }> {
// Reuse the cdr-enrichment path via its runOnce method, but scoped.
// For simplicity we reimplement the single-date logic here so we can