From fbe782b5ac5a386a79a3bd334ae2aaba84ba5a50 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 15 Apr 2026 06:49:02 +0530 Subject: [PATCH] fix+feat: morning QA fixes, worklist pagination, misc sidecar improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/ai/ai-chat.controller.ts | 71 +++++-- src/ai/ai.module.ts | 5 +- src/call-events/lead-enrich.controller.ts | 14 +- src/caller/caller-resolution.controller.ts | 10 - src/caller/caller-resolution.module.ts | 4 +- src/caller/caller-resolution.service.ts | 120 ++++++------ src/config/configuration.ts | 10 + src/livekit-agent/agent.ts | 144 +++++++++++---- src/maint/maint.controller.ts | 104 +++++++++++ src/masterdata/masterdata.service.ts | 12 ++ src/ozonetel/ozonetel-agent.service.ts | 42 +++++ src/widget/widget.module.ts | 5 +- src/widget/widget.service.ts | 128 ++++++++----- .../missed-call-webhook.controller.ts | 74 +++++--- src/worklist/missed-queue.service.ts | 35 ++-- src/worklist/worklist.module.ts | 3 +- src/worklist/worklist.service.ts | 173 ++++++++++++------ 17 files changed, 685 insertions(+), 269 deletions(-) diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index 5972dd6..d6c66e7 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -5,6 +5,7 @@ import { generateText, streamText, tool, stepCountIs } from 'ai'; import type { LanguageModel } from 'ai'; import { z } from 'zod'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { CallerResolutionService } from '../caller/caller-resolution.service'; import { createAiModel, isAiConfigured } from './ai-provider'; import { AiConfigService } from '../config/ai-config.service'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils'; @@ -26,6 +27,7 @@ export class AiChatController { private config: ConfigService, private platform: PlatformGraphqlService, private aiConfig: AiConfigService, + private caller: CallerResolutionService, ) { const cfg = aiConfig.getConfig(); this.aiModel = createAiModel({ @@ -431,16 +433,60 @@ export class AiChatController { this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`); try { const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); - const result = await platformService.queryWithAuth( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + const resolved = await this.caller.resolve(cleanPhone, auth); + const firstName = name.split(' ')[0]; + const lastName = name.split(' ').slice(1).join(' ') || ''; + + if (resolved.isNew) { + // Net-new caller — create Patient + Lead with + // the AI-collected name from the conversation. + let patientId: string | undefined; + try { + const p = await platformService.queryWithAuth( + `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, + { + data: { + fullName: { firstName, lastName }, + phones: { primaryPhoneNumber: `+91${cleanPhone}` }, + patientType: 'NEW', + }, + }, + auth, + ); + patientId = p?.createPatient?.id; + } catch (err: any) { + this.logger.warn(`[TOOL] create_lead patient create failed: ${err.message}`); + } + const created = await platformService.queryWithAuth( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name: `AI Enquiry — ${name}`, + contactName: { firstName, lastName }, + contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, + source: 'PHONE', + status: 'NEW', + interestedService: interest, + ...(patientId ? { patientId } : {}), + }, + }, + auth, + ); + const id = created?.createLead?.id; + if (id) { + return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` }; + } + return { created: false, message: 'Lead creation failed.' }; + } + + // Existing record — update with AI-collected name. + await platformService.queryWithAuth( + `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { + id: resolved.leadId, data: { name: `AI Enquiry — ${name}`, - contactName: { - firstName: name.split(' ')[0], - lastName: name.split(' ').slice(1).join(' ') || '', - }, - contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, + contactName: { firstName, lastName }, source: 'PHONE', status: 'NEW', interestedService: interest, @@ -448,11 +494,14 @@ export class AiChatController { }, auth, ); - const id = result?.createLead?.id; - if (id) { - return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` }; + if (resolved.patientId) { + await platformService.queryWithAuth( + `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, + { id: resolved.patientId, data: { fullName: { firstName, lastName } } }, + auth, + ).catch(() => {}); } - return { created: false, message: 'Lead creation failed.' }; + return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` }; } catch (err: any) { this.logger.error(`[TOOL] create_lead failed: ${err.message}`); return { created: false, message: `Failed: ${err.message}` }; diff --git a/src/ai/ai.module.ts b/src/ai/ai.module.ts index 27d2a80..98a1dc2 100644 --- a/src/ai/ai.module.ts +++ b/src/ai/ai.module.ts @@ -1,10 +1,11 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PlatformModule } from '../platform/platform.module'; import { AiEnrichmentService } from './ai-enrichment.service'; import { AiChatController } from './ai-chat.controller'; +import { CallerResolutionModule } from '../caller/caller-resolution.module'; @Module({ - imports: [PlatformModule], + imports: [PlatformModule, forwardRef(() => CallerResolutionModule)], controllers: [AiChatController], providers: [AiEnrichmentService], exports: [AiEnrichmentService], diff --git a/src/call-events/lead-enrich.controller.ts b/src/call-events/lead-enrich.controller.ts index 5faad51..afb2249 100644 --- a/src/call-events/lead-enrich.controller.ts +++ b/src/call-events/lead-enrich.controller.ts @@ -99,17 +99,9 @@ export class LeadEnrichController { ); } - // 5. Invalidate the caller cache so the next incoming call from - // this phone number does a fresh platform lookup (and picks - // up the corrected identity + new summary). - if (body?.phone) { - try { - await this.callerResolution.invalidate(body.phone); - this.logger.log(`[LEAD-ENRICH] Caller cache invalidated for ${body.phone}`); - } catch (err) { - this.logger.warn(`[LEAD-ENRICH] Failed to invalidate caller cache: ${err}`); - } - } + // Caller resolution no longer caches — every resolve() hits the + // platform fresh via an indexed phone filter. No invalidation + // needed after enrichment. this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`); diff --git a/src/caller/caller-resolution.controller.ts b/src/caller/caller-resolution.controller.ts index 35f2487..112f8cd 100644 --- a/src/caller/caller-resolution.controller.ts +++ b/src/caller/caller-resolution.controller.ts @@ -23,14 +23,4 @@ export class CallerResolutionController { const result = await this.resolution.resolve(phone, auth); return result; } - - @Post('invalidate') - async invalidate(@Body('phone') phone: string) { - if (!phone) { - throw new HttpException('phone is required', HttpStatus.BAD_REQUEST); - } - this.logger.log(`[RESOLVE] Invalidating cache for: ${phone}`); - await this.resolution.invalidate(phone); - return { status: 'ok' }; - } } diff --git a/src/caller/caller-resolution.module.ts b/src/caller/caller-resolution.module.ts index c167d64..a93a367 100644 --- a/src/caller/caller-resolution.module.ts +++ b/src/caller/caller-resolution.module.ts @@ -1,11 +1,11 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PlatformModule } from '../platform/platform.module'; import { AuthModule } from '../auth/auth.module'; import { CallerResolutionController } from './caller-resolution.controller'; import { CallerResolutionService } from './caller-resolution.service'; @Module({ - imports: [PlatformModule, AuthModule], + imports: [PlatformModule, forwardRef(() => AuthModule)], controllers: [CallerResolutionController], providers: [CallerResolutionService], exports: [CallerResolutionService], diff --git a/src/caller/caller-resolution.service.ts b/src/caller/caller-resolution.service.ts index 5bc9675..3b28675 100644 --- a/src/caller/caller-resolution.service.ts +++ b/src/caller/caller-resolution.service.ts @@ -1,9 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; -import { SessionService } from '../auth/session.service'; - -const CACHE_TTL = 3600; // 1 hour -const CACHE_PREFIX = 'caller:'; export type ResolvedCaller = { leadId: string; @@ -11,7 +7,7 @@ export type ResolvedCaller = { firstName: string; lastName: string; phone: string; - isNew: boolean; // true if we just created the lead+patient pair + isNew: boolean; // true if no Lead/Patient exists for this phone }; @Injectable() @@ -20,33 +16,24 @@ export class CallerResolutionService { constructor( private readonly platform: PlatformGraphqlService, - private readonly cache: SessionService, ) {} - // Resolve a caller by phone number. - // Looks up existing lead + patient by phone. If neither exists, returns - // isNew: true with no IDs — records are NOT created automatically. - // Record creation happens when the agent explicitly books an appointment - // or logs an enquiry (per PRD: "System will not identify the patient — - // no summary shown" for unregistered numbers). + // Resolve a caller by phone number via indexed platform queries. No + // cache — every call hits the DB fresh. Cache was previously used to + // compensate for client-side `leads(first: 200)` scans, but we now + // filter by phone directly which is O(log n) with the DB index. + // Cost: ~2 fast queries per resolve; eventual-consistency window = 0. async resolve(phone: string, auth: string): Promise { const normalized = phone.replace(/\D/g, '').slice(-10); if (normalized.length < 10) { throw new Error(`Invalid phone number: ${phone}`); } - // 1. Check cache - const cached = await this.cache.getCache(`${CACHE_PREFIX}${normalized}`); - if (cached) { - this.logger.log(`[RESOLVE] Cache hit for ${normalized}`); - return JSON.parse(cached); - } - - // 2. Look up lead by phone - const lead = await this.findLeadByPhone(normalized, auth); - - // 3. Look up patient by phone - const patient = await this.findPatientByPhone(normalized, auth); + // Lookup lead + patient by phone, in parallel. + const [lead, patient] = await Promise.all([ + this.findLeadByPhone(normalized, auth), + this.findPatientByPhone(normalized, auth), + ]); let result: ResolvedCaller; @@ -56,6 +43,11 @@ export class CallerResolutionService { await this.linkLeadToPatient(lead.id, patient.id, auth); this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`); } + // PRD: "Returning patient (Y/N) will be taken care of by the system" + // Patient is recognized on a subsequent contact → mark as RETURNING + if (patient.patientType === 'NEW') { + this.upgradeToReturning(patient.id, auth); + } result = { leadId: lead.id, patientId: patient.id, @@ -81,6 +73,9 @@ export class CallerResolutionService { // Patient exists, no lead — create lead const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth); this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`); + if (patient.patientType === 'NEW') { + this.upgradeToReturning(patient.id, auth); + } result = { leadId: newLead.id, patientId: patient.id, @@ -90,9 +85,15 @@ export class CallerResolutionService { isNew: false, }; } else { - // Neither exists — return empty result, don't create records. - // Agent will create records when they book an appointment or log an enquiry. - this.logger.log(`[RESOLVE] No existing records for ${normalized} — returning unresolved`); + // Neither exists — return empty IDs with isNew=true. Caller + // code is responsible for creating records with the real name + // they've collected (enquiry form, appointment form, widget, + // AI tools). This avoids the "Unknown" placeholder cascade: + // no Lead/Patient is ever written unless we have a real name + // to attach to it. Missed-call / poller paths that have no + // name persist the Call record with leadName=phone as the + // honest snapshot. + this.logger.log(`[RESOLVE] No existing records for ${normalized} — returning isNew=true`); result = { leadId: '', patientId: '', @@ -103,43 +104,30 @@ export class CallerResolutionService { }; } - // 4. Cache the result - await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, JSON.stringify(result), CACHE_TTL); - return result; } - // Invalidate cache for a phone number (call after updates) - async invalidate(phone: string): Promise { - const normalized = phone.replace(/\D/g, '').slice(-10); - await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, '', 1); // expire immediately - } - + // Indexed lookup — platform filters by phone server-side. Matches on + // the last 10 digits regardless of stored format (+919XXXX / 91XXXX / + // XXXX / +91-XXXX), via the `like: "%XXXXXXXXXX"` predicate. private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> { try { const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>( - `{ leads(first: 200) { edges { node { + `{ leads(first: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node { id contactName { firstName lastName } - contactPhone { primaryPhoneNumber } patientId } } } }`, undefined, auth, ); - - const match = data.leads.edges.find(e => { - const num = (e.node.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10); - return num.length >= 10 && num === phone10; - }); - + const match = data.leads.edges[0]?.node; if (!match) return null; - return { - id: match.node.id, - firstName: match.node.contactName?.firstName ?? '', - lastName: match.node.contactName?.lastName ?? '', - patientId: match.node.patientId || null, + id: match.id, + firstName: match.contactName?.firstName ?? '', + lastName: match.contactName?.lastName ?? '', + patientId: match.patientId || null, }; } catch (err: any) { this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`); @@ -147,29 +135,24 @@ export class CallerResolutionService { } } - private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string } | null> { + private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientType: string | null } | null> { try { const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>( - `{ patients(first: 200) { edges { node { + `{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node { id fullName { firstName lastName } - phones { primaryPhoneNumber } + patientType } } } }`, undefined, auth, ); - - const match = data.patients.edges.find(e => { - const num = (e.node.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10); - return num.length >= 10 && num === phone10; - }); - + const match = data.patients.edges[0]?.node; if (!match) return null; - return { - id: match.node.id, - firstName: match.node.fullName?.firstName ?? '', - lastName: match.node.fullName?.lastName ?? '', + id: match.id, + firstName: match.fullName?.firstName ?? '', + lastName: match.fullName?.lastName ?? '', + patientType: match.patientType ?? null, }; } catch (err: any) { this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`); @@ -210,6 +193,19 @@ export class CallerResolutionService { return data.createLead; } + private upgradeToReturning(patientId: string, auth: string): void { + // Fire-and-forget — don't block caller resolution + this.platform.queryWithAuth( + `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, + { id: patientId, data: { patientType: 'RETURNING' } }, + auth, + ).then(() => { + this.logger.log(`[RESOLVE] Upgraded patient ${patientId} to RETURNING`); + }).catch(err => { + this.logger.warn(`[RESOLVE] Failed to upgrade patient type: ${err.message}`); + }); + } + private async linkLeadToPatient(leadId: string, patientId: string, auth: string): Promise { await this.platform.queryWithAuth( `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 48ad069..c98efc2 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -22,6 +22,16 @@ export default () => ({ missedQueue: { pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10), }, + worklist: { + // Per-page fetch size from the platform GraphQL endpoint. Tuned to + // balance response size vs. page count. Platform's Relay pagination + // typically caps at 100–200 per page. + pageSize: parseInt(process.env.WORKLIST_PAGE_SIZE ?? '50', 10), + // Hard ceiling on pages fetched per poll. Safety valve against + // unbounded cost when a tenant has thousands of pending callbacks. + // maxPages * pageSize = effective worklist size. + maxPages: parseInt(process.env.WORKLIST_MAX_PAGES ?? '10', 10), + }, ai: { provider: process.env.AI_PROVIDER ?? 'openai', anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '', diff --git a/src/livekit-agent/agent.ts b/src/livekit-agent/agent.ts index f357a38..d31c44c 100644 --- a/src/livekit-agent/agent.ts +++ b/src/livekit-agent/agent.ts @@ -27,6 +27,27 @@ async function gql(query: string, variables?: Record): } } +// Resolve a phone to a {leadId, patientId} pair via the sidecar's +// caller-resolution endpoint. Always returns populated IDs (creates +// placeholder lead+patient when none exist). +async function resolveCaller(phone: string): Promise<{ leadId: string; patientId: string; firstName: string; lastName: string; isNew: boolean } | null> { + try { + const res = await fetch(`${SIDECAR_URL}/api/caller/resolve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone }), + }); + if (!res.ok) { + console.error('[AGENT-RESOLVE] Failed:', res.status, await res.text().catch(() => '')); + return null; + } + return await res.json(); + } catch (err) { + console.error('[AGENT-RESOLVE] Failed:', err); + return null; + } +} + // Hospital context — loaded on startup let hospitalContext = { doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>, @@ -133,24 +154,53 @@ const bookAppointment = llm.tool({ }, ); - // Create or find lead + // Resolve caller — if isNew, create Lead + Patient with the + // AI-collected name; otherwise update the existing record. const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); - await gql( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, - { - data: { - name: `AI — ${patientName}`, - contactName: { - firstName: patientName.split(' ')[0], - lastName: patientName.split(' ').slice(1).join(' ') || '', + const resolved = await resolveCaller(cleanPhone); + const fn = patientName.split(' ')[0]; + const ln = patientName.split(' ').slice(1).join(' ') || ''; + if (resolved?.isNew) { + const p = await gql( + `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, + { data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } }, + ); + const newPatientId = p?.createPatient?.id; + await gql( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name: `AI — ${patientName}`, + contactName: { firstName: fn, lastName: ln }, + contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, + source: 'PHONE', + status: 'APPOINTMENT_SET', + interestedService: department, + ...(newPatientId ? { patientId: newPatientId } : {}), }, - contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, - source: 'PHONE', - status: 'APPOINTMENT_SET', - interestedService: department, }, - }, - ); + ); + } else if (resolved?.leadId) { + await gql( + `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, + { + id: resolved.leadId, + data: { + name: `AI — ${patientName}`, + contactName: { firstName: fn, lastName: ln }, + source: 'PHONE', + status: 'APPOINTMENT_SET', + interestedService: department, + }, + }, + ); + if (resolved.patientId) { + await gql( + `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, + { id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } }, + ); + } + } const refNum = `GH-${Date.now().toString().slice(-6)}`; if (result?.createAppointment?.id) { @@ -172,25 +222,53 @@ const collectLeadInfo = llm.tool({ console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`); const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); - const result = await gql( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, - { - data: { - name: `AI Enquiry — ${name}`, - contactName: { - firstName: name.split(' ')[0], - lastName: name.split(' ').slice(1).join(' ') || '', - }, - contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, - source: 'PHONE', - status: 'NEW', - interestedService: interest, - }, - }, - ); + const resolved = await resolveCaller(cleanPhone); + const fn = name.split(' ')[0]; + const ln = name.split(' ').slice(1).join(' ') || ''; - if (result?.createLead?.id) { - console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`); + if (resolved?.isNew) { + // Net-new caller — create Patient + Lead with the AI-collected name. + const p = await gql( + `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, + { data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } }, + ); + const newPatientId = p?.createPatient?.id; + const created = await gql( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name: `AI Enquiry — ${name}`, + contactName: { firstName: fn, lastName: ln }, + contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, + source: 'PHONE', + status: 'NEW', + interestedService: interest, + ...(newPatientId ? { patientId: newPatientId } : {}), + }, + }, + ); + console.log(`[LIVEKIT-AGENT] Lead created: ${created?.createLead?.id ?? 'none'} (patient ${newPatientId ?? 'none'})`); + } else if (resolved?.leadId) { + await gql( + `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, + { + id: resolved.leadId, + data: { + name: `AI Enquiry — ${name}`, + contactName: { firstName: fn, lastName: ln }, + source: 'PHONE', + status: 'NEW', + interestedService: interest, + }, + }, + ); + if (resolved.patientId) { + await gql( + `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, + { id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } }, + ); + } + console.log(`[LIVEKIT-AGENT] Lead updated: ${resolved.leadId} (patient ${resolved.patientId})`); } return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`; }, diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 0a2349d..ae2f26c 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -317,4 +317,108 @@ export class MaintController { this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`); return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } }; } + + // Backfill Call records that lost their identity at ingest (missed-call + // webhook / poller / dispose flow before the caller-resolution wiring). + // Routes each phone through CallerResolutionService so the same code + // path the live system uses also fixes historical rows. Idempotent — + // safe to re-run; only patches calls that are currently missing + // leadName / patientId / leadId. + @Post('backfill-caller-resolution') + async backfillCallerResolution() { + this.logger.log('[MAINT] Backfill caller resolution — patching Calls + Leads via resolver'); + + const apiKey = process.env.PLATFORM_API_KEY ?? ''; + const auth = apiKey ? `Bearer ${apiKey}` : ''; + if (!auth) throw new HttpException('PLATFORM_API_KEY not configured', 500); + + let callsScanned = 0; + let callsPatched = 0; + let callsSkipped = 0; + let leadsResolved = 0; + let resolveErrors = 0; + + // Phone → resolved cache so multiple calls from the same number + // only resolve once during this run. + const resolvedByPhone = new Map(); + + // Page through all calls in chunks of 200. We're after rows where + // leadName is empty OR leadId is null OR patientId is missing. + let cursor: string | null = null; + let hasNext = true; + while (hasNext) { + const pageQuery = cursor + ? `{ calls(first: 200, after: "${cursor}") { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }` + : `{ calls(first: 200) { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`; + let page: any; + try { + page = await this.platform.query(pageQuery); + } catch (err) { + this.logger.warn(`[MAINT] calls page query failed: ${err}`); + break; + } + const edges = page?.calls?.edges ?? []; + hasNext = page?.calls?.pageInfo?.hasNextPage ?? false; + cursor = page?.calls?.pageInfo?.endCursor ?? null; + + for (const edge of edges) { + const call = edge.node; + callsScanned++; + + const phoneRaw = call.callerNumber?.primaryPhoneNumber ?? ''; + const phone10 = phoneRaw.replace(/\D/g, '').slice(-10); + const needsName = !call.leadName || call.leadName === ''; + const needsLead = !call.leadId; + + if (!phone10 || phone10.length < 10) { callsSkipped++; continue; } + if (!needsName && !needsLead) { callsSkipped++; continue; } + + let resolved = resolvedByPhone.get(phone10) ?? null; + if (!resolved) { + try { + const r = await this.callerResolution.resolve(phone10, auth); + resolved = { + leadId: r.leadId, + patientId: r.patientId, + firstName: r.firstName, + lastName: r.lastName, + }; + resolvedByPhone.set(phone10, resolved); + leadsResolved++; + } catch (err) { + this.logger.warn(`[MAINT] resolve failed for ${phone10}: ${err}`); + resolveErrors++; + callsSkipped++; + continue; + } + } + + const fullName = `${resolved.firstName} ${resolved.lastName}`.trim(); + const updateParts: string[] = []; + if (needsLead && resolved.leadId) updateParts.push(`leadId: "${resolved.leadId}"`); + if (needsName && fullName) updateParts.push(`leadName: "${fullName.replace(/"/g, '\\"')}"`); + if (updateParts.length === 0) { callsSkipped++; continue; } + + try { + await this.platform.query( + `mutation { updateCall(id: "${call.id}", data: { ${updateParts.join(', ')} }) { id } }`, + ); + callsPatched++; + } catch (err) { + this.logger.warn(`[MAINT] updateCall failed for ${call.id}: ${err}`); + callsSkipped++; + } + + // Throttle so the platform isn't hammered + await new Promise((r) => setTimeout(r, 100)); + } + } + + this.logger.log(`[MAINT] Backfill caller resolution complete: scanned=${callsScanned} patched=${callsPatched} skipped=${callsSkipped} uniquePhones=${resolvedByPhone.size} leadsResolved=${leadsResolved} resolveErrors=${resolveErrors}`); + return { + status: 'ok', + calls: { scanned: callsScanned, patched: callsPatched, skipped: callsSkipped }, + phones: { unique: resolvedByPhone.size, resolved: leadsResolved, errors: resolveErrors }, + }; + } } diff --git a/src/masterdata/masterdata.service.ts b/src/masterdata/masterdata.service.ts index d64f63c..021ad63 100644 --- a/src/masterdata/masterdata.service.ts +++ b/src/masterdata/masterdata.service.ts @@ -171,6 +171,18 @@ export class MasterdataService implements OnModuleInit { await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL); this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`); + + // Filter out past time slots when the requested date is today (IST). + // Cache stores the full day's slots — filtering happens post-cache + // so slots become available as the day progresses without cache churn. + const todayIST = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' }); + if (date === todayIST) { + const nowHHMM = new Date().toLocaleTimeString('en-GB', { timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit' }); + const filtered = slots.filter(s => s.time >= nowHHMM); + this.logger.log(`[SLOTS] Today filter: ${slots.length} total → ${filtered.length} remaining (now=${nowHHMM} IST)`); + return filtered; + } + return slots; } diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index cca508e..1857eeb 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -394,6 +394,48 @@ export class OzonetelAgentService { } } + // Fetch a single CDR record by UCID. Preferred over fetchCDR + .find() + // for recording lookups — Ozonetel resolves leg-pair UCIDs internally, + // so the agent-side UCID we hold reliably returns the call row. + // Same rate limit as fetchCDR (2 req/min, 15-day window). + async fetchCdrByUCID(params: { date: string; ucid: string }): Promise | null> { + const url = `https://${this.apiDomain}/ca_reports/fetchCdrByUCID`; + this.logger.log(`Fetch CDR by UCID: ucid=${params.ucid} date=${params.date}`); + + try { + const token = await this.getToken(); + const body = { + userName: this.accountId, + fromDate: `${params.date} 00:00:00`, + toDate: `${params.date} 23:59:59`, + ucid: params.ucid, + }; + + const response = await axios({ + method: 'GET', + url, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + }); + + const data = response.data; + if (data.status === 'success' && Array.isArray(data.details) && data.details.length > 0) { + return data.details[0]; + } + if (data.status === 'success' && data.details && !Array.isArray(data.details)) { + return data.details; + } + return null; + } catch (error: any) { + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Fetch CDR by UCID failed: ${error.message} ${responseData}`); + return null; + } + } + async getAgentSummary(agentId: string, date: string): Promise<{ totalLoginDuration: string; totalBusyTime: string; diff --git a/src/widget/widget.module.ts b/src/widget/widget.module.ts index bff91bf..c841b52 100644 --- a/src/widget/widget.module.ts +++ b/src/widget/widget.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { WidgetController } from './widget.controller'; import { WebhooksController } from './webhooks.controller'; import { WidgetService } from './widget.service'; @@ -6,12 +6,13 @@ import { WidgetChatService } from './widget-chat.service'; import { PlatformModule } from '../platform/platform.module'; import { AuthModule } from '../auth/auth.module'; import { ConfigThemeModule } from '../config/config-theme.module'; +import { CallerResolutionModule } from '../caller/caller-resolution.module'; // WidgetKeysService lives in ConfigThemeModule now — injected here via the // module's exports. This module only owns the widget-facing API endpoints // (init / chat / book / lead) plus the NestJS guards that consume the keys. @Module({ - imports: [PlatformModule, AuthModule, ConfigThemeModule], + imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)], controllers: [WidgetController, WebhooksController], providers: [WidgetService, WidgetChatService], }) diff --git a/src/widget/widget.service.ts b/src/widget/widget.service.ts index b3c79a2..7af5534 100644 --- a/src/widget/widget.service.ts +++ b/src/widget/widget.service.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types'; import { ThemeService } from '../config/theme.service'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils'; +import { CallerResolutionService } from '../caller/caller-resolution.service'; // Dedup window: any lead created for this phone within the last 24h is // considered the same visitor's lead — chat + book + contact by the same @@ -25,6 +26,7 @@ export class WidgetService { private platform: PlatformGraphqlService, private theme: ThemeService, private config: ConfigService, + private caller: CallerResolutionService, ) { this.apiKey = config.get('platform.apiKey') ?? ''; } @@ -37,8 +39,10 @@ export class WidgetService { return raw.replace(/[^0-9]/g, '').slice(-10); } - // Shared lead dedup: finds a lead created in the last 24h for the same - // phone, or creates a new one. Public so WidgetChatService can reuse it. + // Shared lead dedup. Resolves via CallerResolutionService; when isNew + // (no prior Lead/Patient), we have a name here (widget form field), + // so we create both records inline. When an existing record is + // returned we update it with the latest channel + name. async findOrCreateLeadByPhone( name: string, rawPhone: string, @@ -47,53 +51,85 @@ export class WidgetService { const phone = this.normalizePhone(rawPhone); if (!phone) throw new Error('Invalid phone number'); - const since = new Date(Date.now() - LEAD_DEDUP_WINDOW_MS).toISOString(); - - try { - const existing = await this.platform.queryWithAuth( - `query($phone: String!, $since: DateTime!) { - leads( - first: 1, - filter: { - contactPhone: { primaryPhoneNumber: { like: $phone } }, - createdAt: { gte: $since } - }, - orderBy: [{ createdAt: DescNullsLast }] - ) { edges { node { id createdAt } } } - }`, - { phone: `%${phone}`, since }, - this.auth, - ); - const match = existing?.leads?.edges?.[0]?.node; - if (match?.id) { - this.logger.log(`Lead dedup: reusing ${match.id} for phone ${phone}`); - return match.id as string; - } - } catch (err) { - this.logger.warn(`Lead dedup lookup failed, falling through to create: ${err}`); - } - - const firstName = name.split(' ')[0] || name; + const resolved = await this.caller.resolve(phone, this.auth); + const firstName = name.split(' ')[0] || name || 'Unknown'; const lastName = name.split(' ').slice(1).join(' ') || ''; - const created = await this.platform.queryWithAuth( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, - { - data: { - name, - contactName: { firstName, lastName }, - contactPhone: { primaryPhoneNumber: `+91${phone}` }, - source: opts.source ?? 'WEBSITE', - status: opts.status ?? 'NEW', - interestedService: opts.interestedService ?? 'Website Enquiry', + if (resolved.isNew) { + // Net-new visitor — create Patient + Lead with the widget- + // collected name. Both records get the real name from the + // first moment they exist. + let patientId: string | undefined; + try { + const p = await this.platform.queryWithAuth( + `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, + { + data: { + fullName: { firstName, lastName }, + phones: { primaryPhoneNumber: `+91${phone}` }, + patientType: 'NEW', + }, + }, + this.auth, + ); + patientId = p?.createPatient?.id; + } catch (err) { + this.logger.warn(`Widget patient create failed (${phone}): ${err}`); + } + const created = await this.platform.queryWithAuth( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name, + contactName: { firstName, lastName }, + contactPhone: { primaryPhoneNumber: `+91${phone}` }, + source: opts.source ?? 'WEBSITE', + status: opts.status ?? 'NEW', + interestedService: opts.interestedService ?? 'Website Enquiry', + ...(patientId ? { patientId } : {}), + }, }, - }, - this.auth, - ); - const id = created?.createLead?.id; - if (!id) throw new Error('Lead creation returned no id'); - this.logger.log(`Lead dedup: created ${id} for ${name} (${phone})`); - return id as string; + this.auth, + ); + const leadId = created?.createLead?.id; + if (!leadId) throw new Error('Lead creation returned no id'); + this.logger.log(`Widget lead created: ${leadId} (patient ${patientId ?? 'none'}) for ${name} (${phone})`); + return leadId; + } + + // Existing Lead found — update with widget-supplied details. + const leadId = resolved.leadId; + try { + await this.platform.queryWithAuth( + `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, + { + id: leadId, + data: { + name, + contactName: { firstName, lastName }, + source: opts.source ?? 'WEBSITE', + status: opts.status ?? 'NEW', + interestedService: opts.interestedService ?? 'Website Enquiry', + }, + }, + this.auth, + ); + } catch (err) { + this.logger.warn(`Lead update after resolve failed (lead=${leadId}): ${err}`); + } + if (resolved.patientId) { + try { + await this.platform.queryWithAuth( + `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, + { id: resolved.patientId, data: { fullName: { firstName, lastName } } }, + this.auth, + ); + } catch (err) { + this.logger.warn(`Patient rename after resolve failed (patient=${resolved.patientId}): ${err}`); + } + } + this.logger.log(`Widget lead updated: ${leadId} (patient ${resolved.patientId}) for ${name} (${phone})`); + return leadId; } // Upgrade a lead's status — used when an existing lead is promoted from diff --git a/src/worklist/missed-call-webhook.controller.ts b/src/worklist/missed-call-webhook.controller.ts index 15fdf77..7734ce4 100644 --- a/src/worklist/missed-call-webhook.controller.ts +++ b/src/worklist/missed-call-webhook.controller.ts @@ -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('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 { const callData: Record = { 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; } diff --git a/src/worklist/missed-queue.service.ts b/src/worklist/missed-queue.service.ts index e133b7c..0f65332 100644 --- a/src/worklist/missed-queue.service.ts +++ b/src/worklist/missed-queue.service.ts @@ -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('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( - `{ 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('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( diff --git a/src/worklist/worklist.module.ts b/src/worklist/worklist.module.ts index 3870089..58c351a 100644 --- a/src/worklist/worklist.module.ts +++ b/src/worklist/worklist.module.ts @@ -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], diff --git a/src/worklist/worklist.service.ts b/src/worklist/worklist.service.ts index ee37b1f..9a15fa5 100644 --- a/src/worklist/worklist.service.ts +++ b/src/worklist/worklist.service.ts @@ -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('worklist.pageSize', 50); + } + + private get maxPages(): number { + return this.config.get('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( + buildQuery: (cursorClause: string) => string, + connectionKey: string, + authHeader: string, + ): Promise { + 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( + 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 { 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 { - try { - const data = await this.platform.queryWithAuth( - `{ 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( + (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 { + const raw = await this.fetchAllPages( + (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( - `{ 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( + `{ patients(first: ${patientIds.length}, filter: { id: { in: [${idsGql}] } }) { edges { node { + id fullName { firstName lastName } phones { primaryPhoneNumber } + } } } }`, + undefined, + authHeader, + ); + const patientMap = new Map(); + 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 { - try { - // FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue. - const data = await this.platform.queryWithAuth( - `{ 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 { + // 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( + (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, + ); } }