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:
2026-04-15 06:49:02 +05:30
parent b6b597fdda
commit fbe782b5ac
17 changed files with 685 additions and 269 deletions

View File

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