mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix+feat: morning QA fixes, worklist pagination, misc sidecar improvements
- caller-resolution: drop cache, use indexed phone filter (lead.contactPhone.primaryPhoneNumber.like) - worklist: externalize page size (WORKLIST_PAGE_SIZE × WORKLIST_MAX_PAGES), paginate getMissedCalls/getAssignedLeads/getPendingFollowUps - maint: unlock-agent, force-ready, backfill-caller-resolution, clear-analysis-cache, fix-timestamps - ozonetel agent.service: force logout+re-login on "already logged in" - ai chat: context expansion - livekit-agent: updates - widget: session handling - masterdata: clinic list cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||
@@ -20,6 +21,7 @@ export class MissedCallWebhookController {
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly caller: CallerResolutionService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
@@ -73,7 +75,38 @@ export class MissedCallWebhookController {
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create call record
|
||||
// Step 1: Resolve caller. CallerResolutionService looks up BOTH
|
||||
// leads and patients — for an existing patient with no lead yet
|
||||
// it creates the lead on the fly and returns the name. This is
|
||||
// the single source of truth for caller identity across webhook,
|
||||
// polling, and agent-initiated paths.
|
||||
let resolved: { leadId: string; leadName: string | null; patientId: string } = {
|
||||
leadId: '',
|
||||
leadName: null,
|
||||
patientId: '',
|
||||
};
|
||||
try {
|
||||
const r = await this.caller.resolve(callerPhone, authHeader);
|
||||
const fullName = `${r.firstName} ${r.lastName}`.trim();
|
||||
resolved = {
|
||||
leadId: r.leadId,
|
||||
// Resolver returns isNew when no Lead/Patient exists for
|
||||
// this phone. We do NOT auto-create records from the
|
||||
// webhook — agents don't have a name to attach, so we
|
||||
// persist the phone as leadName (honest snapshot). The
|
||||
// first agent action (enquiry, appointment) will create
|
||||
// real Lead+Patient records and retroactive identity
|
||||
// isn't a data-layer concern.
|
||||
leadName: r.isNew ? `+91${callerPhone}` : (fullName || null),
|
||||
patientId: r.patientId,
|
||||
};
|
||||
this.logger.log(`[WEBHOOK] Resolved ${callerPhone} → lead=${resolved.leadId || 'none'} name=${resolved.leadName ?? 'unresolved'} isNew=${r.isNew}`);
|
||||
} catch (err) {
|
||||
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,
|
||||
@@ -85,25 +118,21 @@ export class MissedCallWebhookController {
|
||||
recordingUrl,
|
||||
disposition,
|
||||
ucid,
|
||||
leadId: resolved.leadId || null,
|
||||
leadName: resolved.leadName,
|
||||
}, authHeader);
|
||||
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||
|
||||
// Step 2: Find matching lead by phone number
|
||||
const lead = await this.findLeadByPhone(callerPhone, authHeader);
|
||||
|
||||
if (lead) {
|
||||
// Step 3: Link call to lead
|
||||
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
||||
|
||||
// Step 4: Create lead activity
|
||||
// Step 3: Lead-side side-effects (activity log + contact stats)
|
||||
if (resolved.leadId) {
|
||||
const summary = callStatus === 'MISSED'
|
||||
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
||||
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
||||
|
||||
await this.createLeadActivity({
|
||||
leadId: lead.id,
|
||||
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
||||
leadId: resolved.leadId,
|
||||
activityType: 'CALL_RECEIVED',
|
||||
summary,
|
||||
channel: 'PHONE',
|
||||
performedBy: agentName ?? 'System',
|
||||
@@ -111,18 +140,16 @@ export class MissedCallWebhookController {
|
||||
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
||||
}, authHeader);
|
||||
|
||||
// Step 5: Update lead contact timestamps
|
||||
await this.updateLead(lead.id, {
|
||||
// Bump contact timestamps. Read current contactAttempts first
|
||||
// (kept local rather than extending resolve() signature).
|
||||
const leadMeta = await this.findLeadByPhone(callerPhone, authHeader);
|
||||
await this.updateLead(resolved.leadId, {
|
||||
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||
contactAttempts: ((leadMeta?.contactAttempts) ?? 0) + 1,
|
||||
}, authHeader);
|
||||
|
||||
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`);
|
||||
} else {
|
||||
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`);
|
||||
}
|
||||
|
||||
return { received: true, processed: true, callId, leadId: lead?.id ?? null };
|
||||
return { received: true, processed: true, callId, leadId: resolved.leadId || null };
|
||||
} catch (err: any) {
|
||||
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
||||
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
||||
@@ -141,6 +168,8 @@ export class MissedCallWebhookController {
|
||||
recordingUrl: string | null;
|
||||
disposition: string | null;
|
||||
ucid: string | null;
|
||||
leadId?: string | null;
|
||||
leadName?: string | null;
|
||||
}, authHeader: string): Promise<string> {
|
||||
const callData: Record<string, any> = {
|
||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||
@@ -153,6 +182,8 @@ export class MissedCallWebhookController {
|
||||
durationSec: data.duration,
|
||||
disposition: this.mapDisposition(data.disposition),
|
||||
};
|
||||
if (data.leadId) callData.leadId = data.leadId;
|
||||
if (data.leadName) callData.leadName = data.leadName;
|
||||
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||
if (data.callStatus === 'MISSED') {
|
||||
callData.callbackStatus = 'PENDING_CALLBACK';
|
||||
@@ -242,8 +273,9 @@ export class MissedCallWebhookController {
|
||||
'General Enquiry': 'INFO_PROVIDED',
|
||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||
'Not Interested': 'CALLBACK_REQUESTED',
|
||||
'Not Interested': 'NOT_INTERESTED',
|
||||
'Wrong Number': 'WRONG_NUMBER',
|
||||
'No Answer': 'NO_ANSWER',
|
||||
};
|
||||
return map[disposition] ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user