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

View File

@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { TelephonyConfigService } from '../config/telephony-config.service';
import { CallerResolutionService } from '../caller/caller-resolution.service';
// Ozonetel sends all timestamps in IST — convert to UTC for storage
export function istToUtc(istDateStr: string | null): string | null {
@@ -35,6 +36,7 @@ export class MissedQueueService implements OnModuleInit {
private readonly platform: PlatformGraphqlService,
private readonly ozonetel: OzonetelAgentService,
private readonly telephony: TelephonyConfigService,
private readonly caller: CallerResolutionService,
) {
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
}
@@ -90,26 +92,29 @@ export class MissedQueueService implements OnModuleInit {
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
try {
// Look up lead by phone number — strip +91 prefix for flexible matching
const phoneDigits = phone.replace(/^\+91/, '');
// Resolve caller via the shared service — covers the case
// where there's an existing patient but no lead yet (the
// service creates the lead on the fly and returns the name).
// Same source of truth as the webhook path.
let leadId: string | null = null;
let leadName: string | null = null;
try {
const leadResult = await this.platform.query<any>(
`{ leads(first: 1, filter: {
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
);
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
if (matchedLead) {
leadId = matchedLead.id;
const fn = matchedLead.contactName?.firstName ?? '';
const ln = matchedLead.contactName?.lastName ?? '';
leadName = `${fn} ${ln}`.trim() || null;
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
const apiKey = this.config.get<string>('platform.apiKey') ?? '';
const auth = apiKey ? `Bearer ${apiKey}` : '';
const r = await this.caller.resolve(phone, auth);
if (r.isNew) {
// No existing Lead/Patient — write phone as leadName.
// Record creation is deferred to the first agent
// action (enquiry / appointment).
leadName = phone;
} else if (r.leadId) {
leadId = r.leadId;
const fullName = `${r.firstName} ${r.lastName}`.trim();
leadName = fullName || null;
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName ?? 'no name'})`);
}
} catch (err) {
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
this.logger.warn(`Caller resolution failed for ${phone}: ${err}`);
}
const existing = await this.platform.query<any>(

View File

@@ -3,6 +3,7 @@ import { PlatformModule } from '../platform/platform.module';
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { AuthModule } from '../auth/auth.module';
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
import { CallerResolutionModule } from '../caller/caller-resolution.module';
import { TelephonyConfigService } from '../config/telephony-config.service';
import { WorklistController } from './worklist.controller';
import { WorklistService } from './worklist.service';
@@ -11,7 +12,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
import { KookooCallbackController } from './kookoo-callback.controller';
@Module({
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule)],
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
exports: [MissedQueueService],

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
@@ -16,8 +17,49 @@ export class WorklistService {
constructor(
private readonly platform: PlatformGraphqlService,
private readonly worklistConsumer: WorklistConsumer,
private readonly config: ConfigService,
) {}
private get pageSize(): number {
return this.config.get<number>('worklist.pageSize', 50);
}
private get maxPages(): number {
return this.config.get<number>('worklist.maxPages', 10);
}
// Paginate a Relay connection query. Caller provides a function that
// builds the query for a given cursor ('' on first page). Stops when
// the platform reports no more pages OR the safety ceiling hits.
private async fetchAllPages<T>(
buildQuery: (cursorClause: string) => string,
connectionKey: string,
authHeader: string,
): Promise<T[]> {
const all: T[] = [];
let cursor = '';
for (let page = 0; page < this.maxPages; page++) {
const cursorClause = cursor ? `, after: "${cursor}"` : '';
try {
const data = await this.platform.queryWithAuth<any>(
buildQuery(cursorClause),
undefined,
authHeader,
);
const conn = data?.[connectionKey];
if (!conn) break;
all.push(...(conn.edges?.map((e: any) => e.node) ?? []));
if (!conn.pageInfo?.hasNextPage) break;
cursor = conn.pageInfo.endCursor ?? '';
if (!cursor) break;
} catch (err) {
this.logger.warn(`[WORKLIST] ${connectionKey} page ${page} failed: ${err}`);
break;
}
}
return all;
}
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
this.getMissedCalls(agentName, authHeader),
@@ -49,69 +91,94 @@ export class WorklistService {
}
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
try {
const data = await this.platform.queryWithAuth<any>(
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
id createdAt
contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
contactEmail { primaryEmail }
source status interestedService
assignedAgent campaignId
contactAttempts spamScore isSpam
aiSummary aiSuggestedAction
} } } }`,
undefined,
authHeader,
);
return data.leads.edges.map((e: any) => e.node);
} catch (err) {
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
return [];
}
return this.fetchAllPages<any>(
(cursor) => `{ leads(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
id createdAt
contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
contactEmail { primaryEmail }
source status interestedService
assignedAgent campaignId
contactAttempts spamScore isSpam
aiSummary aiSuggestedAction
} } pageInfo { hasNextPage endCursor } } }`,
'leads',
authHeader,
);
}
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
const raw = await this.fetchAllPages<any>(
(cursor) => `{ followUps(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
id name createdAt
typeCustom status scheduledAt completedAt
priority assignedAgent
patientId
} } pageInfo { hasNextPage endCursor } } }`,
'followUps',
authHeader,
);
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
const followUps = raw.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
try {
const data = await this.platform.queryWithAuth<any>(
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
id name createdAt
typeCustom status scheduledAt completedAt
priority assignedAgent
patientId
} } } }`,
undefined,
authHeader,
// Enrich with patient name/phone so the worklist can render them.
// FollowUp stores only patientId — the name in fu.name is free-form
// and phone isn't stored at all, so one patient fetch fills both.
const patientIds: string[] = Array.from(
new Set(followUps.map((f: any) => f.patientId).filter((id: any): id is string => typeof id === 'string' && id.length > 0)),
);
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
return data.followUps.edges
.map((e: any) => e.node)
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
if (patientIds.length > 0) {
try {
const idsGql = patientIds.map((id) => `"${id}"`).join(',');
const patientsData = await this.platform.queryWithAuth<any>(
`{ patients(first: ${patientIds.length}, filter: { id: { in: [${idsGql}] } }) { edges { node {
id fullName { firstName lastName } phones { primaryPhoneNumber }
} } } }`,
undefined,
authHeader,
);
const patientMap = new Map<string, { name: string; phone: string }>();
for (const edge of patientsData.patients.edges) {
const p = edge.node;
const name = [p.fullName?.firstName, p.fullName?.lastName].filter(Boolean).join(' ').trim();
const phone = p.phones?.primaryPhoneNumber ?? '';
patientMap.set(p.id, { name, phone });
}
for (const fu of followUps) {
if (fu.patientId && patientMap.has(fu.patientId)) {
const p = patientMap.get(fu.patientId)!;
fu.patientName = p.name;
fu.patientPhone = p.phone;
}
}
} catch (err) {
this.logger.warn(`Failed to enrich follow-ups with patient data: ${err}`);
}
}
return followUps;
} catch (err) {
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
return [];
}
}
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
try {
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
const data = await this.platform.queryWithAuth<any>(
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
id name createdAt
direction callStatus agentName
callerNumber { primaryPhoneNumber }
startedAt endedAt durationSec
disposition leadId
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
} } } }`,
undefined,
authHeader,
);
return data.calls.edges.map((e: any) => e.node);
} catch (err) {
this.logger.warn(`Failed to fetch missed calls: ${err}`);
return [];
}
private async getMissedCalls(_agentName: string, authHeader: string): Promise<any[]> {
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter —
// missed calls are a shared queue. Paginated via WORKLIST_PAGE_SIZE
// × WORKLIST_MAX_PAGES ceiling.
return this.fetchAllPages<any>(
(cursor) => `{ calls(first: ${this.pageSize}${cursor}, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
id name createdAt
direction callStatus agentName
callerNumber { primaryPhoneNumber }
startedAt endedAt durationSec
disposition leadId leadName
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
} } pageInfo { hasNextPage endCursor } } }`,
'calls',
authHeader,
);
}
}