mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
- Deepgram: multichannel=true + language=multi (captures both speakers, multilingual) - LLM speaker identification (agent vs customer from conversational cues) - Removed summarize=v2 (incompatible with multilingual) - SLA computation on call creation (lead.createdAt → call.startedAt elapsed %) - WebSocket: supervisor room + call:created broadcast for real-time updates - Maint: clear-analysis-cache endpoint + scanKeys/deleteCache on SessionService - AI chat: rules-engine context routing with dedicated system prompt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
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';
|
|
|
|
@Controller('api/maint')
|
|
@UseGuards(MaintGuard)
|
|
export class MaintController {
|
|
private readonly logger = new Logger(MaintController.name);
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
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 agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
|
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
|
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '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.config.get<string>('OZONETEL_AGENT_ID') ?? '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<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: {
|
|
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 } };
|
|
}
|
|
}
|