Files
helix-engage-server/src/maint/maint.controller.ts
saridsa2 96977e84a1
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat(maint): backfill-appointment-clinics endpoint
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>
2026-04-15 12:01:08 +05:30

910 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}
}