diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 465f55d..e2cb8b9 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -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: + // 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( + `{ agents(first: 100) { edges { node { id name ozonetelAgentId } } } }`, + ); + const agentUuidByName = new Map(); // lowercase name → UUID + const agentUuidByOzonetelId = new Map(); // 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(); + + // 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( + `{ 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 = { agentId: uuid }; + if (segments.length > 1) patchData.transferredTo = call.agentName; + + try { + await this.platform.query( + `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