mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
5 Commits
master
...
3bb4315925
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb4315925 | |||
| 7402590969 | |||
| 3f22166ac0 | |||
| 8c8b1e78b0 | |||
| 77b3e917db |
@@ -773,8 +773,8 @@ export class AiChatController {
|
||||
undefined, auth,
|
||||
);
|
||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||
if (packages.length) {
|
||||
sections.push('\n## Health Packages');
|
||||
if (packages.length) {
|
||||
for (const p of packages) {
|
||||
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
||||
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
||||
@@ -791,6 +791,8 @@ export class AiChatController {
|
||||
sections.push(` Includes: ${p.inclusions}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sections.push('No packages available.');
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||
|
||||
@@ -78,6 +78,13 @@ export class CallerContextService {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async invalidateCache(leadId: string): Promise<void> {
|
||||
if (!leadId) return;
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
|
||||
await this.session.deleteCache(cacheKey).catch(() => {});
|
||||
this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`);
|
||||
}
|
||||
|
||||
// Fire-and-forget pre-warm — called from caller resolution
|
||||
// so the cache is hot when the AI stream fires seconds later.
|
||||
prewarm(leadId: string, patientId: string, auth: string): void {
|
||||
@@ -89,8 +96,8 @@ export class CallerContextService {
|
||||
|
||||
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
||||
try {
|
||||
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||
this.platform.queryWithAuth<any>(
|
||||
// Step 1: Fetch lead first to get the authoritative patientId
|
||||
const leadData = await this.platform.queryWithAuth<any>(
|
||||
`{ lead(filter: { id: { eq: "${leadId}" } }) {
|
||||
id contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
@@ -99,9 +106,24 @@ export class CallerContextService {
|
||||
utmCampaign patientId
|
||||
} }`,
|
||||
undefined, auth,
|
||||
),
|
||||
patientId ? this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
);
|
||||
|
||||
const lead = leadData?.lead;
|
||||
if (!lead) return null;
|
||||
|
||||
// Use Lead's patientId as authoritative source — the input
|
||||
// param may be empty if caller resolution just linked them.
|
||||
const resolvedPatientId = patientId || lead.patientId || '';
|
||||
this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`);
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
|
||||
// Step 2: Fetch appointments, calls, activities in parallel
|
||||
// using the resolved patientId from the Lead record.
|
||||
const [appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||
resolvedPatientId ? this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
scheduledAt status doctorName department reasonForVisit
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
@@ -120,12 +142,6 @@ export class CallerContextService {
|
||||
),
|
||||
]);
|
||||
|
||||
const lead = leadData?.lead;
|
||||
if (!lead) return null;
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
|
||||
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
|
||||
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
|
||||
startedAt: e.node.startedAt,
|
||||
@@ -148,7 +164,7 @@ export class CallerContextService {
|
||||
|
||||
return {
|
||||
leadId,
|
||||
patientId: patientId || lead.patientId || '',
|
||||
patientId: resolvedPatientId,
|
||||
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
|
||||
isNew: false,
|
||||
|
||||
@@ -33,4 +33,13 @@ export class CallerResolutionController {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('invalidate-context')
|
||||
async invalidateContext(@Body('leadId') leadId: string) {
|
||||
if (!leadId) {
|
||||
throw new HttpException('leadId is required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
await this.callerContext.invalidateCache(leadId);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,6 @@ RULES:
|
||||
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
|
||||
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||
7. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||
8. Format with bullet points for easy scanning.
|
||||
|
||||
RESPONSE FORMAT (STRICT):
|
||||
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:
|
||||
|
||||
@@ -156,11 +156,13 @@ export class OzonetelAgentController {
|
||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||
}
|
||||
|
||||
// Create call record for outbound calls. Inbound calls are
|
||||
// created by the webhook — but we skip outbound in the webhook
|
||||
// (they're not "missed calls"). So the dispose endpoint is the
|
||||
// only place that creates the call record for outbound dials.
|
||||
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
||||
// Create call record at dispose time for ALL answered calls
|
||||
// (inbound + outbound). The dispose endpoint fires BEFORE the
|
||||
// CDR webhook, so creating here gives us the correct agent-side
|
||||
// UCID and the agent's chosen disposition immediately. The webhook
|
||||
// arrives ~5s later and enriches with recording URL + chain name.
|
||||
if (body.callerPhone) {
|
||||
const isInbound = body.direction !== 'OUTBOUND';
|
||||
try {
|
||||
const durationSec = body.durationSec ?? 0;
|
||||
const endedAt = new Date().toISOString();
|
||||
@@ -168,8 +170,8 @@ export class OzonetelAgentController {
|
||||
? new Date(Date.now() - durationSec * 1000).toISOString()
|
||||
: endedAt;
|
||||
const callData: Record<string, any> = {
|
||||
name: `Outbound — ${body.callerPhone}`,
|
||||
direction: 'OUTBOUND',
|
||||
name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`,
|
||||
direction: isInbound ? 'INBOUND' : 'OUTBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||
agentName: agentId,
|
||||
@@ -196,7 +198,7 @@ export class OzonetelAgentController {
|
||||
{ data: callData },
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
||||
this.logger.log(`[DISPOSE] Created ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`);
|
||||
|
||||
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||
const callId = result.createCall.id;
|
||||
@@ -278,33 +280,9 @@ export class OzonetelAgentController {
|
||||
}
|
||||
}
|
||||
|
||||
// Update disposition on answered inbound calls. The webhook creates
|
||||
// the Call record with the Ozonetel default disposition ("General
|
||||
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
|
||||
// agent has submitted their actual disposition, write it back to the
|
||||
// platform Call record by matching on UCID.
|
||||
//
|
||||
// Skipped for outbound (already created with correct disposition
|
||||
// above) and for missed-call callbacks (handled in the block above).
|
||||
if (!body.missedCallId && body.direction !== 'OUTBOUND' && body.ucid) {
|
||||
try {
|
||||
const callData = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: { ucid: { eq: "${body.ucid}" } }) { edges { node { id } } } }`,
|
||||
);
|
||||
const callId = callData?.calls?.edges?.[0]?.node?.id;
|
||||
if (callId) {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: callId, data: { disposition: body.disposition } },
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Updated inbound call ${callId} disposition → ${body.disposition}`);
|
||||
} else {
|
||||
this.logger.warn(`[DISPOSE] No Call found for ucid=${body.ucid} — disposition not persisted`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to update inbound call disposition: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Inbound disposition is now handled by the call record creation
|
||||
// above — the dispose endpoint creates the record with the correct
|
||||
// disposition. No separate update-by-UCID needed.
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,8 @@ export class SupervisorService implements OnModuleInit {
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||
// monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID.
|
||||
private readonly ucidMap = new Map<string, string>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
||||
// Worklist update stream — emitted when a missed call is created or
|
||||
@@ -78,9 +80,14 @@ export class SupervisorService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
resolveAgentUcid(monitorUcid: string): string | null {
|
||||
return this.ucidMap.get(monitorUcid) ?? null;
|
||||
}
|
||||
|
||||
handleCallEvent(event: any) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
|
||||
const agentId = event.agent_id ?? event.agentID;
|
||||
const callerNumber = event.caller_id ?? event.callerID;
|
||||
const callType = event.call_type ?? event.Type;
|
||||
@@ -89,6 +96,12 @@ export class SupervisorService implements OnModuleInit {
|
||||
|
||||
if (!ucid) return;
|
||||
|
||||
if (monitorUcid && ucid !== monitorUcid) {
|
||||
this.ucidMap.set(monitorUcid, ucid);
|
||||
this.logger.log(`[UCID-MAP] monitor=${monitorUcid} → agent=${ucid}`);
|
||||
setTimeout(() => this.ucidMap.delete(monitorUcid), 600_000);
|
||||
}
|
||||
|
||||
if (action === 'Answered' || action === 'Calling') {
|
||||
// Don't show calls for offline agents (ghost calls)
|
||||
const agentState = this.agentStates.get(agentId);
|
||||
@@ -163,27 +176,31 @@ export class SupervisorService implements OnModuleInit {
|
||||
|
||||
const priorState = this.agentStates.get(agentId)?.state;
|
||||
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
||||
if (mapped) {
|
||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
||||
|
||||
// Persist to AgentEvent table. CALL_START/CALL_END are
|
||||
// handled in handleCallEvent (they arrive via a separate
|
||||
// Ozonetel webhook). Everything else is captured here.
|
||||
// Pass priorState so 'release' → RESUME / ACW_END / READY can
|
||||
// be disambiguated for the session rollup.
|
||||
// Persist to AgentEvent table regardless of state mapping.
|
||||
// login returns null for state (UI waits for release/ready) but
|
||||
// the history pipeline needs LOGIN to compute loginDuration.
|
||||
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
||||
if (historyEventType) {
|
||||
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
||||
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → eventType=${historyEventType} priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'}`);
|
||||
this.history.persistAgentEvent({
|
||||
ozonetelAgentId: agentId,
|
||||
eventType: historyEventType,
|
||||
eventAt: this.parseOzonetelTime(eventTime),
|
||||
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
||||
}).catch(() => {});
|
||||
}).catch((err) => {
|
||||
this.logger.warn(`[AGENT-HISTORY] Failed to persist ${historyEventType} for ${agentId}: ${err?.message ?? err}`);
|
||||
});
|
||||
} else {
|
||||
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → no history event (priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'})`);
|
||||
}
|
||||
|
||||
if (mapped) {
|
||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||
this.logger.log(`[AGENT-STATE] ${agentId} ${priorState ?? 'none'} → ${mapped} (action=${action})`);
|
||||
|
||||
// Layer 3: ACW auto-dispose safety net
|
||||
if (mapped === 'acw') {
|
||||
// Find the most recent UCID for this agent
|
||||
|
||||
@@ -50,7 +50,15 @@ export class MissedCallWebhookController {
|
||||
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
||||
const agentName = payload.AgentName ?? null;
|
||||
const recordingUrl = payload.AudioFile ?? null;
|
||||
const ucid = payload.monitorUCID ?? null;
|
||||
const monitorUcid = payload.monitorUCID ?? null;
|
||||
// Resolve agent-side UCID from real-time event mapping.
|
||||
// The dispose endpoint creates Call records with the agent UCID;
|
||||
// this lets us find and enrich that record instead of duplicating.
|
||||
const agentUcid = monitorUcid ? this.supervisor.resolveAgentUcid(monitorUcid) : null;
|
||||
const ucid = agentUcid ?? monitorUcid;
|
||||
if (agentUcid) {
|
||||
this.logger.log(`[WEBHOOK] Resolved monitorUCID ${monitorUcid} → agent UCID ${agentUcid}`);
|
||||
}
|
||||
const disposition = payload.Disposition ?? null;
|
||||
const hangupBy = payload.HangupBy ?? null;
|
||||
|
||||
@@ -109,24 +117,54 @@ export class MissedCallWebhookController {
|
||||
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
||||
}
|
||||
|
||||
// Step 2: Create call record with leadId + leadName baked in so
|
||||
// the worklist row renders the patient name immediately.
|
||||
const callId = await this.createCall({
|
||||
callerPhone,
|
||||
direction,
|
||||
callStatus,
|
||||
agentName,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
recordingUrl,
|
||||
disposition,
|
||||
ucid,
|
||||
leadId: resolved.leadId || null,
|
||||
leadName: resolved.leadName,
|
||||
// Step 2: For answered calls, the dispose endpoint creates the
|
||||
// Call record ~5s before this webhook fires. Check if it already
|
||||
// exists and enrich it instead of creating a duplicate.
|
||||
let callId: string;
|
||||
if (callStatus === 'COMPLETED' && ucid) {
|
||||
const existing = await this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`,
|
||||
undefined, authHeader,
|
||||
).catch(() => null);
|
||||
const existingId = existing?.calls?.edges?.[0]?.node?.id;
|
||||
if (existingId) {
|
||||
// Enrich existing record with webhook data (recording, chain name, timing)
|
||||
const enrichData: Record<string, any> = {};
|
||||
if (agentName) enrichData.agentName = agentName;
|
||||
if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' };
|
||||
if (resolved.leadId) enrichData.leadId = resolved.leadId;
|
||||
if (resolved.leadName) enrichData.leadName = resolved.leadName;
|
||||
if (startTime) enrichData.startedAt = istToUtc(startTime);
|
||||
if (endTime) enrichData.endedAt = istToUtc(endTime);
|
||||
if (duration) enrichData.durationSec = duration;
|
||||
if (Object.keys(enrichData).length > 0) {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: existingId, data: enrichData },
|
||||
authHeader,
|
||||
).catch(err => this.logger.warn(`[WEBHOOK] Failed to enrich call ${existingId}: ${err}`));
|
||||
}
|
||||
callId = existingId;
|
||||
this.logger.log(`[WEBHOOK] Enriched existing call ${callId} with recording=${recordingUrl ? 'yes' : 'no'} agentName=${agentName}`);
|
||||
} else {
|
||||
// Fallback: dispose didn't create it (edge case) — create normally
|
||||
this.logger.log(`[WEBHOOK] No existing call found for ucid=${ucid} — creating new record`);
|
||||
callId = await this.createCall({
|
||||
callerPhone, direction, callStatus, agentName,
|
||||
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||
}, authHeader);
|
||||
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||
}
|
||||
} else {
|
||||
// Missed calls — always create (no dispose fires for unanswered)
|
||||
callId = await this.createCall({
|
||||
callerPhone, direction, callStatus, agentName,
|
||||
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||
}, authHeader);
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||
}
|
||||
|
||||
// Push worklist SSE so agents see new calls instantly
|
||||
// instead of waiting for the 30s frontend poll.
|
||||
|
||||
Reference in New Issue
Block a user