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