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

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

View File

@@ -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],

View File

@@ -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<ResolvedCaller> {
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<void> {
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<any>(
`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<void> {
await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,