Files
helix-engage-server/src/maint/maint.controller.ts
saridsa2 619e9ab405 feat(onboarding/phase-1): admin-editable telephony, ai, and setup-state config
Phase 1 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md).

Backend foundations to support the upcoming staff-portal Settings hub and
6-step setup wizard. No frontend in this phase.

New config services (mirroring ThemeService / WidgetConfigService):
- SetupStateService    — tracks completion of 6 wizard steps; isWizardRequired()
                         drives the post-login redirect
- TelephonyConfigService — Ozonetel + Exotel + SIP, replaces 8 env vars,
                           seeds from env on first boot, masks secrets on GET,
                           '***masked***' sentinel on PUT means "keep existing"
- AiConfigService      — provider, model, temperature, system prompt addendum;
                         API keys remain in env

New endpoints under /api/config:
- GET  /api/config/setup-state                returns state + wizardRequired flag
- PUT  /api/config/setup-state/steps/:step    mark step complete/incomplete
- POST /api/config/setup-state/dismiss        dismiss wizard
- POST /api/config/setup-state/reset
- GET  /api/config/telephony                  masked
- PUT  /api/config/telephony
- POST /api/config/telephony/reset
- GET  /api/config/ai
- PUT  /api/config/ai
- POST /api/config/ai/reset

ConfigThemeModule is now @Global() so the new sidecar config services are
injectable from AuthModule, OzonetelAgentModule, MaintModule without creating
a circular dependency (ConfigThemeModule already imports AuthModule for
SessionService).

Migrated 11 env-var read sites to use the new services:
- ozonetel-agent.service: exotel API + ozonetel did/sipId via read-through getters
- ozonetel-agent.controller: defaultAgentId/Password/SipId via getters
- kookoo-ivr.controller: sipId/callerId via getters
- auth.controller: OZONETEL_AGENT_PASSWORD (login + logout)
- agent-config.service: sipDomain/wsPort/campaignName via getters
- maint.controller: forceReady + unlockAgent
- ai-provider: createAiModel and isAiConfigured refactored to pure factories
  taking AiProviderOpts; no more ConfigService dependency
- widget-chat.service, recordings.service, ai-enrichment.service,
  ai-chat.controller, ai-insight.consumer, call-assist.service: each builds
  the AI model from AiConfigService.getConfig() + ConfigService API keys

Hot-reload guarantee: every consumer reads via a getter or builds per-call,
so admin updates take effect without sidecar restart. WidgetChatService
specifically rebuilds the model on each streamReply().

Bug fix bundled: dropped widget.json.hospitalName field (the original
duplicate that started this whole thread). WidgetConfigService now reads
brand.hospitalName from ThemeService at the 2 generateKey call sites.
Single source of truth for hospital name is workspace branding.

First-boot env seeding: TelephonyConfigService and AiConfigService both
copy their respective env vars into a fresh data/*.json on onModuleInit if
the file doesn't exist. Existing deployments auto-migrate without manual
intervention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:02:07 +05:30

317 lines
13 KiB
TypeScript

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<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 } };
}
}