import { Body, Controller, HttpException, Post, UseGuards, Logger } from '@nestjs/common'; import { MaintGuard } from './maint.guard'; import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { SessionService } from '../auth/session.service'; 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) export class MaintController { private readonly logger = new Logger(MaintController.name); constructor( private readonly telephony: TelephonyConfigService, private readonly ozonetel: OzonetelAgentService, private readonly platform: PlatformGraphqlService, private readonly session: SessionService, private readonly supervisor: SupervisorService, private readonly callerResolution: CallerResolutionService, private readonly history: AgentHistoryService, private readonly agentLookup: AgentLookupService, private readonly cdrEnrichment: CdrEnrichmentService, ) {} @Post('force-ready') async forceReady(@Body() body: { agentId: string }) { if (!body?.agentId) throw new HttpException('agentId required', 400); const agentId = body.agentId; const oz = this.telephony.getConfig().ozonetel; const password = oz.agentPassword; if (!password) throw new HttpException('agent password not configured', 400); const sipId = oz.sipId; if (!sipId) throw new HttpException('SIP ID not configured', 400); this.logger.log(`[MAINT] Force ready: agent=${agentId}`); try { await this.ozonetel.logoutAgent({ agentId, password }); const result = await this.ozonetel.loginAgent({ agentId, password, phoneNumber: sipId, mode: 'blended', }); this.logger.log(`[MAINT] Force ready complete: ${JSON.stringify(result)}`); return { status: 'ok', message: `Agent ${agentId} force-readied`, result }; } catch (error: any) { const message = error.response?.data?.message ?? error.message ?? 'Force ready failed'; this.logger.error(`[MAINT] Force ready failed: ${message}`); return { status: 'error', message }; } } @Post('unlock-agent') async unlockAgent(@Body() body: { agentId: string }) { if (!body?.agentId) throw new HttpException('agentId required', 400); const agentId = body.agentId; this.logger.log(`[MAINT] Unlock agent session: ${agentId}`); try { const existing = await this.session.getSession(agentId); if (!existing) { return { status: 'ok', message: `No active session for ${agentId}` }; } await this.session.unlockSession(agentId); // Push force-logout via SSE to all connected browsers for this agent this.supervisor.emitForceLogout(agentId); this.logger.log(`[MAINT] Session unlocked + force-logout pushed for ${agentId} (was held by IP ${existing.ip} since ${existing.lockedAt})`); return { status: 'ok', message: `Session unlocked and force-logout sent for ${agentId}`, previousSession: existing }; } catch (error: any) { this.logger.error(`[MAINT] Unlock failed: ${error.message}`); return { status: 'error', message: error.message }; } } @Post('backfill-missed-calls') async backfillMissedCalls() { this.logger.log('[MAINT] Backfill missed call lead names — starting'); // Fetch all missed calls without a leadId const result = await this.platform.query( `{ calls(first: 200, filter: { callStatus: { eq: MISSED }, leadId: { is: NULL } }) { edges { node { id callerNumber { primaryPhoneNumber } } } } }`, ); const calls = result?.calls?.edges?.map((e: any) => e.node) ?? []; if (calls.length === 0) { this.logger.log('[MAINT] No missed calls without leadId found'); return { status: 'ok', total: 0, patched: 0 }; } this.logger.log(`[MAINT] Found ${calls.length} missed calls without leadId`); let patched = 0; let skipped = 0; for (const call of calls) { const phone = call.callerNumber?.primaryPhoneNumber; if (!phone) { skipped++; continue; } const phoneDigits = phone.replace(/^\+91/, ''); try { const leadResult = await this.platform.query( `{ leads(first: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } } }) { edges { node { id contactName { firstName lastName } } } } }`, ); const lead = leadResult?.leads?.edges?.[0]?.node; if (!lead) { skipped++; continue; } const fn = lead.contactName?.firstName ?? ''; const ln = lead.contactName?.lastName ?? ''; const leadName = `${fn} ${ln}`.trim(); await this.platform.query( `mutation { updateCall(id: "${call.id}", data: { leadId: "${lead.id}"${leadName ? `, leadName: "${leadName}"` : ''} }) { id } }`, ); patched++; this.logger.log(`[MAINT] Patched ${phone} → ${leadName} (${lead.id})`); } catch (err) { this.logger.warn(`[MAINT] Failed to patch ${call.id}: ${err}`); skipped++; } } this.logger.log(`[MAINT] Backfill complete: ${patched} patched, ${skipped} skipped out of ${calls.length}`); return { status: 'ok', total: calls.length, patched, skipped }; } @Post('fix-timestamps') async fixTimestamps() { this.logger.log('[MAINT] Fix call timestamps — subtracting 5:30 IST offset from existing records'); const result = await this.platform.query( `{ calls(first: 200) { edges { node { id startedAt endedAt createdAt } } } }`, ); const calls = result?.calls?.edges?.map((e: any) => e.node) ?? []; if (calls.length === 0) { return { status: 'ok', total: 0, fixed: 0 }; } this.logger.log(`[MAINT] Found ${calls.length} call records to check`); let fixed = 0; let skipped = 0; for (const call of calls) { if (!call.startedAt) { skipped++; continue; } // Skip records that don't need fixing: if startedAt is BEFORE createdAt, // it was already corrected (or is naturally correct) const started = new Date(call.startedAt).getTime(); const created = new Date(call.createdAt).getTime(); if (started <= created) { skipped++; continue; } try { const updates: string[] = []; const startDate = new Date(call.startedAt); startDate.setMinutes(startDate.getMinutes() - 330); updates.push(`startedAt: "${startDate.toISOString()}"`); if (call.endedAt) { const endDate = new Date(call.endedAt); endDate.setMinutes(endDate.getMinutes() - 330); updates.push(`endedAt: "${endDate.toISOString()}"`); } await this.platform.query( `mutation { updateCall(id: "${call.id}", data: { ${updates.join(', ')} }) { id } }`, ); fixed++; // Throttle: 700ms between mutations to stay under 100/min rate limit await new Promise(resolve => setTimeout(resolve, 700)); } catch (err) { this.logger.warn(`[MAINT] Failed to fix ${call.id}: ${err}`); skipped++; } } this.logger.log(`[MAINT] Timestamp fix complete: ${fixed} fixed, ${skipped} skipped out of ${calls.length}`); return { status: 'ok', total: calls.length, fixed, skipped }; } @Post('clear-analysis-cache') async clearAnalysisCache() { this.logger.log('[MAINT] Clearing all recording analysis cache'); const keys = await this.session.scanKeys('call:analysis:*'); let cleared = 0; for (const key of keys) { await this.session.deleteCache(key); cleared++; } this.logger.log(`[MAINT] Cleared ${cleared} analysis cache entries`); return { status: 'ok', cleared }; } @Post('backfill-lead-patient-links') async backfillLeadPatientLinks() { this.logger.log('[MAINT] Backfill lead-patient links — matching by phone number'); // Fetch all leads const leadResult = await this.platform.query( `{ leads(first: 200) { edges { node { id patientId contactPhone { primaryPhoneNumber } contactName { firstName lastName } } } } }`, ); const leads = leadResult?.leads?.edges?.map((e: any) => e.node) ?? []; // Fetch all patients const patientResult = await this.platform.query( `{ patients(first: 200) { edges { node { id phones { primaryPhoneNumber } fullName { firstName lastName } } } } }`, ); const patients = patientResult?.patients?.edges?.map((e: any) => e.node) ?? []; // Build patient phone → id map const patientByPhone = new Map(); for (const p of patients) { const phone = (p.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10); if (phone.length === 10) { patientByPhone.set(phone, { id: p.id, firstName: p.fullName?.firstName ?? '', lastName: p.fullName?.lastName ?? '', }); } } let linked = 0; let created = 0; let skipped = 0; for (const lead of leads) { const phone = (lead.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10); if (!phone || phone.length < 10) { skipped++; continue; } if (lead.patientId) { skipped++; continue; } // already linked const matchedPatient = patientByPhone.get(phone); if (matchedPatient) { // Patient exists — link lead to patient try { await this.platform.query( `mutation { updateLead(id: "${lead.id}", data: { patientId: "${matchedPatient.id}" }) { id } }`, ); linked++; this.logger.log(`[MAINT] Linked lead ${lead.id} → patient ${matchedPatient.id} (${phone})`); } catch (err) { this.logger.warn(`[MAINT] Failed to link lead ${lead.id}: ${err}`); skipped++; } } else { // No patient — create one from lead data try { const firstName = lead.contactName?.firstName ?? 'Unknown'; const lastName = lead.contactName?.lastName ?? ''; const result = await this.platform.query( `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, { data: { name: `${firstName} ${lastName}`.trim(), fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${phone}` }, patientType: 'NEW', }, }, ); const newPatientId = result?.createPatient?.id; if (newPatientId) { await this.platform.query( `mutation { updateLead(id: "${lead.id}", data: { patientId: "${newPatientId}" }) { id } }`, ); patientByPhone.set(phone, { id: newPatientId, firstName, lastName }); created++; this.logger.log(`[MAINT] Created patient ${newPatientId} + linked to lead ${lead.id} (${phone})`); } } catch (err) { this.logger.warn(`[MAINT] Failed to create patient for lead ${lead.id}: ${err}`); skipped++; } } // Throttle await new Promise(resolve => setTimeout(resolve, 500)); } // Now backfill appointments — link to patient via lead const apptResult = await this.platform.query( `{ appointments(first: 200) { edges { node { id patientId createdAt } } } }`, ); const appointments = apptResult?.appointments?.edges?.map((e: any) => e.node) ?? []; let apptLinked = 0; // For appointments without patientId, find the lead that was active around that time // and use its patientId. This is best-effort. for (const appt of appointments) { if (appt.patientId) continue; // Find the most recent lead that has a patientId (best-effort match) // In practice, for the current data set this is sufficient // A proper fix would store leadId on the appointment skipped++; } this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`); return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } }; } // Backfill Call records that lost their identity at ingest (missed-call // webhook / poller / dispose flow before the caller-resolution wiring). // Routes each phone through CallerResolutionService so the same code // path the live system uses also fixes historical rows. Idempotent — // safe to re-run; only patches calls that are currently missing // leadName / patientId / leadId. @Post('backfill-caller-resolution') async backfillCallerResolution() { this.logger.log('[MAINT] Backfill caller resolution — patching Calls + Leads via resolver'); const apiKey = process.env.PLATFORM_API_KEY ?? ''; const auth = apiKey ? `Bearer ${apiKey}` : ''; if (!auth) throw new HttpException('PLATFORM_API_KEY not configured', 500); let callsScanned = 0; let callsPatched = 0; let callsSkipped = 0; let leadsResolved = 0; let resolveErrors = 0; // Phone → resolved cache so multiple calls from the same number // only resolve once during this run. const resolvedByPhone = new Map(); // Page through all calls in chunks of 200. We're after rows where // leadName is empty OR leadId is null OR patientId is missing. let cursor: string | null = null; let hasNext = true; while (hasNext) { const pageQuery = cursor ? `{ calls(first: 200, after: "${cursor}") { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }` : `{ calls(first: 200) { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`; let page: any; try { page = await this.platform.query(pageQuery); } catch (err) { this.logger.warn(`[MAINT] calls page query failed: ${err}`); break; } const edges = page?.calls?.edges ?? []; hasNext = page?.calls?.pageInfo?.hasNextPage ?? false; cursor = page?.calls?.pageInfo?.endCursor ?? null; for (const edge of edges) { const call = edge.node; callsScanned++; const phoneRaw = call.callerNumber?.primaryPhoneNumber ?? ''; const phone10 = phoneRaw.replace(/\D/g, '').slice(-10); const needsName = !call.leadName || call.leadName === ''; const needsLead = !call.leadId; if (!phone10 || phone10.length < 10) { callsSkipped++; continue; } if (!needsName && !needsLead) { callsSkipped++; continue; } let resolved = resolvedByPhone.get(phone10) ?? null; if (!resolved) { try { const r = await this.callerResolution.resolve(phone10, auth); resolved = { leadId: r.leadId, patientId: r.patientId, firstName: r.firstName, lastName: r.lastName, }; resolvedByPhone.set(phone10, resolved); leadsResolved++; } catch (err) { this.logger.warn(`[MAINT] resolve failed for ${phone10}: ${err}`); resolveErrors++; callsSkipped++; continue; } } const fullName = `${resolved.firstName} ${resolved.lastName}`.trim(); const updateParts: string[] = []; if (needsLead && resolved.leadId) updateParts.push(`leadId: "${resolved.leadId}"`); if (needsName && fullName) updateParts.push(`leadName: "${fullName.replace(/"/g, '\\"')}"`); if (updateParts.length === 0) { callsSkipped++; continue; } try { await this.platform.query( `mutation { updateCall(id: "${call.id}", data: { ${updateParts.join(', ')} }) { id } }`, ); callsPatched++; } catch (err) { this.logger.warn(`[MAINT] updateCall failed for ${call.id}: ${err}`); callsSkipped++; } // Throttle so the platform isn't hammered await new Promise((r) => setTimeout(r, 100)); } } this.logger.log(`[MAINT] Backfill caller resolution complete: scanned=${callsScanned} patched=${callsPatched} skipped=${callsSkipped} uniquePhones=${resolvedByPhone.size} leadsResolved=${leadsResolved} resolveErrors=${resolveErrors}`); return { status: 'ok', calls: { scanned: callsScanned, patched: callsPatched, skipped: callsSkipped }, phones: { unique: resolvedByPhone.size, resolved: leadsResolved, errors: resolveErrors }, }; } // Recompute durationS on existing AgentEvent rows using the per-category // pairing logic. Fixes rows written before the slot-split fix where // ACW_START clobbered CALL_START's pending entry. Also re-runs the // session rollup for each affected date. Idempotent — only updates rows // whose stored durationS differs from the recomputed value. // // POST /api/maint/backfill-agent-event-durations // body: { date?: "YYYY-MM-DD" | "all" } — default today IST @Post('backfill-agent-event-durations') async backfillAgentEventDurations(@Body() body: { date?: string }) { const target = body?.date ?? this.todayIst(); this.logger.log(`[MAINT] Backfill AgentEvent durations — target=${target}`); // Pull events for the range. If "all", no filter; otherwise scope to the IST day. let events = await this.fetchAgentEventsForBackfill(target); if (events.length === 0) { return { status: 'ok', scanned: 0, patched: 0, skipped: 0, dates: [] }; } this.logger.log(`[MAINT] Fetched ${events.length} AgentEvent rows`); // Group by agent, sort by eventAt ascending. const byAgent = new Map(); for (const e of events) { const k = e.agentId; if (!k) continue; if (!byAgent.has(k)) byAgent.set(k, []); byAgent.get(k)!.push(e); } for (const list of byAgent.values()) { list.sort((a, b) => new Date(a.eventAt).getTime() - new Date(b.eventAt).getTime()); } // Per-category slot pairing, same logic as the live ingest. const slotForStart = (t: AgentEventType): 'pause' | 'call' | 'acw' | null => t === 'PAUSE' ? 'pause' : t === 'CALL_START' ? 'call' : t === 'ACW_START' ? 'acw' : null; const slotForEnd = (t: AgentEventType): 'pause' | 'call' | 'acw' | null => t === 'RESUME' ? 'pause' : t === 'CALL_END' ? 'call' : t === 'ACW_END' ? 'acw' : null; let patched = 0; let skipped = 0; const affectedDates = new Set(); for (const [agentId, agentEvents] of byAgent) { const pending: { pause?: number; call?: number; acw?: number } = {}; for (const e of agentEvents) { const eventMs = new Date(e.eventAt).getTime(); const endSlot = slotForEnd(e.eventType); const startSlot = slotForStart(e.eventType); let computed: number | null = null; if (endSlot) { const at = pending[endSlot]; if (at !== undefined) { computed = Math.max(0, Math.round((eventMs - at) / 1000)); delete pending[endSlot]; } } else if (startSlot) { pending[startSlot] = eventMs; } else if (e.eventType === 'READY' || e.eventType === 'LOGOUT') { delete pending.pause; delete pending.call; delete pending.acw; } // Only patch END events that now have a computed duration // different from what's stored. if (endSlot && computed !== null && computed !== (e.durationS ?? null)) { try { await this.platform.query( `mutation { updateAgentEvent(id: "${e.id}", data: { durationS: ${computed} }) { id } }`, ); patched++; const datePart = (e.eventAt ?? '').slice(0, 10); if (datePart) affectedDates.add(datePart); this.logger.log(`[MAINT] Patched AgentEvent ${e.id} ${e.eventType} agent=${agentId} ${e.durationS ?? 'null'}s → ${computed}s`); await new Promise((r) => setTimeout(r, 80)); } catch (err) { this.logger.warn(`[MAINT] Patch failed for ${e.id}: ${err}`); skipped++; } } else { skipped++; } } } // Re-run rollup for each affected date so AgentSession numbers update. const dates = Array.from(affectedDates); for (const d of dates) { try { await this.history.rollupSessions(d); this.logger.log(`[MAINT] Rollup re-run for ${d}`); } catch (err) { this.logger.warn(`[MAINT] Rollup failed for ${d}: ${err}`); } } this.logger.log(`[MAINT] Backfill AgentEvent durations complete: scanned=${events.length} patched=${patched} skipped=${skipped} dates=${dates.join(',')}`); return { status: 'ok', scanned: events.length, patched, skipped, dates }; } private todayIst(): string { const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000); return ist.toISOString().slice(0, 10); } private async fetchAgentEventsForBackfill(date: string): Promise> { const events: Array<{ id: string; eventType: AgentEventType; eventAt: string; durationS: number | null; agentId: string }> = []; let after: string | null = null; const rangeFilter = date === 'all' ? '' : `, filter: { eventAt: { gte: "${date}T00:00:00+05:30", lte: "${date}T23:59:59+05:30" } }`; for (let page = 0; page < 50; page++) { const cursorArg: string = after ? `, after: "${after}"` : ''; const data: any = await this.platform.query( `{ agentEvents(first: 200${cursorArg}${rangeFilter}, orderBy: [{ eventAt: AscNullsLast }]) { edges { node { id eventType eventAt durationS agentId } } pageInfo { hasNextPage endCursor } } }`, ); const edges = data?.agentEvents?.edges ?? []; for (const e of edges) events.push(e.node); const pageInfo: { hasNextPage?: boolean; endCursor?: string } = data?.agentEvents?.pageInfo ?? {}; if (!pageInfo.hasNextPage) break; after = pageInfo.endCursor ?? null; } 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: // 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 }; } // 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 name → UUID maps for this pass. Three indexes: // - ozonetelAgentId (e.g. "globalhealthx") — matches outbound dispose rows // - ozonetelDisplayName (e.g. "Ganesh Bandi") — matches inbound webhook rows // - platform Agent.name (e.g. "Ganesh Iyer") — last-resort fallback const agentData = await this.platform.query( `{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`, ); const agentUuidByName = new Map(); const agentUuidByOzonetelId = new Map(); const agentUuidByDisplayName = new Map(); 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); if (a.ozonetelDisplayName) agentUuidByDisplayName.set(a.ozonetelDisplayName.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 (outbound rows store // agentName=agentId); fall back to ozonetelDisplayName // (inbound webhook rows store the Ozonetel display string); // last-resort match on platform Agent.name. const key = last.toLowerCase(); const uuid = agentUuidByOzonetelId.get(key) ?? agentUuidByDisplayName.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 // 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(); 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 = []; 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( `{ 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 = {}; 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( `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; } // Infer clinicId on historical Appointments that were written before // the clinicId-persistence fix went live. Lookup path: // Appointment.doctorId + Appointment.scheduledAt.dayOfWeek // → DoctorVisitSlot rows for that doctor on that weekday // → if single clinic → use it // → if multiple clinics → match by time-of-day window (slot covers scheduledAt time) // → if still ambiguous → match by department, else skip // // POST /api/maint/backfill-appointment-clinics // Headers: x-maint-otp: @Post('backfill-appointment-clinics') async backfillAppointmentClinics() { this.logger.log('[MAINT] Backfill Appointment.clinicId — inferring from doctorVisitSlots'); // 1. Pull all appointments missing clinicId const appointments: Array<{ id: string; doctorId: string | null; scheduledAt: string | null; department: string | null }> = []; let after: string | null = null; for (let page = 0; page < 50; page++) { const cursor: string = after ? `, after: "${after}"` : ''; const data: any = await this.platform.query( `{ appointments(first: 200${cursor}, filter: { clinicId: { is: NULL } }) { edges { node { id doctorId scheduledAt department } } pageInfo { hasNextPage endCursor } } }`, ).catch(() => ({ appointments: { edges: [], pageInfo: {} } })); const edges = data?.appointments?.edges ?? []; for (const e of edges) appointments.push(e.node); const info = data?.appointments?.pageInfo ?? {}; if (!info.hasNextPage) break; after = info.endCursor ?? null; } this.logger.log(`[MAINT] Found ${appointments.length} appointments missing clinicId`); if (appointments.length === 0) { return { status: 'ok', scanned: 0, patched: 0, skipped: 0 }; } // 2. For each unique doctorId, pre-load visit slots (7 weekdays × clinic rows). const uniqueDoctorIds = [...new Set(appointments.map((a) => a.doctorId).filter(Boolean) as string[])]; const slotsByDoctor = new Map>(); for (const docId of uniqueDoctorIds) { try { const data: any = await this.platform.query( `{ doctorVisitSlots(first: 50, filter: { doctorId: { eq: "${docId}" } }) { edges { node { dayOfWeek startTime endTime clinic { id clinicName } } } } }`, ); const rows = (data?.doctorVisitSlots?.edges ?? []).map((e: any) => ({ dayOfWeek: e.node.dayOfWeek, startTime: e.node.startTime, endTime: e.node.endTime, clinicId: e.node.clinic?.id, clinicName: e.node.clinic?.clinicName ?? '', })).filter((r: any) => r.clinicId); slotsByDoctor.set(docId, rows); } catch { slotsByDoctor.set(docId, []); } await new Promise((r) => setTimeout(r, 50)); } // 3. Walk each appointment, infer the clinic, patch. let patched = 0; let skipped = 0; const skippedReasons: Record = { noDoctor: 0, noScheduledAt: 0, noSlots: 0, ambiguous: 0 }; for (const appt of appointments) { if (!appt.doctorId) { skipped++; skippedReasons.noDoctor++; continue; } if (!appt.scheduledAt) { skipped++; skippedReasons.noScheduledAt++; continue; } const slots = slotsByDoctor.get(appt.doctorId) ?? []; if (slots.length === 0) { skipped++; skippedReasons.noSlots++; continue; } // Appointment time in IST const ist = new Date(new Date(appt.scheduledAt).getTime() + 5.5 * 60 * 60 * 1000); const dayOfWeek = ist.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' }).toUpperCase(); const apptMinutes = ist.getUTCHours() * 60 + ist.getUTCMinutes(); // Match slots for same weekday where the appointment time falls within the window const toMin = (hhmm: string): number => { const [h, m] = hhmm.split(':').map(Number); return h * 60 + (m ?? 0); }; let candidates = slots.filter((s) => s.dayOfWeek === dayOfWeek); if (candidates.length > 0) { const inWindow = candidates.filter((s) => { const start = toMin(s.startTime ?? '00:00'); const end = toMin(s.endTime ?? '23:59'); return apptMinutes >= start && apptMinutes < end; }); if (inWindow.length > 0) candidates = inWindow; } // Distinct clinics among candidates const distinctClinics = [...new Set(candidates.map((c) => c.clinicId))]; let clinicId: string | null = null; if (distinctClinics.length === 1) { clinicId = distinctClinics[0]; } else if (distinctClinics.length > 1) { // Ambiguous — doctor visits multiple clinics in this window. // Pick deterministically by clinic id lex-order so re-runs land // on the same choice. Log the ambiguity so QA can review. clinicId = [...distinctClinics].sort()[0]; this.logger.debug(`[MAINT] Ambiguous clinic for appt=${appt.id} — doctor=${appt.doctorId} day=${dayOfWeek} candidates=${distinctClinics.join(',')} picked=${clinicId}`); } // Last resort: any clinic for that doctor (pick first) if (!clinicId && slots.length > 0) clinicId = slots[0].clinicId; if (!clinicId) { skipped++; skippedReasons.ambiguous++; continue; } try { await this.platform.query( `mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, { id: appt.id, data: { clinicId } }, ); patched++; await new Promise((r) => setTimeout(r, 40)); } catch (err: any) { this.logger.warn(`[MAINT] updateAppointment(${appt.id}) failed: ${err?.message ?? err}`); skipped++; } } this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`); return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons }; } }