mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Populates clinicId on historical Appointments that predate clinicId persistence. Infers from DoctorVisitSlot records: 1. Pull all appointments with clinicId=NULL 2. Pre-load visit slots per unique doctorId (batched) 3. For each appointment: match slots by weekday + time-window 4. Single clinic → use it 5. Multiple candidates → pick lex-order first (deterministic; logged) 6. Last resort → any slot's clinic No CDR or rate-limited calls — pure platform queries. Safe to re-run; idempotent (only patches rows still missing clinicId). POST /api/maint/backfill-appointment-clinics Header: x-maint-otp: <OTP> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
910 lines
43 KiB
TypeScript
910 lines
43 KiB
TypeScript
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<any>(
|
||
`{ 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<any>(
|
||
`{ 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<any>(
|
||
`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<any>(
|
||
`{ 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<any>(
|
||
`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<any>(
|
||
`{ 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<any>(
|
||
`{ 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<string, { id: string; firstName: string; lastName: string }>();
|
||
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<any>(
|
||
`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<any>(
|
||
`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<any>(
|
||
`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<any>(
|
||
`{ 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<string, { leadId: string; patientId: string; firstName: string; lastName: string }>();
|
||
|
||
// 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<any>(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<any>(
|
||
`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<string, typeof events>();
|
||
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<string>();
|
||
|
||
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<any>(
|
||
`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<Array<{ id: string; eventType: AgentEventType; eventAt: string; durationS: number | null; agentId: string }>> {
|
||
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<any>(
|
||
`{ 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: <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: <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<any>(
|
||
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||
);
|
||
const agentUuidByName = new Map<string, string>();
|
||
const agentUuidByOzonetelId = new Map<string, string>();
|
||
const agentUuidByDisplayName = new Map<string, string>();
|
||
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<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 (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<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
|
||
// 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<string, any>();
|
||
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<any> = [];
|
||
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<any>(
|
||
`{ 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<string, any> = {};
|
||
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<any>(
|
||
`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: <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<any>(
|
||
`{ 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<string, Array<{ dayOfWeek: string; startTime: string; endTime: string; clinicId: string; clinicName: string }>>();
|
||
for (const docId of uniqueDoctorIds) {
|
||
try {
|
||
const data: any = await this.platform.query<any>(
|
||
`{ 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<string, number> = { 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<any>(
|
||
`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 };
|
||
}
|
||
}
|