import { Controller, 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 { CallerResolutionService } from '../caller/caller-resolution.service'; import { TelephonyConfigService } from '../config/telephony-config.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, ) {} @Post('force-ready') async forceReady() { const oz = this.telephony.getConfig().ozonetel; const agentId = oz.agentId || 'agent3'; const password = oz.agentPassword || 'Test123$'; const sipId = oz.sipId || '521814'; 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() { const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3'; 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: { 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 } }; } }