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;
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user